diff --git a/tests/test_cli.py b/tests/test_cli.py index 56ebf1f..88886a0 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -661,3 +661,111 @@ def test_performance_add_multiple_accessed_with_paths( by_id = {r["trov:arrangement"]["@id"]: r for r in accessed} assert by_id["arrangement/0"]["trov:boundTo"] == "/mnt/a" assert "trov:boundTo" not in by_id["arrangement/1"] + + +class TestExtraContextCLI: + """Tests for --extra-context CLI option.""" + + def test_extra_context_prefix_mapping( + self, runner, tmp_path, temp_workspace, trs_profile + ): + """--extra-context PREFIX=URI adds a prefix mapping dict to @context.""" + tro_file = tmp_path / "tro.jsonld" + result = runner.invoke( + cli, + [ + "--declaration", + str(tro_file), + "--profile", + trs_profile, + "--extra-context", + "ex=http://example.org/", + "arrangement", + "add", + "--comment", + "test", + str(temp_workspace), + ], + ) + 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"] + + def test_extra_context_multiple( + self, runner, tmp_path, temp_workspace, trs_profile + ): + """Multiple --extra-context PREFIX=URI flags all appear in @context.""" + tro_file = tmp_path / "tro.jsonld" + result = runner.invoke( + cli, + [ + "--declaration", + str(tro_file), + "--profile", + trs_profile, + "--extra-context", + "ex=http://example.org/", + "--extra-context", + "foaf=http://xmlns.com/foaf/0.1/", + "arrangement", + "add", + str(temp_workspace), + ], + ) + 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"] + + def test_extra_context_invalid_format( + self, runner, tmp_path, temp_workspace, trs_profile + ): + """--extra-context value that is not PREFIX=URI produces an error.""" + tro_file = tmp_path / "tro.jsonld" + result = runner.invoke( + cli, + [ + "--declaration", + str(tro_file), + "--profile", + trs_profile, + "--extra-context", + "https://example.org/vocab.jsonld", + "arrangement", + "add", + str(temp_workspace), + ], + ) + assert result.exit_code != 0 + + def test_extra_context_base_context_preserved( + self, runner, tmp_path, temp_workspace, trs_profile + ): + """The standard base @context entry is always present even with extra context.""" + tro_file = tmp_path / "tro.jsonld" + runner.invoke( + cli, + [ + "--declaration", + str(tro_file), + "--profile", + trs_profile, + "--extra-context", + "ex=http://example.org/", + "arrangement", + "add", + str(temp_workspace), + ], + ) + with open(tro_file) as f: + data = json.load(f) + base = data["@context"][0] + assert "trov" in base + + def test_cli_help_shows_extra_context(self, runner): + """--extra-context option is visible in CLI help output.""" + result = runner.invoke(cli, ["--help"]) + assert result.exit_code == 0 + assert "extra-context" in result.output diff --git a/tests/test_tro_utils.py b/tests/test_tro_utils.py index 3f50c5d..cb43c5c 100644 --- a/tests/test_tro_utils.py +++ b/tests/test_tro_utils.py @@ -2363,3 +2363,88 @@ def test_verification_result_is_valid_property(self): bad = VerificationResult(files_missing_in_arrangement=["x"]) assert not bad.is_valid + + +class TestExtraContext: + """Tests for extra vocabulary support in @context.""" + + def test_extra_context_default_empty(self): + """TransparentResearchObject.extra_context defaults to an empty list.""" + tro = TransparentResearchObject() + 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/"}] + data = tro.to_jsonld() + assert {"ex": "http://example.org/"} in data["@context"] + + def test_extra_context_multiple_entries(self): + """Multiple extra_context dict entries all appear in @context.""" + tro = TransparentResearchObject() + 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"] + + def test_extra_context_base_context_still_present(self): + """The standard base context is always the first entry in @context.""" + tro = TransparentResearchObject() + tro.extra_context = [{"ex": "http://example.org/"}] + data = tro.to_jsonld() + base = data["@context"][0] + 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.""" + tro = TransparentResearchObject() + 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 + + 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/"}], + ) + tro.save() + + with open(tro_file) as f: + data = json.load(f) + assert {"ex": "http://example.org/"} in data["@context"] + + 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_file = tmp_path / "tro.jsonld" + # First save with one extra context entry + tro = TRO( + filepath=str(tro_file), + 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/"}], + ) + 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"] diff --git a/tro_utils/cli.py b/tro_utils/cli.py index cdac7fe..3a87170 100644 --- a/tro_utils/cli.py +++ b/tro_utils/cli.py @@ -21,6 +21,24 @@ def _parse_arrangement_ref(value: str) -> tuple[str, str | None]: return (arrangement_id, path or None) +def _parse_extra_context_value(ctx, param, value): + """Parse ``--extra-context`` values into JSON-LD context dicts. + + Each value must be in ``PREFIX=URI`` form (e.g. ``ex=http://example.org/``). + """ + result = [] + for v in value: + if "=" not in v: + raise click.BadParameter( + f"{v!r} is not a valid PREFIX=URI mapping.", + param=param, + ctx=ctx, + ) + prefix, _, uri = v.partition("=") + result.append({prefix: uri}) + return result + + _TEMPLATES = { "default": { "description": "Default pretty template by Craig Willis", @@ -104,6 +122,20 @@ def convert(self, value, param, ctx): required=False, help="TRO description (only used when creating a new TRO)", ) +@click.option( + "--extra-context", + "-c", + envvar="TRO_EXTRA_CONTEXT", + type=click.STRING, + required=False, + multiple=True, + callback=_parse_extra_context_value, + is_eager=False, + help=( + "Extra PREFIX=URI vocabulary mapping to append to @context " + "(e.g. ex=http://example.org/). May be repeated." + ), +) def cli( declaration, profile, @@ -112,6 +144,7 @@ def cli( tro_creator, tro_name, tro_description, + extra_context, ): pass @@ -308,6 +341,7 @@ def add(ctx, directory, ignore_dir, comment, from_snapshot): tro_name = ctx.params.get("tro_name") tro_description = ctx.params.get("tro_description") tro_creator = ctx.params.get("tro_creator") + extra_context = ctx.params.get("extra_context") or [] tro = TRO( filepath=declaration, gpg_fingerprint=gpg_fingerprint, @@ -316,6 +350,7 @@ def add(ctx, directory, ignore_dir, comment, from_snapshot): tro_creator=tro_creator, tro_name=tro_name, tro_description=tro_description, + extra_context=extra_context or None, ) if from_snapshot: tro.add_arrangement_from_snapshot(from_snapshot, comment=comment) @@ -430,11 +465,13 @@ def performance_add(ctx, comment, start, end, attribute, accessed, modified): gpg_fingerprint = ctx.params.get("gpg_fingerprint") gpg_passphrase = ctx.params.get("gpg_passphrase") profile = ctx.params.get("profile") + extra_context = ctx.params.get("extra_context") or [] tro = TRO( filepath=declaration, gpg_fingerprint=gpg_fingerprint, gpg_passphrase=gpg_passphrase, profile=profile, + extra_context=extra_context or None, ) tro.add_performance( start, diff --git a/tro_utils/models/tro.py b/tro_utils/models/tro.py index 19eeb7b..dc9ba4c 100644 --- a/tro_utils/models/tro.py +++ b/tro_utils/models/tro.py @@ -56,6 +56,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) # ------------------------------------------------------------------ # File I/O @@ -326,7 +327,7 @@ def to_jsonld(self) -> dict[str, Any]: graph_node["trov:hasPerformance"][i].update(extra) return { - "@context": _JSONLD_CONTEXT, + "@context": _JSONLD_CONTEXT + self.extra_context, "@graph": [graph_node], } @@ -345,6 +346,9 @@ 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 [] + vocab_version = Version(graph.get("trov:vocabularyVersion", "0.0.1")) if vocab_version < TROV_VOCABULARY_VERSION: raise RuntimeError( @@ -417,4 +421,5 @@ def from_jsonld(cls, data: dict[str, Any]) -> "TransparentResearchObject": arrangements=arrangements, performances=performances, attributes=attributes, + extra_context=extra_context, ) diff --git a/tro_utils/tro_utils.py b/tro_utils/tro_utils.py index 6559eaa..ef99780 100644 --- a/tro_utils/tro_utils.py +++ b/tro_utils/tro_utils.py @@ -44,6 +44,7 @@ def __init__( tro_creator=None, tro_name=None, tro_description=None, + extra_context=None, ): if filepath is None: self.basename = "some_tro" @@ -80,6 +81,9 @@ def __init__( else: self._model = TransparentResearchObject.load(self.tro_filename) + if extra_context: + self._model.extra_context.extend(extra_context) + self.gpg = gnupg.GPG(gnupghome=GPG_HOME, verbose=False) if gpg_fingerprint: self.gpg_key_id = self.gpg.list_keys().key_map[gpg_fingerprint]["keyid"]