diff --git a/assets/schemas/cell-instance.schema.json b/assets/schemas/cell-instance.schema.json index 825a9cab..4d113978 100644 --- a/assets/schemas/cell-instance.schema.json +++ b/assets/schemas/cell-instance.schema.json @@ -256,6 +256,11 @@ "type": "string" } }, + "license": { + "type": "string", + "minLength": 1, + "description": "License under which this record and its data are released, as an SPDX license identifier (e.g. \"cc-by-4.0\") or a license URL. Stamped from the workspace default (ws.license) and emitted as dcterms:license in JSON-LD." + }, "funding": { "$ref": "#/$defs/Funding" }, diff --git a/assets/schemas/cell-spec.schema.json b/assets/schemas/cell-spec.schema.json index ecfd2279..dcb451e5 100644 --- a/assets/schemas/cell-spec.schema.json +++ b/assets/schemas/cell-spec.schema.json @@ -406,6 +406,11 @@ "type": "string", "description": "Usable extinguishing agent for the battery (EU Battery Regulation Annex VI Part A)." }, + "license": { + "type": "string", + "minLength": 1, + "description": "License under which this record and its data are released, as an SPDX license identifier (e.g. \"cc-by-4.0\") or a license URL. Stamped from the workspace default (ws.license) and emitted as dcterms:license in JSON-LD." + }, "funding": { "$ref": "#/$defs/Funding" }, diff --git a/assets/schemas/test-protocol.schema.json b/assets/schemas/test-protocol.schema.json index 43c8a587..291b1bc8 100644 --- a/assets/schemas/test-protocol.schema.json +++ b/assets/schemas/test-protocol.schema.json @@ -251,6 +251,11 @@ "type": "string" } }, + "license": { + "type": "string", + "minLength": 1, + "description": "License under which this record and its data are released, as an SPDX license identifier (e.g. \"cc-by-4.0\") or a license URL. Stamped from the workspace default (ws.license) and emitted as dcterms:license in JSON-LD." + }, "funding": { "$ref": "#/$defs/Funding" }, diff --git a/assets/schemas/test.schema.json b/assets/schemas/test.schema.json index 560c62f7..5baea5ac 100644 --- a/assets/schemas/test.schema.json +++ b/assets/schemas/test.schema.json @@ -283,6 +283,11 @@ "type": "string" } }, + "license": { + "type": "string", + "minLength": 1, + "description": "License under which this record and its data are released, as an SPDX license identifier (e.g. \"cc-by-4.0\") or a license URL. Stamped from the workspace default (ws.license) and emitted as dcterms:license in JSON-LD." + }, "funding": { "$ref": "#/$defs/Funding" }, diff --git a/src/battinfo/data/schemas/cell-instance.schema.json b/src/battinfo/data/schemas/cell-instance.schema.json index 825a9cab..4d113978 100644 --- a/src/battinfo/data/schemas/cell-instance.schema.json +++ b/src/battinfo/data/schemas/cell-instance.schema.json @@ -256,6 +256,11 @@ "type": "string" } }, + "license": { + "type": "string", + "minLength": 1, + "description": "License under which this record and its data are released, as an SPDX license identifier (e.g. \"cc-by-4.0\") or a license URL. Stamped from the workspace default (ws.license) and emitted as dcterms:license in JSON-LD." + }, "funding": { "$ref": "#/$defs/Funding" }, diff --git a/src/battinfo/data/schemas/cell-spec.schema.json b/src/battinfo/data/schemas/cell-spec.schema.json index ecfd2279..dcb451e5 100644 --- a/src/battinfo/data/schemas/cell-spec.schema.json +++ b/src/battinfo/data/schemas/cell-spec.schema.json @@ -406,6 +406,11 @@ "type": "string", "description": "Usable extinguishing agent for the battery (EU Battery Regulation Annex VI Part A)." }, + "license": { + "type": "string", + "minLength": 1, + "description": "License under which this record and its data are released, as an SPDX license identifier (e.g. \"cc-by-4.0\") or a license URL. Stamped from the workspace default (ws.license) and emitted as dcterms:license in JSON-LD." + }, "funding": { "$ref": "#/$defs/Funding" }, diff --git a/src/battinfo/data/schemas/test-protocol.schema.json b/src/battinfo/data/schemas/test-protocol.schema.json index 43c8a587..291b1bc8 100644 --- a/src/battinfo/data/schemas/test-protocol.schema.json +++ b/src/battinfo/data/schemas/test-protocol.schema.json @@ -251,6 +251,11 @@ "type": "string" } }, + "license": { + "type": "string", + "minLength": 1, + "description": "License under which this record and its data are released, as an SPDX license identifier (e.g. \"cc-by-4.0\") or a license URL. Stamped from the workspace default (ws.license) and emitted as dcterms:license in JSON-LD." + }, "funding": { "$ref": "#/$defs/Funding" }, diff --git a/src/battinfo/data/schemas/test.schema.json b/src/battinfo/data/schemas/test.schema.json index 560c62f7..5baea5ac 100644 --- a/src/battinfo/data/schemas/test.schema.json +++ b/src/battinfo/data/schemas/test.schema.json @@ -283,6 +283,11 @@ "type": "string" } }, + "license": { + "type": "string", + "minLength": 1, + "description": "License under which this record and its data are released, as an SPDX license identifier (e.g. \"cc-by-4.0\") or a license URL. Stamped from the workspace default (ws.license) and emitted as dcterms:license in JSON-LD." + }, "funding": { "$ref": "#/$defs/Funding" }, diff --git a/src/battinfo/jsonld.py b/src/battinfo/jsonld.py index 411af27f..35ec70db 100644 --- a/src/battinfo/jsonld.py +++ b/src/battinfo/jsonld.py @@ -606,6 +606,13 @@ def record_to_jsonld(record: dict, record_type: str, *, context: str = "url") -> people = contributor_to_jsonld(record.get("contributor")) if people is not None: node["schema:contributor"] = people + # Record-level license (FAIR R1.1) → dcterms:license, the same convention + # the dataset emitter uses. Cell specs/instances/tests carry it at the + # record top level (stamped from the workspace default); datasets carry it + # on the dataset body and emit it from dataset_to_jsonld, so this only + # reaches the non-dataset record kinds. + if record.get("license") and "dcterms:license" not in node: + node["dcterms:license"] = {"@id": record["license"]} if context == "url" and isinstance(node.get("@context"), dict): # Swap the inline records context for the hosted reference. Only the # records-context nodes (a dict @context) are affected; material/component diff --git a/src/battinfo/validate/publication.py b/src/battinfo/validate/publication.py index d9e0ab6b..b7951965 100644 --- a/src/battinfo/validate/publication.py +++ b/src/battinfo/validate/publication.py @@ -176,6 +176,33 @@ def _test_condition_issues(data: dict[str, Any], policy: ValidationPolicy) -> Va return ValidationReport(issues=tuple(issues), policy=policy) +def _license_issues(data: dict[str, Any], policy: ValidationPolicy) -> ValidationReport: + """Warn (never block) when the published catalog carries no license — a FAIR R1.1 + nudge to set one so every served record states its reuse terms. Mirrors the + contributor/attribution nudge in tone: a warning pointing at ``ws.license(...)``, + not a publish blocker. Only the top catalog node is checked; per-member licenses + are propagated from it by the builder.""" + issues: list[ValidationIssue] = [] + for path, node in _graph_nodes(data): + types = _type_values(node) + if "dcat:Catalog" not in types and "schema:DataCatalog" not in types: + continue + if node.get("schema:license") or node.get("dcterms:license"): + continue + _append_issue( + issues, + code="publication.license_missing", + severity="warning", # explicit: a nudge, not a publish blocker + path=path, + message=( + "No license on the published record; consumers cannot tell how they " + "may reuse the data (FAIR R1.1). Set a default with " + 'ws.license("cc-by-4.0") before publishing.' + ), + ) + return ValidationReport(issues=tuple(issues), policy=policy) + + def _is_absolute_uri(value: Any) -> bool: if not isinstance(value, str) or not value: return False @@ -379,6 +406,7 @@ def validate_publication_report( _shape_issues(data, resolved_policy), _shacl_publication_report(data, resolved_policy), _test_condition_issues(data, resolved_policy), + _license_issues(data, resolved_policy), policy=resolved_policy, ) diff --git a/src/battinfo/ws.py b/src/battinfo/ws.py index 327e43f5..b2542751 100644 --- a/src/battinfo/ws.py +++ b/src/battinfo/ws.py @@ -621,6 +621,39 @@ def _normalize_orcid(value: str) -> str: return token +# A pragmatic set of the license identifiers Zenodo accepts most often (open +# data + common software licenses), lower-cased for comparison. This is a +# recognition list, not a hard allowlist: an unknown identifier is kept verbatim +# with a warning (see _normalize_license) so a valid-but-unlisted SPDX id is +# never rejected. +_KNOWN_LICENSES: frozenset[str] = frozenset({ + "cc-by-4.0", "cc-by-sa-4.0", "cc-by-nc-4.0", "cc-by-nc-sa-4.0", + "cc-by-nd-4.0", "cc-by-nc-nd-4.0", "cc-by-3.0", "cc-by-sa-3.0", + "cc0-1.0", "cc-pddc", "pddl-1.0", "odbl-1.0", "odc-by-1.0", + "mit", "apache-2.0", "bsd-2-clause", "bsd-3-clause", + "gpl-2.0", "gpl-2.0-only", "gpl-3.0", "gpl-3.0-only", "gpl-3.0-or-later", + "lgpl-2.1", "lgpl-3.0", "agpl-3.0", "mpl-2.0", +}) + + +def _normalize_license(value: str) -> tuple[str, bool]: + """Return ``(license, known)`` for a user-supplied license string. + + A recognised identifier (case-insensitively in :data:`_KNOWN_LICENSES`) is + normalised to its canonical lower-cased SPDX-style form; anything else — + including a license URL — is kept verbatim so a valid-but-unlisted license is + never rejected. ``known`` is False for the kept-verbatim case so the caller + can warn. + """ + token = str(value).strip() + if not token: + raise ValueError("license must be a non-empty string, e.g. ws.license('cc-by-4.0').") + lowered = token.lower() + if lowered in _KNOWN_LICENSES: + return lowered, True + return token, False + + @dataclass class ContributorRef: """The person contributing records from this workspace, identified by ORCID. @@ -1147,6 +1180,10 @@ def __init__( self._session_credentials: dict[str, str] = {} # name -> Cell, keyed by short_id for test matching self._cells_by_short_id: dict[str, Any] = {} + # True once cell instances saved in a previous session have been lazily + # loaded from .battinfo/records/cell-instance/ into the index above, so a + # fresh process can still resolve a cell by serial (day-2 rehydration). + self._cells_rehydrated = False # channel reference -> (equipment_id, channel_id) for test attachment. # Keys: the channel IRI, the channel label, and "unit/CHn" for the # unit's name and serial number. Populated by ws.add("equipment", ...) @@ -1167,6 +1204,11 @@ def __init__( # .battinfo/workspace.json on first access via _get_contributors(). self._contributors: builtins.list[ContributorRef] = [] self._contributors_loaded = False + # workspace-level default license (SPDX id or URL), stamped onto every + # record at save; lazily loaded from .battinfo/workspace.json via + # _get_license(). + self._license: str | None = None + self._license_loaded = False def _make_engine(self) -> "Workspace": """Construct the record engine, choosing the on-disk workspace layout. @@ -1560,15 +1602,20 @@ def _record_stamp(self) -> tuple[Callable[[dict], None] | None, dict[str, int]]: The funding block is set when absent; each contributor (a ``Person`` carrying the ORCID in ``same_as``) is appended if its ORCID is not already - listed. Both are idempotent and back-fill records authored before the - project/contributor was set. Returns ``(None, counters)`` when neither a - project nor a contributor is set. ``counters`` is updated as the callback - runs so :meth:`save` can report how many records the funding block reached. + listed; the workspace default license is set to reflect the current value. + All are idempotent and back-fill records authored before the + project/contributor/license was set — a re-save whose stamped result is + byte-identical is reported ``unchanged``, while changing the workspace + license re-stamps the new value (reported ``updated``). Returns + ``(None, counters)`` when none of project/contributor/license is set. + ``counters`` is updated as the callback runs so :meth:`save` can report + how many records each stamp reached. """ block = self._funding_block() people = [(r.orcid_url, r.person_block()) for r in self._get_contributors()] - counters = {"funding": 0, "contributor": 0} - if block is None and not people: + lic = self._get_license() + counters = {"funding": 0, "contributor": 0, "license": 0} + if block is None and not people and not lic: return None, counters def stamp(doc: dict) -> None: @@ -1590,6 +1637,20 @@ def stamp(doc: dict) -> None: if added: doc["contributor"] = contributors counters["contributor"] += 1 + if lic: + # Datasets carry the license on the dataset body (the existing + # slot the emitter reads and where an explicit ws.add(license=) + # lands); other record types carry it at the top level. An + # explicit per-record dataset license is left untouched — the + # workspace default only back-fills records that have none. + ds_body = doc.get("dataset") + if isinstance(ds_body, dict): + if not ds_body.get("license"): + ds_body["license"] = lic + counters["license"] += 1 + elif doc.get("license") != lic: + doc["license"] = lic + counters["license"] += 1 return stamp, counters @@ -1720,6 +1781,85 @@ def _print_contributors(self, refs: builtins.list["ContributorRef"]) -> None: print(f" {r.summary()} affiliation: {r.affiliation}") print(" Saved to .battinfo/workspace.json; stamped onto records on ws.save().") + def license( + self, + license: str | None = None, + *, + clear: bool = False, + ) -> str | None: + """Set (or show) the default license stamped onto every record you save. + + Mirrors :meth:`contributor` and :meth:`project`: the value is persisted in + ``.battinfo/workspace.json`` and stamped onto each record at ``save()``, so + the license travels with the record itself (FAIR R1.1) rather than existing + only on the Zenodo deposit. Pass an SPDX license identifier (e.g. + ``"cc-by-4.0"``) or a license URL; a recognised identifier is normalised to + canonical lower-case, anything else is kept verbatim with a warning. + + Precedence when a record is published: an explicit ``ws.zenodo(license=...)`` + wins over an explicit ``ws.add(..., license=...)``, which wins over this + workspace default. + + Returns the current default (``None`` when unset or after ``clear=True``). + + Examples + -------- + :: + + ws.license("cc-by-4.0") # set the workspace default + ws.license() # show the current default + ws.license(clear=True) # remove the default + """ + if clear: + self._set_license(None) + print("Workspace license cleared.") + return None + + # Getter: no license supplied. + if license is None: + current = self._get_license() + if current is None: + print('No license set. Set a default license for every record, e.g.:\n' + ' ws.license("cc-by-4.0")') + return None + print(f"Workspace license: {current}") + print(" Saved to .battinfo/workspace.json; stamped onto records on ws.save().") + return current + + # Setter. + normalized, known = _normalize_license(license) + if not known: + import warnings # noqa: PLC0415 + msg = ( + f"license {normalized!r} is not a recognised SPDX identifier; keeping it " + "verbatim. If this is a typo, use a known id such as 'cc-by-4.0'." + ) + warnings.warn(msg, stacklevel=2) + print(f" WARNING: {msg}") + self._set_license(normalized) + print(f"Workspace license: {normalized}") + print(" Saved to .battinfo/workspace.json; stamped onto records on ws.save().") + return normalized + + def _get_license(self) -> str | None: + """Current workspace default license, loaded from disk on first access.""" + if not self._license_loaded: + raw = self._load_workspace_state().get("license") + self._license = raw if isinstance(raw, str) and raw.strip() else None + self._license_loaded = True + return self._license + + def _set_license(self, value: str | None) -> None: + state = self._load_workspace_state() + if not value: + state.pop("license", None) + else: + state.setdefault("schema_version", "0.1.0") + state["license"] = value + self._save_workspace_state(state) + self._license = value or None + self._license_loaded = True + def convert(self, pattern: str | None = None, fmt: str = "csv") -> builtins.list[Path]: """Convert raw cycler files to BDF (Battery Data Format). @@ -2696,11 +2836,27 @@ def reload_cells(self) -> int: ws.save() ws.submit() """ + ci_dir = self._ws.source_root / "cell-instance" + if not ci_dir.exists(): + print(" No saved cell instances found.") + return 0 + count = self._index_saved_cells() + print(f" Loaded {count} cell instance(s) into matching index.") + return count + + def _index_saved_cells(self) -> int: + """Load cell instances saved on disk into the serial → cell index (quiet). + + Shared core of :meth:`reload_cells` and :meth:`_rehydrate_cell_index`; + scans ``.battinfo/records/cell-instance/`` and returns the count loaded. + Marks the index as rehydrated so :meth:`_rehydrate_cell_index` becomes a + no-op afterwards. + """ from battinfo.bundle import Cell + self._cells_rehydrated = True ci_dir = self._ws.source_root / "cell-instance" if not ci_dir.exists(): - print(" No saved cell instances found.") return 0 count = 0 @@ -2728,9 +2884,21 @@ def reload_cells(self) -> int: except Exception as exc: print(f" WARNING: could not load {src.name} -- {exc}") - print(f" Loaded {count} cell instance(s) into matching index.") return count + def _rehydrate_cell_index(self) -> None: + """Lazily populate the serial → cell index from records saved on disk. + + Cells authored in a previous session live under + ``.battinfo/records/cell-instance/`` but are absent from this process's + in-memory index, so ``ws.add("test", cell="S1")`` in a fresh session + would otherwise fail to resolve them. Runs the disk scan at most once per + workspace instance; a no-op when nothing is saved. + """ + if self._cells_rehydrated: + return + self._index_saved_cells() + def _registry_resources(self, resource_type: str, q: str | None = None) -> builtins.list[dict]: """GET /resources?resource_type=... [&q=...] — raw summaries, [] if unreachable.""" import urllib.parse @@ -4045,7 +4213,7 @@ def zenodo( description: str | None = None, creators: builtins.list[dict] | None = None, contributors: builtins.list[dict] | None = None, - license: str = "cc-by-4.0", + license: str | None = None, keywords: builtins.list[str] | None = None, ) -> "ZenodoResult": """Deposit workspace records and data files to Zenodo. @@ -4095,12 +4263,20 @@ def zenodo( result = ws.zenodo(record_id=result.record_id, publish=True) ws.submit(doi=result.doi) + + License precedence: an explicit ``license=`` here is authoritative for the + deposit; otherwise the workspace default (``ws.license(...)``) is used; if + neither is set, ``cc-by-4.0`` is applied (Zenodo requires a license). """ import shutil import tempfile from battinfo.zenodo import ZenodoClient + # License precedence: explicit arg > workspace default > cc-by-4.0. + if license is None: + license = self._get_license() or "cc-by-4.0" + # ── Resolve token ────────────────────────────────────────────────────── tok = token or os.environ.get( "ZENODO_SANDBOX_TOKEN" if sandbox else "ZENODO_API_TOKEN" @@ -4415,6 +4591,12 @@ def preview_jsonld( if not records_dir.exists(): raise RuntimeError("No records found — run ws.save() first.") + # Fall back to the workspace default license (ws.license) when none is + # passed, so the preview reflects what publishing would embed. Left empty + # when neither is set, which the gold-standard panel then nudges on. + if not license: + license = self._get_license() or "" + # Collect data filenames using same logic as the real upload (dry run) tmpdir = Path(tempfile.mkdtemp(prefix="battinfo-preview-")) try: @@ -6761,6 +6943,10 @@ def _add_tests( string (``"conformant"`` / ``"non-conformant"``; ``"unknown"`` = not assessed) or a dict ``{"status": ..., "note": ..., "deviations": [...]}``. Pass the spec via ``spec=`` so the conformance binds to it. + + ``license`` sets the license on the dataset(s) produced here, overriding the + workspace default (``ws.license``) for these records. Precedence at publish: + an explicit ``ws.zenodo(license=...)`` > this ``license=`` > workspace default. """ # Resolve the test type (type=, legacy kind=, or from a linked spec). test_type = type or kind @@ -6930,10 +7116,13 @@ def _as_data_paths(self, data: Any) -> builtins.list[Path]: return out def _resolve_cell(self, ref: Any) -> Any: - """Resolve a cell reference: this session, then local index, then the registry. + """Resolve a cell reference: this session, then records saved on disk, then the registry. Accepts a ``Cell``, a search result, a serial / short ID, or an IRI. - An instance found only in the registry is referenced (not re-published). + Serials authored in a previous session are rehydrated from + ``.battinfo/records/cell-instance/`` (so resolution works in a fresh + process). An instance found only in the registry is referenced (not + re-published). """ from battinfo.bundle import Cell # noqa: PLC0415 if isinstance(ref, Cell): @@ -6944,17 +7133,31 @@ def _resolve_cell(self, ref: Any) -> Any: s = ref.strip() if s.startswith("http"): # an IRI return self._reference_cell({"id": s, "type": "cell"}) - hit = self._cells_by_short_id.get(s) sid = _short_id(s) - if hit is None and sid: - hit = self._cells_by_short_id.get(sid) + + def _lookup() -> Any: + hit = self._cells_by_short_id.get(s) + if hit is None and sid: + hit = self._cells_by_short_id.get(sid) + return hit + + hit = _lookup() + if hit is None: + # Day-2: this fresh process has an empty index; load cells saved + # to disk in a previous session before falling back to the registry. + self._rehydrate_cell_index() + hit = _lookup() if hit is not None: return hit found = self._query_registry_cells(serials=[s]) if found: return self._reference_cell({**found[0], "type": "cell"}) + searched = "this session, the records saved under {root}".format( + root=self._ws.source_root + ) + searched += ", or the registry" if self._registry_url else " (registry not configured)" raise ValueError( - f"Could not resolve cell {ref!r} in this session, locally, or the registry. " + f"Could not resolve cell {ref!r} in {searched}. " "Create it with ws.add('cell', ...), or check the serial." ) raise TypeError("cell= must be a serial, IRI, Cell, or search result.") diff --git a/tests/test_pkg_silent_loss.py b/tests/test_pkg_silent_loss.py index 2fd179e9..092101ad 100644 --- a/tests/test_pkg_silent_loss.py +++ b/tests/test_pkg_silent_loss.py @@ -445,6 +445,42 @@ def test_adding_second_contributor_reports_updated(tmp_path: Path, capsys) -> No assert "[unchanged]" not in out +def test_identical_resave_with_license_reports_unchanged(tmp_path: Path, capsys) -> None: + # The workspace-default license is stamped through the same pre-compare path as + # funding/contributor (PR #324), so a byte-identical re-save with the license + # already set must be a true no-op: [unchanged], identical bytes, no rewrite. + ws = _new_ws(tmp_path) + ws.license("cc-by-4.0") + ws.save() + files = _record_files(ws) + assert files + before = {p: (p.read_bytes(), p.stat().st_mtime_ns) for p in files} + + capsys.readouterr() + ws.save() + out = capsys.readouterr().out + assert "[unchanged]" in out + assert "[updated]" not in out + + after = {p: (p.read_bytes(), p.stat().st_mtime_ns) for p in files} + assert after == before, "an unchanged re-save must not rewrite record files" + + +def test_changing_workspace_license_reports_updated(tmp_path: Path, capsys) -> None: + # Changing the workspace default license between saves re-stamps every record, + # so the re-save must report [updated]. + ws = _new_ws(tmp_path) + ws.license("cc-by-4.0") + ws.save() + + ws.license("mit") + capsys.readouterr() + ws.save() + out = capsys.readouterr().out + assert "[updated]" in out + assert "[unchanged]" not in out + + # ── Item 8: error-text fixes (phantom command + key-request URL) ──────────────── def test_save_gate_message_uses_real_command() -> None: diff --git a/tests/test_ws_license.py b/tests/test_ws_license.py new file mode 100644 index 00000000..96eadd16 --- /dev/null +++ b/tests/test_ws_license.py @@ -0,0 +1,240 @@ +"""Record-level license: workspace default, precedence, emission, and nudge. + +Blind-review finding (Prompt C, finding 2): served/local records carried no +license (FAIR R1.1 failed at the identifier); license lived only on the Zenodo +leg. ``ws.license(...)`` sets a workspace default persisted in workspace.json and +stamped onto every record at save, mirroring ``ws.contributor``. +""" + +from __future__ import annotations + +import json +import warnings +from pathlib import Path + +import pytest + +from battinfo.jsonld import record_to_jsonld +from battinfo.ws import AuthoringWorkspace + +_SPEC_JSON = ( + '{"manufacturer":"Duracell","model":"MN2400","format":"cylindrical",' + '"chemistry":"Zn-MnO2","size_code":"R03","iec_code":"LR03",' + '"properties":{"nominal_voltage":{"value":1.5,"unit":"V"}}}' +) + + +def _ws_with_cell(tmp_path: Path) -> AuthoringWorkspace: + (tmp_path / "d.cell-spec.json").write_text(_SPEC_JSON, encoding="utf-8") + (tmp_path / "f.csv").write_text("unix_time_second,voltage\n0,1\n", encoding="utf-8") + ws = AuthoringWorkspace(root=tmp_path, registry_url=None) + spec = ws.load(tmp_path / "d.cell-spec.json") + ws.add("cell", spec=spec, serial_numbers=["SN-1"]) + return ws + + +def _read_one(ws: AuthoringWorkspace, subdir: str) -> dict: + return json.loads(next((ws._ws.source_root / subdir).glob("*.json")).read_text(encoding="utf-8")) + + +# ── API: setter / getter / persistence / validation ──────────────────────────── + +def test_license_setter_persists_and_getter_returns(tmp_path: Path) -> None: + ws = AuthoringWorkspace(root=tmp_path, registry_url=None) + assert ws.license() is None # unset getter + assert ws.license("cc-by-4.0") == "cc-by-4.0" # setter returns normalized + assert ws.license() == "cc-by-4.0" # getter reflects it + state = json.loads((tmp_path / ".battinfo" / "workspace.json").read_text(encoding="utf-8")) + assert state["license"] == "cc-by-4.0" + # A fresh instance loads it from disk. + assert AuthoringWorkspace(root=tmp_path, registry_url=None).license() == "cc-by-4.0" + + +def test_license_known_id_is_case_normalized(tmp_path: Path) -> None: + ws = AuthoringWorkspace(root=tmp_path, registry_url=None) + assert ws.license("CC-BY-4.0") == "cc-by-4.0" + + +def test_license_unknown_id_kept_verbatim_with_warning(tmp_path: Path) -> None: + ws = AuthoringWorkspace(root=tmp_path, registry_url=None) + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + result = ws.license("My-Custom-License-1.2") + assert result == "My-Custom-License-1.2" # verbatim, not rejected + assert any("not a recognised SPDX" in str(w.message) for w in caught) + + +def test_license_url_kept_verbatim(tmp_path: Path) -> None: + ws = AuthoringWorkspace(root=tmp_path, registry_url=None) + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + assert ws.license("https://creativecommons.org/licenses/by/4.0/") == ( + "https://creativecommons.org/licenses/by/4.0/" + ) + + +def test_license_clear(tmp_path: Path) -> None: + ws = AuthoringWorkspace(root=tmp_path, registry_url=None) + ws.license("cc-by-4.0") + assert ws.license(clear=True) is None + assert ws.license() is None + state = json.loads((tmp_path / ".battinfo" / "workspace.json").read_text(encoding="utf-8")) + assert "license" not in state + + +# ── Stamping: license lands in every saved record ────────────────────────────── + +def test_license_stamped_on_every_saved_record(tmp_path: Path) -> None: + ws = _ws_with_cell(tmp_path) + ws.add("test", type="capacity_check", cell="SN-1", data="f.csv") + ws.license("cc-by-4.0") + ws.save() + + # Non-dataset records carry it at the top level. + for sub in ("cell-spec", "cell-instance", "test"): + assert _read_one(ws, sub)["license"] == "cc-by-4.0", sub + # Datasets carry it on the dataset body (the existing slot the emitter reads). + assert _read_one(ws, "dataset")["dataset"]["license"] == "cc-by-4.0" + + +def test_license_stamped_into_record_to_jsonld(tmp_path: Path) -> None: + ws = _ws_with_cell(tmp_path) + ws.add("test", type="capacity_check", cell="SN-1", data="f.csv") + ws.license("cc-by-4.0") + ws.save() + + for sub, rt in (("cell-spec", "cell-spec"), ("cell-instance", "cell-instance"), + ("test", "test"), ("dataset", "dataset")): + ld = record_to_jsonld(_read_one(ws, sub), rt) + assert ld.get("dcterms:license") == {"@id": "cc-by-4.0"}, sub + + +def test_no_license_means_no_stamp(tmp_path: Path) -> None: + # Without ws.license, saved records must not sprout a license key. + ws = _ws_with_cell(tmp_path) + ws.save() + assert "license" not in _read_one(ws, "cell-instance") + + +# ── Precedence: explicit ws.add(license=) > workspace default ─────────────────── + +def test_explicit_add_license_overrides_workspace_default(tmp_path: Path) -> None: + ws = _ws_with_cell(tmp_path) + ws.license("cc-by-4.0") # workspace default + ws.add("test", type="capacity_check", cell="SN-1", data="f.csv", license="mit") + ws.save() + # The dataset keeps the explicit per-record license; the workspace default + # only back-fills records that have none. + assert _read_one(ws, "dataset")["dataset"]["license"] == "mit" + # A record with no explicit license still gets the workspace default. + assert _read_one(ws, "cell-instance")["license"] == "cc-by-4.0" + + +# ── Emission: preview bundle carries the license; ws.zenodo default resolution ── + +def test_preview_bundle_carries_workspace_license(tmp_path: Path) -> None: + ws = _ws_with_cell(tmp_path) + ws.add("test", type="capacity_check", cell="SN-1", data="f.csv") + ws.license("cc-by-4.0") + ws.save() + out = ws.preview_jsonld(output=tmp_path / "preview.jsonld") + graph = json.loads(Path(out).read_text(encoding="utf-8"))["@graph"] + catalog = next(n for n in graph if "dcat:Catalog" in (n.get("@type") or [])) + assert catalog.get("dcterms:license") or catalog.get("schema:license") + + +class _StubZenodoClient: + """Offline stand-in: returns a minimal deposit so the flow reaches the + JSON-LD builder without touching the network.""" + + def __init__(self, *a, **k) -> None: # noqa: ANN002, ANN003 + pass + + def create_empty_deposit(self) -> dict: + return {"id": 1, "record_id": 1, "metadata": {"prereserve_doi": {"doi": "10.5281/zenodo.1"}}} + + +def test_zenodo_license_defaults_to_workspace_license(monkeypatch, tmp_path: Path) -> None: + # ws.zenodo(license=None) must resolve to the workspace default, not the + # hard-coded cc-by-4.0, when a default is set. + captured: dict = {} + + def _fake_build(self, **kwargs): # noqa: ANN001, ANN003 + captured["license"] = kwargs.get("license") + raise RuntimeError("stop after license resolution") + + ws = _ws_with_cell(tmp_path) + ws.add("test", type="capacity_check", cell="SN-1", data="f.csv") + ws.license("mit") + ws.save() + monkeypatch.setattr("battinfo.zenodo.ZenodoClient", _StubZenodoClient) + monkeypatch.setattr(AuthoringWorkspace, "_build_zenodo_jsonld", _fake_build) + monkeypatch.setenv("ZENODO_API_TOKEN", "x") + # Stop the flow right after license resolution — we only assert the value + # wired downstream, not a real deposit. + with pytest.raises(RuntimeError, match="stop after license resolution"): + ws.zenodo() + assert captured.get("license") == "mit" + + +def test_zenodo_explicit_license_overrides_workspace_default(monkeypatch, tmp_path: Path) -> None: + captured: dict = {} + + def _fake_build(self, **kwargs): # noqa: ANN001, ANN003 + captured["license"] = kwargs.get("license") + raise RuntimeError("stop") + + ws = _ws_with_cell(tmp_path) + ws.add("test", type="capacity_check", cell="SN-1", data="f.csv") + ws.license("mit") + ws.save() + monkeypatch.setattr("battinfo.zenodo.ZenodoClient", _StubZenodoClient) + monkeypatch.setattr(AuthoringWorkspace, "_build_zenodo_jsonld", _fake_build) + monkeypatch.setenv("ZENODO_API_TOKEN", "x") + with pytest.raises(RuntimeError): + ws.zenodo(license="cc-by-4.0") + assert captured.get("license") == "cc-by-4.0" + + +# ── Gold-standard panel: a licenseless publication warns (not errors) ─────────── + +def test_missing_license_produces_a_warning_nudge(tmp_path: Path) -> None: + from battinfo.validate import validate_publication_report + + ws = _ws_with_cell(tmp_path) + ws.add("test", type="capacity_check", cell="SN-1", data="f.csv") + ws.save() + # No license anywhere -> the assembled publication catalog has none. + jsonld = ws._build_zenodo_jsonld( + zenodo_record_id=0, + prereserved_doi="10.5281/zenodo.RECORD_ID", + record_url="https://zenodo.org/records/RECORD_ID", + data_filenames=[], + title="t", + description="d", + license="", + ) + report = validate_publication_report(jsonld, policy="publisher") + lic = [i for i in report.issues if i.code == "publication.license_missing"] + assert lic and all(i.severity == "warning" for i in lic) + assert "ws.license" in lic[0].message + + +def test_present_license_produces_no_nudge(tmp_path: Path) -> None: + from battinfo.validate import validate_publication_report + + ws = _ws_with_cell(tmp_path) + ws.add("test", type="capacity_check", cell="SN-1", data="f.csv") + ws.license("cc-by-4.0") + ws.save() + jsonld = ws._build_zenodo_jsonld( + zenodo_record_id=0, + prereserved_doi="10.5281/zenodo.RECORD_ID", + record_url="https://zenodo.org/records/RECORD_ID", + data_filenames=[], + title="t", + description="d", + license="cc-by-4.0", + ) + report = validate_publication_report(jsonld, policy="publisher") + assert not [i for i in report.issues if i.code == "publication.license_missing"] diff --git a/tests/test_ws_rehydration.py b/tests/test_ws_rehydration.py new file mode 100644 index 00000000..01e05814 --- /dev/null +++ b/tests/test_ws_rehydration.py @@ -0,0 +1,118 @@ +"""Day-2 rehydration: a fresh process must resolve records saved earlier. + +Blind-review finding (Prompt C, finding 1): reopening a workspace in a NEW +Python process and calling ``ws.add("test", cell="S1")`` failed with +``Could not resolve cell 'S1' ...`` even though S1's record sat in +``.battinfo/records/cell-instance/``. The serial->record index was +session-memory only and the resolver's "locally" branch never scanned the saved +records. These tests reproduce the day-2 case with a genuinely cold subprocess. +""" + +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + +from battinfo import workspace +from battinfo.ws import AuthoringWorkspace + +_SPEC_JSON = ( + '{"manufacturer":"Duracell","model":"MN2400","format":"cylindrical",' + '"chemistry":"Zn-MnO2","size_code":"R03","iec_code":"LR03",' + '"properties":{"nominal_voltage":{"value":1.5,"unit":"V"}}}' +) + + +def _session_a(root: Path) -> None: + """Author + save a cell with serial S1 in this process, then let it end.""" + (root / "d.cell-spec.json").write_text(_SPEC_JSON, encoding="utf-8") + (root / "x.csv").write_text("unix_time_second,voltage\n0,1\n", encoding="utf-8") + ws = workspace(root=root, registry_url=None) + spec = ws.load(root / "d.cell-spec.json") + ws.add("cell", spec=spec, serial_numbers=["S1"]) + ws.save() + + +def _run_cold(root: Path, body: str) -> subprocess.CompletedProcess[str]: + """Run *body* in a fresh interpreter with the workspace at *root* (cold state). + + *body* must be column-0 statements (no leading indentation).""" + code = ( + "from pathlib import Path\n" + "from battinfo import workspace\n" + f"root = Path(r{str(root)!r})\n" + "ws = workspace(root=root, registry_url=None)\n" + + body + "\n" + ) + return subprocess.run( + [sys.executable, "-c", code], capture_output=True, text=True, cwd=str(root) + ) + + +def test_cell_serial_resolves_in_a_fresh_process(tmp_path: Path) -> None: + _session_a(tmp_path) + # Session B: a brand-new interpreter, nothing in memory from session A. + proc = _run_cold( + tmp_path, + 'ws.add("test", type="capacity_check", cell="S1", data="x.csv")\n' + 'print("RESOLVED")', + ) + assert proc.returncode == 0, proc.stderr + assert "RESOLVED" in proc.stdout + assert "Could not resolve cell" not in proc.stderr + + +def test_cell_serial_resolves_with_fresh_workspace_instance(tmp_path: Path) -> None: + # A second AuthoringWorkspace over the same directory has its own empty + # index, which is exactly the day-2 condition; rehydration must still work. + _session_a(tmp_path) + ws_b = AuthoringWorkspace(root=tmp_path, registry_url=None) + cell = ws_b._resolve_cell("S1") + assert getattr(cell, "serial_number", None) == "S1" + + +def test_error_text_is_truthful_when_serial_absent(tmp_path: Path) -> None: + # No record for BOGUS exists on disk; the error must name what was actually + # searched (the saved-records directory), not a false "locally". + _session_a(tmp_path) + ws_b = AuthoringWorkspace(root=tmp_path, registry_url=None) + try: + ws_b._resolve_cell("BOGUS") + except ValueError as exc: + msg = str(exc) + else: # pragma: no cover - the resolver must raise + raise AssertionError("expected ValueError for an unknown serial") + assert "records saved under" in msg + assert str(ws_b._ws.source_root) in msg + # registry_url=None, so the message must not claim the registry was searched. + assert "registry not configured" in msg + + +def test_protocol_reference_resolves_in_a_fresh_process(tmp_path: Path) -> None: + # The protocol path already scans disk (_resolve_test_protocol_ref), but pin it + # so it never regresses to the session-only defect the cell path had. + _session_a(tmp_path) + # Session A': author a protocol via a saved test so a test-protocol record lands. + ws_a = AuthoringWorkspace(root=tmp_path, registry_url=None) + from battinfo import TestSpec + + proto = TestSpec(name="500-cycle CCCV", kind="cycling", cycles=500) + ws_a.add("test", cell="S1", data="x.csv", spec=proto) + ws_a.save() + proto_iri = next( + (ws_a._ws.source_root / "test-protocol").glob("*.json") + ) + import json + + proto_id = json.loads(proto_iri.read_text(encoding="utf-8"))["test_spec"]["id"] + + # Session B: fresh process references the saved protocol by IRI; the resolver + # must find its name/kind on disk, not treat it as an unknown external ref. + proc = _run_cold( + tmp_path, + f'ts = ws.add("test", cell="S1", data="x.csv", spec={proto_id!r})\n' + 'print("PROTOCOL_NAME=" + (ts[0].protocol_name or ""))', + ) + assert proc.returncode == 0, proc.stderr + assert "PROTOCOL_NAME=500-cycle CCCV" in proc.stdout diff --git a/web/lib/schemas.generated.ts b/web/lib/schemas.generated.ts index 6979ae0d..246a62a4 100644 --- a/web/lib/schemas.generated.ts +++ b/web/lib/schemas.generated.ts @@ -1024,6 +1024,11 @@ export const schemaFiles: { path: string; schema: Record }[] = "type": "string" } }, + "license": { + "type": "string", + "minLength": 1, + "description": "License under which this record and its data are released, as an SPDX license identifier (e.g. \"cc-by-4.0\") or a license URL. Stamped from the workspace default (ws.license) and emitted as dcterms:license in JSON-LD." + }, "funding": { "$ref": "#/$defs/Funding" }, @@ -1621,6 +1626,11 @@ export const schemaFiles: { path: string; schema: Record }[] = "type": "string", "description": "Usable extinguishing agent for the battery (EU Battery Regulation Annex VI Part A)." }, + "license": { + "type": "string", + "minLength": 1, + "description": "License under which this record and its data are released, as an SPDX license identifier (e.g. \"cc-by-4.0\") or a license URL. Stamped from the workspace default (ws.license) and emitted as dcterms:license in JSON-LD." + }, "funding": { "$ref": "#/$defs/Funding" }, @@ -7514,6 +7524,11 @@ export const schemaFiles: { path: string; schema: Record }[] = "type": "string" } }, + "license": { + "type": "string", + "minLength": 1, + "description": "License under which this record and its data are released, as an SPDX license identifier (e.g. \"cc-by-4.0\") or a license URL. Stamped from the workspace default (ws.license) and emitted as dcterms:license in JSON-LD." + }, "funding": { "$ref": "#/$defs/Funding" }, @@ -8150,6 +8165,11 @@ export const schemaFiles: { path: string; schema: Record }[] = "type": "string" } }, + "license": { + "type": "string", + "minLength": 1, + "description": "License under which this record and its data are released, as an SPDX license identifier (e.g. \"cc-by-4.0\") or a license URL. Stamped from the workspace default (ws.license) and emitted as dcterms:license in JSON-LD." + }, "funding": { "$ref": "#/$defs/Funding" },