Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ interfaces; each entry says what moved. Milestones (M0, M1, …) are described i
## [Unreleased]

### Added
- `slm runs list --json` prints the runs as a JSON array (run ID plus the fields of `status.json`) for
scripts and notebooks. Works with `--project`.
- 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.
Expand Down
6 changes: 6 additions & 0 deletions src/slmkit/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -352,8 +352,11 @@ 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 the runs as a JSON array."),
) -> None:
"""Every run: progress, GPU-hours used and left, best validation loss, last checkpoint."""
import json

from slmkit.train.run import list_runs

rows = list_runs()
Expand All @@ -364,6 +367,9 @@ def runs_list(
if isinstance(st.get("experiment"), str)
and st["experiment"].partition("/")[0] == project
]
if as_json:
typer.echo(json.dumps([{**st, "run_id": path.name} for path, st in rows], indent=2))
return
if not rows:
message = (
"no runs yet under $SLM_HOME/runs"
Expand Down
21 changes: 21 additions & 0 deletions tests/unit/test_runs.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,24 @@ 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(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")

all_runs = CliRunner().invoke(app, ["runs", "list", "--json"])
filtered = CliRunner().invoke(app, ["runs", "list", "--json", "--project", "chess"])

assert all_runs.exit_code == 0, all_runs.output
runs = json.loads(all_runs.output)
assert {r["run_id"] for r in runs} == {"run-abc123456789", "run-chess123456"}
assert runs[0]["tokens_seen"] == 1000
assert [r["name"] for r in json.loads(filtered.output)] == ["chess run"]


def test_runs_list_json_empty(slm_home: Path) -> None:
result = CliRunner().invoke(app, ["runs", "list", "--json"])

assert result.exit_code == 0, result.output
assert json.loads(result.output) == []