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:<field>=<value> 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.
This commit is contained in:
Alpamys 2026-06-06 19:11:38 +05:00
parent 8254091e44
commit 79f01a52a8
2 changed files with 65 additions and 1 deletions

View File

@ -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:<field>=<value>`` 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}],
}

View File

@ -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."""