mirror of https://github.com/razor-ai/soup.git
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:
parent
0b621076b9
commit
8254091e44
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue