feat(bom): thread --track-energy measurement into soup bom emit (#256)

Closes #244

Adds --energy flag to soup bom emit: validates the measurement JSON (cwd containment + symlink rejection + JSON + EnergyMeasurement shape) and attaches energy properties to both CycloneDX and SPDX outputs.

Co-authored-by: gittihub-jpg <gittihub-jpg@users.noreply.github.com>
This commit is contained in:
gittihub-jpg 2026-06-06 16:08:43 +02:00 committed by GitHub
parent 0b621076b9
commit 8254091e44
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 148 additions and 1 deletions

View File

@ -2,14 +2,18 @@
from __future__ import annotations
import json
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
import typer
from rich.console import Console
from rich.markup import escape
from soup_cli.utils.bom import BomEntry, render_bom, write_bom
from soup_cli.utils.bom import BomEntry, attach_energy, render_bom, write_bom
from soup_cli.utils.energy import EnergyMeasurement
from soup_cli.utils.paths import enforce_under_cwd_and_no_symlink
console = Console()
@ -47,6 +51,10 @@ def emit_cmd(
"this is the prefix and Soup writes <prefix>.cdx.json + "
"<prefix>.spdx.json."),
),
energy_path: Optional[str] = typer.Option(
None, "--energy", "-e",
help="Path to energy measurement JSON.",
),
) -> None:
"""Emit a CycloneDX + SPDX BOM from CLI-supplied SHAs."""
fmt_lc = fmt.lower()
@ -57,6 +65,32 @@ def emit_cmd(
)
raise typer.Exit(2)
measurement = None
if energy_path is not None:
try:
validated = enforce_under_cwd_and_no_symlink(energy_path, "--energy")
except ValueError as exc:
console.print(f"[red]Energy path rejected: {escape(str(exc))}[/]")
raise typer.Exit(2)
if not Path(validated).is_file():
console.print(f"[red]Energy file not found: {escape(validated)}[/]")
raise typer.Exit(2)
try:
raw = Path(validated).read_text()
except OSError as exc:
console.print(f"[red]Cannot read energy file: {escape(str(exc))}[/]")
raise typer.Exit(2)
try:
parsed = json.loads(raw)
except json.JSONDecodeError as exc:
console.print(f"[red]Malformed JSON in energy file: {escape(str(exc))}[/]")
raise typer.Exit(2)
try:
measurement = EnergyMeasurement(**parsed)
except (TypeError, ValueError) as exc:
console.print(f"[red]Invalid energy measurement: {escape(str(exc))}[/]")
raise typer.Exit(2)
try:
entry = BomEntry(
name=name,
@ -75,6 +109,9 @@ def emit_cmd(
console.print(f"[red]Invalid BOM input: {escape(str(exc))}[/]")
raise typer.Exit(2)
if measurement is not None:
entry = attach_energy(entry, measurement)
if fmt_lc == "both":
if output is None:
console.print(

View File

@ -1433,3 +1433,113 @@ class TestReviewFollowups:
out = tmp_path / "out.txt"
atomic_write_text("hello", str(out))
assert out.read_text() == "hello"
# ---------- BOM emit: --energy feature tests ----------
class TestBomEnergyCli:
def test_emit_energy_happy_path(self, tmp_path, monkeypatch):
"""Valid JSON energy file produces successful BOM output."""
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",
}))
out = tmp_path / "bom.json"
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", "cyclonedx",
"--output", str(out),
"--energy", str(energy_file),
],
)
assert result.exit_code == 0, (result.output, repr(result.exception))
assert out.is_file()
def test_emit_energy_malformed_json(self, tmp_path, monkeypatch):
"""Malformed JSON in energy file produces exit code 2."""
monkeypatch.chdir(tmp_path)
energy_file = tmp_path / "energy.json"
energy_file.write_text("not valid json {{{")
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",
"--format", "cyclonedx",
"--energy", str(energy_file),
],
)
assert result.exit_code == 2
def test_emit_energy_missing_file(self, tmp_path, monkeypatch):
"""Missing energy file produces exit code 2."""
monkeypatch.chdir(tmp_path)
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",
"--format", "cyclonedx",
"--energy", str(tmp_path / "nonexistent_energy.json"),
],
)
assert result.exit_code == 2
@pytest.mark.skipif(os.name == "nt", reason="POSIX symlink rejection")
def test_emit_energy_rejects_symlink(self, tmp_path, monkeypatch):
"""Symlink passed to --energy is rejected with exit code 2."""
monkeypatch.chdir(tmp_path)
real_file = tmp_path / "real_energy.json"
real_file.write_text(json.dumps({
"energy_kwh": 1.0,
"co2_kg": 0.4,
"pue": 1.2,
"grid_intensity_g_per_kwh": 400.0,
"source": "codecarbon",
}))
symlink = tmp_path / "symlink_energy.json"
os.symlink(str(real_file), str(symlink))
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",
"--format", "cyclonedx",
"--energy", str(symlink),
],
)
assert result.exit_code == 2