From 79f01a52a87b72eca185afb9c6122cc8cfaa871c Mon Sep 17 00:00:00 2001 From: Alpamys Date: Sat, 6 Jun 2026 19:11:38 +0500 Subject: [PATCH] fix(bom): emit energy in SPDX output too + strengthen energy tests PR #256 attached energy only to CycloneDX; #244's contract is "both outputs". Add _energy_annotations() so the SPDX model package carries energy as OTHER annotations (same soup:= naming). Strengthen the happy-path test to assert energy actually lands in the BOM (not just that a file is written) and add a --format both case asserting energy in BOTH cdx + spdx. --- src/soup_cli/utils/bom.py | 21 +++++++++++++++++- tests/test_v0590.py | 45 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 1 deletion(-) diff --git a/src/soup_cli/utils/bom.py b/src/soup_cli/utils/bom.py index b493a16..0e2ca29 100644 --- a/src/soup_cli/utils/bom.py +++ b/src/soup_cli/utils/bom.py @@ -163,6 +163,24 @@ def _energy_properties(entry: BomEntry) -> list[dict]: return props +def _energy_annotations(entry: BomEntry, annotation_date: str) -> list[dict]: + """SPDX has no native properties — surface energy as OTHER annotations. + + Mirrors :func:`_energy_properties` (same ``soup:=`` naming) so + energy data lands in the SPDX output as well as CycloneDX (the #244 contract + is "both outputs"). + """ + return [ + { + "annotator": "Tool: soup-cli", + "annotationDate": annotation_date, + "annotationType": "OTHER", + "annotationComment": f"{prop['name']}={prop['value']}", + } + for prop in _energy_properties(entry) + ] + + def build_cyclonedx_bom(entry: BomEntry) -> dict: """Render a CycloneDX 1.6 ML-BOM dict (in-memory).""" if not isinstance(entry, BomEntry): @@ -264,7 +282,8 @@ def build_spdx_bom(entry: BomEntry) -> dict: "annotationDate": entry.created_at, "annotationType": "OTHER", "annotationComment": f"task={entry.task} base={entry.base_model}", - } + }, + *_energy_annotations(entry, entry.created_at), ], "checksums": [{"algorithm": "SHA256", "checksumValue": entry.config_sha}], } diff --git a/tests/test_v0590.py b/tests/test_v0590.py index e066d7d..d5a7431 100644 --- a/tests/test_v0590.py +++ b/tests/test_v0590.py @@ -1470,6 +1470,51 @@ class TestBomEnergyCli: ) assert result.exit_code == 0, (result.output, repr(result.exception)) assert out.is_file() + # The feature's point: energy values must actually land in the BOM, + # not just produce a file (a no-op attach_energy would pass otherwise). + written = out.read_text() + assert "soup:energy_kwh" in written + assert "12.5" in written + assert "codecarbon" in written + + def test_emit_energy_both_formats(self, tmp_path, monkeypatch): + """--format both writes cdx + spdx, and energy lands in BOTH (#244).""" + monkeypatch.chdir(tmp_path) + energy_file = tmp_path / "energy.json" + energy_file.write_text(json.dumps({ + "energy_kwh": 12.5, + "co2_kg": 4.0, + "pue": 1.2, + "grid_intensity_g_per_kwh": 400.0, + "source": "codecarbon", + })) + prefix = tmp_path / "bom" + runner = CliRunner() + result = runner.invoke( + app, + [ + "bom", "emit", + "--name", "adapter-v1", + "--version", "0.1.0", + "--base-model", "meta-llama/Llama-3.1-8B", + "--base-sha", "a" * 64, + "--config-sha", "b" * 64, + "--task", "sft", + "--license", "apache-2.0", + "--format", "both", + "--output", str(prefix), + "--energy", str(energy_file), + ], + ) + assert result.exit_code == 0, (result.output, repr(result.exception)) + cdx = tmp_path / "bom.cdx.json" + spdx = tmp_path / "bom.spdx.json" + assert cdx.is_file() + assert spdx.is_file() + for path in (cdx, spdx): + body = path.read_text() + assert "12.5" in body, f"energy_kwh missing from {path.name}" + assert "codecarbon" in body, f"energy_source missing from {path.name}" def test_emit_energy_malformed_json(self, tmp_path, monkeypatch): """Malformed JSON in energy file produces exit code 2."""