From 85c865c7bb1706bb8343ec51386c61c68b1671e1 Mon Sep 17 00:00:00 2001 From: "Kacper Kowalik (Xarthisius)" Date: Fri, 3 Apr 2026 14:59:45 -0500 Subject: [PATCH] Treat @context as a set rather than array --- tests/test_cli.py | 8 +++--- tests/test_extra_context.py | 56 ++++++++++++++++++------------------- tro_utils/cli.py | 6 ++-- tro_utils/models/tro.py | 31 ++++++++++++-------- tro_utils/tro_utils.py | 2 +- 5 files changed, 55 insertions(+), 48 deletions(-) diff --git a/tests/test_cli.py b/tests/test_cli.py index 88886a0..aa1a570 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -690,7 +690,7 @@ def test_extra_context_prefix_mapping( assert result.exit_code == 0, result.output with open(tro_file) as f: data = json.load(f) - assert {"ex": "http://example.org/"} in data["@context"] + assert data["@context"]["ex"] == "http://example.org/" def test_extra_context_multiple( self, runner, tmp_path, temp_workspace, trs_profile @@ -716,8 +716,8 @@ def test_extra_context_multiple( assert result.exit_code == 0, result.output with open(tro_file) as f: data = json.load(f) - assert {"ex": "http://example.org/"} in data["@context"] - assert {"foaf": "http://xmlns.com/foaf/0.1/"} in data["@context"] + assert data["@context"]["ex"] == "http://example.org/" + assert data["@context"]["foaf"] == "http://xmlns.com/foaf/0.1/" def test_extra_context_invalid_format( self, runner, tmp_path, temp_workspace, trs_profile @@ -761,7 +761,7 @@ def test_extra_context_base_context_preserved( ) with open(tro_file) as f: data = json.load(f) - base = data["@context"][0] + base = data["@context"] assert "trov" in base def test_cli_help_shows_extra_context(self, runner): diff --git a/tests/test_extra_context.py b/tests/test_extra_context.py index 8ba66e7..c8d1188 100644 --- a/tests/test_extra_context.py +++ b/tests/test_extra_context.py @@ -12,82 +12,82 @@ class TestExtraContext: """Tests for extra vocabulary support in @context.""" def test_extra_context_default_empty(self): - """TransparentResearchObject.extra_context defaults to an empty list.""" + """TransparentResearchObject.extra_context defaults to an empty dict.""" tro = TransparentResearchObject() - assert tro.extra_context == [] + assert tro.extra_context == {} def test_extra_context_dict_in_jsonld(self): """A dict extra_context entry appears in the serialised @context.""" tro = TransparentResearchObject() - tro.extra_context = [{"ex": "http://example.org/"}] + tro.extra_context = {"ex": "http://example.org/"} data = tro.to_jsonld() - assert {"ex": "http://example.org/"} in data["@context"] + assert data["@context"]["ex"] == "http://example.org/" def test_extra_context_multiple_entries(self): - """Multiple extra_context dict entries all appear in @context.""" + """Multiple extra_context entries all appear in @context.""" tro = TransparentResearchObject() - tro.extra_context = [ - {"ex": "http://example.org/"}, - {"foaf": "http://xmlns.com/foaf/0.1/"}, - ] + tro.extra_context = { + "ex": "http://example.org/", + "foaf": "http://xmlns.com/foaf/0.1/", + } data = tro.to_jsonld() - assert {"ex": "http://example.org/"} in data["@context"] - assert {"foaf": "http://xmlns.com/foaf/0.1/"} in data["@context"] + assert data["@context"]["ex"] == "http://example.org/" + assert data["@context"]["foaf"] == "http://xmlns.com/foaf/0.1/" def test_extra_context_base_context_still_present(self): - """The standard base context is always the first entry in @context.""" + """The standard base context keys are always present in @context.""" tro = TransparentResearchObject() - tro.extra_context = [{"ex": "http://example.org/"}] + tro.extra_context = {"ex": "http://example.org/"} data = tro.to_jsonld() - base = data["@context"][0] + base = data["@context"] assert "trov" in base assert "rdf" in base def test_extra_context_roundtrip(self, tmp_path): - """Extra context dict entries survive a save/load roundtrip.""" + """Extra context entries survive a save/load roundtrip.""" tro = TransparentResearchObject() - tro.extra_context = [ - {"ex": "http://example.org/"}, - {"foaf": "http://xmlns.com/foaf/0.1/"}, - ] + tro.extra_context = { + "ex": "http://example.org/", + "foaf": "http://xmlns.com/foaf/0.1/", + } filepath = tmp_path / "tro_with_extra_ctx.jsonld" tro.save(str(filepath)) loaded = TransparentResearchObject.load(str(filepath)) - assert {"ex": "http://example.org/"} in loaded.extra_context - assert {"foaf": "http://xmlns.com/foaf/0.1/"} in loaded.extra_context + assert loaded.extra_context["ex"] == "http://example.org/" + assert loaded.extra_context["foaf"] == "http://xmlns.com/foaf/0.1/" def test_extra_context_via_tro_facade(self, tmp_path): """TRO facade propagates extra_context to the underlying model.""" tro_file = tmp_path / "tro.jsonld" tro = TRO( filepath=str(tro_file), - extra_context=[{"ex": "http://example.org/"}], + extra_context={"ex": "http://example.org/"}, ) tro.save() with open(tro_file) as f: data = json.load(f) - assert {"ex": "http://example.org/"} in data["@context"] + assert data["@context"]["ex"] == "http://example.org/" def test_extra_context_via_tro_facade_extends_existing(self, tmp_path): - """TRO facade appends extra_context to any context already in the file.""" + """TRO facade merges extra_context into any context already in the file.""" tro_file = tmp_path / "tro.jsonld" # First save with one extra context entry tro = TRO( filepath=str(tro_file), - extra_context=[{"ex": "http://example.org/"}], + extra_context={"ex": "http://example.org/"}, ) tro.save() # Re-open and add a second entry tro2 = TRO( filepath=str(tro_file), - extra_context=[{"foaf": "http://xmlns.com/foaf/0.1/"}], + extra_context={"foaf": "http://xmlns.com/foaf/0.1/"}, ) tro2.save() with open(tro_file) as f: data = json.load(f) - assert {"ex": "http://example.org/"} in data["@context"] - assert {"foaf": "http://xmlns.com/foaf/0.1/"} in data["@context"] + assert data["@context"]["ex"] == "http://example.org/" + assert data["@context"]["foaf"] == "http://xmlns.com/foaf/0.1/" diff --git a/tro_utils/cli.py b/tro_utils/cli.py index 3a87170..3eb062a 100644 --- a/tro_utils/cli.py +++ b/tro_utils/cli.py @@ -22,11 +22,11 @@ def _parse_arrangement_ref(value: str) -> tuple[str, str | None]: def _parse_extra_context_value(ctx, param, value): - """Parse ``--extra-context`` values into JSON-LD context dicts. + """Parse ``--extra-context`` values into a JSON-LD context dict. Each value must be in ``PREFIX=URI`` form (e.g. ``ex=http://example.org/``). """ - result = [] + result = {} for v in value: if "=" not in v: raise click.BadParameter( @@ -35,7 +35,7 @@ def _parse_extra_context_value(ctx, param, value): ctx=ctx, ) prefix, _, uri = v.partition("=") - result.append({prefix: uri}) + result[prefix] = uri return result diff --git a/tro_utils/models/tro.py b/tro_utils/models/tro.py index dc9ba4c..140b8ea 100644 --- a/tro_utils/models/tro.py +++ b/tro_utils/models/tro.py @@ -28,14 +28,12 @@ TROV_VOCABULARY_VERSION = Version("0.1") -_JSONLD_CONTEXT = [ - { - "rdf": "http://www.w3.org/1999/02/22-rdf-syntax-ns#", - "rdfs": "http://www.w3.org/2000/01/rdf-schema#", - "trov": f"https://w3id.org/trace/trov/{TROV_VOCABULARY_VERSION}#", - "schema": "https://schema.org", - } -] +_JSONLD_CONTEXT = { + "rdf": "http://www.w3.org/1999/02/22-rdf-syntax-ns#", + "rdfs": "http://www.w3.org/2000/01/rdf-schema#", + "trov": f"https://w3id.org/trace/trov/{TROV_VOCABULARY_VERSION}#", + "schema": "https://schema.org/", +} @dataclass @@ -56,7 +54,7 @@ class TransparentResearchObject(TROVModel): arrangements: list[ArtifactArrangement] = field(default_factory=list) performances: list[TrustedResearchPerformance] = field(default_factory=list) attributes: list[TROAttribute] = field(default_factory=list) - extra_context: list[dict] = field(default_factory=list) + extra_context: dict = field(default_factory=dict) # ------------------------------------------------------------------ # File I/O @@ -327,7 +325,7 @@ def to_jsonld(self) -> dict[str, Any]: graph_node["trov:hasPerformance"][i].update(extra) return { - "@context": _JSONLD_CONTEXT + self.extra_context, + "@context": {**_JSONLD_CONTEXT, **self.extra_context}, "@graph": [graph_node], } @@ -346,8 +344,17 @@ def from_jsonld(cls, data: dict[str, Any]) -> "TransparentResearchObject": """ graph = data["@graph"][0] - raw_context = data.get("@context", []) - extra_context = list(raw_context[1:]) if isinstance(raw_context, list) else [] + raw_context = data.get("@context", {}) + if isinstance(raw_context, list): + # backward compat: merge list of dicts into a single dict + merged: dict = {} + for item in raw_context: + if isinstance(item, dict): + merged.update(item) + raw_context = merged + extra_context = { + k: v for k, v in raw_context.items() if k not in _JSONLD_CONTEXT + } vocab_version = Version(graph.get("trov:vocabularyVersion", "0.0.1")) if vocab_version < TROV_VOCABULARY_VERSION: diff --git a/tro_utils/tro_utils.py b/tro_utils/tro_utils.py index ef99780..4068de7 100644 --- a/tro_utils/tro_utils.py +++ b/tro_utils/tro_utils.py @@ -82,7 +82,7 @@ def __init__( self._model = TransparentResearchObject.load(self.tro_filename) if extra_context: - self._model.extra_context.extend(extra_context) + self._model.extra_context.update(extra_context) self.gpg = gnupg.GPG(gnupghome=GPG_HOME, verbose=False) if gpg_fingerprint: