diff --git a/CHANGELOG.md b/CHANGELOG.md index 9015be4..5ad9150 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 593c867..81d85d5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 uv run slm export --name --version +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 : uv run slm site : # static site; the model runs in the browser uv run slm studio start | stop | status | run # "See it in action": the local web studio diff --git a/src/slmkit/cli.py b/src/slmkit/cli.py index e16af77..8e3e4c1 100644 --- a/src/slmkit/cli.py +++ b/src/slmkit/cli.py @@ -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 diff --git a/tests/unit/test_export.py b/tests/unit/test_export.py index 01f7f63..a6bdd94 100644 --- a/tests/unit/test_export.py +++ b/tests/unit/test_export.py @@ -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: