diff --git a/README.md b/README.md index fb142ae..d92e813 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,16 @@ It keeps a small owned command surface for diagnostics and guidance, and delegates everything else to the native `aim` executable already available in the user's environment. +## Installation + +```bash +# Using uv (recommended) +uv add aimx + +# Or using pip +pip install aimx +``` + ## What aimx owns - `aimx` diff --git a/pyproject.toml b/pyproject.toml index 9285809..5a9616d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "aimx" -version = "0.1.0" +version = "0.2.0" description = "A safe CLI-first companion for native Aim" readme = "README.md" requires-python = ">=3.10,<3.13" diff --git a/src/aimx/__init__.py b/src/aimx/__init__.py index a05eb9a..ae3175a 100644 --- a/src/aimx/__init__.py +++ b/src/aimx/__init__.py @@ -1,3 +1,3 @@ __all__ = ["__version__"] -__version__ = "0.1.0" +__version__ = "0.2.0" diff --git a/src/aimx/commands/query.py b/src/aimx/commands/query.py index f579e9f..223c8f3 100644 --- a/src/aimx/commands/query.py +++ b/src/aimx/commands/query.py @@ -5,10 +5,6 @@ from pathlib import Path from typing import Any -from aim import Repo -from aim.sdk.types import QueryReportMode - - SUPPORTED_TARGETS = {"metrics", "images"} @@ -35,6 +31,18 @@ class QueryCommandResult: error_message: str | None = None +def load_aim_query_support(): + try: + from aim import Repo + from aim.sdk.types import QueryReportMode + except ModuleNotFoundError as error: + raise RuntimeError( + "`aimx query` requires the Python `aim` package in the current environment." + ) from error + + return Repo, QueryReportMode + + def normalize_repo_path(path: Path) -> Path: if not path.exists(): raise ValueError(f"Repository path does not exist: {path}") @@ -108,6 +116,7 @@ def run_query_command(args: list[str]) -> QueryCommandResult: def collect_query_rows(invocation: QueryInvocation, repo_path: Path) -> list[dict[str, Any]]: + Repo, QueryReportMode = load_aim_query_support() repo = Repo(str(repo_path)) if invocation.target == "metrics": diff --git a/tests/integration/test_missing_python_aim_package.py b/tests/integration/test_missing_python_aim_package.py new file mode 100644 index 0000000..6dc5d78 --- /dev/null +++ b/tests/integration/test_missing_python_aim_package.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +import importlib +import sys + + +def _reload_main_without_python_aim(monkeypatch): + monkeypatch.setitem(sys.modules, "aim", None) + monkeypatch.delitem(sys.modules, "aim.sdk", raising=False) + monkeypatch.delitem(sys.modules, "aim.sdk.types", raising=False) + sys.modules.pop("aimx.__main__", None) + sys.modules.pop("aimx.cli", None) + sys.modules.pop("aimx.commands.query", None) + return importlib.import_module("aimx.__main__").main + + +def test_owned_commands_still_work_when_python_aim_package_is_missing( + capsys, monkeypatch +) -> None: + monkeypatch.setenv("PATH", "") + + main = _reload_main_without_python_aim(monkeypatch) + exit_code = main(["version"]) + + captured = capsys.readouterr() + assert exit_code == 0 + assert "aimx 0.1.0" in captured.out + + +def test_query_reports_actionable_error_when_python_aim_package_is_missing( + capsys, monkeypatch, tmp_path +) -> None: + monkeypatch.setenv("PATH", "") + + main = _reload_main_without_python_aim(monkeypatch) + exit_code = main( + ["query", "metrics", "metric.name == 'loss'", "--repo", str(tmp_path)] + ) + + captured = capsys.readouterr() + assert exit_code == 2 + assert "requires the Python `aim` package" in captured.err