diff --git a/src/soup_cli/commands/bom.py b/src/soup_cli/commands/bom.py index 2c51a78..f8d9d38 100644 --- a/src/soup_cli/commands/bom.py +++ b/src/soup_cli/commands/bom.py @@ -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 .cdx.json + " ".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( diff --git a/tests/test_v0590.py b/tests/test_v0590.py index ecf168c..e066d7d 100644 --- a/tests/test_v0590.py +++ b/tests/test_v0590.py @@ -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