From ea0cddfa85a0025560dda49f78ff99577a86f526 Mon Sep 17 00:00:00 2001 From: Lukas Gold Date: Fri, 18 Sep 2026 10:50:24 +0200 Subject: [PATCH] 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 --- src/osw/wtsite.py | 72 +++++----- tests/test_wtsite_jsonld_context_mapping.py | 139 ++++++++++++++++++++ 2 files changed, 177 insertions(+), 34 deletions(-) create mode 100644 tests/test_wtsite_jsonld_context_mapping.py diff --git a/src/osw/wtsite.py b/src/osw/wtsite.py index 8193c3ed..13c0f9f8 100644 --- a/src/osw/wtsite.py +++ b/src/osw/wtsite.py @@ -1321,19 +1321,43 @@ def _replace_jsonld_context_mapping( handle string, list and dict values handle mappings direct to iri as well as {"@id": "http://example.org/property", @type": "@id"} and scoped contexts + the given context is not modified: dicts are copied before rewriting + values of an unhandled type are returned unchanged """ if isinstance(context, str): return context if isinstance(context, list): return [self._replace_jsonld_context_mapping(e, config) for e in context] - if isinstance(context, dict): - context_iter = context.copy() - for key in context_iter: - value = context[key] - if key == "wiki": - context[key] = f"https://{self._site.host}/id/" - # print(f"apply https://{self._site.host}/id/ to {key}") - if isinstance(value, str): + if not isinstance(context, dict): + return context + context = deepcopy(context) + context_iter = context.copy() + for key in context_iter: + value = context[key] + if key == "wiki": + context[key] = f"https://{self._site.host}/id/" + # print(f"apply https://{self._site.host}/id/ to {key}") + if isinstance(value, str): + base_key = key.split("*")[0] + if base_key not in context: + context[base_key] = value + # print(f"apply {key} to {base_key}") + if config.prefer_external_vocal is False: + base_mapping = context[base_key] + if isinstance(base_mapping, dict): + base_mapping = base_mapping["@id"] + mapping = value + if mapping.startswith("Property:") and not base_mapping.startswith( + "Property:" + ): + context[base_key] = value + # print(f"apply {key} to {base_key}") + elif isinstance(value, list): + context[key] = [ + self._replace_jsonld_context_mapping(e, config) for e in value + ] + elif isinstance(value, dict): + if "@id" in value: base_key = key.split("*")[0] if base_key not in context: context[base_key] = value @@ -1342,37 +1366,17 @@ def _replace_jsonld_context_mapping( base_mapping = context[base_key] if isinstance(base_mapping, dict): base_mapping = base_mapping["@id"] - mapping = value + mapping = value["@id"] if mapping.startswith( "Property:" ) and not base_mapping.startswith("Property:"): context[base_key] = value # print(f"apply {key} to {base_key}") - elif isinstance(value, list): - context[key] = [ - self._replace_jsonld_context_mapping(e, config) for e in value - ] - elif isinstance(value, dict): - if "@id" in value: - base_key = key.split("*")[0] - if base_key not in context: - context[base_key] = value - # print(f"apply {key} to {base_key}") - if config.prefer_external_vocal is False: - base_mapping = context[base_key] - if isinstance(base_mapping, dict): - base_mapping = base_mapping["@id"] - mapping = value["@id"] - if mapping.startswith( - "Property:" - ) and not base_mapping.startswith("Property:"): - context[base_key] = value - # print(f"apply {key} to {base_key}") - elif "@context" in value: - context[key] = self._replace_jsonld_context_mapping( - value["@context"], config - ) - return context + elif "@context" in value: + context[key] = self._replace_jsonld_context_mapping( + value["@context"], config + ) + return context @try_and_renew_token def get_jsonld_context_loader(self, params: JsonLdContextLoaderParams = None): diff --git a/tests/test_wtsite_jsonld_context_mapping.py b/tests/test_wtsite_jsonld_context_mapping.py new file mode 100644 index 00000000..29d81299 --- /dev/null +++ b/tests/test_wtsite_jsonld_context_mapping.py @@ -0,0 +1,139 @@ +"""Unit tests for WtSite._replace_jsonld_context_mapping(). + +Regression guard for #177: the method rewrote the context object it was given +in place, so a caller that still needed its own dictionary got it modified. +It must now rewrite a copy and leave the argument untouched. An argument of an +unhandled type must be returned unchanged instead of an implicit None. +""" + +import threading + +from osw.wtsite import WtSite + + +class _FakeSite: + """Stands in for mwclient.Site. Only the host is read.""" + + host = "example.org" + + +def _make_fake_wtsite(): + """A WtSite that performs no network calls.""" + ws = WtSite.__new__(WtSite) + ws._session_lock = threading.RLock() + ws._site = _FakeSite() + return ws + + +def _params(): + return WtSite.JsonLdContextLoaderParams(prefer_external_vocal=False) + + +def test_dict_argument_is_not_modified(): + ws = _make_fake_wtsite() + given = { + "label": {"@id": "skos:prefLabel"}, + "label*": {"@id": "Property:HasLabel"}, + "wiki": "https://original.example/id/", + } + + result = ws._replace_jsonld_context_mapping(given, _params()) + + assert result is not given + assert given == { + "label": {"@id": "skos:prefLabel"}, + "label*": {"@id": "Property:HasLabel"}, + "wiki": "https://original.example/id/", + } + + +def test_dict_argument_is_still_rewritten_in_the_result(): + ws = _make_fake_wtsite() + given = { + "label": {"@id": "skos:prefLabel"}, + "label*": {"@id": "Property:HasLabel"}, + "wiki": "https://original.example/id/", + } + + result = ws._replace_jsonld_context_mapping(given, _params()) + + assert result["label"] == {"@id": "Property:HasLabel"} + assert result["wiki"] == "https://example.org/id/" + + +def test_string_mapping_is_rewritten_without_touching_the_argument(): + ws = _make_fake_wtsite() + given = {"label": "skos:prefLabel", "label*": "Property:HasLabel"} + + result = ws._replace_jsonld_context_mapping(given, _params()) + + assert result["label"] == "Property:HasLabel" + assert given["label"] == "skos:prefLabel" + + +def test_nested_values_are_not_shared_with_the_argument(): + ws = _make_fake_wtsite() + nested = {"@id": "skos:prefLabel", "@type": "@id"} + given = {"label": nested} + + result = ws._replace_jsonld_context_mapping(given, _params()) + + assert result["label"] is not nested + result["label"]["@id"] = "changed" + assert nested["@id"] == "skos:prefLabel" + + +def test_scoped_context_is_not_modified(): + ws = _make_fake_wtsite() + scoped = { + "label": {"@id": "skos:prefLabel"}, + "label*": {"@id": "Property:HasLabel"}, + } + given = {"statements": {"@context": scoped}} + + result = ws._replace_jsonld_context_mapping(given, _params()) + + assert result["statements"]["label"] == {"@id": "Property:HasLabel"} + assert scoped["label"] == {"@id": "skos:prefLabel"} + + +def test_list_elements_are_not_modified(): + ws = _make_fake_wtsite() + element = { + "label": {"@id": "skos:prefLabel"}, + "label*": {"@id": "Property:HasLabel"}, + } + given = ["/wiki/Category:Entity?action=raw&slot=jsonschema", element] + + result = ws._replace_jsonld_context_mapping(given, _params()) + + assert result is not given + assert result[0] == "/wiki/Category:Entity?action=raw&slot=jsonschema" + assert result[1] is not element + assert result[1]["label"] == {"@id": "Property:HasLabel"} + assert element["label"] == {"@id": "skos:prefLabel"} + + +def test_list_value_inside_a_dict_is_not_modified(): + ws = _make_fake_wtsite() + element = { + "label": {"@id": "skos:prefLabel"}, + "label*": {"@id": "Property:HasLabel"}, + } + given = {"statements": [element]} + + result = ws._replace_jsonld_context_mapping(given, _params()) + + assert result["statements"][0]["label"] == {"@id": "Property:HasLabel"} + assert element["label"] == {"@id": "skos:prefLabel"} + + +def test_string_argument_is_returned_unchanged(): + ws = _make_fake_wtsite() + assert ws._replace_jsonld_context_mapping("schema:name", _params()) == "schema:name" + + +def test_unhandled_type_is_returned_unchanged(): + ws = _make_fake_wtsite() + assert ws._replace_jsonld_context_mapping(42, _params()) == 42 + assert ws._replace_jsonld_context_mapping(None, _params()) is None