Skip to content

Commit 707be40

Browse files
committed
fix(wtsite): repair the dangling combine_into reference
- add _combine_into, the recursive dict merge update_dict always called - make update_dict a staticmethod, matching how set_value calls it - drop print(match.full_path), which raised TypeError on integer keys - cover all three with offline tests
1 parent 0d32bc2 commit 707be40

2 files changed

Lines changed: 153 additions & 9 deletions

File tree

‎src/osw/wtsite.py‎

Lines changed: 40 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,31 @@
5050
}
5151

5252

53+
def _combine_into(update: dict, combined: dict) -> None:
54+
"""Merges update into combined in place, recursing into nested dicts
55+
56+
A nested dict is merged key by key, so keys only present in combined
57+
survive. Any other value replaces what is already there.
58+
59+
Parameters
60+
----------
61+
update
62+
The dict to take the new keys and values from.
63+
combined
64+
The dict to merge into. Modified in place.
65+
"""
66+
for key, value in update.items():
67+
target = combined.get(key)
68+
if isinstance(value, dict):
69+
if not isinstance(target, dict):
70+
# nothing to merge with, so start from an empty dict rather
71+
# than storing a reference to the one in update
72+
target = combined[key] = {}
73+
_combine_into(value, target)
74+
else:
75+
combined[key] = value
76+
77+
5378
# Classes
5479
class WtSite:
5580
"""A wrapper class of mwclient.Site, mainly to provide multi-slot page handling and
@@ -1698,14 +1723,19 @@ def get_value(self, jsonpath):
16981723
res.append(match.value)
16991724
return res
17001725

1726+
@staticmethod
17011727
@deprecated("No longer supported")
1702-
def update_dict(self, combined: dict, update: dict) -> None:
1703-
for k, v in update.items():
1704-
if isinstance(v, dict):
1705-
# todo: fix reference for combine_into
1706-
wt.combine_into(v, combined.setdefault(k, {}))
1707-
else:
1708-
combined[k] = v
1728+
def update_dict(combined: dict, update: dict) -> None:
1729+
"""Merges update into combined in place, recursing into nested dicts
1730+
1731+
Parameters
1732+
----------
1733+
combined
1734+
The dict to merge into. Modified in place.
1735+
update
1736+
The dict to take the new keys and values from.
1737+
"""
1738+
_combine_into(update, combined)
17091739

17101740
@deprecated("No longer supported for replace=False")
17111741
def set_value(self, jsonpath_match, value, replace=False):
@@ -1735,10 +1765,11 @@ def set_value(self, jsonpath_match, value, replace=False):
17351765
# else: jsonpath_expr.update(d, value)
17361766
matches = jsonpath_expr.find(d)
17371767
for match in matches:
1738-
print(match.full_path)
1768+
# str(match.full_path) raises TypeError because the keys of d are the
1769+
# list indices, so this cannot be printed or logged as it stands
17391770
# pprint(value)
17401771
if not replace:
1741-
WtPage.update_dict(match.value, value)
1772+
_combine_into(value, match.value)
17421773
value = match.value
17431774
# pprint(value)
17441775
match.full_path.update_or_create(d, value)

‎tests/test_wtpage_update_dict.py‎

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
"""Unit tests for WtPage.update_dict and its merge helper.
2+
3+
Regression guard for #15: update_dict called wt.combine_into, a function that
4+
has never existed in this repository, so every nested dict raised
5+
AttributeError. It was also declared as an instance method but called as
6+
WtPage.update_dict(a, b) in set_value, which raised TypeError before the
7+
missing reference was ever reached.
8+
"""
9+
10+
from typing import Any, Dict, Union
11+
12+
from osw.wtsite import WtPage, _combine_into
13+
14+
15+
class OfflineWtPage(WtPage):
16+
"""A WtPage that never touches a wiki, copied from tests/test_osl.py."""
17+
18+
def __init__(self, wtSite: Any = None, title: str = None):
19+
self.wtSite = wtSite
20+
self.title = title
21+
self.exists = True
22+
self._original_content = ""
23+
self.changed: bool = False
24+
self._dict = []
25+
self._slots: Dict[str, Union[str, dict]] = {"main": ""}
26+
self._slots_changed: Dict[str, bool] = {"main": False}
27+
self._content_model: Dict[str, str] = {"main": "wikitext"}
28+
29+
30+
def test_flat_values_are_replaced():
31+
combined = {"a": 1, "b": 2}
32+
33+
_combine_into({"b": 3}, combined)
34+
35+
assert combined == {"a": 1, "b": 3}
36+
37+
38+
def test_nested_dicts_are_merged_not_replaced():
39+
"""The point of the recursion: 'keep' has to survive the merge."""
40+
combined = {"outer": {"keep": 1, "change": 2}}
41+
42+
_combine_into({"outer": {"change": 3, "add": 4}}, combined)
43+
44+
assert combined == {"outer": {"keep": 1, "change": 3, "add": 4}}
45+
46+
47+
def test_merging_recurses_to_any_depth():
48+
combined = {"a": {"b": {"c": {"keep": 1}}}}
49+
50+
_combine_into({"a": {"b": {"c": {"add": 2}}}}, combined)
51+
52+
assert combined == {"a": {"b": {"c": {"keep": 1, "add": 2}}}}
53+
54+
55+
def test_a_dict_replaces_a_scalar():
56+
combined = {"a": "scalar"}
57+
58+
_combine_into({"a": {"b": 1}}, combined)
59+
60+
assert combined == {"a": {"b": 1}}
61+
62+
63+
def test_a_scalar_replaces_a_dict():
64+
combined = {"a": {"b": 1}}
65+
66+
_combine_into({"a": "scalar"}, combined)
67+
68+
assert combined == {"a": "scalar"}
69+
70+
71+
def test_a_new_nested_key_is_copied_not_aliased():
72+
"""Otherwise editing the result would reach back into the update dict."""
73+
update = {"a": {"b": 1}}
74+
combined = {}
75+
76+
_combine_into(update, combined)
77+
combined["a"]["b"] = 2
78+
79+
assert update == {"a": {"b": 1}}
80+
81+
82+
def test_keys_absent_from_update_are_untouched():
83+
combined = {"a": 1}
84+
85+
_combine_into({}, combined)
86+
87+
assert combined == {"a": 1}
88+
89+
90+
def test_update_dict_merges_in_place_and_returns_none():
91+
combined = {"outer": {"keep": 1}}
92+
93+
assert WtPage.update_dict(combined, {"outer": {"add": 2}}) is None
94+
assert combined == {"outer": {"keep": 1, "add": 2}}
95+
96+
97+
def test_set_value_merges_into_the_existing_entry():
98+
"""set_value(replace=False) is the only caller, and it was broken."""
99+
page = OfflineWtPage(title="Test")
100+
page._dict = [{"Template": {"keep": "yes", "change": "old"}}]
101+
102+
page.set_value("$.*.Template", {"change": "new"})
103+
104+
assert page._dict == [{"Template": {"keep": "yes", "change": "new"}}]
105+
106+
107+
def test_set_value_with_replace_discards_the_existing_entry():
108+
page = OfflineWtPage(title="Test")
109+
page._dict = [{"Template": {"keep": "yes", "change": "old"}}]
110+
111+
page.set_value("$.*.Template", {"change": "new"}, replace=True)
112+
113+
assert page._dict == [{"Template": {"change": "new"}}]

0 commit comments

Comments
 (0)