Skip to content
Closed
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 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.
Expand Down
1 change: 1 addition & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ uv run slm pretrain abc_music/micro_v1 --set train.lr=6e-4
uv run slm eval <run_id>
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 <run_a> <run_b>
uv run slm export <run_id> --name <model> --version <n>
uv run slm serve --model <model>:<n>
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,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
Expand All @@ -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"
Expand Down
42 changes: 42 additions & 0 deletions tests/unit/test_runs.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import json
from pathlib import Path

import pytest
from typer.testing import CliRunner

from slmkit.cli import app
Expand Down Expand Up @@ -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() == "[]"