From 7c1bb7bb0e5b31e403a93010c7ba5b0e765d1577 Mon Sep 17 00:00:00 2001 From: Graham Kimbell <88666181+g-kimbell@users.noreply.github.com> Date: Wed, 21 Oct 2026 14:31:56 +0200 Subject: [PATCH 01/15] Bump minimum battinfoconverter --- pyproject.toml | 2 +- uv.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index e382a9d..5c667fb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,7 @@ readme = "README.md" requires-python = ">=3.10" dependencies = [ "batterydf>=0.1.0,<0.2.0", - "battinfoconverter-backend>=3.3.1", + "battinfoconverter-backend>=3.4.0<4.0.0", "fasteis==0.1.0", "matplotlib>=3.10.6", "pandas>=2.3.2", diff --git a/uv.lock b/uv.lock index 6d72bd6..e9e80d3 100644 --- a/uv.lock +++ b/uv.lock @@ -81,16 +81,16 @@ wheels = [ [[package]] name = "battinfoconverter-backend" -version = "3.3.1" +version = "3.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "openpyxl" }, { name = "pandas" }, { name = "simplejson" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ba/cb/fd73f52e4b84d7e2ba448f485e9a51e80606f985bca6d86437132daa3874/battinfoconverter_backend-3.3.1.tar.gz", hash = "sha256:ce2ebfd4752a712874f4b423d6ab7f6f78b0a1a8fa8e16bbf461d7a1ebda7724", size = 128462, upload-time = "2026-09-18T16:15:20.836Z" } +sdist = { url = "https://files.pythonhosted.org/packages/36/91/467a79d7d9acebc997942031e9243c6900680efc5ec6125a0d5f64282f01/battinfoconverter_backend-3.4.0.tar.gz", hash = "sha256:b42d0fb85515ae94ae1d5558ab424a0637bffbf7fc998bdd98fc8b5cc36b2a04", size = 152286, upload-time = "2026-10-16T13:34:01.767Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d8/25/32f77c9e4d72d83492fb619762865df1fcf6089ce868a9735331056812e0/battinfoconverter_backend-3.3.1-py3-none-any.whl", hash = "sha256:47e0f5cb0cc6c8fa772ae819ae03d700c43c61d006604d77593bdc02a1c9b946", size = 127360, upload-time = "2026-09-18T16:15:19.4Z" }, + { url = "https://files.pythonhosted.org/packages/4c/49/d3adeb042c3b5b4331946be2f9239be08ec4d3ddd95dbdcc12a791de16e3/battinfoconverter_backend-3.4.0-py3-none-any.whl", hash = "sha256:a25ad5b33fc0a0c41e0ead9ed570f574f09082999d79fc10abc1685c7dc6677c", size = 143728, upload-time = "2026-10-16T13:34:00.402Z" }, ] [[package]] @@ -1637,7 +1637,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "batterydf", specifier = ">=0.1.0,<0.2.0" }, - { name = "battinfoconverter-backend", specifier = ">=3.3.1" }, + { name = "battinfoconverter-backend", specifier = ">=3.4.0,<4.0.0" }, { name = "fasteis", specifier = "==0.1.0" }, { name = "matplotlib", specifier = ">=3.10.6" }, { name = "pandas", specifier = ">=2.3.2" }, From 9305b4701582bcc38cdb63f0ed725d3b69aab8a9 Mon Sep 17 00:00:00 2001 From: Graham Kimbell <88666181+g-kimbell@users.noreply.github.com> Date: Wed, 21 Oct 2026 14:48:17 +0200 Subject: [PATCH 02/15] Accept BatteryTest inputs --- pyflowbatt/analysis.py | 15 ++++---- tests/test_config.py | 85 +++++++++++++++++++++++++++++++++++++++++- 2 files changed, 92 insertions(+), 8 deletions(-) diff --git a/pyflowbatt/analysis.py b/pyflowbatt/analysis.py index f5f5fcb..94204fe 100644 --- a/pyflowbatt/analysis.py +++ b/pyflowbatt/analysis.py @@ -357,7 +357,7 @@ def _extract_electrode_area_cm2(raw_battinfo_json: dict, electrode_key: str) -> electrode = raw_battinfo_json.get(electrode_key) if not isinstance(electrode, dict): return None - substrate = electrode.get("Substrate") + substrate = electrode.get("hasSubstrate") or electrode.get("Substrate") # old schema name if not isinstance(substrate, dict): return None properties = substrate.get("hasMeasuredProperty") or [] @@ -596,7 +596,7 @@ def analyse_sample( battinfo_json = None battinfo_sample_id = None - raw_battinfo_json: dict | None = None + battinfo_cell: dict | None = None if not battinfo_files: logger.debug("No BattINFO xlsx found, skipping") reporter.skip("BattINFO", "no xlsx") @@ -609,10 +609,11 @@ def analyse_sample( if raw_json is None: continue battinfo_xlsx_path = file - fcid = raw_json["schema:productID"] - battinfo_sample_id = raw_json["schema:name"] - raw_battinfo_json = raw_json + # The cell might already be in a BatteryTest battinfo_json = battinfo.make_test_object(raw_json) + battinfo_cell = battinfo_json["hasTestObject"] + fcid = battinfo_cell["schema:productID"] + battinfo_sample_id = battinfo_cell["schema:name"] # Create list of everything that can be included in JSON-LD merge_list = [ @@ -655,9 +656,9 @@ def analyse_sample( logger.warning("Couldn't convert any xlsx to BattINFO, no metadata written") sample_id = get_sampleid_from_folderpath(folder, config, battinfo_sample_id) - area_cm2 = get_area_cm2(config, raw_battinfo_json) + area_cm2 = get_area_cm2(config, battinfo_cell) assembled_resistance_ohm = get_assembled_resistance_ohm( - config, raw_battinfo_json, gcpl_files[0].stem if gcpl_files else None + config, battinfo_cell, gcpl_files[0].stem if gcpl_files else None ) av_ocv = (np.nan, np.nan) diff --git a/tests/test_config.py b/tests/test_config.py index 7b97ce1..f669d44 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1001,7 +1001,7 @@ def electrode(area: float | None) -> dict: "hasMeasurementUnit": unit, } ) - return {"Substrate": {"hasMeasuredProperty": properties}} + return {"hasSubstrate": {"hasMeasuredProperty": properties}} raw_json = { "@context": {}, @@ -1020,6 +1020,17 @@ def electrode(area: float | None) -> dict: return raw_json +def _wrap_in_battery_test(raw_json: dict) -> dict: + """Nest a cell in a BatteryTest.""" + cell = dict(raw_json) + return { + "@context": cell.pop("@context"), + "@type": "BatteryTest", + "hasTestObject": cell, + "hasOutput": {"@type": ["BatteryTestResult", "dcat:Dataset"], "dcterms:title": "x"}, + } + + def test_get_area_cm2_default_no_toml_no_battinfo() -> None: """With neither pyflowbatt.toml nor BattINFO, area_cm2 falls back to DEFAULT_AREA_CM2.""" from pyflowbatt.analysis import get_area_cm2 @@ -1044,6 +1055,16 @@ def test_get_area_cm2_from_battinfo_picks_smaller_when_electrodes_disagree() -> assert get_area_cm2(PyFlowBattConfig(), raw_json) == 4.0 +def test_get_area_cm2_from_legacy_substrate_key() -> None: + """Old schemas had "Substrate" not "hasSubstrate" keys.""" + from pyflowbatt.analysis import get_area_cm2 + + raw_json = _make_raw_battinfo_json(pos_area=4.0, neg_area=4.0) + for electrode in ("hasPositiveElectrode", "hasNegativeElectrode"): + raw_json[electrode]["Substrate"] = raw_json[electrode].pop("hasSubstrate") + assert get_area_cm2(PyFlowBattConfig(), raw_json) == 4.0 + + def test_get_area_cm2_ignores_wrong_unit() -> None: """A BattINFO Area entry in a unit other than CentiM2 is ignored.""" from pyflowbatt.analysis import get_area_cm2 @@ -1248,6 +1269,68 @@ def test_analyse_sample_raises_on_toml_battinfo_resistance_conflict( analysis_module.analyse_sample(sample, save_format=None, config=config) +def test_analyse_sample_reads_cell_nested_in_battery_test( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """ID, name, area and resistance are read from a cell nested in a BatteryTest.""" + import pandas as pd + + from pyflowbatt import analysis as analysis_module + + sample = tmp_path / "not-a-normal-sample-name" + sample.mkdir() + (sample / "sample_03_LSV_A.mpr").write_text("x") + (sample / "metadata.xlsx").write_text("x") # content irrelevant; conversion is stubbed + + raw_json = _make_raw_battinfo_json( + pos_area=4.0, + neg_area=4.0, + unit="SquareCentiMetre", + resistance_value=25000, + resistance_unit="Ohm", + ) + nested = _wrap_in_battery_test(raw_json) + monkeypatch.setattr(analysis_module, "convert_excel_to_jsonld", lambda _file: nested) + + captured_areas: list[float] = [] + + def fake_analyse(filepath: Path, area_cm2: float = 5) -> dict: + captured_areas.append(area_cm2) + return { + "Fit cutoff current / A": 0.0, + "Intercept / A": 0.0, + "Slope / Ω⁻¹": 1.0, + "Resistance / Ω": 1.0, + "Area / cm²": area_cm2, + "Area specific resistance / Ω cm²": area_cm2, + "File name": Path(filepath).name, + } + + def fake_plot(df: pd.DataFrame, results: dict) -> tuple: + import matplotlib.pyplot as plt + + return plt.subplots() + + monkeypatch.setattr(analysis_module, "read_to_bdf", lambda file: file) + monkeypatch.setattr(analysis_module.lsv, "analyse", fake_analyse) + monkeypatch.setattr(analysis_module.lsv, "plot", fake_plot) + + config = analysis_module.PyFlowBattConfig.load(sample, home=tmp_path / "home") + _outputs, _extra_inputs, _raw_inputs, fcid, sample_id = analysis_module.analyse_sample( + sample, save_format=None, config=config + ) + + assert fcid == "FCID1" + assert sample_id == "battinfo-name" + assert captured_areas == [4.0] + df = pd.read_excel(sample / "results" / "summary.xlsx", sheet_name="Summary", index_col=0) + assert df.loc["Assembled resistance / Ω", "Value"] == 25000.0 + metadata = json.loads((sample / "metadata.FCID1.json").read_text(encoding="utf-8")) + assert metadata["@type"] == "BatteryTest" + assert metadata["hasTestObject"]["schema:productID"] == "FCID1" + assert metadata["hasOutput"]["dcterms:title"] == "x" + + def test_area_cm2_passed_to_lsv_analyse(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """analyse_sample threads config.area_cm2 through to the real lsv.analyse call. From d7c455ba7571f3d74da06dccc63df0db964f721a Mon Sep 17 00:00:00 2001 From: Graham Kimbell <88666181+g-kimbell@users.noreply.github.com> Date: Wed, 21 Oct 2026 17:10:49 +0200 Subject: [PATCH 03/15] Priorities: ref sheet > pub_info > defaults For publication details, references sheet wins Adds priority merge fuction Allows objects like lists to replace instead of merge --- pyflowbatt/analysis.py | 85 ++++++++-------- pyflowbatt/battinfo.py | 96 +++++++++++++++++- tests/test_config.py | 221 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 360 insertions(+), 42 deletions(-) diff --git a/pyflowbatt/analysis.py b/pyflowbatt/analysis.py index 94204fe..0cc31c2 100644 --- a/pyflowbatt/analysis.py +++ b/pyflowbatt/analysis.py @@ -516,12 +516,14 @@ def analyse_sample( "@id" alone. ``pub_info_path`` is the xlsx ``pub_info`` was parsed from; when given it is recorded - as an input alongside the protocol and BattINFO xlsx files. + as an input alongside the protocol and BattINFO xlsx files. Dataset details from the + BattINFO sheet take priority over ``pub_info``, which takes priority over defaults. ``zenodo_package`` controls how the resolved "dcat:downloadURL" (a separate property - from "@id") points at Zenodo-hosted files, once ``pub_info["zenodo_doi_url"]`` is - known: "files" (default) points directly at each file's own Zenodo download URL - (matching an unzipped upload); "zip" points at one zip's Zenodo download URL with the + from "@id") points at Zenodo-hosted files, once the Zenodo record is known from the + BattINFO sheet's Dataset URL or ``pub_info["zenodo_doi_url"]``: "files" (default) + points directly at each file's own Zenodo download URL (matching an unzipped + upload); "zip" points at one zip's Zenodo download URL with the in-archive path as a fragment (see :func:`pyflowbatt.battinfo.zenodo_download_url`). ``reporter`` is told when each step starts and finishes, to show progress. @@ -597,6 +599,7 @@ def analyse_sample( battinfo_json = None battinfo_sample_id = None battinfo_cell: dict | None = None + zenodo_url: str | None = None if not battinfo_files: logger.debug("No BattINFO xlsx found, skipping") reporter.skip("BattINFO", "no xlsx") @@ -614,43 +617,48 @@ def analyse_sample( battinfo_cell = battinfo_json["hasTestObject"] fcid = battinfo_cell["schema:productID"] battinfo_sample_id = battinfo_cell["schema:name"] + zenodo_url = battinfo.resolve_zenodo_url( + battinfo_json, pub_info.get("zenodo_doi_url") + ) - # Create list of everything that can be included in JSON-LD - merge_list = [ - battinfo_json, - battinfo.add_input_and_output(), - battinfo.add_software(), - ] - if pub_info: - if pub_info.get("zenodo_doi_url"): - merge_list.append(battinfo.add_zenodo_url(pub_info["zenodo_doi_url"])) - if pub_info.get("citation_string"): - merge_list.append(battinfo.add_citation(pub_info["citation_string"])) - if pub_info.get("institution"): - merge_list.append( - battinfo.add_institution( - pub_info["institution"], - pub_info.get("institutions", {}) - .get(pub_info["institution"], {}) - .get("wikidata_url"), - ) - ) - if pub_info.get("authors") and pub_info.get("institutions"): - merge_list.append( - battinfo.add_authors(pub_info["authors"], pub_info["institutions"]), - ) - if pub_info.get("sample_to_fig"): - merge_list.append( - battinfo.add_associated_media( - pub_info.get("publication_doi_url"), - pub_info["sample_to_fig"], - fcid, - battinfo_sample_id, - ), + # Publication info only fills what the BattINFO sheet leaves out + pub_list: list[dict] = [{"@type": "BatteryTest"}] + if zenodo_url: + pub_list.append(battinfo.add_zenodo_url(zenodo_url)) + if pub_info.get("citation_string"): + pub_list.append(battinfo.add_citation(pub_info["citation_string"])) + if pub_info.get("institution"): + pub_list.append( + battinfo.add_institution( + pub_info["institution"], + pub_info.get("institutions", {}) + .get(pub_info["institution"], {}) + .get("wikidata_url"), ) + ) + if pub_info.get("authors") and pub_info.get("institutions"): + pub_list.append( + battinfo.add_authors(pub_info["authors"], pub_info["institutions"]), + ) + if pub_info.get("sample_to_fig"): + pub_list.append( + battinfo.add_associated_media( + pub_info.get("publication_doi_url"), + pub_info["sample_to_fig"], + fcid, + battinfo_sample_id, + ), + ) + defaults = battinfo.merge_jsonld_on_type( + [battinfo.add_input_and_output(), battinfo.add_software()] + ) - # Merge everything - battinfo_json = battinfo.merge_jsonld_on_type(merge_list) + battinfo_json = battinfo.priority_merge( + battinfo_json, + battinfo.merge_jsonld_on_type(pub_list), + names=("BattINFO sheet", "publication info"), + ) + battinfo_json = battinfo.priority_merge(battinfo_json, defaults) break else: logger.warning("Couldn't convert any xlsx to BattINFO, no metadata written") @@ -968,7 +976,6 @@ def analyse_sample( if battinfo_json is not None: with reporter.task("Metadata"): - zenodo_url = pub_info.get("zenodo_doi_url") if pub_info else None zenodo_zip_filename = pub_info.get("zenodo_zip_filename") or f"{root_folder.name}.zip" for label, paths in raw_inputs.items(): for path in paths: diff --git a/pyflowbatt/battinfo.py b/pyflowbatt/battinfo.py index 68f5129..05de5e2 100644 --- a/pyflowbatt/battinfo.py +++ b/pyflowbatt/battinfo.py @@ -361,6 +361,96 @@ def dedupe_jsonld_list(lst: list) -> list: return deduped +# Keys whose values from every source are combined instead of the higher priority one winning +UNION_KEYS = {"@type", "dcat:distribution"} + + +def _short(value: object, limit: int = 150) -> str: + """Compact one-line JSON for log messages.""" + text = json.dumps(value, ensure_ascii=False) + return text if len(text) <= limit else text[:limit] + "..." + + +def _union(high: object, low: object, names: tuple[str, str] | None, path: str) -> object: + """Combine two values or lists, merging nodes that share an @id.""" + merged = deepcopy(high) if isinstance(high, list) else [deepcopy(high)] + for item in low if isinstance(low, list) else [low]: + match = next( + ( + i + for i, existing in enumerate(merged) + if isinstance(item, dict) + and isinstance(existing, dict) + and item.get("@id") is not None + and existing.get("@id") == item.get("@id") + ), + None, + ) + if match is not None: + merged[match] = priority_merge(merged[match], item, names=names, _path=path) + elif item not in merged: + merged.append(deepcopy(item)) + return merged[0] if len(merged) == 1 else merged + + +def priority_merge( + high: dict, + low: dict, + *, + names: tuple[str, str] | None = None, + _path: str = "", +) -> dict: + """Merge two JSON-LD nodes, keeping the values of `high` where both set a key. + + Keys in UNION_KEYS are combined instead, and @context blocks are merged. `names` + labels the two sources in a warning logged for each disagreement, None merges silently. + """ + merged = deepcopy(high) + for key, low_value in low.items(): + path = f"{_path}/{key}" + if key not in merged: + merged[key] = deepcopy(low_value) + continue + high_value = merged[key] + if key == "@context": + merged[key] = merge_contexts_strict(high_value, low_value, on_conflict="keep_left") + elif key in UNION_KEYS: + merged[key] = _union(high_value, low_value, names, path) + elif ( + isinstance(high_value, dict) + and isinstance(low_value, dict) + and high_value.get("@id") == low_value.get("@id") + ): + merged[key] = priority_merge(high_value, low_value, names=names, _path=path) + elif high_value != low_value and names: + logger.warning( + "%s and %s disagree at %s, using %s: %s, ignoring: %s", + *names, + path.lstrip("/"), + names[0], + _short(high_value), + _short(low_value), + ) + return merged + + +def resolve_zenodo_url(battinfo_jsonld: dict, pub_info_url: str | None) -> str | None: + """Zenodo DOI URL of the dataset, from the BattINFO sheet or else publication info. + + Raises ValueError if the two name different Zenodo records. + """ + output = battinfo_jsonld.get("hasOutput") + access = output.get("dcat:accessURL") if isinstance(output, dict) else None + sheet_url = access.get("@id") if isinstance(access, dict) else access + if sheet_url and pub_info_url and zenodo_record_id(sheet_url) != zenodo_record_id(pub_info_url): + msg = ( + f"Zenodo record mismatch: the BattINFO sheet gives {sheet_url} " + f"but publication info gives {pub_info_url}" + ) + raise ValueError(msg) + return sheet_url or pub_info_url + + def make_test_object(battinfo_jsonld: dict) -> dict: """Put BattINFO redox flow battery description inside a BatteryTest object.""" if battinfo_jsonld.get("@type") == "RedoxFlowBattery": @@ -794,14 +884,14 @@ def add_institution( inst_dict = { "@type": "BatteryTest", "hasOutput": { - "dc:publisher": { + "dcterms:publisher": { "@type": "schema:ResearchOrganization", }, }, } if wikidata_url: - inst_dict["hasOutput"]["dc:publisher"]["@id"] = wikidata_url - inst_dict["hasOutput"]["dc:publisher"]["schema:name"] = name + inst_dict["hasOutput"]["dcterms:publisher"]["@id"] = wikidata_url + inst_dict["hasOutput"]["dcterms:publisher"]["schema:name"] = name return inst_dict diff --git a/tests/test_config.py b/tests/test_config.py index f669d44..06dc1bc 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -788,6 +788,118 @@ def test_analyse_sample_default_package_is_files_and_root_relative( assert local_terms["@base"] == "https://zenodo.org/records/20338409/" +SHEET_ZENODO_URL = "https://doi.org/10.5281/zenodo.20338409" + + +def _fake_convert_with_references(_file: Path) -> dict: + """Return converter output with @References included, the cell nested in a BatteryTest.""" + return { + "@context": {}, + "@type": "BatteryTest", + "hasTestObject": { + "@type": "RedoxFlowBattery", + "schema:productID": "empa__fcid123456", + "schema:name": "battinfo-derived-name", + }, + "hasOutput": { + "@type": ["BatteryTestResult", "dcat:Dataset"], + "dcterms:creator": { + "@type": "schema:Person", + "@id": "https://orcid.org/0000-0000-0000-0001", + "schema:name": "Sheet Author", + }, + "dcterms:issued": "2026-08-05", + "dcat:accessURL": {"@id": SHEET_ZENODO_URL}, + }, + } + + +def test_analyse_sample_sheet_references_take_priority_over_pub_info( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + """The sheet beats pub_info, which only fills gaps, and pyflowbatt defaults come last.""" + from pyflowbatt import analysis as analysis_module + + root = tmp_path / "project" + sample = root / "sample_01" + sample.mkdir(parents=True) + (sample / "metadata.xlsx").write_text("x") + monkeypatch.setattr(analysis_module, "convert_excel_to_jsonld", _fake_convert_with_references) + + pub_info = { + "zenodo_doi_url": SHEET_ZENODO_URL, + "citation_string": "Cite me", + "authors": [ + { + "name": "Pub Author", + "orcid": "https://orcid.org/0000-0000-0000-0002", + "affiliation": ["Empa"], + } + ], + "institutions": {"Empa": {"wikidata_url": "http://www.wikidata.org/entity/Q683116"}}, + } + config = analysis_module.PyFlowBattConfig.load(sample, home=tmp_path / "home") + with caplog.at_level("WARNING", logger="pyflowbatt"): + analysis_module.analyse_sample( + sample, save_format=None, config=config, root_folder=root, pub_info=pub_info + ) + + metadata = json.loads((sample / "metadata.empa__fcid123456.json").read_text()) + output = metadata["hasOutput"] + assert output["dcterms:creator"]["schema:name"] == "Sheet Author" + assert output["dcterms:issued"] == "2026-08-05" + assert output["schema:citation"] == "Cite me" + assert output["dcat:endpointURL"] == {"@id": "https://zenodo.org/api/records/20338409"} + messages = [r.getMessage() for r in caplog.records if r.name == "pyflowbatt.battinfo"] + assert len(messages) == 1 + assert "hasOutput/dcterms:creator" in messages[0] + + +def test_analyse_sample_zenodo_record_from_sheet_without_pub_info( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """The sheet's Dataset URL sets @base and download URLs when there is no pub_info.""" + from pyflowbatt import analysis as analysis_module + + root = tmp_path / "project" + sample = root / "sample_01" + sample.mkdir(parents=True) + (sample / "metadata.xlsx").write_text("x") + monkeypatch.setattr(analysis_module, "convert_excel_to_jsonld", _fake_convert_with_references) + + config = analysis_module.PyFlowBattConfig.load(sample, home=tmp_path / "home") + analysis_module.analyse_sample(sample, save_format=None, config=config, root_folder=root) + + metadata = json.loads((sample / "metadata.empa__fcid123456.json").read_text()) + local_terms = next(c for c in metadata["@context"] if isinstance(c, dict)) + assert local_terms["@base"] == "https://zenodo.org/records/20338409/" + expected = "https://zenodo.org/records/20338409/files/sample_01/metadata.xlsx" + assert expected in _all_download_urls(metadata) + + +def test_analyse_sample_raises_on_zenodo_record_mismatch( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """The sheet and pub_info naming different Zenodo records is an error.""" + from pyflowbatt import analysis as analysis_module + + root = tmp_path / "project" + sample = root / "sample_01" + sample.mkdir(parents=True) + (sample / "metadata.xlsx").write_text("x") + monkeypatch.setattr(analysis_module, "convert_excel_to_jsonld", _fake_convert_with_references) + + config = analysis_module.PyFlowBattConfig.load(sample, home=tmp_path / "home") + with pytest.raises(ValueError, match="Zenodo record mismatch"): + analysis_module.analyse_sample( + sample, + save_format=None, + config=config, + root_folder=root, + pub_info={"zenodo_doi_url": "https://doi.org/10.5281/zenodo.99999999"}, + ) + + def test_analyse_sample_records_pub_info_xlsx_as_input( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -1922,6 +2034,115 @@ def test_battinfo_zenodo_access_urls_are_iris() -> None: ] +def test_priority_merge_keeps_high_fills_gaps_and_warns(caplog: pytest.LogCaptureFixture) -> None: + """On a disagreement the high value wins with a warning, missing keys come from low.""" + from pyflowbatt.battinfo import priority_merge + + high = {"@type": "BatteryTest", "hasOutput": {"dcterms:issued": "2026-08-05"}} + low = { + "@type": "BatteryTest", + "hasOutput": {"dcterms:issued": "2026-10-21", "schema:citation": "Cite me"}, + } + with caplog.at_level("WARNING", logger="pyflowbatt"): + merged = priority_merge(high, low, names=("sheet", "pub")) + + assert merged["hasOutput"] == {"dcterms:issued": "2026-08-05", "schema:citation": "Cite me"} + messages = [r.getMessage() for r in caplog.records] + assert len(messages) == 1 + assert "sheet and pub disagree at hasOutput/dcterms:issued" in messages[0] + + +def test_priority_merge_without_names_is_silent(caplog: pytest.LogCaptureFixture) -> None: + """Without source names, disagreements are resolved without a warning.""" + from pyflowbatt.battinfo import priority_merge + + with caplog.at_level("WARNING", logger="pyflowbatt"): + merged = priority_merge({"a": 1}, {"a": 2}) + assert merged == {"a": 1} + assert caplog.records == [] + + +def test_priority_merge_keeps_high_list_whole() -> None: + """A list from high replaces the one from low, e.g. an ordered author list.""" + from pyflowbatt.battinfo import priority_merge + + author_a = {"@id": "https://orcid.org/a", "schema:name": "A"} + author_b = {"@id": "https://orcid.org/b", "schema:name": "B"} + merged = priority_merge( + {"dcterms:creator": [author_a]}, {"dcterms:creator": [author_b, author_a]} + ) + assert merged["dcterms:creator"] == [author_a] + + +def test_priority_merge_nodes_by_id() -> None: + """Nodes with the same @id are merged, a different @id keeps the high node.""" + from pyflowbatt.battinfo import priority_merge + + high = { + "dcterms:publisher": {"@id": "http://www.wikidata.org/entity/Q683116"}, + "dcat:accessURL": {"@id": "https://doi.org/10.5281/zenodo.1"}, + } + low = { + "dcterms:publisher": { + "@id": "http://www.wikidata.org/entity/Q683116", + "schema:name": "Empa", + }, + "dcat:accessURL": {"@id": "https://doi.org/10.5281/zenodo.2"}, + } + merged = priority_merge(high, low) + assert merged["dcterms:publisher"] == { + "@id": "http://www.wikidata.org/entity/Q683116", + "schema:name": "Empa", + } + assert merged["dcat:accessURL"] == {"@id": "https://doi.org/10.5281/zenodo.1"} + + +def test_priority_merge_combines_types_and_distributions() -> None: + """@type and dcat:distribution are combined, files with the same @id merge into one node.""" + from pyflowbatt.battinfo import priority_merge + + high = { + "@type": ["BatteryTestResult"], + "dcat:distribution": [{"@id": "a.csv", "rdfs:comment": "From the sheet"}], + } + low = { + "@type": "dcat:Dataset", + "dcat:distribution": [ + {"@id": "a.csv", "rdfs:comment": "From pyflowbatt", "dcat:downloadURL": {"@id": "u"}}, + {"@id": "b.csv"}, + ], + } + merged = priority_merge(high, low) + assert merged["@type"] == ["BatteryTestResult", "dcat:Dataset"] + assert merged["dcat:distribution"] == [ + {"@id": "a.csv", "rdfs:comment": "From the sheet", "dcat:downloadURL": {"@id": "u"}}, + {"@id": "b.csv"}, + ] + + +@pytest.mark.parametrize( + ("sheet_url", "pub_info_url", "expected"), + [ + ("https://doi.org/10.5281/zenodo.1", None, "https://doi.org/10.5281/zenodo.1"), + (None, "https://doi.org/10.5281/zenodo.2", "https://doi.org/10.5281/zenodo.2"), + ( + "https://doi.org/10.5281/zenodo.1", + "https://doi.org/10.5281/zenodo.1", + "https://doi.org/10.5281/zenodo.1", + ), + (None, None, None), + ], +) +def test_resolve_zenodo_url( + sheet_url: str | None, pub_info_url: str | None, expected: str | None +) -> None: + """The sheet's Dataset URL is used first, then publication info.""" + from pyflowbatt.battinfo import resolve_zenodo_url + + sheet = {"hasOutput": {"dcat:accessURL": {"@id": sheet_url}}} if sheet_url else {} + assert resolve_zenodo_url(sheet, pub_info_url) == expected + + def test_template_every_single_hash_line_uncomments_to_valid_toml( tmp_path: Path, caplog: pytest.LogCaptureFixture ) -> None: From a28b46d2a6888bb71c548c2e8d83abdb93802d47 Mon Sep 17 00:00:00 2001 From: Graham Kimbell <88666181+g-kimbell@users.noreply.github.com> Date: Thu, 22 Oct 2026 09:22:06 +0200 Subject: [PATCH 04/15] Ignore battinfo xlsx files, pyflowbatt wins Only use descriptions from battinfo if present --- pyflowbatt/analysis.py | 49 +++++++++++++++++---------------- pyflowbatt/battinfo.py | 34 ++++++++++++++++++++++- tests/test_config.py | 62 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 121 insertions(+), 24 deletions(-) diff --git a/pyflowbatt/analysis.py b/pyflowbatt/analysis.py index 0cc31c2..720259f 100644 --- a/pyflowbatt/analysis.py +++ b/pyflowbatt/analysis.py @@ -600,6 +600,7 @@ def analyse_sample( battinfo_sample_id = None battinfo_cell: dict | None = None zenodo_url: str | None = None + sheet_files: dict[str, str | None] = {} if not battinfo_files: logger.debug("No BattINFO xlsx found, skipping") reporter.skip("BattINFO", "no xlsx") @@ -617,6 +618,8 @@ def analyse_sample( battinfo_cell = battinfo_json["hasTestObject"] fcid = battinfo_cell["schema:productID"] battinfo_sample_id = battinfo_cell["schema:name"] + # pyflowbatt lists the files it finds, the sheet only adds descriptions + sheet_files = battinfo.pop_sheet_files(battinfo_json) zenodo_url = battinfo.resolve_zenodo_url( battinfo_json, pub_info.get("zenodo_doi_url") ) @@ -977,39 +980,33 @@ def analyse_sample( if battinfo_json is not None: with reporter.task("Metadata"): zenodo_zip_filename = pub_info.get("zenodo_zip_filename") or f"{root_folder.name}.zip" + + def describe(rel: str, default: str | None) -> str | None: + """Prefer the BattINFO sheet's description of a file over pyflowbatt's.""" + return sheet_files.pop(rel, None) or default + for label, paths in raw_inputs.items(): for path in paths: + rel = _rel(path, root_folder) snippet = battinfo.add_data( - _rel(path, root_folder), + rel, zenodo_url, - extras={"rdfs:comment": raw_description(label, path)}, + extras={"rdfs:comment": describe(rel, raw_description(label, path))}, package=zenodo_package, zip_filename=zenodo_zip_filename, ) battinfo_json = battinfo.merge_jsonld_on_type([battinfo_json, snippet]) - for path in mps_files: - snippet = battinfo.add_input_data( - _rel(path, root_folder), - zenodo_url, - EXTRA_INPUT_DESCRIPTIONS["protocol"], - package=zenodo_package, - zip_filename=zenodo_zip_filename, - ) - battinfo_json = battinfo.merge_jsonld_on_type([battinfo_json, snippet]) - if battinfo_xlsx_path: - snippet = battinfo.add_input_data( - _rel(battinfo_xlsx_path, root_folder), - zenodo_url, - EXTRA_INPUT_DESCRIPTIONS["battinfo_xlsx"], - package=zenodo_package, - zip_filename=zenodo_zip_filename, - ) - battinfo_json = battinfo.merge_jsonld_on_type([battinfo_json, snippet]) - for path in tracked_extra_inputs.get("pub_info", []): + input_files = [ + *((path, "protocol") for path in mps_files), + *((path, "battinfo_xlsx") for path in [battinfo_xlsx_path] if path), + *((path, "pub_info") for path in tracked_extra_inputs.get("pub_info", [])), + ] + for path, label in input_files: + rel = _rel(path, root_folder) snippet = battinfo.add_input_data( - _rel(path, root_folder), + rel, zenodo_url, - EXTRA_INPUT_DESCRIPTIONS["pub_info"], + describe(rel, EXTRA_INPUT_DESCRIPTIONS[label]), package=zenodo_package, zip_filename=zenodo_zip_filename, ) @@ -1023,6 +1020,7 @@ def analyse_sample( comment = data_description(label) else: comment = OUTPUT_MISC_DESCRIPTIONS.get(label) + comment = describe(rel, comment) extras = {"rdfs:comment": comment} if comment else None snippet = battinfo.add_data( rel, @@ -1032,6 +1030,11 @@ def analyse_sample( zip_filename=zenodo_zip_filename, ) battinfo_json = battinfo.merge_jsonld_on_type([battinfo_json, snippet]) + for rel in sheet_files: + logger.warning( + "The BattINFO sheet lists %s, which is not a file in this sample, ignoring it", + rel, + ) metadata_path = folder / f"metadata.{fcid or sample_id}.json" with metadata_path.open("w", encoding="utf-8") as mf: json.dump(battinfo_json, mf, indent=4, ensure_ascii=False) diff --git a/pyflowbatt/battinfo.py b/pyflowbatt/battinfo.py index 05de5e2..6a06a66 100644 --- a/pyflowbatt/battinfo.py +++ b/pyflowbatt/battinfo.py @@ -8,7 +8,7 @@ from datetime import datetime from pathlib import Path from typing import Literal -from urllib.parse import quote +from urllib.parse import quote, unquote import pandas as pd @@ -451,6 +451,38 @@ def resolve_zenodo_url(battinfo_jsonld: dict, pub_info_url: str | None) -> str | return sheet_url or pub_info_url +def _sheet_file_path(node: dict) -> str | None: + """Root-relative path of a file node written by the BattINFO converter. + + Unpublished files carry the path as dcterms:identifier, published ones in their + download URL, after the "#" for a file inside a zip. + """ + if node.get("dcterms:identifier"): + return node["dcterms:identifier"] + file_id = node.get("@id") + if not file_id: + return None + for separator in ("#", "/files/"): + if separator in file_id: + return unquote(file_id.split(separator, 1)[1]) + return unquote(file_id) + + +def pop_sheet_files(battinfo_jsonld: dict) -> dict[str, str | None]: + """Remove file nodes from the converter output, return each path and its description.""" + files: dict[str, str | None] = {} + for section in ("hasOutput", "hasInput"): + node = battinfo_jsonld.get(section) + if not isinstance(node, dict) or "dcat:distribution" not in node: + continue + distribution = node.pop("dcat:distribution") + for item in distribution if isinstance(distribution, list) else [distribution]: + path = _sheet_file_path(item) if isinstance(item, dict) else None + if path: + files[path] = item.get("rdfs:comment") + return files + + def make_test_object(battinfo_jsonld: dict) -> dict: """Put BattINFO redox flow battery description inside a BatteryTest object.""" if battinfo_jsonld.get("@type") == "RedoxFlowBattery": diff --git a/tests/test_config.py b/tests/test_config.py index 06dc1bc..007b077 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -900,6 +900,68 @@ def test_analyse_sample_raises_on_zenodo_record_mismatch( ) +def test_analyse_sample_owns_file_list_and_keeps_sheet_descriptions( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + """Sheet file rows only describe files pyflowbatt finds, the rest are dropped with a warning.""" + from pyflowbatt import analysis as analysis_module + + root = tmp_path / "project" + sample = root / "sample_01" + sample.mkdir(parents=True) + (sample / "metadata.xlsx").write_text("x") + + def fake_convert(file: Path) -> dict: + jsonld = _fake_convert_with_references(file) + zip_url = "https://zenodo.org/records/20338409/files/project.zip" + jsonld["hasOutput"]["dcat:distribution"] = [ + {"@id": f"{zip_url}#sample_01/metadata.xlsx", "rdfs:comment": "Sheet description"}, + {"@id": f"{zip_url}#empa__fcid000001/data.parquet", "rdfs:comment": "Other cell"}, + ] + return jsonld + + monkeypatch.setattr(analysis_module, "convert_excel_to_jsonld", fake_convert) + config = analysis_module.PyFlowBattConfig.load(sample, home=tmp_path / "home") + with caplog.at_level("WARNING", logger="pyflowbatt"): + analysis_module.analyse_sample(sample, save_format=None, config=config, root_folder=root) + + metadata = json.loads((sample / "metadata.empa__fcid123456.json").read_text()) + file_ids = [ + d["@id"] + for section in ("hasOutput", "hasInput") + for d in metadata[section]["dcat:distribution"] + if isinstance(metadata[section]["dcat:distribution"], list) + ] + [ + metadata[section]["dcat:distribution"]["@id"] + for section in ("hasOutput", "hasInput") + if isinstance(metadata[section]["dcat:distribution"], dict) + ] + assert file_ids + assert not any("://" in i for i in file_ids) + inputs = metadata["hasInput"]["dcat:distribution"] + xlsx = next(d for d in (inputs if isinstance(inputs, list) else [inputs])) + assert xlsx["@id"] == "sample_01/metadata.xlsx" + assert xlsx["rdfs:comment"] == "Sheet description" + assert "lists empa__fcid000001/data.parquet, which is not a file" in caplog.text + + +@pytest.mark.parametrize( + ("node", "expected"), + [ + ({"dcterms:identifier": "a/b.csv"}, "a/b.csv"), + ({"@id": "https://zenodo.org/records/1/files/data.zip#a/b%20c.csv"}, "a/b c.csv"), + ({"@id": "https://zenodo.org/records/1/files/a/b.csv"}, "a/b.csv"), + ], +) +def test_pop_sheet_files_recovers_paths(node: dict, expected: str) -> None: + """Converter file nodes give back their root-relative path and are removed.""" + from pyflowbatt.battinfo import pop_sheet_files + + jsonld = {"hasInput": {"@type": "dcat:Dataset", "dcat:distribution": node}} + assert pop_sheet_files(jsonld) == {expected: None} + assert jsonld == {"hasInput": {"@type": "dcat:Dataset"}} + + def test_analyse_sample_records_pub_info_xlsx_as_input( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: From f826327124ac1b0a91746bfe1c6aae48a5a26b84 Mon Sep 17 00:00:00 2001 From: Graham Kimbell <88666181+g-kimbell@users.noreply.github.com> Date: Thu, 22 Oct 2026 10:30:27 +0200 Subject: [PATCH 05/15] Remove ro-crate from non-zip runs Can't upload folder structures to zenodo anyway --- pyflowbatt/analysis.py | 83 +++++++++++++++++++++--------------------- pyflowbatt/battinfo.py | 49 +++++++------------------ pyflowbatt/cli.py | 7 ++-- tests/test_config.py | 56 ++++++++++++++++++---------- 4 files changed, 94 insertions(+), 101 deletions(-) diff --git a/pyflowbatt/analysis.py b/pyflowbatt/analysis.py index 720259f..472e979 100644 --- a/pyflowbatt/analysis.py +++ b/pyflowbatt/analysis.py @@ -27,6 +27,7 @@ logger = logging.getLogger(__name__) SAVE_FORMATS = Literal["parquet", "csv"] | None +ROCRATE_FILENAME = "ro-crate-metadata.json" DEFAULT_DEPTH = 6 # Default max search depth in folders DEFAULT_SEARCH = 10000 # Default max number of folders searched @@ -493,7 +494,7 @@ def analyse_sample( save_format: SAVE_FORMATS = "parquet", config: PyFlowBattConfig | None = None, root_folder: str | Path | None = None, - zenodo_package: Literal["zip", "files"] = "files", + zip_filename: str | None = None, reporter: Reporter | None = None, ) -> tuple[dict[str, list[Path]], dict[str, list[Path]], dict[str, list[Path]], str | None, str]: """Read all the files in a folder, analayse and plot everything. @@ -512,18 +513,15 @@ def analyse_sample( and match where the file actually sits once the root folder is packaged for Zenodo. Every file's "@id" is this bare root-relative path, matching its "@id" in the RO-Crate manifest (ro-crate-metadata.json); this deliberately never changes with - ``zenodo_package`` or Zenodo upload, so the two documents can be cross-referenced by - "@id" alone. + zipping or Zenodo upload, so the two documents can be cross-referenced by "@id" alone. ``pub_info_path`` is the xlsx ``pub_info`` was parsed from; when given it is recorded as an input alongside the protocol and BattINFO xlsx files. Dataset details from the BattINFO sheet take priority over ``pub_info``, which takes priority over defaults. - ``zenodo_package`` controls how the resolved "dcat:downloadURL" (a separate property - from "@id") points at Zenodo-hosted files, once the Zenodo record is known from the - BattINFO sheet's Dataset URL or ``pub_info["zenodo_doi_url"]``: "files" (default) - points directly at each file's own Zenodo download URL (matching an unzipped - upload); "zip" points at one zip's Zenodo download URL with the + ``zip_filename`` is the zip the root folder is packaged into for Zenodo. With it, and + a Zenodo record from the BattINFO sheet's Dataset URL or ``pub_info["zenodo_doi_url"]``, + each file gets a "dcat:downloadURL" (separate from its "@id") into that zip, with the in-archive path as a fragment (see :func:`pyflowbatt.battinfo.zenodo_download_url`). ``reporter`` is told when each step starts and finishes, to show progress. @@ -979,7 +977,6 @@ def analyse_sample( if battinfo_json is not None: with reporter.task("Metadata"): - zenodo_zip_filename = pub_info.get("zenodo_zip_filename") or f"{root_folder.name}.zip" def describe(rel: str, default: str | None) -> str | None: """Prefer the BattINFO sheet's description of a file over pyflowbatt's.""" @@ -992,8 +989,7 @@ def describe(rel: str, default: str | None) -> str | None: rel, zenodo_url, extras={"rdfs:comment": describe(rel, raw_description(label, path))}, - package=zenodo_package, - zip_filename=zenodo_zip_filename, + zip_filename=zip_filename, ) battinfo_json = battinfo.merge_jsonld_on_type([battinfo_json, snippet]) input_files = [ @@ -1007,8 +1003,7 @@ def describe(rel: str, default: str | None) -> str | None: rel, zenodo_url, describe(rel, EXTRA_INPUT_DESCRIPTIONS[label]), - package=zenodo_package, - zip_filename=zenodo_zip_filename, + zip_filename=zip_filename, ) battinfo_json = battinfo.merge_jsonld_on_type([battinfo_json, snippet]) for label, label_paths in tracked_outputs.items(): @@ -1026,8 +1021,7 @@ def describe(rel: str, default: str | None) -> str | None: rel, zenodo_url, extras=extras, - package=zenodo_package, - zip_filename=zenodo_zip_filename, + zip_filename=zip_filename, ) battinfo_json = battinfo.merge_jsonld_on_type([battinfo_json, snippet]) for rel in sheet_files: @@ -1182,17 +1176,15 @@ def analyse_all_samples( # noqa: PLR0913 """Take a folder and run all analysis. If ``zip_output`` is True, everything is zipped into a single zip file next to - ``folder`` once analysis finishes, and BattINFO "@id"s reference that zip's Zenodo - download URL with the in-archive path as a fragment. Otherwise (the default), - files are left unzipped and "@id"s reference each file's own Zenodo download URL - directly, for uploading every file individually. + ``folder`` once analysis finishes, for uploading to Zenodo. The zip holds an RO-Crate + manifest, and BattINFO files get download URLs into the zip. Otherwise (the default) + only the analysis results and BattINFO metadata are written. ``reporter`` is told when each sample and step starts and finishes, to show progress. """ folder = Path(folder).resolve() reporter = reporter or Reporter() pub_info, pub_info_path = pub_info_from_root(folder) - zenodo_package: Literal["zip", "files"] = "zip" if zip_output else "files" zip_filename = pub_info.get("zenodo_zip_filename") or f"{folder.name}.zip" if zip_output: check_path_writable(folder.parent / zip_filename) @@ -1228,7 +1220,7 @@ def analyse_all_samples( # noqa: PLR0913 pub_info_path=pub_info_path, config=configs_by_sample_folder[sample_folder], root_folder=folder, - zenodo_package=zenodo_package, + zip_filename=zip_filename if zip_output else None, reporter=reporter, ) @@ -1238,8 +1230,7 @@ def analyse_all_samples( # noqa: PLR0913 reporter.plan( [ *(["Combined summary"] if len(sample_folders) > 1 else []), - "RO-Crate", - *(["Zip"] if zip_output else []), + *(["RO-Crate", "Zip"] if zip_output else []), ] ) if len(sample_folders) > 1: @@ -1256,24 +1247,27 @@ def analyse_all_samples( # noqa: PLR0913 workbook.close() logger.debug("Combined all the results into one big summary") - with reporter.task("RO-Crate", detail="ro-crate-metadata.json"): - write_rocrate( - folder, - sample_folders, - tracked_by_sample_folder, - tracked_extras_by_sample_folder, - fcids_by_sample_folder, - configs_by_sample_folder, - sample_ids_by_sample_folder, - raw_inputs_by_sample_folder, - ) - logger.debug("Written RO-Crate metadata to %s", folder / "ro-crate-metadata.json") - if zip_output: + crate_path = folder / ROCRATE_FILENAME zip_path = folder.parent / zip_filename - with reporter.task("Zip", detail=str(zip_path)): - zip_folder(folder, zip_path) - logger.debug("Zipped everything into %s", zip_path) + try: + with reporter.task("RO-Crate", detail=ROCRATE_FILENAME): + write_rocrate( + folder, + sample_folders, + tracked_by_sample_folder, + tracked_extras_by_sample_folder, + fcids_by_sample_folder, + configs_by_sample_folder, + sample_ids_by_sample_folder, + raw_inputs_by_sample_folder, + ) + with reporter.task("Zip", detail=str(zip_path)): + zip_folder(folder, zip_path) + logger.debug("Zipped everything into %s", zip_path) + finally: + # The ro-crate metadata only exists in the zip + crate_path.unlink(missing_ok=True) reporter.finish() @@ -1332,11 +1326,16 @@ def dry_analyse_all_samples( ) if zip_output: zip_filename = pub_info.get("zenodo_zip_filename") or f"{folder.name}.zip" - logger.info("Then I would zip everything and output to %s.", folder.parent / zip_filename) + logger.info( + "Then I would write an RO-Crate manifest, zip everything and output to %s.", + folder.parent / zip_filename, + ) logger.info("BattINFO metadata would reference files via that zip's download URL.") else: - logger.info("Files would be left unzipped (pass --zip to bundle them into one zip).") - logger.info("BattINFO metadata would reference each file's own download URL.") + logger.info( + "Files would be left unzipped (pass --zip to package them with an RO-Crate " + "manifest for Zenodo)." + ) def pub_info_from_root(folder: Path) -> tuple[dict, Path | None]: diff --git a/pyflowbatt/battinfo.py b/pyflowbatt/battinfo.py index 6a06a66..de38793 100644 --- a/pyflowbatt/battinfo.py +++ b/pyflowbatt/battinfo.py @@ -620,44 +620,31 @@ def zenodo_download_url( root_rel_path: str, zenodo_doi_url: str | None, *, - package: Literal["zip", "files"] = "files", zip_filename: str | None = None, ) -> str | None: - """Build the Zenodo download URL for a file, once it is uploaded to Zenodo. + """Build the Zenodo download URL for a file inside the zip uploaded to Zenodo. This is the resolved *location* of the file (used for "dcat:downloadURL"), kept separate from its "@id" (identity), which stays the bare root-relative path everywhere so it matches the file's "@id" in the RO-Crate manifest. + Zenodo has no folders, so the root folder is uploaded as one zip. Zenodo cannot + link into a zip, so the path within it is a fragment after "#". Args: root_rel_path: path to the file relative to the root folder that gets - uploaded to Zenodo, e.g. sample_01/results/eis_post.bdf.parquet + zipped, e.g. sample_01/results/eis_post.bdf.parquet zenodo_doi_url: Zenodo archive DOI URL, e.g. https://doi.org/10.5281/zenodo.12345678 - package: "zip" if the whole root folder is uploaded as a single zip (Zenodo - has no URL that deep-links into a file inside that zip, so the fragment - after "#" is only useful once the zip has been downloaded and extracted), - or "files" if every file is uploaded individually, preserving the folder - structure in the file's Zenodo "key" (in which case a direct download URL - can be built for it). - zip_filename: name of the zip file as uploaded to Zenodo (required when - package="zip"). + zip_filename: name of the zip file as uploaded to Zenodo Returns: - A Zenodo URL identifying the file's download location, or None if no - zenodo_doi_url is given yet. + A Zenodo URL identifying the file's download location, or None without + both a zenodo_doi_url and a zip_filename. """ - if not zenodo_doi_url: + if not zenodo_doi_url or not zip_filename: return None - record_id = zenodo_record_id(zenodo_doi_url) base = f"https://zenodo.org/records/{record_id}/files" - if package == "files": - return f"{base}/{quote(root_rel_path)}" - - if not zip_filename: - msg = "zip_filename is required when package='zip'" - raise ValueError(msg) return f"{base}/{quote(zip_filename, safe='')}#{quote(root_rel_path)}" @@ -666,7 +653,6 @@ def add_input_data( zenodo_doi_url: str | None, comment: str | None = None, *, - package: Literal["zip", "files"] = "files", zip_filename: str | None = None, ) -> dict: """Add links to raw input files to the hasInput section of json-ld output. @@ -676,8 +662,7 @@ def add_input_data( e.g. sample_01/cell01_GCPL_01.mpr zenodo_doi_url: optional Zenodo archive URL comment: human-readable description of what this file is - package: see :func:`zenodo_file_id` - zip_filename: see :func:`zenodo_file_id` + zip_filename: see :func:`zenodo_download_url` Returns: dict with "BatteryTest" as top level type. @@ -688,9 +673,7 @@ def add_input_data( "@type": "dcat:Distribution", "dcat:mediaType": _media_type_node(rel_file_path), } - download_url = zenodo_download_url( - rel_file_path, zenodo_doi_url, package=package, zip_filename=zip_filename - ) + download_url = zenodo_download_url(rel_file_path, zenodo_doi_url, zip_filename=zip_filename) if download_url: dist["dcat:downloadURL"] = {"@id": download_url} if comment: @@ -710,7 +693,6 @@ def add_data( zenodo_doi_url: str | None, extras: dict | None = None, *, - package: Literal["zip", "files"] = "files", zip_filename: str | None = None, ) -> dict: """Add links to data files to output section of json-ld output. @@ -720,8 +702,7 @@ def add_data( e.g. empa__fcid01345/empa__fcid01345.bdf.parquet zenodo_doi_url: path to zenodo archive e.g. https://doi.org/10.1234/zenodo.12345678 extras: dict with any extra terms to include in metadata - package: see :func:`zenodo_file_id` - zip_filename: see :func:`zenodo_file_id` + zip_filename: see :func:`zenodo_download_url` Returns: dict with "BatteryTest" as top level type. @@ -771,9 +752,7 @@ def add_data( **additions, **extras, } - download_url = zenodo_download_url( - rel_file_path, zenodo_doi_url, package=package, zip_filename=zip_filename - ) + download_url = zenodo_download_url(rel_file_path, zenodo_doi_url, zip_filename=zip_filename) if download_url: dist["dcat:downloadURL"] = {"@id": download_url} @@ -967,9 +946,7 @@ def parse_zenodo_info_xlsx( dict with zenodo info. The "General" sheet's rows are lowercased and underscored into keys, e.g. a "Zenodo Doi Url" row becomes "zenodo_doi_url". An optional "Zenodo Zip Filename" row overrides the default zip filename - (``.zip``) used by :func:`zenodo_file_id` when the CLI's - ``--zip`` flag is used; whether "zip" or "files" packaging applies is decided - by that flag, not by anything in this sheet. + (``.zip``) that the CLI's ``--zip`` flag writes. """ if isinstance(xlsx_file, Path): diff --git a/pyflowbatt/cli.py b/pyflowbatt/cli.py index ccc22ae..af33929 100644 --- a/pyflowbatt/cli.py +++ b/pyflowbatt/cli.py @@ -137,10 +137,9 @@ def main() -> None: "--zip", action="store_true", help=( - "Zip the analysed folder afterwards and reference files in BattINFO metadata via " - "the zip's Zenodo download URL (for uploading one zip). Default: leave files " - "unzipped and reference each one via its own Zenodo download URL (for uploading " - "every file individually)." + "Package the analysed folder for Zenodo: zip it with an RO-Crate manifest inside, " + "and reference files in BattINFO metadata via the zip's Zenodo download URL. " + "Default: only analyse, without a zip or RO-Crate." ), ) parser.add_argument( diff --git a/tests/test_config.py b/tests/test_config.py index 007b077..87762ed 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -753,10 +753,10 @@ def _all_download_urls(obj: object) -> set[str]: return found -def test_analyse_sample_default_package_is_files_and_root_relative( +def test_analyse_sample_without_zip_has_no_download_urls( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - """Default zenodo_package="files": @id stays root-relative, downloadURL is per-file.""" + """Without a zip, @id stays root-relative and @base is set, but nothing has a downloadURL.""" from pyflowbatt import analysis as analysis_module root = tmp_path / "project" @@ -778,10 +778,7 @@ def test_analyse_sample_default_package_is_files_and_root_relative( metadata = json.loads((sample / "metadata.empa__fcid123456.json").read_text()) ids = _all_ids(metadata) assert "sample_01/metadata.xlsx" in ids - - urls = _all_download_urls(metadata) - expected = "https://zenodo.org/records/20338409/files/sample_01/metadata.xlsx" - assert expected in urls + assert _all_download_urls(metadata) == set() context = metadata["@context"] local_terms = next(c for c in context if isinstance(c, dict)) @@ -868,12 +865,14 @@ def test_analyse_sample_zenodo_record_from_sheet_without_pub_info( monkeypatch.setattr(analysis_module, "convert_excel_to_jsonld", _fake_convert_with_references) config = analysis_module.PyFlowBattConfig.load(sample, home=tmp_path / "home") - analysis_module.analyse_sample(sample, save_format=None, config=config, root_folder=root) + analysis_module.analyse_sample( + sample, save_format=None, config=config, root_folder=root, zip_filename="project.zip" + ) metadata = json.loads((sample / "metadata.empa__fcid123456.json").read_text()) local_terms = next(c for c in metadata["@context"] if isinstance(c, dict)) assert local_terms["@base"] == "https://zenodo.org/records/20338409/" - expected = "https://zenodo.org/records/20338409/files/sample_01/metadata.xlsx" + expected = "https://zenodo.org/records/20338409/files/project.zip#sample_01/metadata.xlsx" assert expected in _all_download_urls(metadata) @@ -985,20 +984,21 @@ def test_analyse_sample_records_pub_info_xlsx_as_input( root_folder=root, pub_info={"zenodo_doi_url": "https://doi.org/10.5281/zenodo.20338409"}, pub_info_path=pub_info_xlsx, + zip_filename="project.zip", ) assert extra_inputs["pub_info"] == [pub_info_xlsx] metadata = json.loads((sample / "metadata.empa__fcid123456.json").read_text()) assert "publication_info.xlsx" in _all_ids(metadata["hasInput"]) - expected = "https://zenodo.org/records/20338409/files/publication_info.xlsx" + expected = "https://zenodo.org/records/20338409/files/project.zip#publication_info.xlsx" assert expected in _all_download_urls(metadata["hasInput"]) -def test_analyse_sample_zip_package_id_points_at_zip_with_path_fragment( +def test_analyse_sample_zip_download_url_points_at_zip_with_path_fragment( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - """zenodo_package="zip": @id stays root-relative, downloadURL is the zip with a fragment.""" + """With a zip, @id stays root-relative, downloadURL is the zip with a fragment.""" from pyflowbatt import analysis as analysis_module root = tmp_path / "project" @@ -1014,7 +1014,7 @@ def test_analyse_sample_zip_package_id_points_at_zip_with_path_fragment( save_format=None, config=config, root_folder=root, - zenodo_package="zip", + zip_filename="project.zip", pub_info={"zenodo_doi_url": "https://doi.org/10.5281/zenodo.20338409"}, ) @@ -1031,7 +1031,7 @@ def test_analyse_sample_zip_package_id_points_at_zip_with_path_fragment( assert local_terms["@base"] == "https://zenodo.org/records/20338409/" -def test_analyse_sample_zip_package_base_shared_across_zips_in_one_record( +def test_analyse_sample_base_shared_across_zips_in_one_record( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: """Two zips in the same record share "@base": it's scoped to the record, not the zip. @@ -1059,11 +1059,8 @@ def test_analyse_sample_zip_package_base_shared_across_zips_in_one_record( save_format=None, config=config, root_folder=root, - zenodo_package="zip", - pub_info={ - "zenodo_doi_url": "https://doi.org/10.5281/zenodo.20338409", - "zenodo_zip_filename": zip_filename, - }, + zip_filename=zip_filename, + pub_info={"zenodo_doi_url": "https://doi.org/10.5281/zenodo.20338409"}, ) metadata = json.loads((sample / "metadata.empa__fcid123456.json").read_text()) assert "sample_01/metadata.xlsx" in _all_ids(metadata) @@ -1075,6 +1072,27 @@ def test_analyse_sample_zip_package_base_shared_across_zips_in_one_record( assert download_urls[0] != download_urls[1] +@pytest.mark.parametrize("zip_output", [False, True]) +def test_analyse_all_samples_rocrate_only_inside_zip( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, *, zip_output: bool +) -> None: + """The RO-Crate is only written with a zip, and then only inside it.""" + from pyflowbatt import analysis as analysis_module + + monkeypatch.setattr(Path, "home", lambda: tmp_path / "home") + root = tmp_path / "project" + _write_synthetic_sample(root / "sample_01") + + analysis_module.analyse_all_samples(root, save_format=None, zip_output=zip_output) + + assert not (root / "ro-crate-metadata.json").exists() + zip_path = tmp_path / "project.zip" + assert zip_path.exists() == zip_output + if zip_output: + with zipfile.ZipFile(zip_path) as zf: + assert "ro-crate-metadata.json" in zf.namelist() + + def test_analyse_sample_no_zenodo_url_keeps_bare_relative_path( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -2059,7 +2077,7 @@ def test_battinfo_distribution_values_are_iris( dcat = Namespace(DCAT_NS) csvw = Namespace("http://www.w3.org/ns/csvw#") - dist = add_data(rel_path, "https://doi.org/10.5281/zenodo.12345678") + dist = add_data(rel_path, "https://doi.org/10.5281/zenodo.12345678", zip_filename="data.zip") doc = { "@context": {"@base": "https://example.org/", "dcat": str(dcat), "csvw": str(csvw)}, **dist["hasOutput"]["dcat:distribution"], From 8639d203f4852f1153684aee823f82ead02bcc31 Mon Sep 17 00:00:00 2001 From: Graham Kimbell <88666181+g-kimbell@users.noreply.github.com> Date: Thu, 22 Oct 2026 17:02:56 +0200 Subject: [PATCH 06/15] Inputs are just dcat:Dataset, not RawData --- pyflowbatt/battinfo.py | 11 ++++++++--- tests/test_config.py | 7 ++++++- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/pyflowbatt/battinfo.py b/pyflowbatt/battinfo.py index de38793..3dae6ef 100644 --- a/pyflowbatt/battinfo.py +++ b/pyflowbatt/battinfo.py @@ -480,6 +480,9 @@ def pop_sheet_files(battinfo_jsonld: dict) -> dict[str, str | None]: path = _sheet_file_path(item) if isinstance(item, dict) else None if path: files[path] = item.get("rdfs:comment") + # A section that only described files would leave its @type on pyflowbatt's files + if set(node) <= {"@type"}: + del battinfo_jsonld[section] return files @@ -655,11 +658,13 @@ def add_input_data( *, zip_filename: str | None = None, ) -> dict: - """Add links to raw input files to the hasInput section of json-ld output. + """Add links to input files, like protocols and metadata sheets, to the hasInput section. + + Raw data files are outputs of the BatteryTest, see :func:`add_data`. Args: rel_file_path: path to file relative to the root folder uploaded to Zenodo, - e.g. sample_01/cell01_GCPL_01.mpr + e.g. sample_01/cell01_GCPL.mps zenodo_doi_url: optional Zenodo archive URL comment: human-readable description of what this file is zip_filename: see :func:`zenodo_download_url` @@ -682,7 +687,7 @@ def add_input_data( return { "@type": "BatteryTest", "hasInput": { - "@type": ["RawData", "dcat:Dataset"], + "@type": "dcat:Dataset", "dcat:distribution": dist, }, } diff --git a/tests/test_config.py b/tests/test_config.py index 87762ed..ed7d712 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -917,6 +917,10 @@ def fake_convert(file: Path) -> dict: {"@id": f"{zip_url}#sample_01/metadata.xlsx", "rdfs:comment": "Sheet description"}, {"@id": f"{zip_url}#empa__fcid000001/data.parquet", "rdfs:comment": "Other cell"}, ] + jsonld["hasInput"] = { + "@type": ["RawData", "dcat:Dataset"], + "dcat:distribution": {"@id": f"{zip_url}#empa__fcid000001/raw.mpr"}, + } return jsonld monkeypatch.setattr(analysis_module, "convert_excel_to_jsonld", fake_convert) @@ -941,6 +945,7 @@ def fake_convert(file: Path) -> dict: xlsx = next(d for d in (inputs if isinstance(inputs, list) else [inputs])) assert xlsx["@id"] == "sample_01/metadata.xlsx" assert xlsx["rdfs:comment"] == "Sheet description" + assert metadata["hasInput"]["@type"] == "dcat:Dataset" assert "lists empa__fcid000001/data.parquet, which is not a file" in caplog.text @@ -958,7 +963,7 @@ def test_pop_sheet_files_recovers_paths(node: dict, expected: str) -> None: jsonld = {"hasInput": {"@type": "dcat:Dataset", "dcat:distribution": node}} assert pop_sheet_files(jsonld) == {expected: None} - assert jsonld == {"hasInput": {"@type": "dcat:Dataset"}} + assert jsonld == {} def test_analyse_sample_records_pub_info_xlsx_as_input( From e1cd26068f5ffec1f8c4904730a9b73360d3e14e Mon Sep 17 00:00:00 2001 From: Graham Kimbell <88666181+g-kimbell@users.noreply.github.com> Date: Fri, 23 Oct 2026 09:49:51 +0200 Subject: [PATCH 07/15] Dataset details: ref sheet > pub info > defaults --- pyflowbatt/analysis.py | 8 ++- pyflowbatt/rocrate_output.py | 75 ++++++++++++++++++------ tests/test_config.py | 107 +++++++++++++++++++++++++++++++++++ 3 files changed, 173 insertions(+), 17 deletions(-) diff --git a/pyflowbatt/analysis.py b/pyflowbatt/analysis.py index 472e979..2910425 100644 --- a/pyflowbatt/analysis.py +++ b/pyflowbatt/analysis.py @@ -1224,7 +1224,7 @@ def analyse_all_samples( # noqa: PLR0913 reporter=reporter, ) - from pyflowbatt.rocrate_output import write_rocrate # noqa: PLC0415 + from pyflowbatt.rocrate_output import agreed_dataset_details, write_rocrate # noqa: PLC0415 with reporter.section("Finishing"): reporter.plan( @@ -1252,6 +1252,11 @@ def analyse_all_samples( # noqa: PLR0913 zip_path = folder.parent / zip_filename try: with reporter.task("RO-Crate", detail=ROCRATE_FILENAME): + battinfo_docs = [ + json.loads(path.read_text(encoding="utf-8")) + for tracked in tracked_by_sample_folder.values() + for path in tracked.get("metadata", []) + ] write_rocrate( folder, sample_folders, @@ -1261,6 +1266,7 @@ def analyse_all_samples( # noqa: PLR0913 configs_by_sample_folder, sample_ids_by_sample_folder, raw_inputs_by_sample_folder, + dataset_details=agreed_dataset_details(battinfo_docs), ) with reporter.task("Zip", detail=str(zip_path)): zip_folder(folder, zip_path) diff --git a/pyflowbatt/rocrate_output.py b/pyflowbatt/rocrate_output.py index 316854f..744c298 100644 --- a/pyflowbatt/rocrate_output.py +++ b/pyflowbatt/rocrate_output.py @@ -2,6 +2,7 @@ from __future__ import annotations +import logging from datetime import datetime from pathlib import Path @@ -24,6 +25,8 @@ from pyflowbatt.media_types import media_type from pyflowbatt.version import __title__, __url__, __version__ +logger = logging.getLogger(__name__) + MEASUREMENT_LABELS: dict[str, str] = { "gcpl": "Galvanostatic Cycling with Potential Limitation", "ocv": "Open Circuit Voltage", @@ -52,6 +55,15 @@ def _measurement_technique(label: str) -> str: LICENSE_URL = "https://creativecommons.org/licenses/by/4.0/" +# BattINFO dataset terms: RO-Crate root dataset property +DATASET_TERMS = { + "dcterms:title": "name", + "dcterms:description": "description", + "dcterms:license": "license", + "schema:datePublished": "datePublished", + "dcat:accessURL": "identifier", +} + def _unwrap_len_1_list(entities: list) -> object | None: """Unwrap a one element list.""" @@ -87,22 +99,45 @@ def _classify_inputs( return classify_technique_files(sample_folder, config) -def _add_license(crate: ROCrate) -> None: +def _add_license(crate: ROCrate, license_url: str = LICENSE_URL) -> None: """Describe the crate licence as a contextual entity, not just a bare URL.""" - license_entity = crate.add( - ContextEntity( - crate, - identifier=LICENSE_URL, - properties={ - "@type": "CreativeWork", - "name": "CC BY 4.0", - "description": "Creative Commons Attribution 4.0 International License", - }, - ) - ) + properties = {"@type": "CreativeWork"} + if license_url == LICENSE_URL: + properties["name"] = "CC BY 4.0" + properties["description"] = "Creative Commons Attribution 4.0 International License" + license_entity = crate.add(ContextEntity(crate, identifier=license_url, properties=properties)) crate.root_dataset["license"] = license_entity +def _iri(value: object) -> object: + """Return the IRI of a JSON-LD node reference, or the value itself.""" + return value["@id"] if isinstance(value, dict) and "@id" in value else value + + +def agreed_dataset_details(battinfo_docs: list[dict]) -> dict[str, object]: + """Root dataset properties that every sample's BattINFO metadata agrees on. + + A term the samples disagree on is left out with a warning, so the crate keeps its own. + """ + details: dict[str, object] = {} + for term, prop in DATASET_TERMS.items(): + values: list[object] = [] + for doc in battinfo_docs: + output = doc.get("hasOutput") + value = _iri(output.get(term)) if isinstance(output, dict) else None + if value is not None and value not in values: + values.append(value) + if len(values) == 1: + details[prop] = values[0] + elif values: + logger.warning( + "Samples' BattINFO metadata disagree on %s, the RO-Crate uses its own value: %s", + term, + values, + ) + return details + + def _add_software_provenance(crate: ROCrate) -> None: """Record which PyFlowBatt release built the crate.""" software = crate.add( @@ -136,19 +171,27 @@ def write_rocrate( configs_by_sample_folder: dict[Path, PyFlowBattConfig] | None = None, sample_ids_by_sample_folder: dict[Path, str] | None = None, raw_inputs_by_sample_folder: dict[Path, dict[str, list[Path]]] | None = None, + dataset_details: dict[str, object] | None = None, ) -> None: """Write ro-crate-metadata.json at root_folder describing all inputs and outputs. `raw_inputs_by_sample_folder` is what `analyse_sample` found, so the crate and the BattINFO JSON-LD describe the same files. Without it the folders are re-classified, which finds the same technique files but no unmatched raw data. + + `dataset_details` sets root dataset properties. """ + details = dataset_details or {} crate = ROCrate() - crate.root_dataset["name"] = root_folder.name - crate.root_dataset["description"] = ( - f"Flow battery electrochemical analysis produced by PyFlowBatt for {root_folder.name}" + crate.root_dataset["name"] = details.get("name", root_folder.name) + crate.root_dataset["description"] = details.get( + "description", + f"Flow battery electrochemical analysis produced by PyFlowBatt for {root_folder.name}", ) - _add_license(crate) + for prop in ("datePublished", "identifier"): + if prop in details: + crate.root_dataset[prop] = details[prop] + _add_license(crate, str(details.get("license", LICENSE_URL))) _add_software_provenance(crate) all_sample_datasets = [] diff --git a/tests/test_config.py b/tests/test_config.py index ed7d712..51b015d 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1098,6 +1098,113 @@ def test_analyse_all_samples_rocrate_only_inside_zip( assert "ro-crate-metadata.json" in zf.namelist() +def test_agreed_dataset_details_keeps_agreement_and_warns( + caplog: pytest.LogCaptureFixture, +) -> None: + """Terms all samples agree on are mapped, conflicting ones are left out with a warning.""" + from pyflowbatt.rocrate_output import agreed_dataset_details + + docs = [ + { + "hasOutput": { + "dcterms:title": "Title", + "dcterms:license": {"@id": "https://creativecommons.org/licenses/by/4.0/"}, + "schema:datePublished": "2026-08-05", + "dcat:accessURL": {"@id": SHEET_ZENODO_URL}, + } + }, + { + "hasOutput": { + "dcterms:title": "Title", + "dcterms:license": {"@id": "https://creativecommons.org/publicdomain/zero/1.0/"}, + "schema:datePublished": "2026-08-05", + } + }, + ] + with caplog.at_level("WARNING", logger="pyflowbatt"): + details = agreed_dataset_details(docs) + + assert details == { + "name": "Title", + "datePublished": "2026-08-05", + "identifier": SHEET_ZENODO_URL, + } + messages = [r.getMessage() for r in caplog.records] + assert len(messages) == 1 + assert "disagree on dcterms:license" in messages[0] + + +def test_write_rocrate_root_from_dataset_details(tmp_path: Path) -> None: + """Dataset details replace the crate's own root name, description, license and dates.""" + from pyflowbatt.rocrate_output import write_rocrate + + cc0 = "https://creativecommons.org/publicdomain/zero/1.0/" + write_rocrate( + tmp_path, + [], + {}, + dataset_details={ + "name": "Title", + "description": "Description", + "license": cc0, + "datePublished": "2026-08-05", + "identifier": SHEET_ZENODO_URL, + }, + ) + + crate = json.loads((tmp_path / "ro-crate-metadata.json").read_text(encoding="utf-8")) + entities = {e["@id"]: e for e in crate["@graph"]} + root = entities["./"] + assert root["name"] == "Title" + assert root["description"] == "Description" + assert root["license"] == {"@id": cc0} + assert entities[cc0]["@type"] == "CreativeWork" + assert root["datePublished"] == "2026-08-05" + assert root["identifier"] == SHEET_ZENODO_URL + + +def test_write_rocrate_root_defaults_without_dataset_details(tmp_path: Path) -> None: + """Without dataset details the root keeps the folder name and CC BY 4.0.""" + from pyflowbatt.rocrate_output import LICENSE_URL, write_rocrate + + write_rocrate(tmp_path, [], {}) + + crate = json.loads((tmp_path / "ro-crate-metadata.json").read_text(encoding="utf-8")) + entities = {e["@id"]: e for e in crate["@graph"]} + root = entities["./"] + assert root["name"] == tmp_path.name + assert root["license"] == {"@id": LICENSE_URL} + assert entities[LICENSE_URL]["name"] == "CC BY 4.0" + assert "identifier" not in root + + +def test_analyse_all_samples_rocrate_root_from_battinfo( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """The zipped RO-Crate root takes its name and identifier from the samples' BattINFO.""" + from pyflowbatt import analysis as analysis_module + + def fake_convert(file: Path) -> dict: + jsonld = _fake_convert_with_references(file) + jsonld["hasOutput"]["dcterms:title"] = "Dataset title" + return jsonld + + monkeypatch.setattr(Path, "home", lambda: tmp_path / "home") + monkeypatch.setattr(analysis_module, "convert_excel_to_jsonld", fake_convert) + root = tmp_path / "project" + for name in ("sample_01", "sample_02"): + sample = _write_synthetic_sample(root / name) + (sample / "metadata.xlsx").write_text("x") + + analysis_module.analyse_all_samples(root, save_format=None, zip_output=True) + + with zipfile.ZipFile(tmp_path / "project.zip") as zf: + crate = json.loads(zf.read("ro-crate-metadata.json")) + crate_root = next(e for e in crate["@graph"] if e["@id"] == "./") + assert crate_root["name"] == "Dataset title" + assert crate_root["identifier"] == SHEET_ZENODO_URL + + def test_analyse_sample_no_zenodo_url_keeps_bare_relative_path( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: From ad688fca18b220d0f5eab33692b6812b313b63f8 Mon Sep 17 00:00:00 2001 From: Graham Kimbell <88666181+g-kimbell@users.noreply.github.com> Date: Fri, 23 Oct 2026 10:21:24 +0200 Subject: [PATCH 08/15] Only zip files in ro-crate, add include_files --- pyflowbatt/analysis.py | 102 ++++++++++++++++++++++++++++++----- pyflowbatt/battinfo.py | 41 +++++++++++--- pyflowbatt/config.py | 16 ++++-- pyflowbatt/rocrate_output.py | 30 ++++++++--- tests/test_cli.py | 9 ++-- tests/test_config.py | 75 +++++++++++++++++++++++++- 6 files changed, 237 insertions(+), 36 deletions(-) diff --git a/pyflowbatt/analysis.py b/pyflowbatt/analysis.py index 2910425..d496777 100644 --- a/pyflowbatt/analysis.py +++ b/pyflowbatt/analysis.py @@ -8,6 +8,7 @@ import zipfile from pathlib import Path from typing import Literal +from urllib.parse import unquote import matplotlib.pyplot as plt import numpy as np @@ -123,8 +124,12 @@ OUTPUT_MISC_DESCRIPTIONS: dict[str, str] = { "summary": "Per-sample analysis summary", "metadata": "BattINFO JSON-LD metadata", + "included": "Additional file included with the dataset", } +# Label for files matched by include_files in pyflowbatt.toml +INCLUDED_LABEL = "included" + # Descriptions for extra tracked inputs (protocol file, BattINFO xlsx), keyed by label. EXTRA_INPUT_DESCRIPTIONS: dict[str, str] = { "protocol": "EC-Lab measurement protocol (.mps)", @@ -451,24 +456,61 @@ def check_path_writable(path: Path) -> None: probe.unlink() -def zip_folder(root_folder: Path, zip_path: Path) -> Path: - """Zip every file in root_folder into zip_path, with no wrapping top-level directory. +def find_included_files(folder: Path, patterns: list[str]) -> list[Path]: + """Files in folder matching any include_files glob pattern, in a stable order.""" + found = {path for pattern in patterns for path in folder.glob(pattern) if path.is_file()} + return sorted(found) + + +def _silently_skipped(file: Path) -> bool: + """Check for hidden EC-lab files, Excel lock files and the PyFlowBatt log.""" + return ( + file.suffix in {".mgr", ".sta", ".mpp", ".mgp"} + or file.name.startswith("~$") + or file.name == LOG_FILENAME + ) + + +def crate_file_paths(crate_path: Path) -> list[str]: + """Root-relative paths of the files an RO-Crate manifest describes.""" + crate = json.loads(crate_path.read_text(encoding="utf-8")) + paths = [] + for entity in crate["@graph"]: + types = entity["@type"] if isinstance(entity["@type"], list) else [entity["@type"]] + if "File" in types: + paths.append(unquote(entity["@id"])) + return paths + + +def zip_folder(root_folder: Path, zip_path: Path, members: list[str]) -> Path: + """Zip the listed root-relative files into zip_path, with no wrapping top-level directory. Archive members are root_folder-relative paths, matching the "@id"s used for files in both the RO-Crate manifest and BattINFO metadata, so a file's "@id" - can be found directly inside the zip once it's extracted. - - Ignores hidden EC-lab files, Excel lock files and the PyFlowBatt log. + can be found directly inside the zip once it's extracted. Other files in + root_folder are left out with a warning. """ + wanted = set(members) + skipped = [ + rel + for file in sorted(root_folder.rglob("*")) + if file.is_file() + and not _silently_skipped(file) + and (rel := _rel(file, root_folder)) not in wanted + ] + if skipped: + shown = ", ".join(skipped[:10]) + ( + f" and {len(skipped) - 10} more" if len(skipped) > 10 else "" + ) + logger.warning( + "Not zipping %d file(s) the metadata doesn't describe, " + "add them to include_files in pyflowbatt.toml to package them: %s", + len(skipped), + shown, + ) with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf: - for file in sorted(root_folder.rglob("*")): - if ( - file.is_file() - and file.suffix not in {".mgr", ".sta", ".mpp", ".mgp"} - and not file.name.startswith("~$") - and file.name != LOG_FILENAME - ): - zf.write(file, arcname=_rel(file, root_folder)) + for rel in sorted(wanted): + zf.write(root_folder / rel, arcname=rel) return zip_path @@ -975,6 +1017,20 @@ def analyse_sample( if pub_info_path and pub_info_path != battinfo_xlsx_path: tracked_extra_inputs["pub_info"] = [pub_info_path] + already_tracked = { + path + for group in (raw_inputs, tracked_extra_inputs, tracked_outputs) + for paths in group.values() + for path in paths + } + included = [ + path + for path in find_included_files(folder, config.include_files) + if path not in already_tracked + ] + if included: + tracked_outputs[INCLUDED_LABEL] = included + if battinfo_json is not None: with reporter.task("Metadata"): @@ -1009,6 +1065,15 @@ def describe(rel: str, default: str | None) -> str | None: for label, label_paths in tracked_outputs.items(): for path in label_paths: rel = _rel(path, root_folder) + if label == INCLUDED_LABEL: + snippet = battinfo.add_included_data( + rel, + zenodo_url, + describe(rel, OUTPUT_MISC_DESCRIPTIONS[label]), + zip_filename=zip_filename, + ) + battinfo_json = battinfo.merge_jsonld_on_type([battinfo_json, snippet]) + continue if path.suffix == ".png": comment = plot_description(label) elif rel.endswith((".parquet", ".csv")): @@ -1257,6 +1322,14 @@ def analyse_all_samples( # noqa: PLR0913 for tracked in tracked_by_sample_folder.values() for path in tracked.get("metadata", []) ] + root_included = [ + path + for path in find_included_files(folder, search_config.include_files) + if not any( + path in tracked.get(INCLUDED_LABEL, []) + for tracked in tracked_by_sample_folder.values() + ) + ] write_rocrate( folder, sample_folders, @@ -1267,9 +1340,10 @@ def analyse_all_samples( # noqa: PLR0913 sample_ids_by_sample_folder, raw_inputs_by_sample_folder, dataset_details=agreed_dataset_details(battinfo_docs), + root_included=root_included, ) with reporter.task("Zip", detail=str(zip_path)): - zip_folder(folder, zip_path) + zip_folder(folder, zip_path, [*crate_file_paths(crate_path), ROCRATE_FILENAME]) logger.debug("Zipped everything into %s", zip_path) finally: # The ro-crate metadata only exists in the zip diff --git a/pyflowbatt/battinfo.py b/pyflowbatt/battinfo.py index 3dae6ef..0d9c8b6 100644 --- a/pyflowbatt/battinfo.py +++ b/pyflowbatt/battinfo.py @@ -673,6 +673,38 @@ def add_input_data( dict with "BatteryTest" as top level type. """ + return { + "@type": "BatteryTest", + "hasInput": { + "@type": "dcat:Dataset", + "dcat:distribution": _file_node(rel_file_path, zenodo_doi_url, comment, zip_filename), + }, + } + + +def add_included_data( + rel_file_path: str, + zenodo_doi_url: str | None, + comment: str | None = None, + *, + zip_filename: str | None = None, +) -> dict: + """Add a file listed in include_files to the hasOutput section, without inferring a type.""" + return { + "@type": "BatteryTest", + "hasOutput": { + "dcat:distribution": _file_node(rel_file_path, zenodo_doi_url, comment, zip_filename), + }, + } + + +def _file_node( + rel_file_path: str, + zenodo_doi_url: str | None, + comment: str | None, + zip_filename: str | None, +) -> dict: + """Describe one file as a dcat:Distribution.""" dist: dict = { "@id": quote(rel_file_path), "@type": "dcat:Distribution", @@ -683,14 +715,7 @@ def add_input_data( dist["dcat:downloadURL"] = {"@id": download_url} if comment: dist["rdfs:comment"] = comment - - return { - "@type": "BatteryTest", - "hasInput": { - "@type": "dcat:Dataset", - "dcat:distribution": dist, - }, - } + return dist def add_data( diff --git a/pyflowbatt/config.py b/pyflowbatt/config.py index 224f16c..b1447da 100644 --- a/pyflowbatt/config.py +++ b/pyflowbatt/config.py @@ -137,6 +137,7 @@ def _toml_error_hint(error: str, line: str | None) -> str: "area_cm2", "assembled_resistance_ohm", "summary_n_cycles", + "include_files", }, "extra_patterns": set(_TECHNIQUE_KEYS), "eis_tags": set(), @@ -229,6 +230,7 @@ class PyFlowBattConfig: area_cm2 = 3.14 # electrode area used to normalise LSV resistance assembled_resistance_ohm = 25000 # external resistor value used in the cell assembly summary_n_cycles = [10, 20, 30, 40, 50] # cycle counts reported in the summary sheet + include_files = ["notes.txt", "photos/*.jpg"] # extra files packaged with --zip [extra_patterns] eis = ["*_EIS_*"] # adds to the built-in *_PEIS_* @@ -286,6 +288,8 @@ class PyFlowBattConfig: cv_min_r2: float = 0.8 # fit-quality gate below which CV capacitance isn't reported # cycle counts reported in the summary sheet (N cycles avg. CE/EE/VE/capacity) summary_n_cycles: list[int] = field(default_factory=lambda: [10, 20, 30, 40, 50]) + # glob patterns for extra files described in the metadata and packaged with --zip + include_files: list[str] = field(default_factory=list) @classmethod def load(cls, folder: str | Path, *, home: Path | None = None) -> PyFlowBattConfig: @@ -386,7 +390,7 @@ def _apply_toml(self, path: Path) -> None: "Ignoring bad eis.summary_params in %s (expected list of strings)", path ) - for key in ("extensions", "extra_extensions"): + for key in ("extensions", "extra_extensions", "include_files"): if key in data: values = data[key] if not isinstance(values, list) or not all(isinstance(v, str) for v in values): @@ -395,9 +399,10 @@ def _apply_toml(self, path: Path) -> None: if key == "extensions": self.extensions = list(values) else: + current = self.include_files if key == "include_files" else self.extensions for v in values: - if v not in self.extensions: - self.extensions.append(v) + if v not in current: + current.append(v) if "lsv_threshold" in data: if isinstance(data["lsv_threshold"], int): @@ -485,6 +490,11 @@ def _apply_toml(self, path: Path) -> None: ## --- Cycle counts reported in the summary sheet --- # summary_n_cycles = [10, 20, 30, 40, 50] +## --- Extra files to package with --zip --- +## Glob patterns, matched in each sample folder this file applies to. +## Otherwise only files used in analysis get zipped +# include_files = ["notes.txt", "photos/*.jpg"] + ## --- Extra filename glob patterns per technique (added to the built-in patterns below) --- # [extra_patterns] # gcpl = ["*_cycling_*"] # built-in: *_GCPL_* diff --git a/pyflowbatt/rocrate_output.py b/pyflowbatt/rocrate_output.py index 744c298..8a54775 100644 --- a/pyflowbatt/rocrate_output.py +++ b/pyflowbatt/rocrate_output.py @@ -11,6 +11,7 @@ from pyflowbatt.analysis import ( EXTRA_INPUT_DESCRIPTIONS, + INCLUDED_LABEL, OTHER_RAW_LABEL, OUTPUT_MISC_DESCRIPTIONS, _custom_eis_tag, @@ -78,6 +79,8 @@ def _output_description(label: str, path: Path) -> str | None: Shared with the descriptions `analyse_sample` writes into BattINFO JSON-LD, so a plot and its companion data file get distinct, consistent wording. """ + if label == INCLUDED_LABEL: + return OUTPUT_MISC_DESCRIPTIONS[label] if path.suffix == ".png": return plot_description(label) if path.suffix in (".parquet", ".csv"): @@ -172,6 +175,7 @@ def write_rocrate( sample_ids_by_sample_folder: dict[Path, str] | None = None, raw_inputs_by_sample_folder: dict[Path, dict[str, list[Path]]] | None = None, dataset_details: dict[str, object] | None = None, + root_included: list[Path] | None = None, ) -> None: """Write ro-crate-metadata.json at root_folder describing all inputs and outputs. @@ -179,7 +183,8 @@ def write_rocrate( BattINFO JSON-LD describe the same files. Without it the folders are re-classified, which finds the same technique files but no unmatched raw data. - `dataset_details` sets root dataset properties. + `dataset_details` sets root dataset properties. `root_included` are files outside + the sample folders matched by include_files. """ details = dataset_details or {} crate = ROCrate() @@ -274,7 +279,9 @@ def write_rocrate( extra_entities[label] = label_extra_ents for label, paths in tracked_by_sample_folder.get(sample_folder, {}).items(): - if label == "metadata": + if label == INCLUDED_LABEL: + derived_from = None + elif label == "metadata": derived_from = _unwrap_len_1_list( [ ent @@ -289,11 +296,9 @@ def write_rocrate( for path in paths: if not path.exists(): continue - props: dict = { - "name": path.name, - "encodingFormat": media_type(path), - "wasDerivedFrom": derived_from, - } + props: dict = {"name": path.name, "encodingFormat": media_type(path)} + if derived_from is not None: + props["wasDerivedFrom"] = derived_from desc = _output_description(label, path) if desc: props["description"] = desc @@ -317,4 +322,15 @@ def write_rocrate( }, ) + for path in root_included or []: + crate.add_file( + str(path), + dest_path=_rel(path, root_folder), + properties={ + "name": path.name, + "encodingFormat": media_type(path), + "description": OUTPUT_MISC_DESCRIPTIONS[INCLUDED_LABEL], + }, + ) + crate.write(str(root_folder)) diff --git a/tests/test_cli.py b/tests/test_cli.py index a4bb7c7..42cf235 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -127,14 +127,17 @@ def test_dry_run_writes_no_log_file(tmp_path: Path) -> None: assert not (tmp_path / "pyflowbatt.log").exists() -def test_zip_skips_log_file(tmp_path: Path) -> None: - """The log is not included with the zip.""" +def test_zip_skips_log_file(tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None: + """The log is not included with the zip, and is left out without a warning.""" root = tmp_path / "root" root.mkdir() (root / "data.csv").write_text("x") (root / "pyflowbatt.log").write_text("log") - with zipfile.ZipFile(zip_folder(root, tmp_path / "out.zip")) as zf: + with caplog.at_level("WARNING", logger="pyflowbatt"): + zip_path = zip_folder(root, tmp_path / "out.zip", ["data.csv"]) + with zipfile.ZipFile(zip_path) as zf: assert zf.namelist() == ["data.csv"] + assert caplog.records == [] @pytest.mark.parametrize("verbose", [False, True]) diff --git a/tests/test_config.py b/tests/test_config.py index 51b015d..9d33bce 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1237,13 +1237,86 @@ def test_zip_folder_uses_root_relative_arcnames_no_wrapping_directory(tmp_path: (root / "combined_summary.xlsx").write_bytes(b"fake xlsx") zip_path = tmp_path / "project.zip" - zip_folder(root, zip_path) + zip_folder(root, zip_path, ["sample_01/results/gcpl.png", "combined_summary.xlsx"]) with zipfile.ZipFile(zip_path) as zf: names = set(zf.namelist()) assert names == {"sample_01/results/gcpl.png", "combined_summary.xlsx"} +def test_zip_folder_skips_undescribed_files_with_warning( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """Files not in the member list stay out of the zip and are named in a warning.""" + from pyflowbatt.analysis import zip_folder + + root = tmp_path / "project" + (root / "sample_01").mkdir(parents=True) + (root / "sample_01" / "data.csv").write_text("x") + (root / "sample_01" / "old.bak").write_text("x") + + zip_path = tmp_path / "project.zip" + with caplog.at_level("WARNING", logger="pyflowbatt"): + zip_folder(root, zip_path, ["sample_01/data.csv"]) + + with zipfile.ZipFile(zip_path) as zf: + assert zf.namelist() == ["sample_01/data.csv"] + assert "Not zipping 1 file(s)" in caplog.text + assert "sample_01/old.bak" in caplog.text + + +def test_include_files_adds_up_across_configs(tmp_path: Path) -> None: + """include_files patterns from parent and sample configs are combined.""" + sample = tmp_path / "project" / "sample_01" + sample.mkdir(parents=True) + (sample.parent / "pyflowbatt.toml").write_text('include_files = ["*.txt"]\n') + (sample / "pyflowbatt.toml").write_text('include_files = ["photos/*.jpg", "*.txt"]\n') + + config = PyFlowBattConfig.load(sample, home=tmp_path / "home") + assert config.include_files == ["*.txt", "photos/*.jpg"] + + +def test_analyse_all_samples_zips_included_and_described_files_only( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + """include_files are described in BattINFO and the crate and zipped, strays are skipped.""" + from pyflowbatt import analysis as analysis_module + + monkeypatch.setattr(Path, "home", lambda: tmp_path / "home") + monkeypatch.setattr(analysis_module, "convert_excel_to_jsonld", _fake_convert) + root = tmp_path / "project" + sample = _write_synthetic_sample(root / "sample_01") + (sample / "metadata.xlsx").write_text("x") + (sample / "pyflowbatt.toml").write_text('include_files = ["photos/*.jpg"]\n') + (sample / "photos").mkdir() + (sample / "photos" / "cell.jpg").write_bytes(b"jpg") + (sample / "old.bak").write_text("x") + (root / "pyflowbatt.toml").write_text('include_files = ["README.md"]\n') + (root / "README.md").write_text("readme") + + with caplog.at_level("WARNING", logger="pyflowbatt"): + analysis_module.analyse_all_samples(root, save_format=None, zip_output=True) + + with zipfile.ZipFile(tmp_path / "project.zip") as zf: + names = set(zf.namelist()) + crate = json.loads(zf.read("ro-crate-metadata.json")) + assert {"sample_01/photos/cell.jpg", "README.md"} <= names + assert "sample_01/old.bak" not in names + assert "sample_01/old.bak" in caplog.text + + entities = {e["@id"]: e for e in crate["@graph"]} + photo = entities["sample_01/photos/cell.jpg"] + assert photo["description"] == "Additional file included with the dataset" + assert "wasDerivedFrom" not in photo + assert "README.md" in entities + + metadata = json.loads((sample / "metadata.empa__fcid123456.json").read_text()) + outputs = metadata["hasOutput"]["dcat:distribution"] + photo_node = next(d for d in outputs if d["@id"] == "sample_01/photos/cell.jpg") + assert photo_node["@type"] == "dcat:Distribution" + assert "csvw:tableSchema" not in photo_node + + def test_sample_id_custom_pattern(tmp_path: Path) -> None: """sample_name_pattern in pyflowbatt.toml replaces the default sample-ID regex.""" from pyflowbatt.analysis import get_sampleid_from_folderpath From 3d4d08e81b29eb4d82a280031c3501c61745a800 Mon Sep 17 00:00:00 2001 From: Graham Kimbell <88666181+g-kimbell@users.noreply.github.com> Date: Fri, 23 Oct 2026 10:57:18 +0200 Subject: [PATCH 09/15] Store resolved pyflowbatt tomls in zip Add inherit=False setting to tomls, to skip searching higher Add pyflowbatt descriptions to ro-crate --- pyflowbatt/analysis.py | 57 ++++++++++++++---- pyflowbatt/config.py | 112 ++++++++++++++++++++++++++++++++--- pyflowbatt/rocrate_output.py | 50 ++++++++++++++-- tests/test_config.py | 102 +++++++++++++++++++++++++++++++ 4 files changed, 294 insertions(+), 27 deletions(-) diff --git a/pyflowbatt/analysis.py b/pyflowbatt/analysis.py index d496777..e214a95 100644 --- a/pyflowbatt/analysis.py +++ b/pyflowbatt/analysis.py @@ -17,6 +17,7 @@ from pyflowbatt import battinfo, cv, eis, gcpl, lsv, ocv from pyflowbatt.config import ( + CONFIG_FILENAME, DEFAULT_AREA_CM2, EIS_TAGS, PyFlowBattConfig, @@ -135,6 +136,7 @@ "protocol": "EC-Lab measurement protocol (.mps)", "battinfo_xlsx": "BattINFO converter Excel metadata input", "pub_info": "Publication and Zenodo deposition Excel metadata input", + "settings": "PyFlowBatt settings used here, combining all pyflowbatt.toml files", } # Label for raw data files in a sample folder that no technique pattern matched. @@ -463,11 +465,14 @@ def find_included_files(folder: Path, patterns: list[str]) -> list[Path]: def _silently_skipped(file: Path) -> bool: - """Check for hidden EC-lab files, Excel lock files and the PyFlowBatt log.""" + """Check for hidden EC-lab files, Excel lock files, the PyFlowBatt log and config files. + + The zip holds resolved pyflowbatt.toml files in place of the originals. + """ return ( file.suffix in {".mgr", ".sta", ".mpp", ".mgp"} or file.name.startswith("~$") - or file.name == LOG_FILENAME + or file.name in {LOG_FILENAME, CONFIG_FILENAME} ) @@ -482,13 +487,33 @@ def crate_file_paths(crate_path: Path) -> list[str]: return paths -def zip_folder(root_folder: Path, zip_path: Path, members: list[str]) -> Path: +def resolved_settings( + root_folder: Path, + configs_by_sample_folder: dict[Path, PyFlowBattConfig], + root_config: PyFlowBattConfig, +) -> dict[str, str]: + """Write resolved pyflowbatt.toml text for the root and each sample folder, by path.""" + settings = { + _rel(sample_folder / CONFIG_FILENAME, root_folder): config.to_resolved_toml() + for sample_folder, config in configs_by_sample_folder.items() + } + settings.setdefault(CONFIG_FILENAME, root_config.to_resolved_toml()) + return settings + + +def zip_folder( + root_folder: Path, + zip_path: Path, + members: list[str], + generated: dict[str, str] | None = None, +) -> Path: """Zip the listed root-relative files into zip_path, with no wrapping top-level directory. Archive members are root_folder-relative paths, matching the "@id"s used for files in both the RO-Crate manifest and BattINFO metadata, so a file's "@id" can be found directly inside the zip once it's extracted. Other files in - root_folder are left out with a warning. + root_folder are left out with a warning. `generated` maps archive paths to text + written straight into the zip, never to root_folder. """ wanted = set(members) skipped = [ @@ -508,9 +533,12 @@ def zip_folder(root_folder: Path, zip_path: Path, members: list[str]) -> Path: len(skipped), shown, ) + generated = generated or {} with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf: - for rel in sorted(wanted): + for rel in sorted(wanted - set(generated)): zf.write(root_folder / rel, arcname=rel) + for rel, text in sorted(generated.items()): + zf.writestr(rel, text) return zip_path @@ -1052,6 +1080,8 @@ def describe(rel: str, default: str | None) -> str | None: *((path, "protocol") for path in mps_files), *((path, "battinfo_xlsx") for path in [battinfo_xlsx_path] if path), *((path, "pub_info") for path in tracked_extra_inputs.get("pub_info", [])), + # Only exists in the zip, written from the resolved settings + *([(folder / CONFIG_FILENAME, "settings")] if zip_filename else []), ] for path, label in input_files: rel = _rel(path, root_folder) @@ -1289,7 +1319,7 @@ def analyse_all_samples( # noqa: PLR0913 reporter=reporter, ) - from pyflowbatt.rocrate_output import agreed_dataset_details, write_rocrate # noqa: PLC0415 + from pyflowbatt.rocrate_output import write_rocrate # noqa: PLC0415 with reporter.section("Finishing"): reporter.plan( @@ -1315,13 +1345,9 @@ def analyse_all_samples( # noqa: PLR0913 if zip_output: crate_path = folder / ROCRATE_FILENAME zip_path = folder.parent / zip_filename + settings = resolved_settings(folder, configs_by_sample_folder, search_config) try: with reporter.task("RO-Crate", detail=ROCRATE_FILENAME): - battinfo_docs = [ - json.loads(path.read_text(encoding="utf-8")) - for tracked in tracked_by_sample_folder.values() - for path in tracked.get("metadata", []) - ] root_included = [ path for path in find_included_files(folder, search_config.include_files) @@ -1339,11 +1365,16 @@ def analyse_all_samples( # noqa: PLR0913 configs_by_sample_folder, sample_ids_by_sample_folder, raw_inputs_by_sample_folder, - dataset_details=agreed_dataset_details(battinfo_docs), root_included=root_included, + settings_files=list(settings), ) with reporter.task("Zip", detail=str(zip_path)): - zip_folder(folder, zip_path, [*crate_file_paths(crate_path), ROCRATE_FILENAME]) + zip_folder( + folder, + zip_path, + [*crate_file_paths(crate_path), ROCRATE_FILENAME], + generated=settings, + ) logger.debug("Zipped everything into %s", zip_path) finally: # The ro-crate metadata only exists in the zip diff --git a/pyflowbatt/config.py b/pyflowbatt/config.py index b1447da..1631f2e 100644 --- a/pyflowbatt/config.py +++ b/pyflowbatt/config.py @@ -3,11 +3,15 @@ from __future__ import annotations import difflib +import functools +import json import logging import re from dataclasses import dataclass, field from pathlib import Path +from pyflowbatt.version import __version__ + try: import tomllib except ImportError: @@ -30,6 +34,7 @@ # ------------ Warning/error messages for reading in settings files ---------- # _TOML_ERROR_LINE_RE = re.compile(r"at line (\d+)") +_TOML_BARE_KEY_RE = re.compile(r"^[A-Za-z0-9_-]+$") _TOML_TABLE_RE = re.compile(r"^\s*\[\s*([^\]]+?)\s*\]") _TOML_KEY_RE = re.compile(r"""^\s*["']?([^"'=\s]+)["']?\s*=""") @@ -138,6 +143,7 @@ def _toml_error_hint(error: str, line: str | None) -> str: "assembled_resistance_ohm", "summary_n_cycles", "include_files", + "inherit", }, "extra_patterns": set(_TECHNIQUE_KEYS), "eis_tags": set(), @@ -212,6 +218,33 @@ def _warn_misplaced_settings(data: dict, text: str, path: Path) -> None: logger.warning("Ignoring %s %s (%s): %s", key, where, location, hint) +@functools.cache +def _warn_home_ignored(home_config: Path) -> None: + """Warn once per run that settings are skipping the home config.""" + logger.warning( + "Not using %s where a pyflowbatt.toml sets inherit = false, " + "e.g. in a folder unpacked from a PyFlowBatt zip", + home_config, + ) + + +def _toml_key(key: str) -> str: + """Format a key as TOML, quoted only when it is not a bare key like v_max.""" + return key if _TOML_BARE_KEY_RE.match(key) else json.dumps(key) + + +def _toml_value(value: object) -> str: + """Format a setting value as TOML.""" + if isinstance(value, bool): + return "true" if value else "false" + if isinstance(value, (int, float)): + return repr(value) + if isinstance(value, list): + return "[" + ", ".join(_toml_value(v) for v in value) + "]" + # JSON string escapes are valid in TOML basic strings + return json.dumps(value) + + @dataclass class PyFlowBattConfig: r"""Configuration controlling how PyFlowBatt detects technique files and sample IDs. @@ -295,7 +328,9 @@ class PyFlowBattConfig: def load(cls, folder: str | Path, *, home: Path | None = None) -> PyFlowBattConfig: """Load config from ~/pyflowbatt.toml, the parent folder, and the sample folder. - Returns a config with only built-in defaults when no config file exists. + A file setting ``inherit = false`` is complete on its own, so the files below it + in priority are skipped. Returns a config with only built-in defaults when no + config file exists. """ folder = Path(folder).resolve() home = home if home is not None else Path.home() @@ -308,11 +343,17 @@ def load(cls, folder: str | Path, *, home: Path | None = None) -> PyFlowBattConf folder.parent / CONFIG_FILENAME, folder / CONFIG_FILENAME, ] - - for path in candidates: - if path.is_file(): - logger.debug("Loading PyFlowBatt config from %s", path) - config._apply_toml(path) + parsed = [(path, *_read_toml(path)) for path in candidates if path.is_file()] + for i in range(len(parsed) - 1, 0, -1): + if parsed[i][1].get("inherit") is False: + if any(path == candidates[0] for path, _, _ in parsed[:i]): + _warn_home_ignored(candidates[0]) + parsed = parsed[i:] + break + + for path, data, text in parsed: + logger.debug("Loading PyFlowBatt config from %s", path) + config._apply_toml(path, data, text) return config @@ -336,10 +377,61 @@ def all_patterns(self) -> list[str]: result.append(full) return result - def _apply_toml(self, path: Path) -> None: - """Merge a single TOML config file into this config (mutates in place).""" - data, text = _read_toml(path) + def to_resolved_toml(self) -> str: + """Write these settings as a pyflowbatt.toml that needs no other config file. + + Only settings that differ from the defaults are written, and inherit = false + stops home and parent configs filling in the rest when it is loaded. + """ + defaults = PyFlowBattConfig() + lines = [ + f"## Settings PyFlowBatt {__version__} used here, combining all pyflowbatt.toml files.", + "## inherit = false: no other pyflowbatt.toml is combined with this one.", + "inherit = false", + ] + for key in ( + "sample_name", + "sample_name_pattern", + "extensions", + "lsv_threshold", + "area_cm2", + "assembled_resistance_ohm", + "summary_n_cycles", + "include_files", + ): + value = getattr(self, key) + if value is not None and value != getattr(defaults, key): + lines.append(f"{key} = {_toml_value(value)}") + + sections: dict[str, dict[str, object]] = { + "extra_patterns": { + toml_key: [p for p in getattr(self, attr) if p not in getattr(defaults, attr)] + for toml_key, attr in _TECHNIQUE_KEYS.items() + }, + "eis_tags": dict(self.eis_tag_patterns), + "eis": {"model": self.eis_model, "summary_params": self.eis_summary_params}, + "cv": {toml_key: getattr(self, attr) for toml_key, attr in _CV_KEYS.items()}, + } + section_defaults: dict[str, dict[str, object]] = { + "eis": {"model": defaults.eis_model, "summary_params": defaults.eis_summary_params}, + "cv": {toml_key: getattr(defaults, attr) for toml_key, attr in _CV_KEYS.items()}, + } + for name, table in sections.items(): + changed = { + key: value + for key, value in table.items() + if value != [] and value != section_defaults.get(name, {}).get(key) + } + if changed: + lines += ["", f"[{name}]"] + lines += [f"{_toml_key(key)} = {_toml_value(v)}" for key, v in changed.items()] + return "\n".join(lines) + "\n" + + def _apply_toml(self, path: Path, data: dict, text: str) -> None: + """Merge a single parsed TOML config file into this config (mutates in place).""" _warn_misplaced_settings(data, text, path) + if "inherit" in data and not isinstance(data["inherit"], bool): + logger.warning("Ignoring bad inherit in %s (expected true or false)", path) extra = _section(data, "extra_patterns") for toml_key, attr in _TECHNIQUE_KEYS.items(): @@ -469,6 +561,8 @@ def _apply_toml(self, path: Path) -> None: ## The parent folder: applies to every sample folder inside ## The sample folder itself: only applies to that sample ## On conflicts, sample takes priority, then parent, then home. +## You can stop this behaviour and only use this file by setting inherit to false +# inherit = false ## --- Sample name (default: reads from battinfo, otherwise uses folder name) --- # sample_name = "daves-new-sample-01" # hard-code an explicit sample name diff --git a/pyflowbatt/rocrate_output.py b/pyflowbatt/rocrate_output.py index 8a54775..cee9fa6 100644 --- a/pyflowbatt/rocrate_output.py +++ b/pyflowbatt/rocrate_output.py @@ -2,7 +2,9 @@ from __future__ import annotations +import json import logging +import warnings from datetime import datetime from pathlib import Path @@ -22,7 +24,7 @@ plot_description, raw_description, ) -from pyflowbatt.config import PyFlowBattConfig, classify_technique_files +from pyflowbatt.config import CONFIG_FILENAME, PyFlowBattConfig, classify_technique_files from pyflowbatt.media_types import media_type from pyflowbatt.version import __title__, __url__, __version__ @@ -176,6 +178,7 @@ def write_rocrate( raw_inputs_by_sample_folder: dict[Path, dict[str, list[Path]]] | None = None, dataset_details: dict[str, object] | None = None, root_included: list[Path] | None = None, + settings_files: list[str] | None = None, ) -> None: """Write ro-crate-metadata.json at root_folder describing all inputs and outputs. @@ -183,10 +186,23 @@ def write_rocrate( BattINFO JSON-LD describe the same files. Without it the folders are re-classified, which finds the same technique files but no unmatched raw data. - `dataset_details` sets root dataset properties. `root_included` are files outside - the sample folders matched by include_files. + `dataset_details` sets root dataset properties, by default the ones the samples' + BattINFO metadata agree on. `root_included` are files outside the sample folders + matched by include_files. `settings_files` are root-relative paths of resolved + pyflowbatt.toml files that only exist in the zip, so they are described but never + written to root_folder. """ - details = dataset_details or {} + if dataset_details is None: + dataset_details = agreed_dataset_details( + [ + json.loads(path.read_text(encoding="utf-8")) + for tracked in tracked_by_sample_folder.values() + for path in tracked.get("metadata", []) + if path.exists() + ] + ) + details = dataset_details + pending_settings = set(settings_files or []) crate = ROCrate() crate.root_dataset["name"] = details.get("name", root_folder.name) crate.root_dataset["description"] = details.get( @@ -307,6 +323,11 @@ def write_rocrate( ) sample_file_entities.append(output_ent) + settings_rel = _rel(sample_folder / CONFIG_FILENAME, root_folder) + if settings_rel in pending_settings: + pending_settings.discard(settings_rel) + sample_file_entities.append(_add_settings_file(crate, settings_rel)) + sample_dataset["hasPart"] = sample_file_entities # type: ignore[index] combined_summary = root_folder / "combined_summary.xlsx" @@ -333,4 +354,23 @@ def write_rocrate( }, ) - crate.write(str(root_folder)) + for settings_rel in sorted(pending_settings): + _add_settings_file(crate, settings_rel) + + with warnings.catch_warnings(): + # Settings files have no source on disk, they are written into the zip instead + warnings.filterwarnings("ignore", message="No source for", category=UserWarning) + crate.write(str(root_folder)) + + +def _add_settings_file(crate: ROCrate, rel: str) -> object: + """Describe a resolved pyflowbatt.toml without a source, so the crate never writes it.""" + return crate.add_file( + None, + dest_path=rel, + properties={ + "name": CONFIG_FILENAME, + "encodingFormat": media_type(Path(rel)), + "description": EXTRA_INPUT_DESCRIPTIONS["settings"], + }, + ) diff --git a/tests/test_config.py b/tests/test_config.py index 9d33bce..e6957f8 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -10,6 +10,7 @@ from pyflowbatt import config as config_module from pyflowbatt.config import PyFlowBattConfig, classify_technique_files from pyflowbatt.rocrate_output import _classify_inputs +from pyflowbatt.version import __version__ # Synthetic filename scheme mirroring real EC-Lab exports: ___. # Sizes are included as the largest GCPL is used as cycling. @@ -1265,6 +1266,65 @@ def test_zip_folder_skips_undescribed_files_with_warning( assert "sample_01/old.bak" in caplog.text +def test_inherit_false_skips_home_and_parent_and_warns_once( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """A config with inherit = false is used alone, with one warning about the home config.""" + home = tmp_path / "home" + home.mkdir() + (home / "pyflowbatt.toml").write_text("area_cm2 = 9\nextra_extensions = ['.mgr']\n") + project = tmp_path / "project" + (project / "pyflowbatt.toml").parent.mkdir() + (project / "pyflowbatt.toml").write_text("assembled_resistance_ohm = 1000\n") + samples = [project / "sample_01", project / "sample_02"] + for sample in samples: + sample.mkdir() + (sample / "pyflowbatt.toml").write_text("inherit = false\nlsv_threshold = 3\n") + + with caplog.at_level("WARNING", logger="pyflowbatt"): + configs = [PyFlowBattConfig.load(sample, home=home) for sample in samples] + + for config in configs: + assert config == PyFlowBattConfig(lsv_threshold=3) + messages = [r.getMessage() for r in caplog.records] + assert len(messages) == 1 + assert str(home / "pyflowbatt.toml") in messages[0] + + +def test_resolved_toml_reproduces_settings_despite_other_home_config(tmp_path: Path) -> None: + """A resolved pyflowbatt.toml loads back to the same settings, whatever the home config.""" + home = tmp_path / "home" + home.mkdir() + (home / "pyflowbatt.toml").write_text( + 'extra_extensions = [".mgr"]\narea_cm2 = 3\n\n[extra_patterns]\ngcpl = ["*_GCD_*"]\n' + ) + sample = tmp_path / "project" / "sample_01" + sample.mkdir(parents=True) + (sample.parent / "pyflowbatt.toml").write_text( + 'include_files = ["*.txt"]\nsummary_n_cycles = [5, 10]\n\n[eis]\nmodel = "R0-(R1,CPE1)"\n' + ) + (sample / "pyflowbatt.toml").write_text( + 'sample_name = "cell \\"A\\""\nlsv_threshold = 4\n\n' + '[eis_tags]\n"pre-20%SOC" = ["*_03_PEIS_*"]\n\n[cv]\nv_max = 0.65\n' + ) + original = PyFlowBattConfig.load(sample, home=home) + resolved = original.to_resolved_toml() + + unpacked = tmp_path / "unpacked" / "sample_01" + unpacked.mkdir(parents=True) + (unpacked / "pyflowbatt.toml").write_text(resolved, encoding="utf-8") + other_home = tmp_path / "other_home" + other_home.mkdir() + (other_home / "pyflowbatt.toml").write_text( + 'assembled_resistance_ohm = 5\nextra_extensions = [".mpt"]\n\n[eis_tags]\n"post" = ["*"]\n' + ) + + assert PyFlowBattConfig.load(unpacked, home=other_home) == original + assert resolved.startswith(f"## Settings PyFlowBatt {__version__} used here") + assert "inherit = false" in resolved + assert "area_cm2 = 3.0" in resolved + + def test_include_files_adds_up_across_configs(tmp_path: Path) -> None: """include_files patterns from parent and sample configs are combined.""" sample = tmp_path / "project" / "sample_01" @@ -1317,6 +1377,48 @@ def test_analyse_all_samples_zips_included_and_described_files_only( assert "csvw:tableSchema" not in photo_node +def test_analyse_all_samples_zips_resolved_settings_without_touching_originals( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + """The zip holds resolved pyflowbatt.toml files that reproduce each folder's settings.""" + from pyflowbatt import analysis as analysis_module + + home = tmp_path / "home" + home.mkdir() + (home / "pyflowbatt.toml").write_text("area_cm2 = 3\n") + monkeypatch.setattr(Path, "home", lambda: home) + monkeypatch.setattr(analysis_module, "convert_excel_to_jsonld", _fake_convert) + root = tmp_path / "project" + sample = _write_synthetic_sample(root / "group" / "sample_01") + (sample / "metadata.xlsx").write_text("x") + sample_toml = "lsv_threshold = 4\n" + (sample / "pyflowbatt.toml").write_text(sample_toml) + (root / "group" / "pyflowbatt.toml").write_text("assembled_resistance_ohm = 1000\n") + original = analysis_module.PyFlowBattConfig.load(sample) + + with caplog.at_level("WARNING", logger="pyflowbatt"): + analysis_module.analyse_all_samples(root, save_format=None, zip_output=True) + + assert (sample / "pyflowbatt.toml").read_text() == sample_toml + assert "pyflowbatt.toml" not in caplog.text + with zipfile.ZipFile(tmp_path / "project.zip") as zf: + names = set(zf.namelist()) + crate = json.loads(zf.read("ro-crate-metadata.json")) + zf.extractall(tmp_path / "unpacked") + assert {"group/sample_01/pyflowbatt.toml", "pyflowbatt.toml"} <= names + assert "group/pyflowbatt.toml" not in names + entities = {e["@id"]: e for e in crate["@graph"]} + assert "pyflowbatt.toml" in entities["group/sample_01/pyflowbatt.toml"]["description"] + metadata = json.loads((sample / "metadata.empa__fcid123456.json").read_text()) + assert "group/sample_01/pyflowbatt.toml" in _all_ids(metadata["hasInput"]) + + other_home = tmp_path / "other_home" + other_home.mkdir() + (other_home / "pyflowbatt.toml").write_text("lsv_threshold = 12\n") + unpacked_sample = tmp_path / "unpacked" / "group" / "sample_01" + assert analysis_module.PyFlowBattConfig.load(unpacked_sample, home=other_home) == original + + def test_sample_id_custom_pattern(tmp_path: Path) -> None: """sample_name_pattern in pyflowbatt.toml replaces the default sample-ID regex.""" from pyflowbatt.analysis import get_sampleid_from_folderpath From 76f0ac9722093d7af89504fb6004cd495155caf9 Mon Sep 17 00:00:00 2001 From: Graham Kimbell <88666181+g-kimbell@users.noreply.github.com> Date: Fri, 23 Oct 2026 12:03:24 +0200 Subject: [PATCH 10/15] Fix pyproject toml typo --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 5c667fb..503d01b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,7 @@ readme = "README.md" requires-python = ">=3.10" dependencies = [ "batterydf>=0.1.0,<0.2.0", - "battinfoconverter-backend>=3.4.0<4.0.0", + "battinfoconverter-backend>=3.4.0,<4.0.0", "fasteis==0.1.0", "matplotlib>=3.10.6", "pandas>=2.3.2", From 4bf3f677df46326871584c521e7df0ad362e3600 Mon Sep 17 00:00:00 2001 From: Graham Kimbell <88666181+g-kimbell@users.noreply.github.com> Date: Fri, 23 Oct 2026 13:50:12 +0200 Subject: [PATCH 11/15] Warn if metadata doesn't look like cell --- pyflowbatt/analysis.py | 7 +++ pyflowbatt/battinfo.py | 25 +++++++---- tests/test_config.py | 99 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 122 insertions(+), 9 deletions(-) diff --git a/pyflowbatt/analysis.py b/pyflowbatt/analysis.py index e214a95..c739c95 100644 --- a/pyflowbatt/analysis.py +++ b/pyflowbatt/analysis.py @@ -684,6 +684,13 @@ def analyse_sample( # The cell might already be in a BatteryTest battinfo_json = battinfo.make_test_object(raw_json) battinfo_cell = battinfo_json["hasTestObject"] + if not battinfo.is_cell_type(battinfo_cell.get("@type")): + logger.warning( + "Cell type %r in %s doesn't look like a battery or cell class, " + "the metadata will describe the tested object as that", + battinfo_cell.get("@type"), + _rel(file, root_folder), + ) fcid = battinfo_cell["schema:productID"] battinfo_sample_id = battinfo_cell["schema:name"] # pyflowbatt lists the files it finds, the sheet only adds descriptions diff --git a/pyflowbatt/battinfo.py b/pyflowbatt/battinfo.py index 0d9c8b6..c4654dd 100644 --- a/pyflowbatt/battinfo.py +++ b/pyflowbatt/battinfo.py @@ -486,18 +486,25 @@ def pop_sheet_files(battinfo_jsonld: dict) -> dict[str, str | None]: return files +# Battery and cell classes in the ontology end with one of these, e.g. RedoxFlowBattery, CoinCell +CELL_TYPE_SUFFIXES = ("Battery", "Cell") + + def make_test_object(battinfo_jsonld: dict) -> dict: - """Put BattINFO redox flow battery description inside a BatteryTest object.""" - if battinfo_jsonld.get("@type") == "RedoxFlowBattery": - return { - "@context": battinfo_jsonld.pop("@context"), - "@type": "BatteryTest", - "hasTestObject": battinfo_jsonld, - } + """Put a BattINFO cell description inside a BatteryTest object, unless it is one already.""" if battinfo_jsonld.get("@type") == "BatteryTest": return battinfo_jsonld - msg = "BattINFO JSON-LD must have RedoxFlowBattery or BatteryTest as top level @type" - raise ValueError(msg) + return { + "@context": battinfo_jsonld.pop("@context", {}), + "@type": "BatteryTest", + "hasTestObject": battinfo_jsonld, + } + + +def is_cell_type(cell_type: object) -> bool: + """Check that a cell's @type looks like a battery or cell class, e.g. CoinCell.""" + types = cell_type if isinstance(cell_type, list) else [cell_type] + return any(isinstance(t, str) and t.endswith(CELL_TYPE_SUFFIXES) for t in types) def add_input_and_output() -> dict: diff --git a/tests/test_config.py b/tests/test_config.py index e6957f8..4aadcf0 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -626,6 +626,105 @@ def test_rocrate_describes_unmatched_raw_data( ) +@pytest.mark.parametrize( + "cell_type", + [ + "RedoxFlowBattery", + "VanadiumRedoxFlowBattery", + "FlowCell", + ["RedoxFlowBattery", "IronRedoxFlowBattery"], + ], +) +def test_make_test_object_wraps_cell_types(cell_type: str | list[str]) -> None: + """Cell classes, including subtypes from the v0.2.0 template, become the test object.""" + from pyflowbatt.battinfo import make_test_object + + test = make_test_object({"@context": {}, "@type": cell_type, "schema:productID": "FCID1"}) + assert test["@type"] == "BatteryTest" + assert test["hasTestObject"]["@type"] == cell_type + assert test["hasTestObject"]["schema:productID"] == "FCID1" + + +@pytest.mark.parametrize( + ("cell_type", "expected"), + [ + ("RedoxFlowBattery", True), + ("VanadiumRedoxFlowBattery", True), + ("LithiumIonBattery", True), + ("FlowCell", True), + ("CoinCell", True), + (["Thing", "RedoxFlowBattery"], True), + ("Cheese", False), + ("Electrode", False), + (None, False), + ], +) +def test_is_cell_type(cell_type: str | list[str] | None, *, expected: bool) -> None: + """Battery and cell classes are recognised, anything else is not.""" + from pyflowbatt.battinfo import is_cell_type + + assert is_cell_type(cell_type) == expected + + +def test_analyse_sample_accepts_cell_subtype( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A subtype like VanadiumRedoxFlowBattery is analysed like any flow cell.""" + from pyflowbatt import analysis as analysis_module + + sample = tmp_path / "sample" + sample.mkdir() + (sample / "metadata.xlsx").write_text("x") # content is irrelevant; conversion is stubbed + + def fake_convert(_file: Path) -> dict: + return { + "@context": {}, + "@type": "VanadiumRedoxFlowBattery", + "schema:productID": "empa__fcid123456", + "schema:name": "vanadium-cell", + } + + monkeypatch.setattr(analysis_module, "convert_excel_to_jsonld", fake_convert) + + config = analysis_module.PyFlowBattConfig.load(sample, home=tmp_path / "home") + _outputs, _extra_inputs, _raw_inputs, fcid, sample_id = analysis_module.analyse_sample( + sample, save_format=None, config=config + ) + assert fcid == "empa__fcid123456" + assert sample_id == "vanadium-cell" + metadata = json.loads((sample / "metadata.empa__fcid123456.json").read_text()) + assert metadata["hasTestObject"]["@type"] == "VanadiumRedoxFlowBattery" + + +def test_analyse_sample_warns_about_odd_cell_type( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + """A cell type that is not a battery or cell class is warned about, naming the sheet.""" + from pyflowbatt import analysis as analysis_module + + sample = tmp_path / "sample" + sample.mkdir() + (sample / "metadata.xlsx").write_text("x") # content is irrelevant; conversion is stubbed + + def fake_convert(_file: Path) -> dict: + return { + "@context": {}, + "@type": "Cheese", + "schema:productID": "FCID1", + "schema:name": "cheese-cell", + } + + monkeypatch.setattr(analysis_module, "convert_excel_to_jsonld", fake_convert) + + config = analysis_module.PyFlowBattConfig.load(sample, home=tmp_path / "home") + with caplog.at_level("WARNING", logger="pyflowbatt"): + analysis_module.analyse_sample(sample, save_format=None, config=config) + + assert "Cell type 'Cheese' in metadata.xlsx doesn't look like a battery" in caplog.text + metadata = json.loads((sample / "metadata.FCID1.json").read_text()) + assert metadata["hasTestObject"]["@type"] == "Cheese" + + def test_analyse_sample_uses_battinfo_fcid_without_toml( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: From 6e3897aa27b6b301201a30ecf4ee9ea509712a39 Mon Sep 17 00:00:00 2001 From: Graham Kimbell <88666181+g-kimbell@users.noreply.github.com> Date: Fri, 23 Oct 2026 13:57:04 +0200 Subject: [PATCH 12/15] Avoid mps settings going into raw data --- pyflowbatt/analysis.py | 2 +- pyflowbatt/battinfo.py | 2 -- tests/test_config.py | 39 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 40 insertions(+), 3 deletions(-) diff --git a/pyflowbatt/analysis.py b/pyflowbatt/analysis.py index c739c95..b050b1e 100644 --- a/pyflowbatt/analysis.py +++ b/pyflowbatt/analysis.py @@ -1038,7 +1038,7 @@ def analyse_sample( path for ext in config.extensions for path in sorted(folder.glob(f"*{ext}")) - if path not in classified_files + if path not in classified_files and path not in mps_files # protocols are inputs ] if other_raw: logger.info("- Found %d raw data file(s) not matching any technique", len(other_raw)) diff --git a/pyflowbatt/battinfo.py b/pyflowbatt/battinfo.py index c4654dd..7cb7443 100644 --- a/pyflowbatt/battinfo.py +++ b/pyflowbatt/battinfo.py @@ -776,8 +776,6 @@ def add_data( "@type": ["dcat:Distribution", "RawData"], "rdfs:comment": "Raw electrochemical data in proprietary Biologic .mpr binary format", } - elif rel_file_path.endswith(".mps"): - additions = {"rdfs:comment": "Cycling protocol in text-based Biologic .mps format"} else: # Any other raw data file that happens to sit in the sample folder. additions = {"@type": ["dcat:Distribution", "RawData"]} diff --git a/tests/test_config.py b/tests/test_config.py index 4aadcf0..0e55ae1 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1049,6 +1049,45 @@ def fake_convert(file: Path) -> dict: assert "lists empa__fcid000001/data.parquet, which is not a file" in caplog.text +def test_analyse_sample_lists_protocol_once_as_input( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A protocol is only an input, even if .mps is a raw data extension and the sheet lists it.""" + from pyflowbatt import analysis as analysis_module + + root = tmp_path / "project" + sample = root / "sample_01" + sample.mkdir(parents=True) + (sample / "metadata.xlsx").write_text("x") + (sample / "cell.mps").write_text("EC-LAB SETTING FILE") + (sample / "pyflowbatt.toml").write_text('extra_extensions = [".mps"]\n') + + def fake_convert(file: Path) -> dict: + jsonld = _fake_convert_with_references(file) + jsonld["hasInput"] = { + "@type": "dcat:Dataset", + "dcat:distribution": { + "dcterms:identifier": "sample_01/cell.mps", + "rdfs:comment": "Sheet protocol description", + }, + } + return jsonld + + monkeypatch.setattr(analysis_module, "convert_excel_to_jsonld", fake_convert) + config = analysis_module.PyFlowBattConfig.load(sample, home=tmp_path / "home") + analysis_module.analyse_sample(sample, save_format=None, config=config, root_folder=root) + + metadata = json.loads((sample / "metadata.empa__fcid123456.json").read_text()) + nodes = {} + for section in ("hasInput", "hasOutput"): + dist = metadata[section]["dcat:distribution"] + dist = dist if isinstance(dist, list) else [dist] + nodes[section] = [d for d in dist if d["@id"] == "sample_01/cell.mps"] + assert nodes["hasOutput"] == [] + assert len(nodes["hasInput"]) == 1 + assert nodes["hasInput"][0]["rdfs:comment"] == "Sheet protocol description" + + @pytest.mark.parametrize( ("node", "expected"), [ From d42fa12becd44b6d032293015bafb20019ed8dea Mon Sep 17 00:00:00 2001 From: Graham Kimbell <88666181+g-kimbell@users.noreply.github.com> Date: Fri, 23 Oct 2026 14:59:29 +0200 Subject: [PATCH 13/15] Include files in ref sheet, dedupe --- pyflowbatt/analysis.py | 60 ++++++++++++++++++++++++-------- pyflowbatt/battinfo.py | 17 ++++++--- tests/test_config.py | 78 ++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 132 insertions(+), 23 deletions(-) diff --git a/pyflowbatt/analysis.py b/pyflowbatt/analysis.py index b050b1e..e80e1da 100644 --- a/pyflowbatt/analysis.py +++ b/pyflowbatt/analysis.py @@ -458,9 +458,22 @@ def check_path_writable(path: Path) -> None: probe.unlink() +def is_pyflowbatt_file(path: Path) -> bool: + """Check for files PyFlowBatt writes or reads as settings, which it always adds itself.""" + name = path.name + return name in {CONFIG_FILENAME, LOG_FILENAME, "combined_summary.xlsx"} or ( + name.startswith("metadata.") and name.endswith(".json") + ) + + def find_included_files(folder: Path, patterns: list[str]) -> list[Path]: """Files in folder matching any include_files glob pattern, in a stable order.""" - found = {path for pattern in patterns for path in folder.glob(pattern) if path.is_file()} + found = { + path + for pattern in patterns + for path in folder.glob(pattern) + if path.is_file() and not is_pyflowbatt_file(path) + } return sorted(found) @@ -1034,11 +1047,13 @@ def analyse_sample( # metadata describes the data that ships rather than only what could be analysed. raw_inputs: dict[str, list[Path]] = {label: list(paths) for label, paths in classified.items()} classified_files = {path for paths in classified.values() for path in paths} + # Protocols, the BattINFO sheet and PyFlowBatt's own files are added in their own role + own_inputs = {*mps_files, *([battinfo_xlsx_path] if battinfo_xlsx_path else [])} other_raw = [ path for ext in config.extensions for path in sorted(folder.glob(f"*{ext}")) - if path not in classified_files and path not in mps_files # protocols are inputs + if path not in classified_files and path not in own_inputs and not is_pyflowbatt_file(path) ] if other_raw: logger.info("- Found %d raw data file(s) not matching any technique", len(other_raw)) @@ -1058,11 +1073,17 @@ def analyse_sample( for paths in group.values() for path in paths } - included = [ - path - for path in find_included_files(folder, config.include_files) - if path not in already_tracked - ] + # Files the BattINFO sheet asks for are included like include_files, if in this sample + sheet_requested = { + root_folder / rel + for rel in sheet_files + if (root_folder / rel).is_file() + and (root_folder / rel).resolve().is_relative_to(folder.resolve()) + and not is_pyflowbatt_file(root_folder / rel) + } + included = sorted( + {*find_included_files(folder, config.include_files), *sheet_requested} - already_tracked + ) if included: tracked_outputs[INCLUDED_LABEL] = included @@ -1071,7 +1092,8 @@ def analyse_sample( def describe(rel: str, default: str | None) -> str | None: """Prefer the BattINFO sheet's description of a file over pyflowbatt's.""" - return sheet_files.pop(rel, None) or default + sheet_file = sheet_files.pop(rel, None) + return (sheet_file.comment if sheet_file else None) or default for label, paths in raw_inputs.items(): for path in paths: @@ -1103,7 +1125,11 @@ def describe(rel: str, default: str | None) -> str | None: for path in label_paths: rel = _rel(path, root_folder) if label == INCLUDED_LABEL: - snippet = battinfo.add_included_data( + # Input file rows in the BattINFO sheet stay inputs + sheet_file = sheet_files.get(rel) + is_input = sheet_file is not None and sheet_file.section == "hasInput" + add = battinfo.add_input_data if is_input else battinfo.add_included_data + snippet = add( rel, zenodo_url, describe(rel, OUTPUT_MISC_DESCRIPTIONS[label]), @@ -1127,10 +1153,8 @@ def describe(rel: str, default: str | None) -> str | None: ) battinfo_json = battinfo.merge_jsonld_on_type([battinfo_json, snippet]) for rel in sheet_files: - logger.warning( - "The BattINFO sheet lists %s, which is not a file in this sample, ignoring it", - rel, - ) + where = "outside this sample folder" if (root_folder / rel).is_file() else "missing" + logger.warning("The BattINFO sheet lists %s, which is %s, ignoring it", rel, where) metadata_path = folder / f"metadata.{fcid or sample_id}.json" with metadata_path.open("w", encoding="utf-8") as mf: json.dump(battinfo_json, mf, indent=4, ensure_ascii=False) @@ -1359,8 +1383,14 @@ def analyse_all_samples( # noqa: PLR0913 path for path in find_included_files(folder, search_config.include_files) if not any( - path in tracked.get(INCLUDED_LABEL, []) - for tracked in tracked_by_sample_folder.values() + path in paths + for by_sample in ( + tracked_by_sample_folder, + tracked_extras_by_sample_folder, + raw_inputs_by_sample_folder, + ) + for tracked in by_sample.values() + for paths in tracked.values() ) ] write_rocrate( diff --git a/pyflowbatt/battinfo.py b/pyflowbatt/battinfo.py index 7cb7443..e23be04 100644 --- a/pyflowbatt/battinfo.py +++ b/pyflowbatt/battinfo.py @@ -7,7 +7,7 @@ from copy import deepcopy from datetime import datetime from pathlib import Path -from typing import Literal +from typing import Literal, NamedTuple from urllib.parse import quote, unquote import pandas as pd @@ -468,9 +468,16 @@ def _sheet_file_path(node: dict) -> str | None: return unquote(file_id) -def pop_sheet_files(battinfo_jsonld: dict) -> dict[str, str | None]: - """Remove file nodes from the converter output, return each path and its description.""" - files: dict[str, str | None] = {} +class SheetFile(NamedTuple): + """A file listed in the BattINFO sheet: its section (hasInput/hasOutput) and description.""" + + section: str + comment: str | None + + +def pop_sheet_files(battinfo_jsonld: dict) -> dict[str, SheetFile]: + """Remove file nodes from the converter output, return each path's section and description.""" + files: dict[str, SheetFile] = {} for section in ("hasOutput", "hasInput"): node = battinfo_jsonld.get(section) if not isinstance(node, dict) or "dcat:distribution" not in node: @@ -479,7 +486,7 @@ def pop_sheet_files(battinfo_jsonld: dict) -> dict[str, str | None]: for item in distribution if isinstance(distribution, list) else [distribution]: path = _sheet_file_path(item) if isinstance(item, dict) else None if path: - files[path] = item.get("rdfs:comment") + files[path] = SheetFile(section, item.get("rdfs:comment")) # A section that only described files would leave its @type on pyflowbatt's files if set(node) <= {"@type"}: del battinfo_jsonld[section] diff --git a/tests/test_config.py b/tests/test_config.py index 0e55ae1..5cb51d2 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1046,7 +1046,79 @@ def fake_convert(file: Path) -> dict: assert xlsx["@id"] == "sample_01/metadata.xlsx" assert xlsx["rdfs:comment"] == "Sheet description" assert metadata["hasInput"]["@type"] == "dcat:Dataset" - assert "lists empa__fcid000001/data.parquet, which is not a file" in caplog.text + assert "lists empa__fcid000001/data.parquet, which is missing" in caplog.text + + +def test_analyse_all_samples_collects_requested_files_once( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + """Default, include_files and BattINFO-requested files are each described and zipped once.""" + from pyflowbatt import analysis as analysis_module + + monkeypatch.setattr(Path, "home", lambda: tmp_path / "home") + root = tmp_path / "project" + sample = _write_synthetic_sample(root / "sample_01") + other = _write_synthetic_sample(root / "sample_02") + (sample / "metadata.xlsx").write_text("x") + (other / "metadata.xlsx").write_text("x") + for name in ("notes.pdf", "setup.txt", "photo.png"): + (sample / name).write_text("x") + (other / "other_notes.pdf").write_text("x") + # Extensions that also match PyFlowBatt's own files, and an overlapping include pattern + (sample / "pyflowbatt.toml").write_text( + 'extra_extensions = [".png", ".xlsx", ".json", ".toml"]\ninclude_files = ["*.pdf"]\n' + ) + + def fake_convert(file: Path) -> dict: + jsonld = _fake_convert_with_references(file) + if file.parent.name == "sample_01": + jsonld["hasOutput"]["dcat:distribution"] = [ + {"dcterms:identifier": "sample_01/notes.pdf", "rdfs:comment": "Lab notes"}, + {"dcterms:identifier": "sample_02/other_notes.pdf"}, + {"dcterms:identifier": "sample_01/missing.csv"}, + ] + jsonld["hasInput"] = { + "@type": "dcat:Dataset", + "dcat:distribution": { + "dcterms:identifier": "sample_01/setup.txt", + "rdfs:comment": "Rig setup", + }, + } + else: + jsonld["hasTestObject"]["schema:productID"] = "empa__fcid000002" + return jsonld + + monkeypatch.setattr(analysis_module, "convert_excel_to_jsonld", fake_convert) + for _ in range(2): # the second run also sees the first run's metadata files + caplog.clear() + with caplog.at_level("WARNING", logger="pyflowbatt"): + analysis_module.analyse_all_samples(root, save_format=None, zip_output=True) + + metadata = json.loads((sample / "metadata.empa__fcid123456.json").read_text()) + nodes: dict[str, list[tuple[str, dict]]] = {} + for section in ("hasInput", "hasOutput"): + dist = metadata[section]["dcat:distribution"] + for node in dist if isinstance(dist, list) else [dist]: + nodes.setdefault(node["@id"], []).append((section, node)) + assert all(len(found) == 1 for found in nodes.values()) + assert nodes["sample_01/notes.pdf"][0][0] == "hasOutput" + assert nodes["sample_01/notes.pdf"][0][1]["rdfs:comment"] == "Lab notes" + assert nodes["sample_01/setup.txt"][0][0] == "hasInput" + assert nodes["sample_01/setup.txt"][0][1]["rdfs:comment"] == "Rig setup" + assert nodes["sample_01/metadata.xlsx"][0][0] == "hasInput" + assert nodes["sample_01/pyflowbatt.toml"][0][0] == "hasInput" + assert not any(node_id.endswith(".json") for node_id in nodes) + assert "sample_02/other_notes.pdf" not in nodes + + assert "lists sample_02/other_notes.pdf, which is outside this sample folder" in caplog.text + assert "lists sample_01/missing.csv, which is missing" in caplog.text + # Only sample_02's own sheet or config could ask for its file + assert "Not zipping 1 file(s)" in caplog.text + assert "package them: sample_02/other_notes.pdf" in caplog.text + with zipfile.ZipFile(tmp_path / "project.zip") as zf: + names = zf.namelist() + assert {"sample_01/notes.pdf", "sample_01/setup.txt", "sample_01/photo.png"} <= set(names) + assert len(names) == len(set(names)) def test_analyse_sample_lists_protocol_once_as_input( @@ -1098,10 +1170,10 @@ def fake_convert(file: Path) -> dict: ) def test_pop_sheet_files_recovers_paths(node: dict, expected: str) -> None: """Converter file nodes give back their root-relative path and are removed.""" - from pyflowbatt.battinfo import pop_sheet_files + from pyflowbatt.battinfo import SheetFile, pop_sheet_files jsonld = {"hasInput": {"@type": "dcat:Dataset", "dcat:distribution": node}} - assert pop_sheet_files(jsonld) == {expected: None} + assert pop_sheet_files(jsonld) == {expected: SheetFile("hasInput", None)} assert jsonld == {} From 0656977b320e9a93a528ef7629441e177bf75f5c Mon Sep 17 00:00:00 2001 From: Graham Kimbell <88666181+g-kimbell@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:26:18 +0200 Subject: [PATCH 14/15] Prevent files outside folder trying to zip --- pyflowbatt/analysis.py | 53 +++++++++++++++++++++++++++++++++--------- tests/test_config.py | 53 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+), 11 deletions(-) diff --git a/pyflowbatt/analysis.py b/pyflowbatt/analysis.py index e80e1da..885893a 100644 --- a/pyflowbatt/analysis.py +++ b/pyflowbatt/analysis.py @@ -6,7 +6,7 @@ import re import uuid import zipfile -from pathlib import Path +from pathlib import Path, PurePosixPath from typing import Literal from urllib.parse import unquote @@ -467,13 +467,29 @@ def is_pyflowbatt_file(path: Path) -> bool: def find_included_files(folder: Path, patterns: list[str]) -> list[Path]: - """Files in folder matching any include_files glob pattern, in a stable order.""" - found = { - path - for pattern in patterns - for path in folder.glob(pattern) - if path.is_file() and not is_pyflowbatt_file(path) - } + """Files in folder matching any include_files glob pattern, in a stable order. + + Matches outside folder, e.g. from "../" in a pattern, are left out with a warning. + """ + base = folder.resolve() + found = set() + for pattern in patterns: + try: + matches = list(folder.glob(pattern)) + except (NotImplementedError, ValueError): + logger.warning("Ignoring include_files pattern %r: it must be relative", pattern) + continue + for path in matches: + resolved = path.resolve() + if not resolved.is_relative_to(base): + logger.warning( + "Ignoring %s from include_files pattern %r: it is outside %s", + resolved, + pattern, + folder, + ) + elif path.is_file() and not is_pyflowbatt_file(path): + found.add(folder / resolved.relative_to(base)) return sorted(found) @@ -514,6 +530,16 @@ def resolved_settings( return settings +def _escapes_root(rel: str) -> bool: + """Check if a zip entry name is absolute or climbs out with "..", so could extract anywhere.""" + posix = rel.replace("\\", "/") + return ( + posix.startswith("/") + or re.match(r"^[A-Za-z]:", posix) is not None + or ".." in PurePosixPath(posix).parts + ) + + def zip_folder( root_folder: Path, zip_path: Path, @@ -526,9 +552,15 @@ def zip_folder( files in both the RO-Crate manifest and BattINFO metadata, so a file's "@id" can be found directly inside the zip once it's extracted. Other files in root_folder are left out with a warning. `generated` maps archive paths to text - written straight into the zip, never to root_folder. + written straight into the zip, never to root_folder. Entries that could extract + outside the target folder, like "../x" or "C:/x", are never written. """ - wanted = set(members) + generated = generated or {} + unsafe = sorted(rel for rel in {*members, *generated} if _escapes_root(rel)) + if unsafe: + logger.warning("Not zipping %s: zip entries must stay inside the zipped folder", unsafe) + generated = {rel: text for rel, text in generated.items() if rel not in unsafe} + wanted = set(members) - set(unsafe) skipped = [ rel for file in sorted(root_folder.rglob("*")) @@ -546,7 +578,6 @@ def zip_folder( len(skipped), shown, ) - generated = generated or {} with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf: for rel in sorted(wanted - set(generated)): zf.write(root_folder / rel, arcname=rel) diff --git a/tests/test_config.py b/tests/test_config.py index 5cb51d2..0b217ca 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1535,6 +1535,59 @@ def test_resolved_toml_reproduces_settings_despite_other_home_config(tmp_path: P assert "area_cm2 = 3.0" in resolved +def test_find_included_files_stays_inside_folder( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """Patterns reaching outside the folder, or absolute ones, are ignored with a warning.""" + from pyflowbatt.analysis import find_included_files + + root = tmp_path / "project" + sample = root / "sample_01" + (sample / "sub").mkdir(parents=True) + (sample / "note.txt").write_text("x") + (root / "shared.txt").write_text("x") + (tmp_path / "outside.txt").write_text("x") + patterns = [ + "sub/../note.txt", + "../shared.txt", + "../../outside.txt", + str(tmp_path / "outside.txt"), + ] + + with caplog.at_level("WARNING", logger="pyflowbatt"): + found = find_included_files(sample, patterns) + + assert found == [sample / "note.txt"] + messages = [r.getMessage() for r in caplog.records] + assert sum("is outside" in m for m in messages) == 2 + assert sum("must be relative" in m for m in messages) == 1 + + +def test_zip_folder_never_writes_entries_outside_the_zip_root( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """Absolute or ".." entry names are refused, whether from files or generated text.""" + from pyflowbatt.analysis import zip_folder + + root = tmp_path / "project" + root.mkdir() + (root / "ok.txt").write_text("x") + (tmp_path / "evil.txt").write_text("x") + + zip_path = tmp_path / "out.zip" + with caplog.at_level("WARNING", logger="pyflowbatt"): + zip_folder( + root, + zip_path, + ["ok.txt", "../evil.txt", "C:/evil.txt", "/evil.txt"], + generated={"a/../../evil.toml": "x", "settings.toml": "x"}, + ) + + with zipfile.ZipFile(zip_path) as zf: + assert sorted(zf.namelist()) == ["ok.txt", "settings.toml"] + assert "zip entries must stay inside the zipped folder" in caplog.text + + def test_include_files_adds_up_across_configs(tmp_path: Path) -> None: """include_files patterns from parent and sample configs are combined.""" sample = tmp_path / "project" / "sample_01" From e2bb9b73ca402df69da104491fffdd2c66c199b1 Mon Sep 17 00:00:00 2001 From: Graham Kimbell <88666181+g-kimbell@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:53:29 +0200 Subject: [PATCH 15/15] Better zenodo record extraction --- pyflowbatt/battinfo.py | 19 +++++++++++++++---- tests/test_config.py | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 4 deletions(-) diff --git a/pyflowbatt/battinfo.py b/pyflowbatt/battinfo.py index e23be04..17e6dee 100644 --- a/pyflowbatt/battinfo.py +++ b/pyflowbatt/battinfo.py @@ -4,6 +4,7 @@ import io import json import logging +import re from copy import deepcopy from datetime import datetime from pathlib import Path @@ -18,6 +19,8 @@ logger = logging.getLogger(__name__) BDF_SCHEMA_IRI = "https://w3id.org/battery-data-alliance/ontology/battery-data-format/schema" +# Record number in a Zenodo DOI (zenodo.123) or record page URL (/records/123, older /record/123) +_ZENODO_RECORD_RE = re.compile(r"zenodo\.(\d+)|/records?/(\d+)") # DCAT expects dcat:mediaType values from the IANA register, as IRIs in this form IANA_MEDIA_TYPES = "http://www.iana.org/assignments/media-types/" @@ -625,12 +628,20 @@ def add_fcid_output( } -def zenodo_record_id(zenodo_doi_url: str) -> str: - """Extract the numeric Zenodo record ID from a Zenodo DOI URL. +def zenodo_record_id(zenodo_url: str) -> str: + """Extract the numeric Zenodo record ID from a Zenodo DOI or record page URL. - e.g. "https://doi.org/10.5281/zenodo.20338409" -> "20338409" + e.g. "https://doi.org/10.5281/zenodo.20338409" or "https://zenodo.org/records/20338409" + -> "20338409". Raises ValueError for a URL with neither form. """ - return zenodo_doi_url.rsplit(".", maxsplit=1)[-1] + match = _ZENODO_RECORD_RE.search(zenodo_url) + if match is None: + msg = ( + f"Cannot find a Zenodo record number in {zenodo_url!r}, expected e.g. " + "https://doi.org/10.5281/zenodo.12345678 or https://zenodo.org/records/12345678" + ) + raise ValueError(msg) + return match.group(1) or match.group(2) def zenodo_download_url( diff --git a/tests/test_config.py b/tests/test_config.py index 0b217ca..fee44bb 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -2773,6 +2773,43 @@ def test_resolve_zenodo_url( assert resolve_zenodo_url(sheet, pub_info_url) == expected +@pytest.mark.parametrize( + "url", + [ + "https://doi.org/10.5281/zenodo.21805277", + "https://doi.org/10.5281/zenodo.21805277/", + "https://zenodo.org/records/21805277", + "https://zenodo.org/records/21805277?preview=1", + "https://zenodo.org/record/21805277", + "https://zenodo.org/doi/10.5281/zenodo.21805277", + ], +) +def test_zenodo_record_id_from_doi_and_record_urls(url: str) -> None: + """DOIs and record page URLs give the same record number and @base.""" + from pyflowbatt.battinfo import add_zenodo_url, zenodo_record_id + + assert zenodo_record_id(url) == "21805277" + base = add_zenodo_url(url)["@context"][0]["@base"] + assert base == "https://zenodo.org/records/21805277/" + + +def test_zenodo_record_id_rejects_other_urls() -> None: + """A URL without a Zenodo record number is an error naming the URL.""" + from pyflowbatt.battinfo import zenodo_record_id + + with pytest.raises(ValueError, match=re.escape("example.org/dataset/42")): + zenodo_record_id("https://example.org/dataset/42") + + +def test_resolve_zenodo_url_same_record_in_different_forms() -> None: + """A record page URL in the sheet and a DOI in pub_info for one record do not conflict.""" + from pyflowbatt.battinfo import resolve_zenodo_url + + sheet = {"hasOutput": {"dcat:accessURL": {"@id": "https://zenodo.org/records/21805277"}}} + pub_info_url = "https://doi.org/10.5281/zenodo.21805277" + assert resolve_zenodo_url(sheet, pub_info_url) == "https://zenodo.org/records/21805277" + + def test_template_every_single_hash_line_uncomments_to_valid_toml( tmp_path: Path, caplog: pytest.LogCaptureFixture ) -> None: