Skip to content
Open
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ interfaces; each entry says what moved. Milestones (M0, M1, …) are described i
## [Unreleased]

### Added
- `slm models list --project NAME` lists only exports whose manifest belongs to that project (#11).
- `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
Expand Down
2 changes: 2 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,8 @@ uv run slm runs list --project abc_music # only runs for one project
uv run slm runs list --json # the same, as JSON 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 models list # every exported model
uv run slm models list --project abc_music # only exports for one project
uv run slm serve --model <model>:<n>
uv run slm site <model>:<n> # static site; the model runs in the browser
uv run slm studio start | stop | status | run # "See it in action": the local web studio
Expand Down
15 changes: 13 additions & 2 deletions src/slmkit/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -592,13 +592,24 @@ def export(


@models_app.command("list")
def models_list() -> None:
def models_list(
project: str | None = typer.Option(
None, "--project", help="Only show models for this project."
),
) -> None:
"""Every exported model: source run, stage, size and parity result."""
from slmkit.export.hf import list_exports

rows = list_exports()
if project is not None:
rows = [m for m in rows if m.get("project") == project]
if not rows:
typer.echo("no exported models yet under $SLM_HOME/models")
message = (
"no exported models yet under $SLM_HOME/models"
if project is None
else f"no models found for project {project}"
)
typer.echo(message)
return
typer.echo(f"{'MODEL':<20} {'STAGE':<9} {'PARAMS':>9} {'RUN':<17} {'STEP':>6} "
f"{'HF PARITY':>10} CREATED") # fmt: skip
Expand Down
73 changes: 73 additions & 0 deletions tests/unit/test_export.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,79 @@ def test_export_cli_and_models_list(toy_run: str) -> None:
assert listed.exit_code == 0 and "toy-model:1" in listed.output


def test_models_list_filters_by_project(toy_run: str) -> None:
"""Filter manifest ownership, not the export name, without hiding unfiltered models."""
export_run(toy_run, "first-model", 1, **QUIET)
other = export_run(toy_run, "second-model", 1, **QUIET)
manifest_path = other / "manifest.json"
manifest = json.loads(manifest_path.read_text())
manifest["project"] = "other"
manifest_path.write_text(json.dumps(manifest))
runner = CliRunner()

for project, included, excluded in (
("toy", "first-model:1", "second-model:1"),
("other", "second-model:1", "first-model:1"),
):
result = runner.invoke(app, ["models", "list", "--project", project])
assert result.exit_code == 0, result.output
assert included in result.output and excluded not in result.output

missing = runner.invoke(app, ["models", "list", "--project", "to"])
assert missing.exit_code == 0
assert missing.output == "no models found for project to\n"

unfiltered = runner.invoke(app, ["models", "list"])
assert unfiltered.exit_code == 0
assert "first-model:1" in unfiltered.output and "second-model:1" in unfiltered.output


@pytest.mark.parametrize("project", [None, "missing"])
def test_models_list_empty(slm_home: Path, project: str | None) -> None:
"""Distinguish an empty store from a project with no matches."""
args = ["models", "list"] + ([] if project is None else ["--project", project])
result = CliRunner().invoke(app, args)
assert result.exit_code == 0
expected = (
"no exported models yet under $SLM_HOME/models"
if project is None
else f"no models found for project {project}"
)
assert result.output == expected + "\n"


def test_models_list_reads_manifest_projects(slm_home: Path) -> None:
"""List stored manifests, including legacy exports without project ownership."""
for name, owner in (("first", "one"), ("second", "two"), ("legacy", None)):
path = slm_home / "models" / name / "1"
path.mkdir(parents=True)
manifest = {
"id": f"{name}:1",
"stats": {"stage": "pretrain", "params": 100, "step": 10},
"inputs": {"run": "test-run"},
"created": "2026-01-01T00:00:00+00:00",
}
if owner is not None:
manifest["project"] = owner
(path / "manifest.json").write_text(json.dumps(manifest))

runner = CliRunner()
for owner, included, excluded in (
("one", "first:1", "second:1"),
("two", "second:1", "first:1"),
):
result = runner.invoke(app, ["models", "list", "--project", owner])
assert result.exit_code == 0, result.output
assert included in result.output and excluded not in result.output
assert "legacy:1" not in result.output

missing = runner.invoke(app, ["models", "list", "--project", "on"])
assert missing.exit_code == 0 and missing.output == "no models found for project on\n"
unfiltered = runner.invoke(app, ["models", "list"])
assert unfiltered.exit_code == 0
assert all(ref in unfiltered.output for ref in ("first:1", "second:1", "legacy:1"))


def test_a_project_viewer_is_copied_into_the_export(
toy_run: str, monkeypatch: pytest.MonkeyPatch, tmp_path: Path, slm_home: Path
) -> None:
Expand Down