Skip to content

Commit ea0cddf

Browse files
committed
fix(wtsite): copy the jsonld context before rewriting it
- _replace_jsonld_context_mapping deep-copies a dict argument at entry - values of an unhandled type are returned unchanged instead of None - add unit tests covering dicts, lists, scoped contexts and copy depth Closes #177
1 parent 848615c commit ea0cddf

2 files changed

Lines changed: 177 additions & 34 deletions

File tree

‎src/osw/wtsite.py‎

Lines changed: 38 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1321,19 +1321,43 @@ def _replace_jsonld_context_mapping(
13211321
handle string, list and dict values
13221322
handle mappings direct to iri as well as
13231323
{"@id": "http://example.org/property", @type": "@id"} and scoped contexts
1324+
the given context is not modified: dicts are copied before rewriting
1325+
values of an unhandled type are returned unchanged
13241326
"""
13251327
if isinstance(context, str):
13261328
return context
13271329
if isinstance(context, list):
13281330
return [self._replace_jsonld_context_mapping(e, config) for e in context]
1329-
if isinstance(context, dict):
1330-
context_iter = context.copy()
1331-
for key in context_iter:
1332-
value = context[key]
1333-
if key == "wiki":
1334-
context[key] = f"https://{self._site.host}/id/"
1335-
# print(f"apply https://{self._site.host}/id/ to {key}")
1336-
if isinstance(value, str):
1331+
if not isinstance(context, dict):
1332+
return context
1333+
context = deepcopy(context)
1334+
context_iter = context.copy()
1335+
for key in context_iter:
1336+
value = context[key]
1337+
if key == "wiki":
1338+
context[key] = f"https://{self._site.host}/id/"
1339+
# print(f"apply https://{self._site.host}/id/ to {key}")
1340+
if isinstance(value, str):
1341+
base_key = key.split("*")[0]
1342+
if base_key not in context:
1343+
context[base_key] = value
1344+
# print(f"apply {key} to {base_key}")
1345+
if config.prefer_external_vocal is False:
1346+
base_mapping = context[base_key]
1347+
if isinstance(base_mapping, dict):
1348+
base_mapping = base_mapping["@id"]
1349+
mapping = value
1350+
if mapping.startswith("Property:") and not base_mapping.startswith(
1351+
"Property:"
1352+
):
1353+
context[base_key] = value
1354+
# print(f"apply {key} to {base_key}")
1355+
elif isinstance(value, list):
1356+
context[key] = [
1357+
self._replace_jsonld_context_mapping(e, config) for e in value
1358+
]
1359+
elif isinstance(value, dict):
1360+
if "@id" in value:
13371361
base_key = key.split("*")[0]
13381362
if base_key not in context:
13391363
context[base_key] = value
@@ -1342,37 +1366,17 @@ def _replace_jsonld_context_mapping(
13421366
base_mapping = context[base_key]
13431367
if isinstance(base_mapping, dict):
13441368
base_mapping = base_mapping["@id"]
1345-
mapping = value
1369+
mapping = value["@id"]
13461370
if mapping.startswith(
13471371
"Property:"
13481372
) and not base_mapping.startswith("Property:"):
13491373
context[base_key] = value
13501374
# print(f"apply {key} to {base_key}")
1351-
elif isinstance(value, list):
1352-
context[key] = [
1353-
self._replace_jsonld_context_mapping(e, config) for e in value
1354-
]
1355-
elif isinstance(value, dict):
1356-
if "@id" in value:
1357-
base_key = key.split("*")[0]
1358-
if base_key not in context:
1359-
context[base_key] = value
1360-
# print(f"apply {key} to {base_key}")
1361-
if config.prefer_external_vocal is False:
1362-
base_mapping = context[base_key]
1363-
if isinstance(base_mapping, dict):
1364-
base_mapping = base_mapping["@id"]
1365-
mapping = value["@id"]
1366-
if mapping.startswith(
1367-
"Property:"
1368-
) and not base_mapping.startswith("Property:"):
1369-
context[base_key] = value
1370-
# print(f"apply {key} to {base_key}")
1371-
elif "@context" in value:
1372-
context[key] = self._replace_jsonld_context_mapping(
1373-
value["@context"], config
1374-
)
1375-
return context
1375+
elif "@context" in value:
1376+
context[key] = self._replace_jsonld_context_mapping(
1377+
value["@context"], config
1378+
)
1379+
return context
13761380

13771381
@try_and_renew_token
13781382
def get_jsonld_context_loader(self, params: JsonLdContextLoaderParams = None):
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
"""Unit tests for WtSite._replace_jsonld_context_mapping().
2+
3+
Regression guard for #177: the method rewrote the context object it was given
4+
in place, so a caller that still needed its own dictionary got it modified.
5+
It must now rewrite a copy and leave the argument untouched. An argument of an
6+
unhandled type must be returned unchanged instead of an implicit None.
7+
"""
8+
9+
import threading
10+
11+
from osw.wtsite import WtSite
12+
13+
14+
class _FakeSite:
15+
"""Stands in for mwclient.Site. Only the host is read."""
16+
17+
host = "example.org"
18+
19+
20+
def _make_fake_wtsite():
21+
"""A WtSite that performs no network calls."""
22+
ws = WtSite.__new__(WtSite)
23+
ws._session_lock = threading.RLock()
24+
ws._site = _FakeSite()
25+
return ws
26+
27+
28+
def _params():
29+
return WtSite.JsonLdContextLoaderParams(prefer_external_vocal=False)
30+
31+
32+
def test_dict_argument_is_not_modified():
33+
ws = _make_fake_wtsite()
34+
given = {
35+
"label": {"@id": "skos:prefLabel"},
36+
"label*": {"@id": "Property:HasLabel"},
37+
"wiki": "https://original.example/id/",
38+
}
39+
40+
result = ws._replace_jsonld_context_mapping(given, _params())
41+
42+
assert result is not given
43+
assert given == {
44+
"label": {"@id": "skos:prefLabel"},
45+
"label*": {"@id": "Property:HasLabel"},
46+
"wiki": "https://original.example/id/",
47+
}
48+
49+
50+
def test_dict_argument_is_still_rewritten_in_the_result():
51+
ws = _make_fake_wtsite()
52+
given = {
53+
"label": {"@id": "skos:prefLabel"},
54+
"label*": {"@id": "Property:HasLabel"},
55+
"wiki": "https://original.example/id/",
56+
}
57+
58+
result = ws._replace_jsonld_context_mapping(given, _params())
59+
60+
assert result["label"] == {"@id": "Property:HasLabel"}
61+
assert result["wiki"] == "https://example.org/id/"
62+
63+
64+
def test_string_mapping_is_rewritten_without_touching_the_argument():
65+
ws = _make_fake_wtsite()
66+
given = {"label": "skos:prefLabel", "label*": "Property:HasLabel"}
67+
68+
result = ws._replace_jsonld_context_mapping(given, _params())
69+
70+
assert result["label"] == "Property:HasLabel"
71+
assert given["label"] == "skos:prefLabel"
72+
73+
74+
def test_nested_values_are_not_shared_with_the_argument():
75+
ws = _make_fake_wtsite()
76+
nested = {"@id": "skos:prefLabel", "@type": "@id"}
77+
given = {"label": nested}
78+
79+
result = ws._replace_jsonld_context_mapping(given, _params())
80+
81+
assert result["label"] is not nested
82+
result["label"]["@id"] = "changed"
83+
assert nested["@id"] == "skos:prefLabel"
84+
85+
86+
def test_scoped_context_is_not_modified():
87+
ws = _make_fake_wtsite()
88+
scoped = {
89+
"label": {"@id": "skos:prefLabel"},
90+
"label*": {"@id": "Property:HasLabel"},
91+
}
92+
given = {"statements": {"@context": scoped}}
93+
94+
result = ws._replace_jsonld_context_mapping(given, _params())
95+
96+
assert result["statements"]["label"] == {"@id": "Property:HasLabel"}
97+
assert scoped["label"] == {"@id": "skos:prefLabel"}
98+
99+
100+
def test_list_elements_are_not_modified():
101+
ws = _make_fake_wtsite()
102+
element = {
103+
"label": {"@id": "skos:prefLabel"},
104+
"label*": {"@id": "Property:HasLabel"},
105+
}
106+
given = ["/wiki/Category:Entity?action=raw&slot=jsonschema", element]
107+
108+
result = ws._replace_jsonld_context_mapping(given, _params())
109+
110+
assert result is not given
111+
assert result[0] == "/wiki/Category:Entity?action=raw&slot=jsonschema"
112+
assert result[1] is not element
113+
assert result[1]["label"] == {"@id": "Property:HasLabel"}
114+
assert element["label"] == {"@id": "skos:prefLabel"}
115+
116+
117+
def test_list_value_inside_a_dict_is_not_modified():
118+
ws = _make_fake_wtsite()
119+
element = {
120+
"label": {"@id": "skos:prefLabel"},
121+
"label*": {"@id": "Property:HasLabel"},
122+
}
123+
given = {"statements": [element]}
124+
125+
result = ws._replace_jsonld_context_mapping(given, _params())
126+
127+
assert result["statements"][0]["label"] == {"@id": "Property:HasLabel"}
128+
assert element["label"] == {"@id": "skos:prefLabel"}
129+
130+
131+
def test_string_argument_is_returned_unchanged():
132+
ws = _make_fake_wtsite()
133+
assert ws._replace_jsonld_context_mapping("schema:name", _params()) == "schema:name"
134+
135+
136+
def test_unhandled_type_is_returned_unchanged():
137+
ws = _make_fake_wtsite()
138+
assert ws._replace_jsonld_context_mapping(42, _params()) == 42
139+
assert ws._replace_jsonld_context_mapping(None, _params()) is None

0 commit comments

Comments
 (0)