From 79ded0f06af50d9aa2217a68d78cf7201f59e9ec Mon Sep 17 00:00:00 2001 From: Ali Date: Mon, 7 Sep 2026 13:59:32 +0100 Subject: [PATCH 1/2] Use ro-crate-py helpers: Person, append_to and add_action Replace hand-built JSON-LD references with the library's own APIs: - add people via rocrate.model.Person instead of a ContextEntity with a manual @type - link root and entity properties with append_to, which preserves existing values instead of overwriting them - build the CreateAction with crate.add_action, which sets the @type and takes instrument/object/result directly Root author, conformsTo and license are now arrays rather than single values, which is valid JSON-LD and is what append_to produces; three test assertions and the committed example crate are updated to match. --- .../quickstart-pytorch/ro-crate-metadata.json | 24 +++++--- flwrcrate/crate_builder.py | 59 ++++++++++--------- tests/test_crate_builder.py | 4 +- tests/test_integration.py | 2 +- 4 files changed, 48 insertions(+), 41 deletions(-) diff --git a/examples/quickstart-pytorch/ro-crate-metadata.json b/examples/quickstart-pytorch/ro-crate-metadata.json index 6040d34..259a707 100644 --- a/examples/quickstart-pytorch/ro-crate-metadata.json +++ b/examples/quickstart-pytorch/ro-crate-metadata.json @@ -4,12 +4,16 @@ { "@id": "./", "@type": "Dataset", - "author": { - "@id": "https://orcid.org/0009-0000-0000-0000" - }, - "conformsTo": { - "@id": "https://esciencelab.org.uk/federated-learning-ro-crate-profile/federated-learning-profile.html" - }, + "author": [ + { + "@id": "https://orcid.org/0009-0000-0000-0000" + } + ], + "conformsTo": [ + { + "@id": "https://esciencelab.org.uk/federated-learning-ro-crate-profile/federated-learning-profile.html" + } + ], "datePublished": "2026-06-10T10:25:49+00:00", "description": "RO-Crate describing a federated learning run captured with flwrCrate.", "hasPart": [ @@ -20,9 +24,11 @@ "@id": "metrics_log.json" } ], - "license": { - "@id": "https://spdx.org/licenses/MIT.html" - }, + "license": [ + { + "@id": "https://spdx.org/licenses/MIT.html" + } + ], "mentions": [ { "@id": "#fl-run" diff --git a/flwrcrate/crate_builder.py b/flwrcrate/crate_builder.py index a98ee66..40168fa 100644 --- a/flwrcrate/crate_builder.py +++ b/flwrcrate/crate_builder.py @@ -12,6 +12,7 @@ from pathlib import Path from rocrate.rocrate import ROCrate +from rocrate.model.person import Person from rocrate.model.contextentity import ContextEntity from .metrics import metric_to_property_value @@ -37,18 +38,16 @@ def _person(crate, spec, fallback_id): ``name``, ``id``/``orcid`` and ``affiliation``. Returns the added entity. """ if isinstance(spec, dict): - name = spec.get("name") pid = spec.get("id") or spec.get("orcid") or fallback_id - props = {"@type": "Person"} - if name: - props["name"] = name + props = {} + if spec.get("name"): + props["name"] = spec["name"] if spec.get("affiliation"): props["affiliation"] = spec["affiliation"] else: - name = str(spec) pid = fallback_id - props = {"@type": "Person", "name": name} - return crate.add(ContextEntity(crate, pid, properties=props)) + props = {"name": str(spec)} + return crate.add(Person(crate, pid, properties=props)) def build_crate(captured: dict, crate_dir, metrics_log_path=None, model_path=None, @@ -68,7 +67,7 @@ def build_crate(captured: dict, crate_dir, metrics_log_path=None, model_path=Non "@type": "CreativeWork", "name": "Federated Learning RO-Crate profile v0.1", })) - crate.root_dataset["conformsTo"] = {"@id": profile.id} + crate.root_dataset.append_to("conformsTo", profile) # --- #5 license / author / agent scaffolding ------------------------------- if license: @@ -76,9 +75,9 @@ def build_crate(captured: dict, crate_dir, metrics_log_path=None, model_path=Non lic = crate.add(ContextEntity(crate, str(license), properties={ "@type": "CreativeWork", "name": str(license), })) - crate.root_dataset["license"] = {"@id": lic.id} + crate.root_dataset.append_to("license", lic) else: - crate.root_dataset["license"] = str(license) + crate.root_dataset.append_to("license", str(license)) else: logger.warning( "No license set for the RO-Crate. Pass license=... (e.g. an SPDX URL " @@ -89,7 +88,7 @@ def build_crate(captured: dict, crate_dir, metrics_log_path=None, model_path=Non author_ref = None if author: author_ref = _person(crate, author, "#author") - crate.root_dataset["author"] = {"@id": author_ref.id} + crate.root_dataset.append_to("author", author_ref) else: logger.warning( "No author set for the RO-Crate. Pass author='Your Name' (or a dict " @@ -109,7 +108,7 @@ def build_crate(captured: dict, crate_dir, metrics_log_path=None, model_path=Non if flwr_version: flower_props["softwareVersion"] = flwr_version flower = crate.add(ContextEntity(crate, "#flower", properties=flower_props)) - instruments.append({"@id": flower.id}) + instruments.append(flower) for fw in captured.get("frameworks", []) or []: props = {"@type": "SoftwareApplication", "name": fw["name"]} @@ -120,7 +119,7 @@ def build_crate(captured: dict, crate_dir, metrics_log_path=None, model_path=Non if fw.get("declared"): props["softwareRequirements"] = fw["declared"] # spec from pyproject.toml ent = crate.add(ContextEntity(crate, f"#framework-{_slug(fw['package'])}", properties=props)) - instruments.append({"@id": ent.id}) + instruments.append(ent) # --- #2 Aggregation strategy as a SoftwareApplication with hyperparameters --- strat = captured.get("strategy", {}) or {} @@ -133,14 +132,14 @@ def build_crate(captured: dict, crate_dir, metrics_log_path=None, model_path=Non hp_refs = [] for k, v in (strat.get("attributes") or {}).items(): pid = f"#strategy-param-{_slug(k)}" - crate.add(ContextEntity(crate, pid, properties={ + hp = crate.add(ContextEntity(crate, pid, properties={ "@type": "PropertyValue", "name": k, "value": v, })) - hp_refs.append({"@id": pid}) + hp_refs.append(hp) if hp_refs: strat_props["additionalProperty"] = hp_refs strategy = crate.add(ContextEntity(crate, "#fl-strategy", properties=strat_props)) - instruments.append({"@id": strategy.id}) + instruments.append(strategy) # --- Outputs (results): model file + per-round / federation log file --- results = [] @@ -151,7 +150,7 @@ def build_crate(captured: dict, crate_dir, metrics_log_path=None, model_path=Non "name": "Final aggregated model", "description": "Final global model produced by the federated learning run.", }) - results.append({"@id": model_entity.id}) + results.append(model_entity) if metrics_log_path and Path(metrics_log_path).exists(): log_entity = crate.add_file(str(metrics_log_path), Path(metrics_log_path).name, properties={ @@ -164,7 +163,7 @@ def build_crate(captured: dict, crate_dir, metrics_log_path=None, model_path=Non ), "encodingFormat": "application/json", }) - results.append({"@id": log_entity.id}) + results.append(log_entity) # --- Final metrics as PropertyValues --- final = captured.get("final_metrics", {}) or {} @@ -173,7 +172,7 @@ def build_crate(captured: dict, crate_dir, metrics_log_path=None, model_path=Non for name, value in final_metrics.items(): pv = metric_to_property_value(name, value, uri_map) ent = crate.add(ContextEntity(crate, f"#metric-{_slug(name)}", properties=pv)) - metric_refs.append({"@id": ent.id}) + metric_refs.append(ent) # --- Run configuration as PropertyValues (inputs / s:object) --- config = captured.get("environment_config", {}) or {} @@ -182,21 +181,17 @@ def build_crate(captured: dict, crate_dir, metrics_log_path=None, model_path=Non ent = crate.add(ContextEntity(crate, f"#param-{_slug(name)}", properties={ "@type": "PropertyValue", "name": name, "value": value, })) - config_refs.append({"@id": ent.id}) + config_refs.append(ent) # --- The CreateAction: the FL run itself --- timing = captured.get("run_timing", {}) or {} - action_props = {"@type": "CreateAction", "name": "Federated learning training run", "instrument": instruments} + action_props = {"name": "Federated learning training run"} if timing.get("start_time"): action_props["startTime"] = timing["start_time"] if timing.get("end_time"): action_props["endTime"] = timing["end_time"] - if config_refs: - action_props["object"] = config_refs - if results: - action_props["result"] = results if agent_ref is not None: - action_props["agent"] = {"@id": agent_ref.id} + action_props["agent"] = agent_ref if strat: action_props["description"] = ( f"Run using strategy {strat.get('class_name')} ({strat.get('module')}), " @@ -208,15 +203,21 @@ def build_crate(captured: dict, crate_dir, metrics_log_path=None, model_path=Non else: action_props["actionStatus"] = {"@id": SCHEMA + "CompletedActionStatus"} - action = crate.add(ContextEntity(crate, "#fl-run", properties=action_props)) + action = crate.add_action( + instruments, + identifier="#fl-run", + object=config_refs, + result=results, + properties=action_props, + ) # --- #1 Link the action from the root so it is discoverable --- - crate.root_dataset["mentions"] = [{"@id": action.id}] + crate.root_dataset.append_to("mentions", action) # Final metrics attach to the output model, else to the action. if metric_refs: host = model_entity if model_entity is not None else action - host["additionalProperty"] = metric_refs + host.append_to("additionalProperty", metric_refs) crate.write(crate_dir) return crate_dir diff --git a/tests/test_crate_builder.py b/tests/test_crate_builder.py index 7e2f3d4..7d529b8 100644 --- a/tests/test_crate_builder.py +++ b/tests/test_crate_builder.py @@ -88,8 +88,8 @@ def test_build_crate_core_entities(tmp_path): assert g["#framework-torch"]["softwareVersion"] == "2.8.0" assert g["#flower"]["softwareVersion"] == "1.30.0" # #5 provenance - assert g["./"]["license"]["@id"] == "https://spdx.org/licenses/MIT.html" - assert g["./"]["author"]["@id"] == "https://orcid.org/0000-0000-0000-0001" + assert g["./"]["license"][0]["@id"] == "https://spdx.org/licenses/MIT.html" + assert g["./"]["author"][0]["@id"] == "https://orcid.org/0000-0000-0000-0001" assert g["#fl-run"]["agent"]["@id"] == "https://orcid.org/0000-0000-0000-0001" diff --git a/tests/test_integration.py b/tests/test_integration.py index 1497299..a23ca9a 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -46,7 +46,7 @@ def test_full_lifecycle_produces_complete_crate( assert g["#fl-strategy"]["name"] == "FedAvg" # #2 assert any(i["@id"] == "metrics_log.json" for i in g["#fl-run"]["result"]) # #3 assert "#framework-torch" in g # #4 - assert g["./"]["license"]["@id"].endswith("MIT.html") # #5 + assert g["./"]["license"][0]["@id"].endswith("MIT.html") # #5 assert g["#fl-run"]["agent"]["@id"].endswith("0000-0001") # result-side capture happened (the record_result path) From 0f9c2f97aab3316f97a159af7077c93487c36215 Mon Sep 17 00:00:00 2001 From: Ali Date: Thu, 10 Sep 2026 12:39:52 +0100 Subject: [PATCH 2/2] Declare conformance to Process Run Crate and fix validation issues Root conformsTo now lists the Process Run Crate profile alongside the FL profile, with a Profile contextual entity for each as RO-Crate 1.2 requires. Validating with rocrate-validator surfaced several defects, all fixed: timestamps carried sub-second precision that failed ISO 8601 checks, the model file had no encodingFormat, File entities had no contentSize, and SoftwareApplication entities for the strategy and for dependencies outside the known-frameworks map had no url. Version is now recorded as version rather than softwareVersion, which the RO-Crate 1.2 profile requires. The crate validates against ro-crate-1.2 at REQUIRED severity with no issues. The example crate is regenerated to match. --- README.md | 2 +- .../quickstart-pytorch/ro-crate-metadata.json | 42 ++++++++++++++----- flwrcrate/crate_builder.py | 34 +++++++++++---- flwrcrate/framework.py | 6 ++- flwrcrate/tracker.py | 6 +-- tests/test_crate_builder.py | 9 +++- tests/test_framework.py | 3 ++ 7 files changed, 76 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index aa6784d..c7dd85b 100644 --- a/README.md +++ b/README.md @@ -204,7 +204,7 @@ The crate's `@graph` contains, linked together: | `./` | `Dataset` | Root: name, author, license, `conformsTo` the FL profile, `mentions` the run | | `#fl-run` | `CreateAction` | The run: `agent`, `startTime`/`endTime`, `actionStatus`, instrument/object/result | | `#flower` | `SoftwareApplication` | Flower with its installed version | -| `#framework-*` | `SoftwareApplication` | Every declared dependency (minus an infrastructure deny-list): `softwareRequirements` = the declared version spec, `softwareVersion` = the actually-installed version | +| `#framework-*` | `SoftwareApplication` | Every declared dependency (minus an infrastructure deny-list): `softwareRequirements` = the declared version spec, `version` = the actually-installed version | | `#fl-strategy` | `SoftwareApplication` | The aggregation strategy with its hyperparameters as `PropertyValue`s | | `#param-*` | `PropertyValue` | Run configuration inputs (the action's `object`) | | `#metric-*` | `PropertyValue` | Final-round metrics, attached to the output model (with `propertyID` when mapped) | diff --git a/examples/quickstart-pytorch/ro-crate-metadata.json b/examples/quickstart-pytorch/ro-crate-metadata.json index 259a707..75b20a6 100644 --- a/examples/quickstart-pytorch/ro-crate-metadata.json +++ b/examples/quickstart-pytorch/ro-crate-metadata.json @@ -12,6 +12,9 @@ "conformsTo": [ { "@id": "https://esciencelab.org.uk/federated-learning-ro-crate-profile/federated-learning-profile.html" + }, + { + "@id": "https://w3id.org/ro/wfrun/process/0.5" } ], "datePublished": "2026-06-10T10:25:49+00:00", @@ -48,8 +51,21 @@ }, { "@id": "https://esciencelab.org.uk/federated-learning-ro-crate-profile/federated-learning-profile.html", - "@type": "CreativeWork", - "name": "Federated Learning RO-Crate profile v0.1" + "@type": [ + "CreativeWork", + "Profile" + ], + "name": "Federated Learning RO-Crate profile", + "version": "0.1" + }, + { + "@id": "https://w3id.org/ro/wfrun/process/0.5", + "@type": [ + "CreativeWork", + "Profile" + ], + "name": "Process Run Crate", + "version": "0.5" }, { "@id": "https://spdx.org/licenses/MIT.html", @@ -65,24 +81,24 @@ "@id": "#flower", "@type": "SoftwareApplication", "name": "Flower", - "softwareVersion": "1.30.0", - "url": "https://flower.ai/" + "url": "https://flower.ai/", + "version": "1.30.0" }, { "@id": "#framework-torch", "@type": "SoftwareApplication", "name": "PyTorch", "softwareRequirements": "==2.8.0", - "softwareVersion": "2.8.0", - "url": "https://pytorch.org/" + "url": "https://pytorch.org/", + "version": "2.8.0" }, { "@id": "#framework-torchvision", "@type": "SoftwareApplication", "name": "TorchVision", "softwareRequirements": "==0.23.0", - "softwareVersion": "0.23.0", - "url": "https://pytorch.org/vision/" + "url": "https://pytorch.org/vision/", + "version": "0.23.0" }, { "@id": "#strategy-param-arrayrecord-key", @@ -162,7 +178,9 @@ } ], "description": "Federated aggregation strategy (flwr.serverapp.strategy.fedavg).", - "name": "FedAvg" + "name": "FedAvg", + "url": "https://flower.ai/", + "version": "1.30.0" }, { "@id": "final_model.pt", @@ -185,11 +203,13 @@ } ], "description": "Final global model produced by the federated learning run.", + "encodingFormat": "application/octet-stream", "name": "Final aggregated model" }, { "@id": "metrics_log.json", "@type": "File", + "contentSize": "1511", "description": "Per-round training and evaluation metrics, plus federation details (participant/supernode counts and configuration) for the whole run.", "encodingFormat": "application/json", "name": "Per-round metrics and federation log" @@ -271,7 +291,7 @@ "@id": "https://orcid.org/0009-0000-0000-0000" }, "description": "Run using strategy FedAvg (flwr.serverapp.strategy.fedavg), 3 rounds.", - "endTime": "2026-06-10T10:25:49.281661+00:00", + "endTime": "2026-06-10T10:25:49+00:00", "instrument": [ { "@id": "#flower" @@ -315,7 +335,7 @@ "@id": "metrics_log.json" } ], - "startTime": "2026-06-10T10:25:00.091740+00:00" + "startTime": "2026-06-10T10:25:00+00:00" } ] } \ No newline at end of file diff --git a/flwrcrate/crate_builder.py b/flwrcrate/crate_builder.py index 40168fa..7a3c3b7 100644 --- a/flwrcrate/crate_builder.py +++ b/flwrcrate/crate_builder.py @@ -23,6 +23,7 @@ "https://esciencelab.org.uk/federated-learning-ro-crate-profile/" "federated-learning-profile.html" ) +PROCESS_RUN_CRATE = "https://w3id.org/ro/wfrun/process/0.5" FLOWER_HOMEPAGE = "https://flower.ai/" SCHEMA = "http://schema.org/" @@ -62,13 +63,25 @@ def build_crate(captured: dict, crate_dir, metrics_log_path=None, model_path=Non "RO-Crate describing a federated learning run captured with flwrCrate." ) - # Conformance to the FL profile (RO-Crate spec conformance is set by ro-crate-py). + # Conformance to the profiles the crate follows. RO-Crate spec conformance + # is set on the metadata descriptor by ro-crate-py; profile conformance + # goes on the root data entity, and may list several profiles. profile = crate.add(ContextEntity(crate, FL_PROFILE, properties={ - "@type": "CreativeWork", - "name": "Federated Learning RO-Crate profile v0.1", + "@type": ["CreativeWork", "Profile"], + "name": "Federated Learning RO-Crate profile", + "version": "0.1", })) crate.root_dataset.append_to("conformsTo", profile) + # The FL profile extends Process Run Crate, so declare that too: it lets + # validators and generic provenance tools check the run structure. + prc = crate.add(ContextEntity(crate, PROCESS_RUN_CRATE, properties={ + "@type": ["CreativeWork", "Profile"], + "name": "Process Run Crate", + "version": "0.5", + })) + crate.root_dataset.append_to("conformsTo", prc) + # --- #5 license / author / agent scaffolding ------------------------------- if license: if str(license).startswith("http"): @@ -106,7 +119,7 @@ def build_crate(captured: dict, crate_dir, metrics_log_path=None, model_path=Non flwr_version = (captured.get("flower") or {}).get("version") flower_props = {"@type": "SoftwareApplication", "name": "Flower", "url": FLOWER_HOMEPAGE} if flwr_version: - flower_props["softwareVersion"] = flwr_version + flower_props["version"] = flwr_version flower = crate.add(ContextEntity(crate, "#flower", properties=flower_props)) instruments.append(flower) @@ -115,7 +128,7 @@ def build_crate(captured: dict, crate_dir, metrics_log_path=None, model_path=Non if fw.get("homepage"): props["url"] = fw["homepage"] if fw.get("installed_version"): - props["softwareVersion"] = fw["installed_version"] + props["version"] = fw["installed_version"] if fw.get("declared"): props["softwareRequirements"] = fw["declared"] # spec from pyproject.toml ent = crate.add(ContextEntity(crate, f"#framework-{_slug(fw['package'])}", properties=props)) @@ -128,7 +141,13 @@ def build_crate(captured: dict, crate_dir, metrics_log_path=None, model_path=Non "@type": "SoftwareApplication", "name": strat["class_name"], "description": f"Federated aggregation strategy ({strat.get('module')}).", + # The strategy implements Flower's strategy API and is run by Flower, + # so Flower is its reference url and version (RO-Crate requires both + # on a SoftwareApplication). + "url": FLOWER_HOMEPAGE, } + if flwr_version: + strat_props["version"] = flwr_version hp_refs = [] for k, v in (strat.get("attributes") or {}).items(): pid = f"#strategy-param-{_slug(k)}" @@ -145,15 +164,16 @@ def build_crate(captured: dict, crate_dir, metrics_log_path=None, model_path=Non results = [] model_entity = None if model_path and Path(model_path).exists(): - model_entity = crate.add_file(str(model_path), Path(model_path).name, properties={ + model_entity = crate.add_file(str(model_path), Path(model_path).name, record_size=True, properties={ "@type": "File", "name": "Final aggregated model", "description": "Final global model produced by the federated learning run.", + "encodingFormat": "application/octet-stream", }) results.append(model_entity) if metrics_log_path and Path(metrics_log_path).exists(): - log_entity = crate.add_file(str(metrics_log_path), Path(metrics_log_path).name, properties={ + log_entity = crate.add_file(str(metrics_log_path), Path(metrics_log_path).name, record_size=True, properties={ "@type": "File", "name": "Per-round metrics and federation log", "description": ( diff --git a/flwrcrate/framework.py b/flwrcrate/framework.py index 73b36c7..625ddb2 100644 --- a/flwrcrate/framework.py +++ b/flwrcrate/framework.py @@ -90,11 +90,13 @@ def detect_frameworks(pyproject_path: str = "pyproject.toml") -> list: if known: display, homepage = KNOWN_FRAMEWORKS[name] else: - display, homepage = name, None + # Fall back to the PyPI project page so the entity still has a + # resolvable url, which RO-Crate requires on a SoftwareApplication. + display, homepage = name, f"https://pypi.org/project/{name}/" logger.info( "Recording dependency %r as software used (not in the known-" "frameworks map; add it to KNOWN_FRAMEWORKS for a friendly " - "name + homepage).", name, + "name + curated homepage).", name, ) found.append({ "package": name, diff --git a/flwrcrate/tracker.py b/flwrcrate/tracker.py index e15114b..22fdf07 100755 --- a/flwrcrate/tracker.py +++ b/flwrcrate/tracker.py @@ -74,7 +74,7 @@ def __init__(self, context, strategy, output_dir=None, self._capture = { "app_name": app_name, - "run_timing": {"start_time": datetime.now(timezone.utc).isoformat(), "end_time": None}, + "run_timing": {"start_time": datetime.now(timezone.utc).isoformat(timespec="seconds"), "end_time": None}, "environment_config": run_config, "federation": self._federation, "flower": {"version": flwr_version}, @@ -171,7 +171,7 @@ def wrapped(server_round, arrays): if mr is not None: slot = self._per_round.setdefault(str(server_round), {}) slot["server_side_evaluate"] = metricrecord_to_dict(mr) - slot["captured_at"] = datetime.now(timezone.utc).isoformat() + slot["captured_at"] = datetime.now(timezone.utc).isoformat(timespec="seconds") self._save_metrics_log() return mr @@ -183,7 +183,7 @@ def record_result(self, result, model_path=None): if model_path is not None: self.model_path = Path(model_path) - self._capture["run_timing"]["end_time"] = datetime.now(timezone.utc).isoformat() + self._capture["run_timing"]["end_time"] = datetime.now(timezone.utc).isoformat(timespec="seconds") for attr, label in (("train_metrics_clientapp", "train_clientapp"), ("evaluate_metrics_clientapp", "evaluate_clientapp")): diff --git a/tests/test_crate_builder.py b/tests/test_crate_builder.py index 7d529b8..846fb50 100644 --- a/tests/test_crate_builder.py +++ b/tests/test_crate_builder.py @@ -85,8 +85,13 @@ def test_build_crate_core_entities(tmp_path): assert "additionalProperty" in g["#fl-strategy"] # #4 framework with declared + installed versions assert g["#framework-torch"]["softwareRequirements"] == "==2.8.0" - assert g["#framework-torch"]["softwareVersion"] == "2.8.0" - assert g["#flower"]["softwareVersion"] == "1.30.0" + assert g["#framework-torch"]["version"] == "2.8.0" + # #20 conformance: both profiles declared, each as a Profile entity + conforms = {r["@id"] for r in g["./"]["conformsTo"]} + assert "https://w3id.org/ro/wfrun/process/0.5" in conforms + assert any("federated-learning-profile" in i for i in conforms) + assert "Profile" in g["https://w3id.org/ro/wfrun/process/0.5"]["@type"] + assert g["#flower"]["version"] == "1.30.0" # #5 provenance assert g["./"]["license"][0]["@id"] == "https://spdx.org/licenses/MIT.html" assert g["./"]["author"][0]["@id"] == "https://orcid.org/0000-0000-0000-0001" diff --git a/tests/test_framework.py b/tests/test_framework.py index 6d44599..d395ac4 100644 --- a/tests/test_framework.py +++ b/tests/test_framework.py @@ -67,6 +67,9 @@ def test_detect_returns_declared_and_installed(app_pyproject): # pytest is installed in this env, so the installed-version lookup resolves assert by_pkg["pytest"]["installed_version"] is not None assert by_pkg["pytest"]["known_framework"] is False # not an ML framework + # unknown packages still get a resolvable url (RO-Crate requires one on + # SoftwareApplication); the PyPI project page stands in for a homepage + assert by_pkg["pytest"]["homepage"] == "https://pypi.org/project/pytest/" def test_detect_excludes_infrastructure(app_pyproject):