From 11f6979790c3787ec8c14be4b88c257a07c27505 Mon Sep 17 00:00:00 2001 From: Ovid Date: Sun, 22 Mar 2026 15:03:50 +0100 Subject: [PATCH 01/19] feat: add LauncherConfig dataclass and arg parsing for explorer launcher --- src/ananta/explorers/launcher.py | 28 ++++++++++++++++++ tests/unit/explorers/test_launcher.py | 41 +++++++++++++++++++++++++++ 2 files changed, 69 insertions(+) create mode 100644 src/ananta/explorers/launcher.py create mode 100644 tests/unit/explorers/test_launcher.py diff --git a/src/ananta/explorers/launcher.py b/src/ananta/explorers/launcher.py new file mode 100644 index 00000000..f4349e50 --- /dev/null +++ b/src/ananta/explorers/launcher.py @@ -0,0 +1,28 @@ +"""Shared launcher logic for Ananta explorer applications.""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class LauncherConfig: + """Per-explorer configuration for the shared launcher.""" + + app_name: str + entry_point: str + frontend_dir: str + requires_git: bool = False + shared_frontend_dir: str | None = None + + +def parse_launcher_args(argv: list[str]) -> tuple[bool, list[str]]: + """Strip --rebuild from argv, return (rebuild, passthrough_args).""" + rebuild = False + passthrough: list[str] = [] + for arg in argv: + if arg == "--rebuild": + rebuild = True + else: + passthrough.append(arg) + return rebuild, passthrough diff --git a/tests/unit/explorers/test_launcher.py b/tests/unit/explorers/test_launcher.py new file mode 100644 index 00000000..4aeb8af5 --- /dev/null +++ b/tests/unit/explorers/test_launcher.py @@ -0,0 +1,41 @@ +"""Tests for the shared explorer launcher.""" + +from ananta.explorers.launcher import LauncherConfig, parse_launcher_args + + +class TestLauncherConfig: + def test_required_fields(self) -> None: + config = LauncherConfig( + app_name="Test App", + entry_point="test-app", + frontend_dir="src/ananta/explorers/test/frontend", + ) + assert config.app_name == "Test App" + assert config.entry_point == "test-app" + assert config.frontend_dir == "src/ananta/explorers/test/frontend" + assert config.requires_git is False + assert config.shared_frontend_dir is None + + +class TestParseLauncherArgs: + def test_no_args(self) -> None: + rebuild, passthrough = parse_launcher_args([]) + assert rebuild is False + assert passthrough == [] + + def test_rebuild_stripped(self) -> None: + rebuild, passthrough = parse_launcher_args(["--rebuild", "--port", "9000"]) + assert rebuild is True + assert passthrough == ["--port", "9000"] + + def test_passthrough_preserved(self) -> None: + rebuild, passthrough = parse_launcher_args( + ["--port", "8080", "--open", "--model", "gpt-4o"] + ) + assert rebuild is False + assert passthrough == ["--port", "8080", "--open", "--model", "gpt-4o"] + + def test_rebuild_only(self) -> None: + rebuild, passthrough = parse_launcher_args(["--rebuild"]) + assert rebuild is True + assert passthrough == [] From 629d1107a613b1b5464172ded160019c45fd41e5 Mon Sep 17 00:00:00 2001 From: Ovid Date: Sun, 22 Mar 2026 15:05:07 +0100 Subject: [PATCH 02/19] feat: add command existence and Python version preflight checks --- src/ananta/explorers/launcher.py | 17 ++++++++++++ tests/unit/explorers/test_launcher.py | 38 ++++++++++++++++++++++++++- 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/src/ananta/explorers/launcher.py b/src/ananta/explorers/launcher.py index f4349e50..4b728272 100644 --- a/src/ananta/explorers/launcher.py +++ b/src/ananta/explorers/launcher.py @@ -2,6 +2,8 @@ from __future__ import annotations +import shutil +import sys from dataclasses import dataclass @@ -26,3 +28,18 @@ def parse_launcher_args(argv: list[str]) -> tuple[bool, list[str]]: else: passthrough.append(arg) return rebuild, passthrough + + +def check_command(cmd: str, install_hint: str) -> str | None: + """Return an error string if cmd is not on PATH, else None.""" + if shutil.which(cmd) is None: + return f" - Install {cmd}: {install_hint}" + return None + + +def check_python_version() -> str | None: + """Return an error string if Python < 3.11, else None.""" + major, minor = sys.version_info[:2] + if (major, minor) < (3, 11): + return f" - Upgrade Python: 3.11+ required, found {major}.{minor}" + return None diff --git a/tests/unit/explorers/test_launcher.py b/tests/unit/explorers/test_launcher.py index 4aeb8af5..9de19edd 100644 --- a/tests/unit/explorers/test_launcher.py +++ b/tests/unit/explorers/test_launcher.py @@ -1,6 +1,13 @@ """Tests for the shared explorer launcher.""" -from ananta.explorers.launcher import LauncherConfig, parse_launcher_args +from unittest.mock import patch + +from ananta.explorers.launcher import ( + LauncherConfig, + check_command, + check_python_version, + parse_launcher_args, +) class TestLauncherConfig: @@ -39,3 +46,32 @@ def test_rebuild_only(self) -> None: rebuild, passthrough = parse_launcher_args(["--rebuild"]) assert rebuild is True assert passthrough == [] + + +class TestCheckCommand: + def test_command_found(self) -> None: + with patch("ananta.explorers.launcher.shutil.which", return_value="/usr/bin/python3"): + assert check_command("python3", "https://python.org") is None + + def test_command_missing(self) -> None: + with patch("ananta.explorers.launcher.shutil.which", return_value=None): + error = check_command("python3", "https://python.org") + assert error is not None + assert "python3" in error + assert "https://python.org" in error + + +class TestCheckPythonVersion: + def test_version_ok(self) -> None: + with patch("ananta.explorers.launcher.sys.version_info", (3, 12, 0)): + assert check_python_version() is None + + def test_version_exactly_3_11(self) -> None: + with patch("ananta.explorers.launcher.sys.version_info", (3, 11, 0)): + assert check_python_version() is None + + def test_version_too_old(self) -> None: + with patch("ananta.explorers.launcher.sys.version_info", (3, 10, 5)): + error = check_python_version() + assert error is not None + assert "3.11" in error From a7c11f144581f759e0098d40d3d8a69dbf5b7be0 Mon Sep 17 00:00:00 2001 From: Ovid Date: Sun, 22 Mar 2026 15:06:35 +0100 Subject: [PATCH 03/19] feat: add env var, Docker daemon, and sandbox image preflight checks --- src/ananta/explorers/launcher.py | 50 +++++++++++++++++ tests/unit/explorers/test_launcher.py | 77 +++++++++++++++++++++++++++ 2 files changed, 127 insertions(+) diff --git a/src/ananta/explorers/launcher.py b/src/ananta/explorers/launcher.py index 4b728272..d8f8f0bd 100644 --- a/src/ananta/explorers/launcher.py +++ b/src/ananta/explorers/launcher.py @@ -2,7 +2,9 @@ from __future__ import annotations +import os import shutil +import subprocess import sys from dataclasses import dataclass @@ -43,3 +45,51 @@ def check_python_version() -> str | None: if (major, minor) < (3, 11): return f" - Upgrade Python: 3.11+ required, found {major}.{minor}" return None + + +def check_env_var(var: str, hint: str) -> str | None: + """Return an error string if the env var is unset or empty, else None.""" + if not os.environ.get(var): + return f" - Set {var}: {hint}" + return None + + +def check_docker_running() -> str | None: + """Return an error string if Docker daemon is not running, else None.""" + if shutil.which("docker") is None: + return None # check_command will catch this + try: + subprocess.run( + ["docker", "info"], + capture_output=True, + check=True, + ) + except subprocess.CalledProcessError: + return " - Start Docker daemon (e.g. open Docker Desktop)" + return None + + +def ensure_sandbox_image(project_root: str) -> str | None: + """Build the sandbox image if missing. Return error string on failure, else None.""" + image = os.environ.get("ANANTA_SANDBOX_IMAGE", "ananta-sandbox") + if shutil.which("docker") is None: + return None # check_command will catch this + try: + subprocess.run( + ["docker", "image", "inspect", image], + capture_output=True, + check=True, + ) + return None # Image exists + except subprocess.CalledProcessError: + pass # Image missing, try to build + + print(f"[ananta] Building sandbox image ({image})...") + try: + subprocess.run( + ["docker", "build", "-t", image, f"{project_root}/src/ananta/sandbox/"], + check=True, + ) + except subprocess.CalledProcessError: + return f" - Failed to build Docker image '{image}'" + return None diff --git a/tests/unit/explorers/test_launcher.py b/tests/unit/explorers/test_launcher.py index 9de19edd..7faff400 100644 --- a/tests/unit/explorers/test_launcher.py +++ b/tests/unit/explorers/test_launcher.py @@ -1,11 +1,16 @@ """Tests for the shared explorer launcher.""" +import os +import subprocess from unittest.mock import patch from ananta.explorers.launcher import ( LauncherConfig, check_command, + check_docker_running, + check_env_var, check_python_version, + ensure_sandbox_image, parse_launcher_args, ) @@ -75,3 +80,75 @@ def test_version_too_old(self) -> None: error = check_python_version() assert error is not None assert "3.11" in error + + +class TestCheckEnvVar: + def test_var_set(self) -> None: + with patch.dict(os.environ, {"ANANTA_API_KEY": "sk-test"}): + assert check_env_var("ANANTA_API_KEY", "export ANANTA_API_KEY=") is None + + def test_var_missing(self) -> None: + env = os.environ.copy() + env.pop("ANANTA_API_KEY", None) + with patch.dict(os.environ, env, clear=True): + error = check_env_var("ANANTA_API_KEY", "export ANANTA_API_KEY=") + assert error is not None + assert "ANANTA_API_KEY" in error + + def test_var_empty(self) -> None: + with patch.dict(os.environ, {"ANANTA_API_KEY": ""}): + error = check_env_var("ANANTA_API_KEY", "export ANANTA_API_KEY=") + assert error is not None + + +class TestCheckDockerRunning: + def test_docker_running(self) -> None: + with patch("ananta.explorers.launcher.subprocess.run") as mock_run: + mock_run.return_value = subprocess.CompletedProcess([], 0) + assert check_docker_running() is None + + def test_docker_not_running(self) -> None: + with patch("ananta.explorers.launcher.subprocess.run", side_effect=subprocess.CalledProcessError(1, "docker")): + error = check_docker_running() + assert error is not None + assert "Docker" in error + + def test_docker_not_installed(self) -> None: + with patch("ananta.explorers.launcher.shutil.which", return_value=None): + # If docker isn't on PATH, skip the check (already caught by check_command) + assert check_docker_running() is None + + +class TestEnsureSandboxImage: + def test_image_exists(self) -> None: + with patch("ananta.explorers.launcher.subprocess.run") as mock_run: + mock_run.return_value = subprocess.CompletedProcess([], 0) + assert ensure_sandbox_image("/project/root") is None + + def test_image_missing_build_succeeds(self, capsys: object) -> None: + call_count = 0 + + def side_effect(*args: object, **kwargs: object) -> subprocess.CompletedProcess[str]: + nonlocal call_count + call_count += 1 + if call_count == 1: + # docker image inspect fails + raise subprocess.CalledProcessError(1, "docker") + # docker build succeeds + return subprocess.CompletedProcess([], 0) + + with patch("ananta.explorers.launcher.subprocess.run", side_effect=side_effect): + assert ensure_sandbox_image("/project/root") is None + + def test_image_missing_build_fails(self) -> None: + with patch( + "ananta.explorers.launcher.subprocess.run", + side_effect=subprocess.CalledProcessError(1, "docker"), + ): + error = ensure_sandbox_image("/project/root") + assert error is not None + assert "sandbox" in error.lower() or "image" in error.lower() + + def test_docker_not_installed(self) -> None: + with patch("ananta.explorers.launcher.shutil.which", return_value=None): + assert ensure_sandbox_image("/project/root") is None From fb5d17eb7c11915962bd4507bd6288634a41c17b Mon Sep 17 00:00:00 2001 From: Ovid Date: Sun, 22 Mar 2026 15:08:27 +0100 Subject: [PATCH 04/19] feat: add run_preflight orchestrator collecting all check errors --- src/ananta/explorers/launcher.py | 22 ++++++++++ tests/unit/explorers/test_launcher.py | 58 +++++++++++++++++++++++++++ 2 files changed, 80 insertions(+) diff --git a/src/ananta/explorers/launcher.py b/src/ananta/explorers/launcher.py index d8f8f0bd..faebb207 100644 --- a/src/ananta/explorers/launcher.py +++ b/src/ananta/explorers/launcher.py @@ -93,3 +93,25 @@ def ensure_sandbox_image(project_root: str) -> str | None: except subprocess.CalledProcessError: return f" - Failed to build Docker image '{image}'" return None + + +def run_preflight(config: LauncherConfig, project_root: str) -> list[str]: + """Run all preflight checks. Return list of error strings (empty = all OK).""" + errors: list[str] = [] + + def collect(result: str | None) -> None: + if result is not None: + errors.append(result) + + collect(check_python_version()) + collect(check_command("node", "https://nodejs.org/")) + collect(check_command("npm", "https://nodejs.org/")) + collect(check_command("docker", "https://www.docker.com/get-started/")) + if config.requires_git: + collect(check_command("git", "https://git-scm.com/")) + collect(check_env_var("ANANTA_API_KEY", "export ANANTA_API_KEY=")) + collect(check_env_var("ANANTA_MODEL", "export ANANTA_MODEL=")) + collect(check_docker_running()) + collect(ensure_sandbox_image(project_root)) + + return errors diff --git a/tests/unit/explorers/test_launcher.py b/tests/unit/explorers/test_launcher.py index 7faff400..71a8968c 100644 --- a/tests/unit/explorers/test_launcher.py +++ b/tests/unit/explorers/test_launcher.py @@ -12,6 +12,7 @@ check_python_version, ensure_sandbox_image, parse_launcher_args, + run_preflight, ) @@ -152,3 +153,60 @@ def test_image_missing_build_fails(self) -> None: def test_docker_not_installed(self) -> None: with patch("ananta.explorers.launcher.shutil.which", return_value=None): assert ensure_sandbox_image("/project/root") is None + + +class TestRunPreflight: + def _make_config(self, requires_git: bool = False) -> LauncherConfig: + return LauncherConfig( + app_name="Test App", + entry_point="test-app", + frontend_dir="src/test/frontend", + requires_git=requires_git, + ) + + @patch("ananta.explorers.launcher.check_command", return_value=None) + @patch("ananta.explorers.launcher.check_python_version", return_value=None) + @patch("ananta.explorers.launcher.check_env_var", return_value=None) + @patch("ananta.explorers.launcher.check_docker_running", return_value=None) + @patch("ananta.explorers.launcher.ensure_sandbox_image", return_value=None) + def test_all_pass(self, *mocks: object) -> None: + errors = run_preflight(self._make_config(), "/project") + assert errors == [] + + @patch("ananta.explorers.launcher.ensure_sandbox_image", return_value=None) + @patch("ananta.explorers.launcher.check_docker_running", return_value=None) + @patch("ananta.explorers.launcher.check_env_var", return_value=None) + @patch("ananta.explorers.launcher.check_python_version", return_value=None) + @patch("ananta.explorers.launcher.check_command") + def test_collects_multiple_errors(self, mock_cmd: object, *mocks: object) -> None: + mock_cmd.side_effect = lambda cmd, hint: f" - missing {cmd}" if cmd == "node" else None + errors = run_preflight(self._make_config(), "/project") + assert len(errors) == 1 + assert "node" in errors[0] + + @patch("ananta.explorers.launcher.ensure_sandbox_image", return_value=None) + @patch("ananta.explorers.launcher.check_docker_running", return_value=None) + @patch("ananta.explorers.launcher.check_env_var", return_value=None) + @patch("ananta.explorers.launcher.check_python_version", return_value=None) + @patch("ananta.explorers.launcher.check_command", return_value=None) + def test_git_checked_when_required( + self, mock_cmd: object, *mocks: object + ) -> None: + """When requires_git=True, git is in the check_command call list.""" + config = self._make_config(requires_git=True) + run_preflight(config, "/project") + cmd_names = [call.args[0] for call in mock_cmd.call_args_list] # type: ignore[attr-defined] + assert "git" in cmd_names + + @patch("ananta.explorers.launcher.ensure_sandbox_image", return_value=None) + @patch("ananta.explorers.launcher.check_docker_running", return_value=None) + @patch("ananta.explorers.launcher.check_env_var", return_value=None) + @patch("ananta.explorers.launcher.check_python_version", return_value=None) + @patch("ananta.explorers.launcher.check_command", return_value=None) + def test_git_not_checked_when_not_required( + self, mock_cmd: object, *mocks: object + ) -> None: + config = self._make_config(requires_git=False) + run_preflight(config, "/project") + cmd_names = [call.args[0] for call in mock_cmd.call_args_list] # type: ignore[attr-defined] + assert "git" not in cmd_names From 9e7280fcd41af9f60504a1031339421ef1459852 Mon Sep 17 00:00:00 2001 From: Ovid Date: Sun, 22 Mar 2026 15:09:57 +0100 Subject: [PATCH 05/19] feat: add frontend build logic with shared UI and --rebuild support --- src/ananta/explorers/launcher.py | 26 ++++++++++++ tests/unit/explorers/test_launcher.py | 58 +++++++++++++++++++++++++++ 2 files changed, 84 insertions(+) diff --git a/src/ananta/explorers/launcher.py b/src/ananta/explorers/launcher.py index faebb207..940084d7 100644 --- a/src/ananta/explorers/launcher.py +++ b/src/ananta/explorers/launcher.py @@ -7,6 +7,7 @@ import subprocess import sys from dataclasses import dataclass +from pathlib import Path @dataclass(frozen=True) @@ -95,6 +96,31 @@ def ensure_sandbox_image(project_root: str) -> str | None: return None +def build_frontend(config: LauncherConfig, project_root: str, *, rebuild: bool) -> None: + """Build the explorer frontend (and shared UI if configured).""" + frontend_path = Path(config.frontend_dir) + if not frontend_path.is_absolute(): + frontend_path = Path(project_root) / frontend_path + dist_path = frontend_path / "dist" + + needs_build = rebuild or not dist_path.is_dir() + + if not needs_build: + print("[ananta] Frontend already built. Use --rebuild to force.") + return + + if config.shared_frontend_dir: + shared_path = Path(config.shared_frontend_dir) + if not shared_path.is_absolute(): + shared_path = Path(project_root) / shared_path + print("[ananta] Installing shared UI dependencies...") + subprocess.run(["npm", "install", "--silent"], cwd=shared_path, check=True) + + print("[ananta] Building frontend...") + subprocess.run(["npm", "install", "--silent"], cwd=frontend_path, check=True) + subprocess.run(["npm", "run", "build"], cwd=frontend_path, check=True) + + def run_preflight(config: LauncherConfig, project_root: str) -> list[str]: """Run all preflight checks. Return list of error strings (empty = all OK).""" errors: list[str] = [] diff --git a/tests/unit/explorers/test_launcher.py b/tests/unit/explorers/test_launcher.py index 71a8968c..c62d1937 100644 --- a/tests/unit/explorers/test_launcher.py +++ b/tests/unit/explorers/test_launcher.py @@ -4,8 +4,11 @@ import subprocess from unittest.mock import patch +from pathlib import Path + from ananta.explorers.launcher import ( LauncherConfig, + build_frontend, check_command, check_docker_running, check_env_var, @@ -210,3 +213,58 @@ def test_git_not_checked_when_not_required( run_preflight(config, "/project") cmd_names = [call.args[0] for call in mock_cmd.call_args_list] # type: ignore[attr-defined] assert "git" not in cmd_names + + +class TestBuildFrontend: + def _make_config( + self, + frontend_dir: str = "src/test/frontend", + shared_frontend_dir: str | None = None, + ) -> LauncherConfig: + return LauncherConfig( + app_name="Test App", + entry_point="test-app", + frontend_dir=frontend_dir, + shared_frontend_dir=shared_frontend_dir, + ) + + @patch("ananta.explorers.launcher.subprocess.run") + def test_build_when_dist_missing(self, mock_run: object, tmp_path: Path) -> None: + frontend = tmp_path / "frontend" + frontend.mkdir() + config = self._make_config(frontend_dir=str(frontend)) + build_frontend(config, str(tmp_path), rebuild=False) + # Should have called npm install + npm run build + assert mock_run.call_count == 2 # type: ignore[attr-defined] + + @patch("ananta.explorers.launcher.subprocess.run") + def test_skip_when_dist_exists(self, mock_run: object, tmp_path: Path) -> None: + frontend = tmp_path / "frontend" + frontend.mkdir() + (frontend / "dist").mkdir() + config = self._make_config(frontend_dir=str(frontend)) + build_frontend(config, str(tmp_path), rebuild=False) + mock_run.assert_not_called() # type: ignore[attr-defined] + + @patch("ananta.explorers.launcher.subprocess.run") + def test_rebuild_forces_build(self, mock_run: object, tmp_path: Path) -> None: + frontend = tmp_path / "frontend" + frontend.mkdir() + (frontend / "dist").mkdir() + config = self._make_config(frontend_dir=str(frontend)) + build_frontend(config, str(tmp_path), rebuild=True) + assert mock_run.call_count == 2 # type: ignore[attr-defined] + + @patch("ananta.explorers.launcher.subprocess.run") + def test_shared_frontend_installed(self, mock_run: object, tmp_path: Path) -> None: + frontend = tmp_path / "frontend" + frontend.mkdir() + shared = tmp_path / "shared" + shared.mkdir() + config = self._make_config( + frontend_dir=str(frontend), + shared_frontend_dir=str(shared), + ) + build_frontend(config, str(tmp_path), rebuild=False) + # shared npm install + frontend npm install + npm run build = 3 + assert mock_run.call_count == 3 # type: ignore[attr-defined] From 839f9af82076389352cb0c214850ba91502cb3f2 Mon Sep 17 00:00:00 2001 From: Ovid Date: Sun, 22 Mar 2026 15:11:22 +0100 Subject: [PATCH 06/19] feat: add launch() function tying together preflight, build, and exec --- src/ananta/explorers/launcher.py | 36 ++++++++++++++++ tests/unit/explorers/test_launcher.py | 59 +++++++++++++++++++++++++++ 2 files changed, 95 insertions(+) diff --git a/src/ananta/explorers/launcher.py b/src/ananta/explorers/launcher.py index 940084d7..63c0cd41 100644 --- a/src/ananta/explorers/launcher.py +++ b/src/ananta/explorers/launcher.py @@ -121,6 +121,42 @@ def build_frontend(config: LauncherConfig, project_root: str, *, rebuild: bool) subprocess.run(["npm", "run", "build"], cwd=frontend_path, check=True) +def launch( + config: LauncherConfig, + *, + argv: list[str] | None = None, + project_root: str | None = None, +) -> int: + """Run preflight checks, build frontend, and launch the explorer. + + Returns the process exit code (0 = success). + """ + if argv is None: + argv = sys.argv[1:] + if project_root is None: + # Resolve from this file: src/ananta/explorers/launcher.py -> project root + project_root = str(Path(__file__).resolve().parents[3]) + + rebuild, passthrough = parse_launcher_args(argv) + + # Preflight + errors = run_preflight(config, project_root) + if errors: + print(f"\033[0;31m[ananta]\033[0m Cannot start {config.app_name}. Fix the following:", + file=sys.stderr) + for e in errors: + print(e, file=sys.stderr) + return 1 + + # Build frontend + build_frontend(config, project_root, rebuild=rebuild) + + # Launch + print(f"[ananta] Starting {config.app_name}...") + result = subprocess.run([config.entry_point, *passthrough]) + return result.returncode + + def run_preflight(config: LauncherConfig, project_root: str) -> list[str]: """Run all preflight checks. Return list of error strings (empty = all OK).""" errors: list[str] = [] diff --git a/tests/unit/explorers/test_launcher.py b/tests/unit/explorers/test_launcher.py index c62d1937..e6464f44 100644 --- a/tests/unit/explorers/test_launcher.py +++ b/tests/unit/explorers/test_launcher.py @@ -14,6 +14,7 @@ check_env_var, check_python_version, ensure_sandbox_image, + launch, parse_launcher_args, run_preflight, ) @@ -268,3 +269,61 @@ def test_shared_frontend_installed(self, mock_run: object, tmp_path: Path) -> No build_frontend(config, str(tmp_path), rebuild=False) # shared npm install + frontend npm install + npm run build = 3 assert mock_run.call_count == 3 # type: ignore[attr-defined] + + +class TestLaunch: + def _make_config(self) -> LauncherConfig: + return LauncherConfig( + app_name="Test App", + entry_point="test-app", + frontend_dir="src/test/frontend", + ) + + @patch("ananta.explorers.launcher.subprocess.run") + @patch("ananta.explorers.launcher.build_frontend") + @patch("ananta.explorers.launcher.run_preflight", return_value=[]) + def test_launch_success( + self, + mock_preflight: object, + mock_build: object, + mock_run: object, + ) -> None: + mock_run.return_value = subprocess.CompletedProcess([], 0) + config = self._make_config() + exit_code = launch(config, argv=["--port", "9000"], project_root="/project") + assert exit_code == 0 + mock_run.assert_called_once() # type: ignore[attr-defined] + call_args = mock_run.call_args # type: ignore[attr-defined] + assert call_args[0][0] == ["test-app", "--port", "9000"] + + @patch("ananta.explorers.launcher.build_frontend") + @patch("ananta.explorers.launcher.run_preflight", return_value=[" - missing node"]) + def test_launch_preflight_failure( + self, + mock_preflight: object, + mock_build: object, + ) -> None: + config = self._make_config() + exit_code = launch(config, argv=[], project_root="/project") + assert exit_code == 1 + mock_build.assert_not_called() # type: ignore[attr-defined] + + @patch("ananta.explorers.launcher.subprocess.run") + @patch("ananta.explorers.launcher.build_frontend") + @patch("ananta.explorers.launcher.run_preflight", return_value=[]) + def test_rebuild_passed_to_build( + self, + mock_preflight: object, + mock_build: object, + mock_run: object, + ) -> None: + mock_run.return_value = subprocess.CompletedProcess([], 0) + config = self._make_config() + launch(config, argv=["--rebuild", "--open"], project_root="/project") + mock_build.assert_called_once() # type: ignore[attr-defined] + _, kwargs = mock_build.call_args # type: ignore[attr-defined] + assert kwargs["rebuild"] is True + # --rebuild should NOT be passed to the entry point + call_args = mock_run.call_args # type: ignore[attr-defined] + assert "--rebuild" not in call_args[0][0] + assert "--open" in call_args[0][0] From d8c38c9ef77bf6660e697e52ea13eee614aaee3a Mon Sep 17 00:00:00 2001 From: Ovid Date: Sun, 22 Mar 2026 15:12:22 +0100 Subject: [PATCH 07/19] feat: add per-explorer launch.py config files --- arxiv-explorer/launch.py | 15 +++++++++++++++ code-explorer/launch.py | 17 +++++++++++++++++ document-explorer/launch.py | 16 ++++++++++++++++ 3 files changed, 48 insertions(+) create mode 100644 arxiv-explorer/launch.py create mode 100644 code-explorer/launch.py create mode 100644 document-explorer/launch.py diff --git a/arxiv-explorer/launch.py b/arxiv-explorer/launch.py new file mode 100644 index 00000000..028e61c5 --- /dev/null +++ b/arxiv-explorer/launch.py @@ -0,0 +1,15 @@ +#!/usr/bin/env python3 +"""Launch the Ananta arXiv Web Explorer.""" + +import sys + +from ananta.explorers.launcher import LauncherConfig, launch + +config = LauncherConfig( + app_name="Ananta arXiv Web Explorer", + entry_point="ananta-web", + frontend_dir="src/ananta/explorers/arxiv/frontend", +) + +if __name__ == "__main__": + sys.exit(launch(config)) diff --git a/code-explorer/launch.py b/code-explorer/launch.py new file mode 100644 index 00000000..47cb57c1 --- /dev/null +++ b/code-explorer/launch.py @@ -0,0 +1,17 @@ +#!/usr/bin/env python3 +"""Launch the Ananta Code Explorer.""" + +import sys + +from ananta.explorers.launcher import LauncherConfig, launch + +config = LauncherConfig( + app_name="Ananta Code Explorer", + entry_point="ananta-code", + frontend_dir="src/ananta/explorers/code/frontend", + requires_git=True, + shared_frontend_dir="src/ananta/explorers/shared_ui/frontend", +) + +if __name__ == "__main__": + sys.exit(launch(config)) diff --git a/document-explorer/launch.py b/document-explorer/launch.py new file mode 100644 index 00000000..c6cbaf79 --- /dev/null +++ b/document-explorer/launch.py @@ -0,0 +1,16 @@ +#!/usr/bin/env python3 +"""Launch the Ananta Document Explorer.""" + +import sys + +from ananta.explorers.launcher import LauncherConfig, launch + +config = LauncherConfig( + app_name="Ananta Document Explorer", + entry_point="ananta-document-explorer", + frontend_dir="src/ananta/explorers/document/frontend", + shared_frontend_dir="src/ananta/explorers/shared_ui/frontend", +) + +if __name__ == "__main__": + sys.exit(launch(config)) From 6da2ca090d5be087adedc6b637a4ac8f1f1907fd Mon Sep 17 00:00:00 2001 From: Ovid Date: Sun, 22 Mar 2026 15:14:12 +0100 Subject: [PATCH 08/19] refactor: rewrite explorer shell scripts as thin venv-bootstrap shims --- arxiv-explorer/arxiv-explorer.sh | 27 +++++++++++++++++------- code-explorer/code-explorer.sh | 29 +++++++++++++++++--------- document-explorer/document-explorer.sh | 28 +++++++++++++++++-------- 3 files changed, 57 insertions(+), 27 deletions(-) diff --git a/arxiv-explorer/arxiv-explorer.sh b/arxiv-explorer/arxiv-explorer.sh index 987c9541..7c16b2ea 100755 --- a/arxiv-explorer/arxiv-explorer.sh +++ b/arxiv-explorer/arxiv-explorer.sh @@ -4,19 +4,30 @@ # ./arxiv-explorer/arxiv-explorer.sh # defaults # ./arxiv-explorer/arxiv-explorer.sh --model gpt-5-mini # pass args to ananta-web # ./arxiv-explorer/arxiv-explorer.sh --port 8080 # custom port -# ./arxiv-explorer/arxiv-explorer.sh --open # open browser on startup +# ./arxiv-explorer/arxiv-explorer.sh --open # open browser on startup # ./arxiv-explorer/arxiv-explorer.sh --rebuild # force frontend rebuild set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" - -APP_NAME="Ananta arXiv Web Explorer" -APP_SLUG="ananta-web" +VENV_DIR="$PROJECT_ROOT/.venv" PIP_EXTRA="web" -ENTRY_POINT="ananta-web" -FRONTEND_DIR="$PROJECT_ROOT/src/ananta/explorers/arxiv/frontend" +APP_SLUG="ananta-web" + +if [ ! -d "$VENV_DIR" ]; then + echo "[ananta] Creating virtual environment..." + python3 -m venv "$VENV_DIR" +fi + +# shellcheck source=/dev/null +source "$VENV_DIR/bin/activate" + +MARKER="$VENV_DIR/.${APP_SLUG}-installed" +if [ ! -f "$MARKER" ] || [ "$PROJECT_ROOT/pyproject.toml" -nt "$MARKER" ]; then + echo "[ananta] Installing Python dependencies..." + pip install -q -e "$PROJECT_ROOT[$PIP_EXTRA]" + touch "$MARKER" +fi -source "$PROJECT_ROOT/scripts/common.sh" -launch "$@" +exec python "$SCRIPT_DIR/launch.py" "$@" diff --git a/code-explorer/code-explorer.sh b/code-explorer/code-explorer.sh index bf34578b..775a3032 100755 --- a/code-explorer/code-explorer.sh +++ b/code-explorer/code-explorer.sh @@ -4,21 +4,30 @@ # ./code-explorer/code-explorer.sh # defaults # ./code-explorer/code-explorer.sh --model gpt-5-mini # pass args to ananta-code # ./code-explorer/code-explorer.sh --port 9000 # custom port -# ./code-explorer/code-explorer.sh --open # open browser on startup +# ./code-explorer/code-explorer.sh --open # open browser on startup # ./code-explorer/code-explorer.sh --rebuild # force frontend rebuild set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" - -APP_NAME="Ananta Code Explorer" -APP_SLUG="ananta-code" +VENV_DIR="$PROJECT_ROOT/.venv" PIP_EXTRA="web" -ENTRY_POINT="ananta-code" -REQUIRES_GIT=true -FRONTEND_DIR="$PROJECT_ROOT/src/ananta/explorers/code/frontend" -SHARED_FRONTEND_DIR="$PROJECT_ROOT/src/ananta/explorers/shared_ui/frontend" +APP_SLUG="ananta-code" + +if [ ! -d "$VENV_DIR" ]; then + echo "[ananta] Creating virtual environment..." + python3 -m venv "$VENV_DIR" +fi + +# shellcheck source=/dev/null +source "$VENV_DIR/bin/activate" + +MARKER="$VENV_DIR/.${APP_SLUG}-installed" +if [ ! -f "$MARKER" ] || [ "$PROJECT_ROOT/pyproject.toml" -nt "$MARKER" ]; then + echo "[ananta] Installing Python dependencies..." + pip install -q -e "$PROJECT_ROOT[$PIP_EXTRA]" + touch "$MARKER" +fi -source "$PROJECT_ROOT/scripts/common.sh" -launch "$@" +exec python "$SCRIPT_DIR/launch.py" "$@" diff --git a/document-explorer/document-explorer.sh b/document-explorer/document-explorer.sh index 8aea3b1e..ec2cf198 100755 --- a/document-explorer/document-explorer.sh +++ b/document-explorer/document-explorer.sh @@ -4,20 +4,30 @@ # ./document-explorer/document-explorer.sh # defaults # ./document-explorer/document-explorer.sh --model gpt-5-mini # pass args # ./document-explorer/document-explorer.sh --port 9000 # custom port -# ./document-explorer/document-explorer.sh --open # open browser on startup +# ./document-explorer/document-explorer.sh --open # open browser on startup # ./document-explorer/document-explorer.sh --rebuild # force frontend rebuild set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" - -APP_NAME="Ananta Document Explorer" -APP_SLUG="ananta-document-explorer" +VENV_DIR="$PROJECT_ROOT/.venv" PIP_EXTRA="document-explorer" -ENTRY_POINT="ananta-document-explorer" -FRONTEND_DIR="$PROJECT_ROOT/src/ananta/explorers/document/frontend" -SHARED_FRONTEND_DIR="$PROJECT_ROOT/src/ananta/explorers/shared_ui/frontend" +APP_SLUG="ananta-document-explorer" + +if [ ! -d "$VENV_DIR" ]; then + echo "[ananta] Creating virtual environment..." + python3 -m venv "$VENV_DIR" +fi + +# shellcheck source=/dev/null +source "$VENV_DIR/bin/activate" + +MARKER="$VENV_DIR/.${APP_SLUG}-installed" +if [ ! -f "$MARKER" ] || [ "$PROJECT_ROOT/pyproject.toml" -nt "$MARKER" ]; then + echo "[ananta] Installing Python dependencies..." + pip install -q -e "$PROJECT_ROOT[$PIP_EXTRA]" + touch "$MARKER" +fi -source "$PROJECT_ROOT/scripts/common.sh" -launch "$@" +exec python "$SCRIPT_DIR/launch.py" "$@" From 90bab65f35ed0ea4ed90c5235d33bb288269627c Mon Sep 17 00:00:00 2001 From: Ovid Date: Sun, 22 Mar 2026 15:16:02 +0100 Subject: [PATCH 09/19] remove: delete scripts/common.sh and stale examples/arxiv-explorer.sh --- examples/arxiv-explorer.sh | 102 ------------------------ scripts/common.sh | 155 ------------------------------------- 2 files changed, 257 deletions(-) delete mode 100755 examples/arxiv-explorer.sh delete mode 100755 scripts/common.sh diff --git a/examples/arxiv-explorer.sh b/examples/arxiv-explorer.sh deleted file mode 100755 index 993fa074..00000000 --- a/examples/arxiv-explorer.sh +++ /dev/null @@ -1,102 +0,0 @@ -#!/usr/bin/env bash -# Launch the Ananta arXiv Web Explorer. -# Handles venv creation, dependency installation, frontend build, and startup. -# -# Usage: -# ./examples/arxiv-explorer.sh # defaults -# ./examples/arxiv-explorer.sh --model gpt-5-mini # pass args to ananta-web -# ./examples/arxiv-explorer.sh --port 8080 # custom port -# ./examples/arxiv-explorer.sh --open # open browser on startup -# ./examples/arxiv-explorer.sh --rebuild # force frontend rebuild - -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" -VENV_DIR="$PROJECT_ROOT/.venv" -FRONTEND_DIR="$PROJECT_ROOT/src/ananta/experimental/web/frontend" -FRONTEND_DIST="$FRONTEND_DIR/dist" - -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -NC='\033[0m' - -info() { echo -e "${GREEN}[ananta]${NC} $*"; } -warn() { echo -e "${YELLOW}[ananta]${NC} $*"; } -error() { echo -e "${RED}[ananta]${NC} $*" >&2; } - -# --- Parse our own flags (strip --rebuild before passing to ananta-web) --- -REBUILD=false -ANANTA_ARGS=() -for arg in "$@"; do - if [ "$arg" = "--rebuild" ]; then - REBUILD=true - else - ANANTA_ARGS+=("$arg") - fi -done - -# --- Check prerequisites --- -check_command() { - if ! command -v "$1" &>/dev/null; then - error "$1 is required but not found. $2" - exit 1 - fi -} - -check_command python3 "Install from https://www.python.org/downloads/" -check_command node "Install from https://nodejs.org/" -check_command npm "Install from https://nodejs.org/" -check_command docker "Install from https://www.docker.com/get-started/" - -# Check Python version >= 3.12 -PYVER=$(python3 -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")') -PYMAJOR=$(echo "$PYVER" | cut -d. -f1) -PYMINOR=$(echo "$PYVER" | cut -d. -f2) -if [ "$PYMAJOR" -lt 3 ] || { [ "$PYMAJOR" -eq 3 ] && [ "$PYMINOR" -lt 12 ]; }; then - error "Python 3.12+ required, found $PYVER" - exit 1 -fi - -# Check for an API key -if [ -z "${ANANTA_API_KEY:-}" ]; then - warn "No ANANTA_API_KEY detected. Set it before querying papers. Continuing anyway..." -fi - -# Check Docker is running -if ! docker info &>/dev/null 2>&1; then - warn "Docker daemon is not running. Start Docker Desktop before querying papers." -fi - -# --- Virtual environment --- -if [ ! -d "$VENV_DIR" ]; then - info "Creating virtual environment..." - python3 -m venv "$VENV_DIR" -fi - -# shellcheck source=/dev/null -source "$VENV_DIR/bin/activate" - -# --- Python dependencies --- -# Install/update if ananta-web command doesn't exist or pyproject.toml is newer -MARKER="$VENV_DIR/.ananta-web-installed" -if [ ! -f "$MARKER" ] || [ "$PROJECT_ROOT/pyproject.toml" -nt "$MARKER" ]; then - info "Installing Python dependencies..." - pip install -q -e "$PROJECT_ROOT[web]" - touch "$MARKER" -else - info "Python dependencies up to date." -fi - -# --- Frontend build --- -if [ "$REBUILD" = true ] || [ ! -d "$FRONTEND_DIST" ]; then - info "Building frontend..." - (cd "$FRONTEND_DIR" && npm install --silent && npm run build) -else - info "Frontend already built. Use --rebuild to force." -fi - -# --- Launch --- -info "Starting Ananta arXiv Web Explorer..." -exec ananta-web ${ANANTA_ARGS[@]+"${ANANTA_ARGS[@]}"} diff --git a/scripts/common.sh b/scripts/common.sh deleted file mode 100755 index c8e6622f..00000000 --- a/scripts/common.sh +++ /dev/null @@ -1,155 +0,0 @@ -#!/usr/bin/env bash -# Shared launcher logic for Ananta explorer scripts. -# Source this file after setting config variables — see individual launchers. - -# --- Derived paths --- -VENV_DIR="$PROJECT_ROOT/.venv" -FRONTEND_DIST="$FRONTEND_DIR/dist" - -# --- Colors --- -RED='\033[0;31m' -GREEN='\033[0;32m' -NC='\033[0m' - -info() { echo -e "${GREEN}[ananta]${NC} $*"; } -error() { echo -e "${RED}[ananta]${NC} $*" >&2; } - -# --- Parse flags (strip --rebuild before passing to the explorer) --- -REBUILD=false -ANANTA_ARGS=() -for arg in "$@"; do - if [ "$arg" = "--rebuild" ]; then - REBUILD=true - else - ANANTA_ARGS+=("$arg") - fi -done - -# --- Preflight validation --- -ERRORS=() - -require_command() { - local cmd="$1" install_hint="$2" - if ! command -v "$cmd" &>/dev/null; then - ERRORS+=(" - Install $cmd: $install_hint") - fi -} - -check_python_version() { - if ! command -v python3 &>/dev/null; then return; fi - local ver major minor - ver=$(python3 -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")') - major=${ver%%.*}; minor=${ver##*.} - if [ "$major" -lt 3 ] || { [ "$major" -eq 3 ] && [ "$minor" -lt 11 ]; }; then - ERRORS+=(" - Upgrade Python: 3.11+ required, found $ver") - fi -} - -require_env() { - local var="$1" hint="$2" - if [ -z "${!var:-}" ]; then - ERRORS+=(" - Set $var: $hint") - fi -} - -check_docker_running() { - if ! command -v docker &>/dev/null; then return; fi - if ! docker info &>/dev/null; then - ERRORS+=(" - Start Docker daemon (e.g. open Docker Desktop)") - fi -} - -ensure_sandbox_image() { - local image="${ANANTA_SANDBOX_IMAGE:-ananta-sandbox}" - if ! command -v docker &>/dev/null; then return; fi - if ! docker info &>/dev/null; then return; fi - if ! docker image inspect "$image" &>/dev/null; then - info "Building sandbox image ($image)..." - docker build -t "$image" "$PROJECT_ROOT/src/ananta/sandbox/" || { - ERRORS+=(" - Failed to build Docker image '$image'") - return - } - fi -} - -report_and_exit() { - if [ ${#ERRORS[@]} -gt 0 ]; then - error "Cannot start $APP_NAME. Fix the following:" - for e in "${ERRORS[@]}"; do echo -e "$e" >&2; done - exit 1 - fi -} - -run_preflight() { - require_command python3 "https://www.python.org/downloads/" - require_command node "https://nodejs.org/" - require_command npm "https://nodejs.org/" - require_command docker "https://www.docker.com/get-started/" - if [ "${REQUIRES_GIT:-false}" = true ]; then - require_command git "https://git-scm.com/" - fi - check_python_version - require_env ANANTA_API_KEY "export ANANTA_API_KEY=" - require_env ANANTA_MODEL "export ANANTA_MODEL=" - check_docker_running - ensure_sandbox_image - report_and_exit -} - -# --- Lifecycle --- -setup_venv() { - if [ ! -d "$VENV_DIR" ]; then - info "Creating virtual environment..." - python3 -m venv "$VENV_DIR" - fi - # shellcheck source=/dev/null - source "$VENV_DIR/bin/activate" -} - -install_python_deps() { - local marker="$VENV_DIR/.${APP_SLUG}-installed" - if [ ! -f "$marker" ] || [ "$PROJECT_ROOT/pyproject.toml" -nt "$marker" ]; then - info "Installing Python dependencies..." - pip install -q -e "$PROJECT_ROOT[$PIP_EXTRA]" - touch "$marker" - else - info "Python dependencies up to date." - fi -} - -build_frontend() { - if [ -n "${SHARED_FRONTEND_DIR:-}" ]; then - if [ "$REBUILD" = true ] || [ ! -d "$FRONTEND_DIST" ]; then - info "Installing shared UI dependencies..." - (cd "$SHARED_FRONTEND_DIR" && npm install --silent) - fi - fi - if [ "$REBUILD" = true ] || [ ! -d "$FRONTEND_DIST" ]; then - info "Building frontend..." - (cd "$FRONTEND_DIR" && npm install --silent && npm run build) - else - info "Frontend already built. Use --rebuild to force." - fi -} - -stderr_filter() { - # Suppress Python GC "Exception ignored" traceback blocks that appear - # during shutdown (harmless but scary-looking to users). - awk ' - /^Exception ignored/ { skip=1; next } - skip && /^[A-Za-z_][A-Za-z0-9_.]*:/ { skip=0; print; next } - skip && /^Traceback / { next } - skip && /^[^ ]/ { skip=0 } - skip { next } - { print } - ' -} - -launch() { - run_preflight - setup_venv - install_python_deps - build_frontend - info "Starting $APP_NAME..." - "$ENTRY_POINT" ${ANANTA_ARGS[@]+"${ANANTA_ARGS[@]}"} 2> >(stderr_filter >&2) -} From b2d20497a20ee89aacb059d4983be63750630002 Mon Sep 17 00:00:00 2001 From: Ovid Date: Sun, 22 Mar 2026 15:16:38 +0100 Subject: [PATCH 10/19] docs: update README Document Explorer section and changelog for launcher rewrite --- CHANGELOG.md | 9 +++++++++ README.md | 8 +++----- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a9e100a5..783d4268 100755 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Explorer launcher scripts rewritten: bash logic moved to testable Python (`src/ananta/explorers/launcher.py`), shell scripts reduced to venv-bootstrap shims + +### Removed + +- `scripts/common.sh` — replaced by Python launcher module +- `examples/arxiv-explorer.sh` — stale launcher pointing at old paths + ## [0.24.0] - 2026-03-22 ### Changed diff --git a/README.md b/README.md index 3447e0b3..9b073602 100644 --- a/README.md +++ b/README.md @@ -314,16 +314,16 @@ The [arXiv Explorer](arxiv-explorer/) is a web-based research tool that lets you _The screenshot above shows Ananta searching through nearly 25 MB of research papers to answer a complex question._ -## Document Explorer (Experimental) +## Document Explorer A web-based interface for uploading documents, organizing them into topics, and querying them with Ananta. Upload PDFs, Word documents, PowerPoint, Excel, RTF, or plain text files, group them by topic, then ask questions across your collection. ```bash # Launch the Document Explorer -python -m ananta.experimental.document_explorer +./document-explorer/document-explorer.sh # Options -python -m ananta.experimental.document_explorer --port 8003 --open --model gpt-4o +./document-explorer/document-explorer.sh --port 8003 --open --model gpt-4o ``` The explorer provides: @@ -332,8 +332,6 @@ The explorer provides: - **Live query streaming** via WebSocket — watch Ananta think in real time - **Conversation history** per topic for follow-up questions -> **Note:** This is experimental and under active development. - ## DeepWiki We love DeepWiki — it's an amazing tool that covers much of the same ground. But there are reasons you might prefer Ananta: From 03b93a9467d7b18be9865cdd326232210812fd6e Mon Sep 17 00:00:00 2001 From: Ovid Date: Sun, 22 Mar 2026 15:18:55 +0100 Subject: [PATCH 11/19] chore: lint and format fixes for launcher module --- src/ananta/explorers/launcher.py | 6 ++++-- tests/unit/explorers/test_launcher.py | 14 +++++--------- 2 files changed, 9 insertions(+), 11 deletions(-) diff --git a/src/ananta/explorers/launcher.py b/src/ananta/explorers/launcher.py index 63c0cd41..70cdfdc6 100644 --- a/src/ananta/explorers/launcher.py +++ b/src/ananta/explorers/launcher.py @@ -142,8 +142,10 @@ def launch( # Preflight errors = run_preflight(config, project_root) if errors: - print(f"\033[0;31m[ananta]\033[0m Cannot start {config.app_name}. Fix the following:", - file=sys.stderr) + print( + f"\033[0;31m[ananta]\033[0m Cannot start {config.app_name}. Fix the following:", + file=sys.stderr, + ) for e in errors: print(e, file=sys.stderr) return 1 diff --git a/tests/unit/explorers/test_launcher.py b/tests/unit/explorers/test_launcher.py index e6464f44..32f13b79 100644 --- a/tests/unit/explorers/test_launcher.py +++ b/tests/unit/explorers/test_launcher.py @@ -2,9 +2,8 @@ import os import subprocess -from unittest.mock import patch - from pathlib import Path +from unittest.mock import patch from ananta.explorers.launcher import ( LauncherConfig, @@ -113,7 +112,8 @@ def test_docker_running(self) -> None: assert check_docker_running() is None def test_docker_not_running(self) -> None: - with patch("ananta.explorers.launcher.subprocess.run", side_effect=subprocess.CalledProcessError(1, "docker")): + side_effect = subprocess.CalledProcessError(1, "docker") + with patch("ananta.explorers.launcher.subprocess.run", side_effect=side_effect): error = check_docker_running() assert error is not None assert "Docker" in error @@ -193,9 +193,7 @@ def test_collects_multiple_errors(self, mock_cmd: object, *mocks: object) -> Non @patch("ananta.explorers.launcher.check_env_var", return_value=None) @patch("ananta.explorers.launcher.check_python_version", return_value=None) @patch("ananta.explorers.launcher.check_command", return_value=None) - def test_git_checked_when_required( - self, mock_cmd: object, *mocks: object - ) -> None: + def test_git_checked_when_required(self, mock_cmd: object, *mocks: object) -> None: """When requires_git=True, git is in the check_command call list.""" config = self._make_config(requires_git=True) run_preflight(config, "/project") @@ -207,9 +205,7 @@ def test_git_checked_when_required( @patch("ananta.explorers.launcher.check_env_var", return_value=None) @patch("ananta.explorers.launcher.check_python_version", return_value=None) @patch("ananta.explorers.launcher.check_command", return_value=None) - def test_git_not_checked_when_not_required( - self, mock_cmd: object, *mocks: object - ) -> None: + def test_git_not_checked_when_not_required(self, mock_cmd: object, *mocks: object) -> None: config = self._make_config(requires_git=False) run_preflight(config, "/project") cmd_names = [call.args[0] for call in mock_cmd.call_args_list] # type: ignore[attr-defined] From 25bfeba62306b377108eef5e5b79f72eb6cd889d Mon Sep 17 00:00:00 2001 From: Ovid Date: Sun, 22 Mar 2026 15:22:11 +0100 Subject: [PATCH 12/19] Remove completed TODO items --- TODO.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/TODO.md b/TODO.md index d80a41c7..f982ec63 100644 --- a/TODO.md +++ b/TODO.md @@ -1,7 +1,4 @@ # Explorers - - - rename "experimental" directories to "explorers" - - Also, need to write the shell scripts in Python. - put all explorers in a single directory (scripts) - Node.js is v23.3.0 (EOL odd-number release). Homebrew has 25.8.1. Run `brew upgrade node` and update `.nvmrc` to match. From 2a8e57f6c80d6d55c590393330e4aca860f182a9 Mon Sep 17 00:00:00 2001 From: Ovid Date: Sun, 22 Mar 2026 15:22:40 +0100 Subject: [PATCH 13/19] Add Python launcher design and implementation docs --- .../2026-03-22-python-launcher-design.md | 153 +++ ...26-03-22-python-launcher-implementation.md | 1038 +++++++++++++++++ 2 files changed, 1191 insertions(+) create mode 100644 docs/plans/2026-03-22-python-launcher-design.md create mode 100644 docs/plans/2026-03-22-python-launcher-implementation.md diff --git a/docs/plans/2026-03-22-python-launcher-design.md b/docs/plans/2026-03-22-python-launcher-design.md new file mode 100644 index 00000000..b02259bd --- /dev/null +++ b/docs/plans/2026-03-22-python-launcher-design.md @@ -0,0 +1,153 @@ +# Python Launcher Scripts Design + +**Date:** 2026-03-22 +**Status:** Approved (post-pushback) + +## Motivation + +The three explorer launcher scripts (`arxiv-explorer.sh`, `code-explorer.sh`, +`document-explorer.sh`) and their shared logic (`scripts/common.sh`) are bash. +This causes three problems: + +1. **Testability** — the launcher logic (preflight checks, build steps) can't be unit tested +2. **Consolidation** — shared logic deserves a proper Python module, not a sourced shell file +3. **Maintainability** — the bash has grown complex enough (staleness markers, shared UI logic, stderr filter) that Python would be clearer + +Note: Cross-platform (Windows) support is a future goal but not achieved by this +design alone — the bash shims are still Unix-only. However, the Python logic +(`launch.py` + `launcher.py`) is fully portable, so users who install via pip +can run explorers on Windows without the shims. + +## Design + +### Architecture + +Each explorer keeps a bash shim (`*-explorer.sh`) that handles only the +bootstrapping problem: ensuring a venv exists and dependencies are installed. +All real logic moves to Python. + +``` +*-explorer/*-explorer.sh (bash shim: venv + pip install) + └── *-explorer/launch.py (thin config + call shared launcher) + └── src/ananta/explorers/launcher.py (all shared logic) +``` + +### Bash Shim (~20 lines each) + +The shim's only responsibilities: +- Determine `PROJECT_ROOT` and `VENV_DIR` +- Create venv if missing +- Activate venv +- Install pip dependencies if stale (marker file vs pyproject.toml mtime) +- `exec python launch.py "$@"` + +Each shim differs only in `PIP_EXTRA` and `APP_SLUG` variables. + +Example (arxiv): + +```bash +#!/usr/bin/env bash +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +VENV_DIR="$PROJECT_ROOT/.venv" +PIP_EXTRA="web" +APP_SLUG="ananta-web" + +if [ ! -d "$VENV_DIR" ]; then + echo "[ananta] Creating virtual environment..." + python3 -m venv "$VENV_DIR" +fi + +# shellcheck source=/dev/null +source "$VENV_DIR/bin/activate" + +MARKER="$VENV_DIR/.${APP_SLUG}-installed" +if [ ! -f "$MARKER" ] || [ "$PROJECT_ROOT/pyproject.toml" -nt "$MARKER" ]; then + echo "[ananta] Installing Python dependencies..." + pip install -q -e "$PROJECT_ROOT[$PIP_EXTRA]" + touch "$MARKER" +fi + +exec python "$SCRIPT_DIR/launch.py" "$@" +``` + +### Python `launch.py` (thin, per-explorer) + +Each explorer gets a `launch.py` that defines config and calls the shared launcher: + +```python +#!/usr/bin/env python3 +"""Launch the Ananta arXiv Web Explorer.""" + +from ananta.explorers.launcher import LauncherConfig, launch + +config = LauncherConfig( + app_name="Ananta arXiv Web Explorer", + entry_point="ananta-web", + frontend_dir="src/ananta/explorers/arxiv/frontend", + requires_git=False, + shared_frontend_dir=None, +) + +if __name__ == "__main__": + launch(config) +``` + +### Shared Launcher (`src/ananta/explorers/launcher.py`) + +Responsibilities, in order: + +1. **Parse args** — strip `--rebuild`, pass the rest through to the entry point +2. **Preflight checks** — collect all errors, report at once: + - Python version >= 3.11 + - `node`, `npm`, `docker` on PATH + - `git` on PATH (if `requires_git`) + - `ANANTA_API_KEY` and `ANANTA_MODEL` env vars set + - Docker daemon running + - Sandbox image exists — attempt to build if missing, only error if build fails +3. **Build frontend** — `npm install` + `npm run build` for shared UI (if configured) and explorer frontend, skipped if `dist/` exists unless `--rebuild` +4. **Launch** — `subprocess.run()` + `sys.exit()` the entry point with remaining args (not `os.execvp()`, for Windows compatibility) + +Key design points: +- `LauncherConfig` is a dataclass +- Each preflight check is a small method returning an optional error string (testable) +- No stderr filter (dropped — fix root causes instead of filtering symptoms) +- `--rebuild` is the only flag consumed by the launcher; all others pass through unchanged + +## Files Changed + +### Created +- `src/ananta/explorers/launcher.py` — shared launcher logic +- `arxiv-explorer/launch.py` — arxiv config +- `code-explorer/launch.py` — code config +- `document-explorer/launch.py` — document config + +### Rewritten +- `arxiv-explorer/arxiv-explorer.sh` — bash shim +- `code-explorer/code-explorer.sh` — bash shim +- `document-explorer/document-explorer.sh` — bash shim + +### Deleted +- `scripts/common.sh` — replaced by `launcher.py` +- `examples/arxiv-explorer.sh` — stale, points at old paths + +### Updated +- `README.md` — update Document Explorer section: drop "(Experimental)" label, replace `python -m` invocation with launcher script reference +- `CHANGELOG.md` — entry under Unreleased/Changed + +## Decisions + +| Decision | Choice | Reason | +|----------|--------|--------| +| Invocation style | Same paths, `.sh` preserved | Preserves existing UX | +| Shared logic location | `src/ananta/explorers/launcher.py` | Importable, testable, scoped to explorers | +| Venv bootstrap | Stays in bash shim | Avoids chicken-and-egg (launcher can't import before pip install) | +| Pip install | Stays in bash shim | Same bootstrap reason | +| Stderr filter | Dropped | Fix root causes, not symptoms | +| Frontend build | Python (`subprocess.run`) | Maximizes testability | +| `--rebuild` handling | Python (only flag consumed by launcher) | Same reason | +| Process launch | `subprocess.run()` + `sys.exit()` | `os.execvp()` is Unix-only; Python logic should be portable | +| Sandbox image build | Auto-build during preflight, error only on failure | Matches current behavior; good UX | +| Per-explorer configs | Separate `launch.py` files (not a registry) | Simpler now; trivial to consolidate into a dict later for unified launcher | +| Cross-platform | Bash shims Unix-only; Python logic portable | Windows users can `pip install` and run `launch.py` directly | diff --git a/docs/plans/2026-03-22-python-launcher-implementation.md b/docs/plans/2026-03-22-python-launcher-implementation.md new file mode 100644 index 00000000..17123087 --- /dev/null +++ b/docs/plans/2026-03-22-python-launcher-implementation.md @@ -0,0 +1,1038 @@ +# Python Launcher Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Replace bash launcher scripts and `scripts/common.sh` with testable Python: a shared `launcher.py` module and thin per-explorer `launch.py` configs, keeping bash shims only for venv bootstrapping. + +**Architecture:** Each explorer directory keeps a ~20-line bash shim (venv + pip install only). A `launch.py` in each directory defines a `LauncherConfig` dataclass and calls `launch()` from `src/ananta/explorers/launcher.py`, which handles preflight checks, frontend build, and process launch via `subprocess.run()`. + +**Tech Stack:** Python 3.11+, `subprocess`, `shutil.which`, `dataclasses`, pytest + +**Design doc:** `docs/plans/2026-03-22-python-launcher-design.md` + +--- + +### Task 1: LauncherConfig dataclass and arg parsing + +**Files:** +- Create: `src/ananta/explorers/launcher.py` +- Create: `tests/unit/explorers/test_launcher.py` + +**Step 1: Write the failing test for LauncherConfig** + +```python +"""Tests for the shared explorer launcher.""" + +from ananta.explorers.launcher import LauncherConfig + + +class TestLauncherConfig: + def test_required_fields(self) -> None: + config = LauncherConfig( + app_name="Test App", + entry_point="test-app", + frontend_dir="src/ananta/explorers/test/frontend", + ) + assert config.app_name == "Test App" + assert config.entry_point == "test-app" + assert config.frontend_dir == "src/ananta/explorers/test/frontend" + assert config.requires_git is False + assert config.shared_frontend_dir is None +``` + +**Step 2: Run test to verify it fails** + +Run: `pytest tests/unit/explorers/test_launcher.py::TestLauncherConfig::test_required_fields -v` +Expected: FAIL — `ImportError: cannot import name 'LauncherConfig'` + +**Step 3: Write the failing test for parse_launcher_args** + +Add to `tests/unit/explorers/test_launcher.py`: + +```python +from ananta.explorers.launcher import parse_launcher_args + + +class TestParseLauncherArgs: + def test_no_args(self) -> None: + rebuild, passthrough = parse_launcher_args([]) + assert rebuild is False + assert passthrough == [] + + def test_rebuild_stripped(self) -> None: + rebuild, passthrough = parse_launcher_args(["--rebuild", "--port", "9000"]) + assert rebuild is True + assert passthrough == ["--port", "9000"] + + def test_passthrough_preserved(self) -> None: + rebuild, passthrough = parse_launcher_args( + ["--port", "8080", "--open", "--model", "gpt-4o"] + ) + assert rebuild is False + assert passthrough == ["--port", "8080", "--open", "--model", "gpt-4o"] + + def test_rebuild_only(self) -> None: + rebuild, passthrough = parse_launcher_args(["--rebuild"]) + assert rebuild is True + assert passthrough == [] +``` + +**Step 4: Run tests to verify they fail** + +Run: `pytest tests/unit/explorers/test_launcher.py -v` +Expected: FAIL — `ImportError: cannot import name 'parse_launcher_args'` + +**Step 5: Write minimal implementation** + +Create `src/ananta/explorers/launcher.py`: + +```python +"""Shared launcher logic for Ananta explorer applications.""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class LauncherConfig: + """Per-explorer configuration for the shared launcher.""" + + app_name: str + entry_point: str + frontend_dir: str + requires_git: bool = False + shared_frontend_dir: str | None = None + + +def parse_launcher_args(argv: list[str]) -> tuple[bool, list[str]]: + """Strip --rebuild from argv, return (rebuild, passthrough_args).""" + rebuild = False + passthrough: list[str] = [] + for arg in argv: + if arg == "--rebuild": + rebuild = True + else: + passthrough.append(arg) + return rebuild, passthrough +``` + +**Step 6: Run tests to verify they pass** + +Run: `pytest tests/unit/explorers/test_launcher.py -v` +Expected: all PASS + +**Step 7: Commit** + +``` +git add src/ananta/explorers/launcher.py tests/unit/explorers/test_launcher.py +git commit -m "feat: add LauncherConfig dataclass and arg parsing for explorer launcher" +``` + +--- + +### Task 2: Preflight checks — command existence and Python version + +**Files:** +- Modify: `src/ananta/explorers/launcher.py` +- Modify: `tests/unit/explorers/test_launcher.py` + +**Step 1: Write failing tests for command checks** + +Add to `tests/unit/explorers/test_launcher.py`: + +```python +from unittest.mock import patch + +from ananta.explorers.launcher import check_command, check_python_version + + +class TestCheckCommand: + def test_command_found(self) -> None: + with patch("ananta.explorers.launcher.shutil.which", return_value="/usr/bin/python3"): + assert check_command("python3", "https://python.org") is None + + def test_command_missing(self) -> None: + with patch("ananta.explorers.launcher.shutil.which", return_value=None): + error = check_command("python3", "https://python.org") + assert error is not None + assert "python3" in error + assert "https://python.org" in error + + +class TestCheckPythonVersion: + def test_version_ok(self) -> None: + with patch("ananta.explorers.launcher.sys.version_info", (3, 12, 0)): + assert check_python_version() is None + + def test_version_exactly_3_11(self) -> None: + with patch("ananta.explorers.launcher.sys.version_info", (3, 11, 0)): + assert check_python_version() is None + + def test_version_too_old(self) -> None: + with patch("ananta.explorers.launcher.sys.version_info", (3, 10, 5)): + error = check_python_version() + assert error is not None + assert "3.11" in error +``` + +**Step 2: Run tests to verify they fail** + +Run: `pytest tests/unit/explorers/test_launcher.py::TestCheckCommand -v` +Expected: FAIL — `ImportError: cannot import name 'check_command'` + +**Step 3: Write minimal implementation** + +Add to `src/ananta/explorers/launcher.py`: + +```python +import shutil +import sys + + +def check_command(cmd: str, install_hint: str) -> str | None: + """Return an error string if cmd is not on PATH, else None.""" + if shutil.which(cmd) is None: + return f" - Install {cmd}: {install_hint}" + return None + + +def check_python_version() -> str | None: + """Return an error string if Python < 3.11, else None.""" + major, minor = sys.version_info[:2] + if (major, minor) < (3, 11): + return f" - Upgrade Python: 3.11+ required, found {major}.{minor}" + return None +``` + +**Step 4: Run tests to verify they pass** + +Run: `pytest tests/unit/explorers/test_launcher.py::TestCheckCommand tests/unit/explorers/test_launcher.py::TestCheckPythonVersion -v` +Expected: all PASS + +**Step 5: Commit** + +``` +git add src/ananta/explorers/launcher.py tests/unit/explorers/test_launcher.py +git commit -m "feat: add command existence and Python version preflight checks" +``` + +--- + +### Task 3: Preflight checks — env vars, Docker daemon, sandbox image + +**Files:** +- Modify: `src/ananta/explorers/launcher.py` +- Modify: `tests/unit/explorers/test_launcher.py` + +**Step 1: Write failing tests for env var check** + +Add to `tests/unit/explorers/test_launcher.py`: + +```python +import os + +from ananta.explorers.launcher import check_env_var + + +class TestCheckEnvVar: + def test_var_set(self) -> None: + with patch.dict(os.environ, {"ANANTA_API_KEY": "sk-test"}): + assert check_env_var("ANANTA_API_KEY", "export ANANTA_API_KEY=") is None + + def test_var_missing(self) -> None: + env = os.environ.copy() + env.pop("ANANTA_API_KEY", None) + with patch.dict(os.environ, env, clear=True): + error = check_env_var("ANANTA_API_KEY", "export ANANTA_API_KEY=") + assert error is not None + assert "ANANTA_API_KEY" in error + + def test_var_empty(self) -> None: + with patch.dict(os.environ, {"ANANTA_API_KEY": ""}): + error = check_env_var("ANANTA_API_KEY", "export ANANTA_API_KEY=") + assert error is not None +``` + +**Step 2: Write failing tests for Docker daemon check** + +```python +import subprocess + +from ananta.explorers.launcher import check_docker_running + + +class TestCheckDockerRunning: + def test_docker_running(self) -> None: + with patch("ananta.explorers.launcher.subprocess.run") as mock_run: + mock_run.return_value = subprocess.CompletedProcess([], 0) + assert check_docker_running() is None + + def test_docker_not_running(self) -> None: + with patch("ananta.explorers.launcher.subprocess.run", side_effect=subprocess.CalledProcessError(1, "docker")): + error = check_docker_running() + assert error is not None + assert "Docker" in error + + def test_docker_not_installed(self) -> None: + with patch("ananta.explorers.launcher.shutil.which", return_value=None): + # If docker isn't on PATH, skip the check (already caught by check_command) + assert check_docker_running() is None +``` + +**Step 3: Write failing tests for sandbox image check** + +```python +from ananta.explorers.launcher import ensure_sandbox_image + + +class TestEnsureSandboxImage: + def test_image_exists(self) -> None: + with patch("ananta.explorers.launcher.subprocess.run") as mock_run: + mock_run.return_value = subprocess.CompletedProcess([], 0) + assert ensure_sandbox_image("/project/root") is None + + def test_image_missing_build_succeeds(self, capsys: object) -> None: + call_count = 0 + + def side_effect(*args: object, **kwargs: object) -> subprocess.CompletedProcess[str]: + nonlocal call_count + call_count += 1 + if call_count == 1: + # docker image inspect fails + raise subprocess.CalledProcessError(1, "docker") + # docker build succeeds + return subprocess.CompletedProcess([], 0) + + with patch("ananta.explorers.launcher.subprocess.run", side_effect=side_effect): + assert ensure_sandbox_image("/project/root") is None + + def test_image_missing_build_fails(self) -> None: + with patch( + "ananta.explorers.launcher.subprocess.run", + side_effect=subprocess.CalledProcessError(1, "docker"), + ): + error = ensure_sandbox_image("/project/root") + assert error is not None + assert "sandbox" in error.lower() or "image" in error.lower() + + def test_docker_not_installed(self) -> None: + with patch("ananta.explorers.launcher.shutil.which", return_value=None): + assert ensure_sandbox_image("/project/root") is None +``` + +**Step 4: Run tests to verify they fail** + +Run: `pytest tests/unit/explorers/test_launcher.py::TestCheckEnvVar tests/unit/explorers/test_launcher.py::TestCheckDockerRunning tests/unit/explorers/test_launcher.py::TestEnsureSandboxImage -v` +Expected: FAIL — `ImportError` + +**Step 5: Write minimal implementation** + +Add to `src/ananta/explorers/launcher.py`: + +```python +import os +import subprocess + + +def check_env_var(var: str, hint: str) -> str | None: + """Return an error string if the env var is unset or empty, else None.""" + if not os.environ.get(var): + return f" - Set {var}: {hint}" + return None + + +def check_docker_running() -> str | None: + """Return an error string if Docker daemon is not running, else None.""" + if shutil.which("docker") is None: + return None # check_command will catch this + try: + subprocess.run( + ["docker", "info"], + capture_output=True, + check=True, + ) + except subprocess.CalledProcessError: + return " - Start Docker daemon (e.g. open Docker Desktop)" + return None + + +def ensure_sandbox_image(project_root: str) -> str | None: + """Build the sandbox image if missing. Return error string on failure, else None.""" + image = os.environ.get("ANANTA_SANDBOX_IMAGE", "ananta-sandbox") + if shutil.which("docker") is None: + return None # check_command will catch this + try: + subprocess.run( + ["docker", "image", "inspect", image], + capture_output=True, + check=True, + ) + return None # Image exists + except subprocess.CalledProcessError: + pass # Image missing, try to build + + print(f"[ananta] Building sandbox image ({image})...") + try: + subprocess.run( + ["docker", "build", "-t", image, f"{project_root}/src/ananta/sandbox/"], + check=True, + ) + except subprocess.CalledProcessError: + return f" - Failed to build Docker image '{image}'" + return None +``` + +**Step 6: Run tests to verify they pass** + +Run: `pytest tests/unit/explorers/test_launcher.py::TestCheckEnvVar tests/unit/explorers/test_launcher.py::TestCheckDockerRunning tests/unit/explorers/test_launcher.py::TestEnsureSandboxImage -v` +Expected: all PASS + +**Step 7: Commit** + +``` +git add src/ananta/explorers/launcher.py tests/unit/explorers/test_launcher.py +git commit -m "feat: add env var, Docker daemon, and sandbox image preflight checks" +``` + +--- + +### Task 4: Preflight orchestrator — run_preflight() + +**Files:** +- Modify: `src/ananta/explorers/launcher.py` +- Modify: `tests/unit/explorers/test_launcher.py` + +**Step 1: Write failing tests** + +Add to `tests/unit/explorers/test_launcher.py`: + +```python +from ananta.explorers.launcher import run_preflight, LauncherConfig + + +class TestRunPreflight: + def _make_config(self, requires_git: bool = False) -> LauncherConfig: + return LauncherConfig( + app_name="Test App", + entry_point="test-app", + frontend_dir="src/test/frontend", + requires_git=requires_git, + ) + + @patch("ananta.explorers.launcher.check_command", return_value=None) + @patch("ananta.explorers.launcher.check_python_version", return_value=None) + @patch("ananta.explorers.launcher.check_env_var", return_value=None) + @patch("ananta.explorers.launcher.check_docker_running", return_value=None) + @patch("ananta.explorers.launcher.ensure_sandbox_image", return_value=None) + def test_all_pass(self, *mocks: object) -> None: + errors = run_preflight(self._make_config(), "/project") + assert errors == [] + + @patch("ananta.explorers.launcher.check_command") + @patch("ananta.explorers.launcher.check_python_version", return_value=None) + @patch("ananta.explorers.launcher.check_env_var", return_value=None) + @patch("ananta.explorers.launcher.check_docker_running", return_value=None) + @patch("ananta.explorers.launcher.ensure_sandbox_image", return_value=None) + def test_collects_multiple_errors(self, mock_cmd: object, *mocks: object) -> None: + mock_cmd.side_effect = lambda cmd, hint: f" - missing {cmd}" if cmd == "node" else None + errors = run_preflight(self._make_config(), "/project") + assert len(errors) == 1 + assert "node" in errors[0] + + @patch("ananta.explorers.launcher.ensure_sandbox_image", return_value=None) + @patch("ananta.explorers.launcher.check_docker_running", return_value=None) + @patch("ananta.explorers.launcher.check_env_var", return_value=None) + @patch("ananta.explorers.launcher.check_python_version", return_value=None) + @patch("ananta.explorers.launcher.check_command", return_value=None) + def test_git_checked_when_required( + self, mock_cmd: object, *mocks: object + ) -> None: + """When requires_git=True, git is in the check_command call list.""" + config = self._make_config(requires_git=True) + run_preflight(config, "/project") + cmd_names = [call.args[0] for call in mock_cmd.call_args_list] # type: ignore[attr-defined] + assert "git" in cmd_names + + @patch("ananta.explorers.launcher.ensure_sandbox_image", return_value=None) + @patch("ananta.explorers.launcher.check_docker_running", return_value=None) + @patch("ananta.explorers.launcher.check_env_var", return_value=None) + @patch("ananta.explorers.launcher.check_python_version", return_value=None) + @patch("ananta.explorers.launcher.check_command", return_value=None) + def test_git_not_checked_when_not_required( + self, mock_cmd: object, *mocks: object + ) -> None: + config = self._make_config(requires_git=False) + run_preflight(config, "/project") + cmd_names = [call.args[0] for call in mock_cmd.call_args_list] # type: ignore[attr-defined] + assert "git" not in cmd_names +``` + +**Step 2: Run tests to verify they fail** + +Run: `pytest tests/unit/explorers/test_launcher.py::TestRunPreflight -v` +Expected: FAIL — `ImportError: cannot import name 'run_preflight'` + +**Step 3: Write minimal implementation** + +Add to `src/ananta/explorers/launcher.py`: + +```python +def run_preflight(config: LauncherConfig, project_root: str) -> list[str]: + """Run all preflight checks. Return list of error strings (empty = all OK).""" + errors: list[str] = [] + + def collect(result: str | None) -> None: + if result is not None: + errors.append(result) + + collect(check_python_version()) + collect(check_command("node", "https://nodejs.org/")) + collect(check_command("npm", "https://nodejs.org/")) + collect(check_command("docker", "https://www.docker.com/get-started/")) + if config.requires_git: + collect(check_command("git", "https://git-scm.com/")) + collect(check_env_var("ANANTA_API_KEY", "export ANANTA_API_KEY=")) + collect(check_env_var("ANANTA_MODEL", "export ANANTA_MODEL=")) + collect(check_docker_running()) + collect(ensure_sandbox_image(project_root)) + + return errors +``` + +**Step 4: Run tests to verify they pass** + +Run: `pytest tests/unit/explorers/test_launcher.py::TestRunPreflight -v` +Expected: all PASS + +**Step 5: Commit** + +``` +git add src/ananta/explorers/launcher.py tests/unit/explorers/test_launcher.py +git commit -m "feat: add run_preflight orchestrator collecting all check errors" +``` + +--- + +### Task 5: Frontend build logic + +**Files:** +- Modify: `src/ananta/explorers/launcher.py` +- Modify: `tests/unit/explorers/test_launcher.py` + +**Step 1: Write failing tests** + +Add to `tests/unit/explorers/test_launcher.py`: + +```python +from pathlib import Path + +from ananta.explorers.launcher import build_frontend, LauncherConfig + + +class TestBuildFrontend: + def _make_config( + self, + frontend_dir: str = "src/test/frontend", + shared_frontend_dir: str | None = None, + ) -> LauncherConfig: + return LauncherConfig( + app_name="Test App", + entry_point="test-app", + frontend_dir=frontend_dir, + shared_frontend_dir=shared_frontend_dir, + ) + + @patch("ananta.explorers.launcher.subprocess.run") + def test_build_when_dist_missing(self, mock_run: object, tmp_path: Path) -> None: + frontend = tmp_path / "frontend" + frontend.mkdir() + config = self._make_config(frontend_dir=str(frontend)) + build_frontend(config, str(tmp_path), rebuild=False) + # Should have called npm install + npm run build + assert mock_run.call_count == 2 # type: ignore[attr-defined] + + @patch("ananta.explorers.launcher.subprocess.run") + def test_skip_when_dist_exists(self, mock_run: object, tmp_path: Path) -> None: + frontend = tmp_path / "frontend" + frontend.mkdir() + (frontend / "dist").mkdir() + config = self._make_config(frontend_dir=str(frontend)) + build_frontend(config, str(tmp_path), rebuild=False) + mock_run.assert_not_called() # type: ignore[attr-defined] + + @patch("ananta.explorers.launcher.subprocess.run") + def test_rebuild_forces_build(self, mock_run: object, tmp_path: Path) -> None: + frontend = tmp_path / "frontend" + frontend.mkdir() + (frontend / "dist").mkdir() + config = self._make_config(frontend_dir=str(frontend)) + build_frontend(config, str(tmp_path), rebuild=True) + assert mock_run.call_count == 2 # type: ignore[attr-defined] + + @patch("ananta.explorers.launcher.subprocess.run") + def test_shared_frontend_installed(self, mock_run: object, tmp_path: Path) -> None: + frontend = tmp_path / "frontend" + frontend.mkdir() + shared = tmp_path / "shared" + shared.mkdir() + config = self._make_config( + frontend_dir=str(frontend), + shared_frontend_dir=str(shared), + ) + build_frontend(config, str(tmp_path), rebuild=False) + # shared npm install + frontend npm install + npm run build = 3 + assert mock_run.call_count == 3 # type: ignore[attr-defined] +``` + +**Step 2: Run tests to verify they fail** + +Run: `pytest tests/unit/explorers/test_launcher.py::TestBuildFrontend -v` +Expected: FAIL — `ImportError: cannot import name 'build_frontend'` + +**Step 3: Write minimal implementation** + +Add to `src/ananta/explorers/launcher.py`: + +```python +from pathlib import Path + + +def build_frontend(config: LauncherConfig, project_root: str, *, rebuild: bool) -> None: + """Build the explorer frontend (and shared UI if configured).""" + frontend_path = Path(config.frontend_dir) + if not frontend_path.is_absolute(): + frontend_path = Path(project_root) / frontend_path + dist_path = frontend_path / "dist" + + needs_build = rebuild or not dist_path.is_dir() + + if not needs_build: + print("[ananta] Frontend already built. Use --rebuild to force.") + return + + if config.shared_frontend_dir: + shared_path = Path(config.shared_frontend_dir) + if not shared_path.is_absolute(): + shared_path = Path(project_root) / shared_path + print("[ananta] Installing shared UI dependencies...") + subprocess.run(["npm", "install", "--silent"], cwd=shared_path, check=True) + + print("[ananta] Building frontend...") + subprocess.run(["npm", "install", "--silent"], cwd=frontend_path, check=True) + subprocess.run(["npm", "run", "build"], cwd=frontend_path, check=True) +``` + +**Step 4: Run tests to verify they pass** + +Run: `pytest tests/unit/explorers/test_launcher.py::TestBuildFrontend -v` +Expected: all PASS + +**Step 5: Commit** + +``` +git add src/ananta/explorers/launcher.py tests/unit/explorers/test_launcher.py +git commit -m "feat: add frontend build logic with shared UI and --rebuild support" +``` + +--- + +### Task 6: launch() function — tie it all together + +**Files:** +- Modify: `src/ananta/explorers/launcher.py` +- Modify: `tests/unit/explorers/test_launcher.py` + +**Step 1: Write failing tests** + +Add to `tests/unit/explorers/test_launcher.py`: + +```python +from ananta.explorers.launcher import launch, LauncherConfig + + +class TestLaunch: + def _make_config(self) -> LauncherConfig: + return LauncherConfig( + app_name="Test App", + entry_point="test-app", + frontend_dir="src/test/frontend", + ) + + @patch("ananta.explorers.launcher.subprocess.run") + @patch("ananta.explorers.launcher.build_frontend") + @patch("ananta.explorers.launcher.run_preflight", return_value=[]) + def test_launch_success( + self, + mock_preflight: object, + mock_build: object, + mock_run: object, + ) -> None: + mock_run.return_value = subprocess.CompletedProcess([], 0) + config = self._make_config() + exit_code = launch(config, argv=["--port", "9000"], project_root="/project") + assert exit_code == 0 + mock_run.assert_called_once() # type: ignore[attr-defined] + call_args = mock_run.call_args # type: ignore[attr-defined] + assert call_args[0][0] == ["test-app", "--port", "9000"] + + @patch("ananta.explorers.launcher.build_frontend") + @patch("ananta.explorers.launcher.run_preflight", return_value=[" - missing node"]) + def test_launch_preflight_failure( + self, + mock_preflight: object, + mock_build: object, + ) -> None: + config = self._make_config() + exit_code = launch(config, argv=[], project_root="/project") + assert exit_code == 1 + mock_build.assert_not_called() # type: ignore[attr-defined] + + @patch("ananta.explorers.launcher.subprocess.run") + @patch("ananta.explorers.launcher.build_frontend") + @patch("ananta.explorers.launcher.run_preflight", return_value=[]) + def test_rebuild_passed_to_build( + self, + mock_preflight: object, + mock_build: object, + mock_run: object, + ) -> None: + mock_run.return_value = subprocess.CompletedProcess([], 0) + config = self._make_config() + launch(config, argv=["--rebuild", "--open"], project_root="/project") + mock_build.assert_called_once() # type: ignore[attr-defined] + _, kwargs = mock_build.call_args # type: ignore[attr-defined] + assert kwargs["rebuild"] is True + # --rebuild should NOT be passed to the entry point + call_args = mock_run.call_args # type: ignore[attr-defined] + assert "--rebuild" not in call_args[0][0] + assert "--open" in call_args[0][0] +``` + +**Step 2: Run tests to verify they fail** + +Run: `pytest tests/unit/explorers/test_launcher.py::TestLaunch -v` +Expected: FAIL — `launch` doesn't exist or wrong signature + +**Step 3: Write minimal implementation** + +Add to `src/ananta/explorers/launcher.py`: + +```python +def launch( + config: LauncherConfig, + *, + argv: list[str] | None = None, + project_root: str | None = None, +) -> int: + """Run preflight checks, build frontend, and launch the explorer. + + Returns the process exit code (0 = success). + """ + if argv is None: + argv = sys.argv[1:] + if project_root is None: + # Resolve from this file: src/ananta/explorers/launcher.py -> project root + project_root = str(Path(__file__).resolve().parents[3]) + + rebuild, passthrough = parse_launcher_args(argv) + + # Preflight + errors = run_preflight(config, project_root) + if errors: + print(f"\033[0;31m[ananta]\033[0m Cannot start {config.app_name}. Fix the following:", + file=sys.stderr) + for e in errors: + print(e, file=sys.stderr) + return 1 + + # Build frontend + build_frontend(config, project_root, rebuild=rebuild) + + # Launch + print(f"[ananta] Starting {config.app_name}...") + result = subprocess.run([config.entry_point, *passthrough]) + return result.returncode +``` + +**Step 4: Run tests to verify they pass** + +Run: `pytest tests/unit/explorers/test_launcher.py::TestLaunch -v` +Expected: all PASS + +**Step 5: Run all launcher tests** + +Run: `pytest tests/unit/explorers/test_launcher.py -v` +Expected: all PASS + +**Step 6: Commit** + +``` +git add src/ananta/explorers/launcher.py tests/unit/explorers/test_launcher.py +git commit -m "feat: add launch() function tying together preflight, build, and exec" +``` + +--- + +### Task 7: Create per-explorer launch.py files + +**Files:** +- Create: `arxiv-explorer/launch.py` +- Create: `code-explorer/launch.py` +- Create: `document-explorer/launch.py` + +**Step 1: Create arxiv-explorer/launch.py** + +```python +#!/usr/bin/env python3 +"""Launch the Ananta arXiv Web Explorer.""" + +import sys + +from ananta.explorers.launcher import LauncherConfig, launch + +config = LauncherConfig( + app_name="Ananta arXiv Web Explorer", + entry_point="ananta-web", + frontend_dir="src/ananta/explorers/arxiv/frontend", +) + +if __name__ == "__main__": + sys.exit(launch(config)) +``` + +**Step 2: Create code-explorer/launch.py** + +```python +#!/usr/bin/env python3 +"""Launch the Ananta Code Explorer.""" + +import sys + +from ananta.explorers.launcher import LauncherConfig, launch + +config = LauncherConfig( + app_name="Ananta Code Explorer", + entry_point="ananta-code", + frontend_dir="src/ananta/explorers/code/frontend", + requires_git=True, + shared_frontend_dir="src/ananta/explorers/shared_ui/frontend", +) + +if __name__ == "__main__": + sys.exit(launch(config)) +``` + +**Step 3: Create document-explorer/launch.py** + +```python +#!/usr/bin/env python3 +"""Launch the Ananta Document Explorer.""" + +import sys + +from ananta.explorers.launcher import LauncherConfig, launch + +config = LauncherConfig( + app_name="Ananta Document Explorer", + entry_point="ananta-document-explorer", + frontend_dir="src/ananta/explorers/document/frontend", + shared_frontend_dir="src/ananta/explorers/shared_ui/frontend", +) + +if __name__ == "__main__": + sys.exit(launch(config)) +``` + +**Step 4: Verify all three files parse correctly** + +Run: `python -c "import ast; [ast.parse(open(f).read()) for f in ['arxiv-explorer/launch.py', 'code-explorer/launch.py', 'document-explorer/launch.py']]" && echo "All OK"` +Expected: `All OK` + +**Step 5: Commit** + +``` +git add arxiv-explorer/launch.py code-explorer/launch.py document-explorer/launch.py +git commit -m "feat: add per-explorer launch.py config files" +``` + +--- + +### Task 8: Rewrite bash shims + +**Files:** +- Rewrite: `arxiv-explorer/arxiv-explorer.sh` +- Rewrite: `code-explorer/code-explorer.sh` +- Rewrite: `document-explorer/document-explorer.sh` + +**Step 1: Rewrite arxiv-explorer/arxiv-explorer.sh** + +```bash +#!/usr/bin/env bash +# Launch the Ananta arXiv Web Explorer. +# Usage: +# ./arxiv-explorer/arxiv-explorer.sh # defaults +# ./arxiv-explorer/arxiv-explorer.sh --model gpt-5-mini # pass args to ananta-web +# ./arxiv-explorer/arxiv-explorer.sh --port 8080 # custom port +# ./arxiv-explorer/arxiv-explorer.sh --open # open browser on startup +# ./arxiv-explorer/arxiv-explorer.sh --rebuild # force frontend rebuild + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +VENV_DIR="$PROJECT_ROOT/.venv" +PIP_EXTRA="web" +APP_SLUG="ananta-web" + +if [ ! -d "$VENV_DIR" ]; then + echo "[ananta] Creating virtual environment..." + python3 -m venv "$VENV_DIR" +fi + +# shellcheck source=/dev/null +source "$VENV_DIR/bin/activate" + +MARKER="$VENV_DIR/.${APP_SLUG}-installed" +if [ ! -f "$MARKER" ] || [ "$PROJECT_ROOT/pyproject.toml" -nt "$MARKER" ]; then + echo "[ananta] Installing Python dependencies..." + pip install -q -e "$PROJECT_ROOT[$PIP_EXTRA]" + touch "$MARKER" +fi + +exec python "$SCRIPT_DIR/launch.py" "$@" +``` + +**Step 2: Rewrite code-explorer/code-explorer.sh** + +Same structure, with `PIP_EXTRA="web"` and `APP_SLUG="ananta-code"`. + +**Step 3: Rewrite document-explorer/document-explorer.sh** + +Same structure, with `PIP_EXTRA="document-explorer"` and `APP_SLUG="ananta-document-explorer"`. + +**Step 4: Verify all shims parse** + +Run: `bash -n arxiv-explorer/arxiv-explorer.sh && bash -n code-explorer/code-explorer.sh && bash -n document-explorer/document-explorer.sh && echo "All OK"` +Expected: `All OK` + +**Step 5: Commit** + +``` +git add arxiv-explorer/arxiv-explorer.sh code-explorer/code-explorer.sh document-explorer/document-explorer.sh +git commit -m "refactor: rewrite explorer shell scripts as thin venv-bootstrap shims" +``` + +--- + +### Task 9: Delete stale files + +**Files:** +- Delete: `scripts/common.sh` +- Delete: `examples/arxiv-explorer.sh` + +**Step 1: Verify no other files source common.sh** + +Run: `grep -r "common.sh" --include="*.sh" . | grep -v ".git/"` — should only show the three old shims (now rewritten) and itself. + +**Step 2: Delete the files** + +```bash +git rm scripts/common.sh examples/arxiv-explorer.sh +``` + +**Step 3: Remove scripts/ directory if empty** + +Run: `rmdir scripts/ 2>/dev/null || true` + +**Step 4: Commit** + +``` +git commit -m "remove: delete scripts/common.sh and stale examples/arxiv-explorer.sh" +``` + +--- + +### Task 10: Update README.md and CHANGELOG.md + +**Files:** +- Modify: `README.md:317-335` +- Modify: `CHANGELOG.md:8` + +**Step 1: Update README Document Explorer section** + +Replace lines 317-335 in `README.md` with: + +```markdown +## Document Explorer + +A web-based interface for uploading documents, organizing them into topics, and querying them with Ananta. Upload PDFs, Word documents, PowerPoint, Excel, RTF, or plain text files, group them by topic, then ask questions across your collection. + +```bash +# Launch the Document Explorer +./document-explorer/document-explorer.sh + +# Options +./document-explorer/document-explorer.sh --port 8003 --open --model gpt-4o +``` + +The explorer provides: +- **Drag-and-drop upload** with automatic text extraction +- **Topic organization** — group related documents and query within a topic +- **Live query streaming** via WebSocket — watch Ananta think in real time +- **Conversation history** per topic for follow-up questions +``` + +Note: the `> **Note:** This is experimental...` line is also removed. + +**Step 2: Update CHANGELOG.md** + +Add under `## [Unreleased]`: + +```markdown +### Changed + +- Explorer launcher scripts rewritten: bash logic moved to testable Python (`src/ananta/explorers/launcher.py`), shell scripts reduced to venv-bootstrap shims + +### Removed + +- `scripts/common.sh` — replaced by Python launcher module +- `examples/arxiv-explorer.sh` — stale launcher pointing at old paths +``` + +**Step 3: Commit** + +``` +git add README.md CHANGELOG.md +git commit -m "docs: update README Document Explorer section and changelog for launcher rewrite" +``` + +--- + +### Task 11: Run full test suite and lint + +**Step 1: Run ruff** + +Run: `ruff check src/ananta/explorers/launcher.py tests/unit/explorers/test_launcher.py` +Expected: no errors (fix any that appear) + +**Step 2: Run ruff format** + +Run: `ruff format src/ananta/explorers/launcher.py tests/unit/explorers/test_launcher.py` + +**Step 3: Run mypy** + +Run: `mypy src/ananta/explorers/launcher.py` +Expected: no errors (fix any that appear) + +**Step 4: Run full test suite** + +Run: `make all` +Expected: all pass + +**Step 5: Commit any fixes** + +``` +git add -u +git commit -m "chore: lint and format fixes for launcher module" +``` From c928c31bcc9ac2b66f154d9e12844569b44d7ee4 Mon Sep 17 00:00:00 2001 From: Ovid Date: Sun, 22 Mar 2026 15:36:01 +0100 Subject: [PATCH 14/19] remove: delete stale tests/scripts/test_common.bats referencing deleted common.sh --- tests/scripts/test_common.bats | 152 --------------------------------- 1 file changed, 152 deletions(-) delete mode 100644 tests/scripts/test_common.bats diff --git a/tests/scripts/test_common.bats b/tests/scripts/test_common.bats deleted file mode 100644 index 537b81c0..00000000 --- a/tests/scripts/test_common.bats +++ /dev/null @@ -1,152 +0,0 @@ -#!/usr/bin/env bats -# Tests for scripts/common.sh preflight validation. - -COMMON="$BATS_TEST_DIRNAME/../../scripts/common.sh" - -setup() { - # Minimal config required by common.sh - export PROJECT_ROOT="$BATS_TEST_DIRNAME/../.." - export APP_NAME="Test Explorer" - export APP_SLUG="test-explorer" - export PIP_EXTRA="dev" - export ENTRY_POINT="echo" - export FRONTEND_DIR="$BATS_TEST_TMPDIR/frontend" - mkdir -p "$FRONTEND_DIR/dist" -} - -# --- require_command --- - -@test "require_command adds error for missing command" { - source "$COMMON" - ERRORS=() - require_command "nonexistent_cmd_xyz" "https://example.com" - [ ${#ERRORS[@]} -eq 1 ] - [[ "${ERRORS[0]}" == *"Install nonexistent_cmd_xyz"* ]] -} - -@test "require_command succeeds for existing command" { - source "$COMMON" - ERRORS=() - require_command "bash" "https://example.com" - [ ${#ERRORS[@]} -eq 0 ] -} - -# --- require_env --- - -@test "require_env adds error for unset variable" { - unset TOTALLY_UNSET_VAR - source "$COMMON" - ERRORS=() - require_env "TOTALLY_UNSET_VAR" "export TOTALLY_UNSET_VAR=value" - [ ${#ERRORS[@]} -eq 1 ] - [[ "${ERRORS[0]}" == *"Set TOTALLY_UNSET_VAR"* ]] -} - -@test "require_env adds error for empty variable" { - export EMPTY_VAR="" - source "$COMMON" - ERRORS=() - require_env "EMPTY_VAR" "export EMPTY_VAR=value" - [ ${#ERRORS[@]} -eq 1 ] -} - -@test "require_env passes for set variable" { - export PRESENT_VAR="hello" - source "$COMMON" - ERRORS=() - require_env "PRESENT_VAR" "export PRESENT_VAR=value" - [ ${#ERRORS[@]} -eq 0 ] -} - -# --- check_python_version --- - -@test "check_python_version passes for current python" { - source "$COMMON" - ERRORS=() - check_python_version - [ ${#ERRORS[@]} -eq 0 ] -} - -# --- report_and_exit --- - -@test "report_and_exit does nothing when no errors" { - source "$COMMON" - ERRORS=() - run report_and_exit - [ "$status" -eq 0 ] -} - -@test "report_and_exit exits 1 and prints errors" { - source "$COMMON" - ERRORS=(" - Error one" " - Error two") - run report_and_exit - [ "$status" -eq 1 ] - [[ "$output" == *"Cannot start Test Explorer"* ]] - [[ "$output" == *"Error one"* ]] - [[ "$output" == *"Error two"* ]] -} - -# --- Error collection (multiple failures) --- - -@test "multiple failures are all collected" { - unset ANANTA_API_KEY - unset ANANTA_MODEL - source "$COMMON" - ERRORS=() - require_command "nonexistent_cmd_xyz" "https://example.com" - require_env "ANANTA_API_KEY" "export ANANTA_API_KEY=" - require_env "ANANTA_MODEL" "export ANANTA_MODEL=" - [ ${#ERRORS[@]} -eq 3 ] -} - -# --- Flag parsing --- - -@test "--rebuild flag is stripped from args" { - export ANANTA_API_KEY="test" - export ANANTA_MODEL="test" - set -- --port 9000 --rebuild --no-browser - source "$COMMON" - [ "$REBUILD" = true ] - [ ${#ANANTA_ARGS[@]} -eq 3 ] - [[ "${ANANTA_ARGS[*]}" == "--port 9000 --no-browser" ]] -} - -@test "args without --rebuild are passed through" { - export ANANTA_API_KEY="test" - export ANANTA_MODEL="test" - set -- --port 9000 - source "$COMMON" - [ "$REBUILD" = false ] - [ ${#ANANTA_ARGS[@]} -eq 2 ] -} - -# --- stderr filter --- - -@test "stderr_filter suppresses Exception-ignored blocks" { - source "$COMMON" - input="$(cat <<'BLOCK' -INFO: Shutting down -Exception ignored while finalizing file : -Traceback (most recent call last): - File "/opt/homebrew/lib/python3.14/http/client.py", line 437, in close - super().close() - File "/opt/homebrew/lib/python3.14/http/client.py", line 450, in flush - self.fp.flush() -ValueError: I/O operation on closed file. -INFO: Finished server process [66553] -BLOCK -)" - result="$(printf '%s\n' "$input" | stderr_filter)" - [[ "$result" == *"Shutting down"* ]] - [[ "$result" == *"Finished server process"* ]] - [[ "$result" != *"Exception ignored"* ]] - [[ "$result" != *"ValueError"* ]] - [[ "$result" != *"Traceback"* ]] -} - -@test "stderr_filter preserves normal error output" { - source "$COMMON" - input="ERROR: something went wrong" - result="$(printf '%s\n' "$input" | stderr_filter)" - [[ "$result" == *"something went wrong"* ]] -} From dcf541e4855d3ba8a06a8d9c45d6d83552232fba Mon Sep 17 00:00:00 2001 From: Ovid Date: Sun, 22 Mar 2026 15:36:12 +0100 Subject: [PATCH 15/19] fix: update arxiv-explorer README to reference current launcher path --- arxiv-explorer/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/arxiv-explorer/README.md b/arxiv-explorer/README.md index d4aebb64..31682111 100644 --- a/arxiv-explorer/README.md +++ b/arxiv-explorer/README.md @@ -20,7 +20,7 @@ Ananta lets you ask plain-English questions across dozens of arXiv papers at onc ## Setup -If you're comfortable with software development, you can run `./examples/arxiv-explorer.sh` to launch this quickly. It's not well tested (it still requires Docker because the Python code runs in a locked-down Docker container). Otherwise, you `cd arxiv-explorer; docker compose up`, after ensuring the prerequisites are satisfied. Note, the docker and shell script solutions share different data storage, so using one means it won't see what you put in the other. +If you're comfortable with software development, you can run `./arxiv-explorer/arxiv-explorer.sh` to launch this quickly. It still requires Docker because the Python code runs in a locked-down Docker container. Otherwise, you `cd arxiv-explorer; docker compose up`, after ensuring the prerequisites are satisfied. Note, the docker and shell script solutions share different data storage, so using one means it won't see what you put in the other. ### Installing Docker From 1e1f9f570e92bb0d67f2f7393167e9ff3ead35b0 Mon Sep 17 00:00:00 2001 From: Ovid Date: Sun, 22 Mar 2026 15:36:53 +0100 Subject: [PATCH 16/19] fix: add missing shutil.which mocks to Docker-related launcher tests --- tests/unit/explorers/test_launcher.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/tests/unit/explorers/test_launcher.py b/tests/unit/explorers/test_launcher.py index 32f13b79..2fc4d3f2 100644 --- a/tests/unit/explorers/test_launcher.py +++ b/tests/unit/explorers/test_launcher.py @@ -106,12 +106,14 @@ def test_var_empty(self) -> None: class TestCheckDockerRunning: - def test_docker_running(self) -> None: + @patch("ananta.explorers.launcher.shutil.which", return_value="/usr/bin/docker") + def test_docker_running(self, _mock_which: object) -> None: with patch("ananta.explorers.launcher.subprocess.run") as mock_run: mock_run.return_value = subprocess.CompletedProcess([], 0) assert check_docker_running() is None - def test_docker_not_running(self) -> None: + @patch("ananta.explorers.launcher.shutil.which", return_value="/usr/bin/docker") + def test_docker_not_running(self, _mock_which: object) -> None: side_effect = subprocess.CalledProcessError(1, "docker") with patch("ananta.explorers.launcher.subprocess.run", side_effect=side_effect): error = check_docker_running() @@ -125,12 +127,14 @@ def test_docker_not_installed(self) -> None: class TestEnsureSandboxImage: - def test_image_exists(self) -> None: + @patch("ananta.explorers.launcher.shutil.which", return_value="/usr/bin/docker") + def test_image_exists(self, _mock_which: object) -> None: with patch("ananta.explorers.launcher.subprocess.run") as mock_run: mock_run.return_value = subprocess.CompletedProcess([], 0) assert ensure_sandbox_image("/project/root") is None - def test_image_missing_build_succeeds(self, capsys: object) -> None: + @patch("ananta.explorers.launcher.shutil.which", return_value="/usr/bin/docker") + def test_image_missing_build_succeeds(self, _mock_which: object, capsys: object) -> None: call_count = 0 def side_effect(*args: object, **kwargs: object) -> subprocess.CompletedProcess[str]: @@ -145,7 +149,8 @@ def side_effect(*args: object, **kwargs: object) -> subprocess.CompletedProcess[ with patch("ananta.explorers.launcher.subprocess.run", side_effect=side_effect): assert ensure_sandbox_image("/project/root") is None - def test_image_missing_build_fails(self) -> None: + @patch("ananta.explorers.launcher.shutil.which", return_value="/usr/bin/docker") + def test_image_missing_build_fails(self, _mock_which: object) -> None: with patch( "ananta.explorers.launcher.subprocess.run", side_effect=subprocess.CalledProcessError(1, "docker"), From 29111967ec568517adc467c68a2fbb2ac0f68a7b Mon Sep 17 00:00:00 2001 From: Ovid Date: Sun, 22 Mar 2026 15:37:35 +0100 Subject: [PATCH 17/19] fix: catch CalledProcessError from build_frontend in launch() for clean exit --- src/ananta/explorers/launcher.py | 9 ++++++++- tests/unit/explorers/test_launcher.py | 12 ++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/ananta/explorers/launcher.py b/src/ananta/explorers/launcher.py index 70cdfdc6..224abc62 100644 --- a/src/ananta/explorers/launcher.py +++ b/src/ananta/explorers/launcher.py @@ -151,7 +151,14 @@ def launch( return 1 # Build frontend - build_frontend(config, project_root, rebuild=rebuild) + try: + build_frontend(config, project_root, rebuild=rebuild) + except subprocess.CalledProcessError: + print( + f"\033[0;31m[ananta]\033[0m Frontend build failed for {config.app_name}.", + file=sys.stderr, + ) + return 1 # Launch print(f"[ananta] Starting {config.app_name}...") diff --git a/tests/unit/explorers/test_launcher.py b/tests/unit/explorers/test_launcher.py index 2fc4d3f2..00c8076d 100644 --- a/tests/unit/explorers/test_launcher.py +++ b/tests/unit/explorers/test_launcher.py @@ -328,3 +328,15 @@ def test_rebuild_passed_to_build( call_args = mock_run.call_args # type: ignore[attr-defined] assert "--rebuild" not in call_args[0][0] assert "--open" in call_args[0][0] + + @patch("ananta.explorers.launcher.build_frontend") + @patch("ananta.explorers.launcher.run_preflight", return_value=[]) + def test_launch_build_failure_returns_exit_code_1( + self, + mock_preflight: object, + mock_build: object, + ) -> None: + mock_build.side_effect = subprocess.CalledProcessError(1, "npm") # type: ignore[attr-defined] + config = self._make_config() + exit_code = launch(config, argv=[], project_root="/project") + assert exit_code == 1 From d3ed0cb684d32f852fa8c9dd7276723972442621 Mon Sep 17 00:00:00 2001 From: Ovid Date: Sun, 22 Mar 2026 15:38:09 +0100 Subject: [PATCH 18/19] fix: document sandbox image default dependency on AnantaConfig.sandbox_image --- src/ananta/explorers/launcher.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/ananta/explorers/launcher.py b/src/ananta/explorers/launcher.py index 224abc62..b9c70d53 100644 --- a/src/ananta/explorers/launcher.py +++ b/src/ananta/explorers/launcher.py @@ -72,6 +72,7 @@ def check_docker_running() -> str | None: def ensure_sandbox_image(project_root: str) -> str | None: """Build the sandbox image if missing. Return error string on failure, else None.""" + # Default must match AnantaConfig.sandbox_image in config.py image = os.environ.get("ANANTA_SANDBOX_IMAGE", "ananta-sandbox") if shutil.which("docker") is None: return None # check_command will catch this From 9a5e6a7d65e8e836ecb3cf6db8a992bfb0c743a8 Mon Sep 17 00:00:00 2001 From: Ovid Date: Mon, 23 Mar 2026 08:04:19 +0100 Subject: [PATCH 19/19] PAAD agentic review --- ...s-to-python-2026-03-22-15-32-48-2a8e57f.md | 77 +++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 paad/code-reviews/ovid-explorer-launchers-to-python-2026-03-22-15-32-48-2a8e57f.md diff --git a/paad/code-reviews/ovid-explorer-launchers-to-python-2026-03-22-15-32-48-2a8e57f.md b/paad/code-reviews/ovid-explorer-launchers-to-python-2026-03-22-15-32-48-2a8e57f.md new file mode 100644 index 00000000..6e3968ff --- /dev/null +++ b/paad/code-reviews/ovid-explorer-launchers-to-python-2026-03-22-15-32-48-2a8e57f.md @@ -0,0 +1,77 @@ +# Agentic Code Review: ovid/explorer-launchers-to-python + +**Date:** 2026-03-22 15:32:48 +**Branch:** ovid/explorer-launchers-to-python -> main +**Commit:** 2a8e57f6c80d6d55c590393330e4aca860f182a9 +**Files changed:** 15 | **Lines changed:** +1814 / -292 +**Diff size category:** Large + +## Executive Summary + +Clean refactor that moves explorer launcher bash logic into a testable Python module. The core `launcher.py` and its tests are well-structured. However, two stale references to deleted files were missed (a bats test file and an explorer README), and the Docker-related tests are environment-dependent due to missing `shutil.which` mocks. One subprocess error path (`build_frontend`) lacks the same graceful handling found elsewhere in the module. + +## Critical Issues + +### [C1] Stale bats test file sources deleted `scripts/common.sh` +- **File:** `tests/scripts/test_common.bats:4` +- **Bug:** The file sets `COMMON="$BATS_TEST_DIRNAME/../../scripts/common.sh"` and all 13 tests source it. `scripts/common.sh` was deleted in this branch. +- **Impact:** All 13 bats tests will fail on every run. CI breakage if bats tests are in the test suite. +- **Suggested fix:** Delete `tests/scripts/test_common.bats` — its coverage is now provided by `tests/unit/explorers/test_launcher.py`. +- **Confidence:** High +- **Found by:** Contract & Integration + +## Important Issues + +### [I1] `arxiv-explorer/README.md` references deleted `examples/arxiv-explorer.sh` +- **File:** `arxiv-explorer/README.md:23` +- **Bug:** Setup instructions tell users to run `./examples/arxiv-explorer.sh`, which was deleted in this branch. +- **Impact:** Broken first-run instructions for anyone reading the arxiv-explorer README. +- **Suggested fix:** Update to `./arxiv-explorer/arxiv-explorer.sh`. +- **Confidence:** High +- **Found by:** Contract & Integration + +### [I2] Docker-related tests missing `shutil.which` mock — environment-dependent +- **File:** `tests/unit/explorers/test_launcher.py:109-119` (TestCheckDockerRunning) and `tests/unit/explorers/test_launcher.py:128-155` (TestEnsureSandboxImage) +- **Bug:** `test_docker_running`, `test_docker_not_running`, `test_image_exists`, `test_image_missing_build_succeeds`, and `test_image_missing_build_fails` mock `subprocess.run` but not `shutil.which`. The production code calls `shutil.which("docker")` first and returns `None` early if Docker is absent. On machines without Docker, these tests either pass vacuously or fail outright (e.g., `test_docker_not_running` expects an error string but gets `None`). +- **Impact:** Tests are unreliable across environments. The `test_docker_not_installed` variants in both classes do mock `shutil.which` correctly, showing the pattern is known but inconsistently applied. +- **Suggested fix:** Add `@patch("ananta.explorers.launcher.shutil.which", return_value="/usr/bin/docker")` to the affected tests. +- **Confidence:** High +- **Found by:** Logic & Correctness, Contract & Integration, Concurrency & State (5 specialists agreed) + +### [I3] `build_frontend()` lets `CalledProcessError` propagate as raw traceback +- **File:** `src/ananta/explorers/launcher.py:117-121` +- **Bug:** `build_frontend` calls `subprocess.run(["npm", ...], check=True)` without catching `CalledProcessError`. A failed npm command produces a Python traceback instead of a clean error message. This contrasts with `ensure_sandbox_image` and `check_docker_running`, which both catch subprocess failures gracefully. +- **Impact:** Users see a raw traceback on npm build failure instead of the `[ananta] Cannot start...` pattern used everywhere else. +- **Suggested fix:** Wrap npm subprocess calls in try/except, or catch the exception in `launch()` around the `build_frontend` call. +- **Confidence:** Medium +- **Found by:** Logic & Correctness, Error Handling & Edge Cases + +### [I4] Hardcoded sandbox image name duplicates config constant +- **File:** `src/ananta/explorers/launcher.py:75` +- **Bug:** `os.environ.get("ANANTA_SANDBOX_IMAGE", "ananta-sandbox")` hardcodes the default `"ananta-sandbox"`. The canonical default lives in `src/ananta/config.py:44` as `AnantaConfig.sandbox_image`. If the default changes in one place, the other silently diverges. +- **Impact:** Silent behavior mismatch if the default image name is ever changed in config.py. +- **Suggested fix:** Import and use the constant from `AnantaConfig`, or extract a shared constant. +- **Confidence:** Medium +- **Found by:** Contract & Integration + +## Suggestions + +- `launcher.py:136-138` — `project_root` auto-detection via `Path(__file__).resolve().parents[3]` assumes editable install layout and is never validated. Consider checking for `pyproject.toml` at the resolved path. +- `launcher.py:158` — `config.entry_point` is not existence-checked before `subprocess.run`. A missing binary produces `FileNotFoundError` instead of a clean preflight error. Consider adding it to `run_preflight`. +- The three shell shims (`*-explorer.sh`) are nearly identical (differ only in `PIP_EXTRA` and `APP_SLUG`). Acceptable given they're thin bootstrap scripts, but a single parameterized script would reduce maintenance. + +## Plan Alignment + +- **Implemented:** All 11 tasks from the implementation plan are complete. LauncherConfig, all preflight checks, frontend build, launch orchestration, per-explorer configs, bash shim rewrites, file deletions, README/CHANGELOG updates, and lint pass. +- **Not yet implemented:** None — all planned work is present. +- **Deviations:** Implementation correctly adds `sys.exit(launch(config))` in launch.py files where the design doc example omitted it. This is an improvement. Decorator order in one test differs from plan but is functionally equivalent. + +## Review Metadata + +- **Agents dispatched:** Logic & Correctness, Error Handling & Edge Cases, Contract & Integration, Concurrency & State, Security, Plan Alignment +- **Scope:** 15 changed files + `tests/scripts/test_common.bats`, `arxiv-explorer/README.md`, `src/ananta/config.py` (adjacent) +- **Raw findings:** 26 (before verification) +- **Verified findings:** 8 (after verification) +- **Filtered out:** 18 +- **Steering files consulted:** CLAUDE.md +- **Plan/design docs consulted:** docs/plans/2026-03-22-python-launcher-design.md, docs/plans/2026-03-22-python-launcher-implementation.md