diff --git a/CHANGELOG.md b/CHANGELOG.md index 260a1a0..65f1ac7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ interfaces; each entry says what moved. Milestones (M0, M1, …) are described i ## [Unreleased] ### Added +- `slm runs list --json` prints full run statuses as a JSON array for scripts, optionally filtered + with `--project`; empty results produce `[]`. - Model cards open with Hugging Face Hub metadata (licence, library, task, tags), so an export can be uploaded as is. A new optional Project API hook, `model_card_info()`, gives the licence, subject tags and data statement; `abc_music` and `shakespeare_char` state theirs (ADR 0010). Export code version 4. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3bc712e..981c581 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -139,6 +139,7 @@ uv run slm pretrain abc_music/micro_v1 --set train.lr=6e-4 uv run slm eval uv run slm runs list # tokens done/target, GPU-hours, last checkpoint uv run slm runs list --project abc_music # only runs for one project +uv run slm runs list --json # full run statuses for scripts; accepts --project uv run slm runs compare uv run slm export --name --version uv run slm serve --model : diff --git a/src/slmkit/cli.py b/src/slmkit/cli.py index 4fdaf62..c069f88 100644 --- a/src/slmkit/cli.py +++ b/src/slmkit/cli.py @@ -352,6 +352,7 @@ def _short(n: float) -> str: @_friendly_errors def runs_list( project: str | None = typer.Option(None, "--project", help="Only show runs for this project."), + as_json: bool = typer.Option(False, "--json", help="Print run statuses as a JSON array."), ) -> None: """Every run: progress, GPU-hours used and left, best validation loss, last checkpoint.""" from slmkit.train.run import list_runs @@ -364,6 +365,11 @@ def runs_list( if isinstance(st.get("experiment"), str) and st["experiment"].partition("/")[0] == project ] + if as_json: + import json + + typer.echo(json.dumps([st for _, st in rows])) + return if not rows: message = ( "no runs yet under $SLM_HOME/runs" diff --git a/tests/unit/test_runs.py b/tests/unit/test_runs.py index 37f8ebc..c1bcd74 100644 --- a/tests/unit/test_runs.py +++ b/tests/unit/test_runs.py @@ -5,6 +5,7 @@ import json from pathlib import Path +import pytest from typer.testing import CliRunner from slmkit.cli import app @@ -43,3 +44,44 @@ def test_runs_list_can_filter_by_project(slm_home: Path) -> None: assert filtered.exit_code == 0, filtered.output assert "abc run" in all_runs.output and "chess run" in all_runs.output assert "abc run" in filtered.output and "chess run" not in filtered.output + + +def test_runs_list_json_preserves_status_fields(slm_home: Path) -> None: + _write_status(slm_home, "run-abc123456789", "abc_music/micro_v1", "abc run") + _write_status(slm_home, "run-chess123456", "chess/nano", "chess run") + + result = CliRunner().invoke(app, ["runs", "list", "--json"]) + + assert result.exit_code == 0, result.output + statuses = json.loads(result.output) + expected = [json.loads(path.read_text()) for path in (slm_home / "runs").glob("*/status.json")] + assert sorted(statuses, key=lambda st: st["run_id"]) == sorted( + expected, key=lambda st: st["run_id"] + ) + + +def test_runs_list_json_can_filter_by_project(slm_home: Path) -> None: + _write_status(slm_home, "run-abc123456789", "abc_music/micro_v1", "abc run") + _write_status(slm_home, "run-chess123456", "chess/nano", "chess run") + + result = CliRunner().invoke(app, ["runs", "list", "--json", "--project", "abc_music"]) + + assert result.exit_code == 0, result.output + expected = json.loads((slm_home / "runs" / "run-abc123456789" / "status.json").read_text()) + assert json.loads(result.output) == [expected] + + +@pytest.mark.parametrize( + "project, has_runs", [(None, False), ("abc_music", False), ("abc_music", True)] +) +def test_runs_list_json_empty_results(slm_home: Path, project: str | None, has_runs: bool) -> None: + if has_runs: + _write_status(slm_home, "run-chess123456", "chess/nano", "chess run") + args = ["runs", "list", "--json"] + if project is not None: + args += ["--project", project] + + result = CliRunner().invoke(app, args) + + assert result.exit_code == 0, result.output + assert result.output.strip() == "[]"