diff --git a/src/modeldock/cli/app.py b/src/modeldock/cli/app.py index b01f2c7..62f749d 100644 --- a/src/modeldock/cli/app.py +++ b/src/modeldock/cli/app.py @@ -22,6 +22,7 @@ from modeldock.cli.commands.search import search_cmd from modeldock.cli.commands.sources import sources_app from modeldock.cli.commands.update import update_cmd +from modeldock.cli.commands.verify import verify_cmd from modeldock.common.logging import configure_logging # Allowed log levels surfaced to the user via --help and validated in the CLI @@ -46,6 +47,7 @@ app.command("search")(search_cmd) app.command("info")(info_cmd) app.command("update")(update_cmd) +app.command("verify")(verify_cmd) app.command("remove")(remove_cmd) app.command("run")(run_cmd) app.command("runtimes")(runtimes_cmd) diff --git a/src/modeldock/cli/commands/verify.py b/src/modeldock/cli/commands/verify.py new file mode 100644 index 0000000..14c570e --- /dev/null +++ b/src/modeldock/cli/commands/verify.py @@ -0,0 +1,44 @@ +"""CLI command: verify.""" + +from __future__ import annotations + +import typer + +from modeldock.cli.console import print_error, render_json, render_verify +from modeldock.cli.factory import manager_for + + +def verify_cmd( + models: list[str] = typer.Argument(None, help="Model name(s) to verify"), + all_: bool = typer.Option(False, "--all", help="Verify every installed model"), + backend: str = typer.Option(None, "--backend", help="Runtime backend"), + json_out: bool = typer.Option(False, "--json", help="Emit machine-readable JSON"), + debug: bool = typer.Option(False, "--debug", help="Show traceback"), +) -> None: + """Verify installed model(s) integrity.""" + try: + mgr = manager_for(backend) + + if all_: + if models: + raise typer.BadParameter("Cannot pass model names together with --all") + names = [ref.name for ref in mgr.installed()] + else: + if not models: + raise typer.BadParameter("Provide model name(s) or use --all") + names = models + + results = [(name, mgr.verify(name)) for name in names] + + if json_out: + render_json([{"name": name, "ok": ok} for name, ok in results]) + else: + render_verify(results) + + if not all(ok for _, ok in results): + raise typer.Exit(code=1) + except typer.Exit: + raise + except Exception as exc: # noqa: BLE001 - top-level CLI boundary + print_error(exc, debug, as_json=json_out) + raise typer.Exit(code=1) # noqa: B904 diff --git a/src/modeldock/cli/console.py b/src/modeldock/cli/console.py index 4830fb9..7362dc7 100644 --- a/src/modeldock/cli/console.py +++ b/src/modeldock/cli/console.py @@ -156,6 +156,20 @@ def render_sources(sources: List[Any]) -> None: console.print(table) +def render_verify(results: List[Any]) -> None: + """Render verify results (name, ok) pairs as a rich table.""" + from rich.console import Console + from rich.table import Table + + console = Console() + table = Table(title="Verify Results") + table.add_column("Name") + table.add_column("Status") + for name, ok in results: + table.add_row(name, "[green]OK[/]" if ok else "[red]FAILED[/]") + console.print(table) + + __all__ = [ "print_error", "render_installed", @@ -163,5 +177,6 @@ def render_sources(sources: List[Any]) -> None: "render_models", "render_runtimes", "render_sources", + "render_verify", "to_jsonable", ] diff --git a/tests/unit/test_verify_cli.py b/tests/unit/test_verify_cli.py new file mode 100644 index 0000000..ee07603 --- /dev/null +++ b/tests/unit/test_verify_cli.py @@ -0,0 +1,109 @@ +"""Unit tests: verify command — exit codes, --all behavior, JSON output.""" + +from __future__ import annotations + +from typing import Any, Optional + +import pytest +from typer.testing import CliRunner + +import modeldock.cli.factory as factory +from modeldock.cli.app import app +from modeldock.domain.model import ModelRef + +runner = CliRunner() + + +class _VerifyManager: + """Stub manager: installed() returns configured refs, verify() looks up results.""" + + def __init__( + self, + installed_refs: Optional[list[ModelRef]] = None, + verify_results: Optional[dict[str, bool]] = None, + **_: Any, + ) -> None: + self._installed_refs = installed_refs or [] + self._verify_results = verify_results or {} + + def installed(self) -> list[ModelRef]: + return self._installed_refs + + def verify(self, name: str) -> bool: + return self._verify_results.get(name, True) + + +# --------------------------------------------------------------------------- +# --all +# --------------------------------------------------------------------------- + + +def test_verify_all_all_pass_exits_0(monkeypatch: pytest.MonkeyPatch) -> None: + refs = [ModelRef.parse("llama3"), ModelRef.parse("qwen3")] + mgr = _VerifyManager(installed_refs=refs, verify_results={"llama3": True, "qwen3": True}) + monkeypatch.setattr(factory, "ModelManager", lambda **kw: mgr) + result = runner.invoke(app, ["verify", "--all"]) + assert result.exit_code == 0 + + +def test_verify_all_one_fails_exits_1(monkeypatch: pytest.MonkeyPatch) -> None: + refs = [ModelRef.parse("llama3"), ModelRef.parse("qwen3")] + mgr = _VerifyManager(installed_refs=refs, verify_results={"llama3": True, "qwen3": False}) + monkeypatch.setattr(factory, "ModelManager", lambda **kw: mgr) + result = runner.invoke(app, ["verify", "--all"]) + assert result.exit_code == 1 + + +def test_verify_all_empty_installed_exits_0(monkeypatch: pytest.MonkeyPatch) -> None: + mgr = _VerifyManager(installed_refs=[]) + monkeypatch.setattr(factory, "ModelManager", lambda **kw: mgr) + result = runner.invoke(app, ["verify", "--all"]) + assert result.exit_code == 0 + + +def test_verify_all_with_explicit_models_exits_1(monkeypatch: pytest.MonkeyPatch) -> None: + """--all combined with explicit model names is invalid, not silently merged.""" + mgr = _VerifyManager() + monkeypatch.setattr(factory, "ModelManager", lambda **kw: mgr) + result = runner.invoke(app, ["verify", "--all", "llama3"]) + assert result.exit_code == 1 + + +# --------------------------------------------------------------------------- +# explicit model names +# --------------------------------------------------------------------------- + + +def test_verify_explicit_model_passes_exits_0(monkeypatch: pytest.MonkeyPatch) -> None: + mgr = _VerifyManager(verify_results={"llama3": True}) + monkeypatch.setattr(factory, "ModelManager", lambda **kw: mgr) + result = runner.invoke(app, ["verify", "llama3"]) + assert result.exit_code == 0 + + +def test_verify_explicit_model_fails_exits_1(monkeypatch: pytest.MonkeyPatch) -> None: + mgr = _VerifyManager(verify_results={"llama3": False}) + monkeypatch.setattr(factory, "ModelManager", lambda **kw: mgr) + result = runner.invoke(app, ["verify", "llama3"]) + assert result.exit_code == 1 + + +def test_verify_no_args_no_all_exits_1(monkeypatch: pytest.MonkeyPatch) -> None: + mgr = _VerifyManager() + monkeypatch.setattr(factory, "ModelManager", lambda **kw: mgr) + result = runner.invoke(app, ["verify"]) + assert result.exit_code == 1 + + +# --------------------------------------------------------------------------- +# --json +# --------------------------------------------------------------------------- + + +def test_verify_json_output_shape(monkeypatch: pytest.MonkeyPatch) -> None: + mgr = _VerifyManager(verify_results={"llama3": True}) + monkeypatch.setattr(factory, "ModelManager", lambda **kw: mgr) + result = runner.invoke(app, ["verify", "llama3", "--json"]) + assert result.exit_code == 0 + assert '"name": "llama3"' in result.stdout + assert '"ok": true' in result.stdout