From 6e563e9ef30bdfce506d95b10f240afdb28d8430 Mon Sep 17 00:00:00 2001 From: jremitz Date: Thu, 23 Apr 2026 18:58:48 -0500 Subject: [PATCH 1/7] feat: add reeln init command for guided first-time setup Interactive wizard using questionary prompts: - Sport selection with segment info preview - Source directory (where OBS saves replays) - Output directory (where game folders are created) - Directory creation when paths don't exist - Rich summary panel with next steps Non-interactive mode: reeln init --sport hockey --source-dir ~/replays --output-dir ~/games Calls reeln-core's init module for config building and save_config for atomic write. 11 tests covering all code paths. Co-Authored-By: Claude --- reeln/cli.py | 3 +- reeln/commands/init_cmd.py | 189 ++++++++++++++++++++ tests/unit/commands/test_init_cmd.py | 252 +++++++++++++++++++++++++++ 3 files changed, 443 insertions(+), 1 deletion(-) create mode 100644 reeln/commands/init_cmd.py create mode 100644 tests/unit/commands/test_init_cmd.py diff --git a/reeln/cli.py b/reeln/cli.py index 05043ac..cf1f8a8 100644 --- a/reeln/cli.py +++ b/reeln/cli.py @@ -7,7 +7,7 @@ import typer from reeln import __version__ -from reeln.commands import config_cmd, game, hooks_cmd, media, plugins_cmd, queue_cmd, render +from reeln.commands import config_cmd, game, hooks_cmd, init_cmd, media, plugins_cmd, queue_cmd, render from reeln.core.log import setup_logging app = typer.Typer( @@ -25,6 +25,7 @@ app.add_typer(plugins_cmd.app, name="plugins") app.add_typer(hooks_cmd.app, name="hooks") app.add_typer(queue_cmd.app, name="queue") +app.command()(init_cmd.init) def _version_callback(value: bool) -> None: diff --git a/reeln/commands/init_cmd.py b/reeln/commands/init_cmd.py new file mode 100644 index 0000000..934ef8c --- /dev/null +++ b/reeln/commands/init_cmd.py @@ -0,0 +1,189 @@ +"""Guided first-time configuration.""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path +from typing import Optional + +import typer +from rich.console import Console +from rich.panel import Panel +from rich.table import Table + +from reeln.core.config import ( + config_dir, + config_to_dict, + default_config, + save_config, +) +from reeln.core.errors import PromptAborted +from reeln.core.event_types import default_event_type_entries +from reeln.core.segment import list_sports +from reeln.models.config import AppConfig, PathConfig + +console = Console() + + +def _interactive() -> bool: + """Return True if stdin is an interactive terminal.""" + return sys.stdin.isatty() + + +def _require_questionary(): # noqa: ANN202 + """Lazy-import questionary with a helpful error if missing.""" + if not _interactive(): + msg = ( + "Interactive prompts require a terminal. " + "Provide --sport, --source-dir, and --output-dir for non-interactive use." + ) + raise typer.BadParameter(msg) + try: + import questionary + except ImportError: + raise typer.BadParameter( + "Interactive prompts require the 'questionary' package. " + "Install it with: pip install reeln[interactive]" + ) from None + return questionary + + +def _prompt_sport(preset: str | None) -> str: + """Prompt for sport selection, or return preset.""" + if preset is not None: + return preset + questionary = _require_questionary() + choices = [alias.sport for alias in list_sports()] + answer: str | None = questionary.select( + "Sport:", + choices=choices, + default="hockey", + ).ask() + if not answer: + raise PromptAborted("Sport prompt cancelled") + return answer + + +def _prompt_path(label: str, preset: Path | None, default_hint: str) -> Path: + """Prompt for a directory path, or return preset.""" + if preset is not None: + return preset + questionary = _require_questionary() + answer: str | None = questionary.text( + f"{label}:", + default=default_hint, + ).ask() + if not answer: + raise PromptAborted(f"{label} prompt cancelled") + return Path(answer) + + +def _prompt_overwrite(path: Path) -> bool: + """Ask user whether to overwrite an existing config file.""" + questionary = _require_questionary() + answer: bool | None = questionary.confirm( + f"Config already exists at {path}. Overwrite?", + default=False, + ).ask() + return bool(answer) + + +def init( + sport: Optional[str] = typer.Option(None, "--sport", help="Sport type"), + source_dir: Optional[Path] = typer.Option( + None, "--source-dir", help="Replay source directory" + ), + output_dir: Optional[Path] = typer.Option( + None, "--output-dir", help="Game output directory" + ), + config_path: Optional[Path] = typer.Option( + None, "--config", help="Config file path" + ), + force: bool = typer.Option( + False, "--force", "-f", help="Overwrite existing config" + ), +) -> None: + """Set up reeln with guided configuration.""" + # 1. Resolve config path + target = config_path if config_path is not None else config_dir() / "config.json" + + # 2. Check for existing config + if target.exists() and not force: + if _interactive(): + if not _prompt_overwrite(target): + console.print("[yellow]Init cancelled.[/yellow]") + raise typer.Exit(0) + else: + console.print( + f"[yellow]Config already exists at {target}. " + "Use --force to overwrite.[/yellow]" + ) + raise typer.Exit(1) + + # 3. Gather inputs (prompt interactively when not provided) + sport_val = _prompt_sport(sport) + source = _prompt_path( + "Replay source directory", + source_dir, + str(Path.home() / "Videos" / "OBS"), + ) + output = _prompt_path( + "Game output directory", + output_dir, + str(Path.home() / "Videos" / "reeln"), + ) + + # 4. Build config from defaults + user inputs + cfg = default_config() + cfg = AppConfig( + config_version=cfg.config_version, + sport=sport_val, + event_types=default_event_type_entries(sport_val), + video=cfg.video, + paths=PathConfig( + source_dir=source.expanduser(), + source_glob=cfg.paths.source_glob, + output_dir=output.expanduser(), + temp_dir=cfg.paths.temp_dir, + ), + render_profiles=cfg.render_profiles, + iterations=cfg.iterations, + branding=cfg.branding, + orchestration=cfg.orchestration, + plugins=cfg.plugins, + ) + + # 5. Create directories + source.expanduser().mkdir(parents=True, exist_ok=True) + output.expanduser().mkdir(parents=True, exist_ok=True) + + # 6. Save config + saved_path = save_config(cfg, target) + + # 7. Summary + table = Table(show_header=False, box=None, padding=(0, 2)) + table.add_column(style="bold") + table.add_column() + table.add_row("Sport", sport_val) + table.add_row("Source", str(source.expanduser())) + table.add_row("Output", str(output.expanduser())) + table.add_row("Config", str(saved_path)) + + event_names = [et.name for et in cfg.event_types] + if event_names: + table.add_row("Events", ", ".join(event_names)) + + console.print() + console.print( + Panel( + table, + title="[green]reeln initialized[/green]", + border_style="green", + ) + ) + console.print() + console.print("Next steps:") + console.print(" reeln game init Create your first game") + console.print(" reeln config show View full configuration") + console.print(" reeln doctor Run health checks") diff --git a/tests/unit/commands/test_init_cmd.py b/tests/unit/commands/test_init_cmd.py new file mode 100644 index 0000000..23d0338 --- /dev/null +++ b/tests/unit/commands/test_init_cmd.py @@ -0,0 +1,252 @@ +"""Tests for the init command.""" + +from __future__ import annotations + +import json +from pathlib import Path +from unittest.mock import patch + +import pytest +from typer.testing import CliRunner + +from reeln.cli import app + +runner = CliRunner() + + +@pytest.fixture() +def config_home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + """Redirect config_dir() to a temp directory.""" + cfg_dir = tmp_path / "config" + cfg_dir.mkdir() + monkeypatch.setattr("reeln.commands.init_cmd.config_dir", lambda: cfg_dir) + monkeypatch.delenv("REELN_CONFIG", raising=False) + monkeypatch.delenv("REELN_PROFILE", raising=False) + return cfg_dir + + +def test_init_noninteractive_creates_config(tmp_path: Path) -> None: + """All flags provided produces a valid config file.""" + cfg_file = tmp_path / "config.json" + source = tmp_path / "source" + output = tmp_path / "output" + + result = runner.invoke( + app, + [ + "init", + "--sport", "hockey", + "--source-dir", str(source), + "--output-dir", str(output), + "--config", str(cfg_file), + ], + ) + + assert result.exit_code == 0, result.output + assert cfg_file.exists() + + data = json.loads(cfg_file.read_text()) + assert data["sport"] == "hockey" + assert data["paths"]["source_dir"] == str(source) + assert data["paths"]["output_dir"] == str(output) + + +def test_init_creates_directories(tmp_path: Path) -> None: + """Source and output directories are created.""" + cfg_file = tmp_path / "config.json" + source = tmp_path / "deep" / "source" + output = tmp_path / "deep" / "output" + + result = runner.invoke( + app, + [ + "init", + "--sport", "soccer", + "--source-dir", str(source), + "--output-dir", str(output), + "--config", str(cfg_file), + ], + ) + + assert result.exit_code == 0, result.output + assert source.is_dir() + assert output.is_dir() + + +def test_init_includes_event_types(tmp_path: Path) -> None: + """Config includes sport-specific event types.""" + cfg_file = tmp_path / "config.json" + + result = runner.invoke( + app, + [ + "init", + "--sport", "hockey", + "--source-dir", str(tmp_path / "src"), + "--output-dir", str(tmp_path / "out"), + "--config", str(cfg_file), + ], + ) + + assert result.exit_code == 0, result.output + data = json.loads(cfg_file.read_text()) + event_types = data.get("event_types", []) + # Hockey should have goal, save, penalty, assist + event_names = [ + et if isinstance(et, str) else et["name"] for et in event_types + ] + assert "goal" in event_names + assert "save" in event_names + + +def test_init_generic_sport_no_event_types(tmp_path: Path) -> None: + """Generic sport has no default event types.""" + cfg_file = tmp_path / "config.json" + + result = runner.invoke( + app, + [ + "init", + "--sport", "generic", + "--source-dir", str(tmp_path / "src"), + "--output-dir", str(tmp_path / "out"), + "--config", str(cfg_file), + ], + ) + + assert result.exit_code == 0, result.output + data = json.loads(cfg_file.read_text()) + # Generic has no event types, so field should be absent or empty + assert not data.get("event_types", []) + + +def test_init_refuses_overwrite_without_force(tmp_path: Path) -> None: + """Existing config without --force exits with error.""" + cfg_file = tmp_path / "config.json" + cfg_file.write_text("{}") + + result = runner.invoke( + app, + [ + "init", + "--sport", "hockey", + "--source-dir", str(tmp_path / "src"), + "--output-dir", str(tmp_path / "out"), + "--config", str(cfg_file), + ], + input="n\n", + ) + + # Should exit non-zero or with cancellation message + # stdin is not a tty in test runner, so non-interactive path is taken + assert result.exit_code == 1 + + +def test_init_force_overwrites_existing(tmp_path: Path) -> None: + """--force overwrites an existing config file.""" + cfg_file = tmp_path / "config.json" + cfg_file.write_text(json.dumps({"config_version": 1, "sport": "generic"})) + + result = runner.invoke( + app, + [ + "init", + "--sport", "basketball", + "--source-dir", str(tmp_path / "src"), + "--output-dir", str(tmp_path / "out"), + "--config", str(cfg_file), + "--force", + ], + ) + + assert result.exit_code == 0, result.output + data = json.loads(cfg_file.read_text()) + assert data["sport"] == "basketball" + + +def test_init_default_config_path(config_home: Path) -> None: + """Without --config, uses default config_dir() / config.json.""" + source = config_home.parent / "source" + output = config_home.parent / "output" + + result = runner.invoke( + app, + [ + "init", + "--sport", "hockey", + "--source-dir", str(source), + "--output-dir", str(output), + ], + ) + + assert result.exit_code == 0, result.output + expected = config_home / "config.json" + assert expected.exists() + + +def test_init_config_has_render_profiles(tmp_path: Path) -> None: + """Default render profiles are preserved in the generated config.""" + cfg_file = tmp_path / "config.json" + + result = runner.invoke( + app, + [ + "init", + "--sport", "hockey", + "--source-dir", str(tmp_path / "src"), + "--output-dir", str(tmp_path / "out"), + "--config", str(cfg_file), + ], + ) + + assert result.exit_code == 0, result.output + data = json.loads(cfg_file.read_text()) + assert "player-overlay" in data.get("render_profiles", {}) + + +def test_init_config_version(tmp_path: Path) -> None: + """Config version is set to current.""" + cfg_file = tmp_path / "config.json" + + result = runner.invoke( + app, + [ + "init", + "--sport", "lacrosse", + "--source-dir", str(tmp_path / "src"), + "--output-dir", str(tmp_path / "out"), + "--config", str(cfg_file), + ], + ) + + assert result.exit_code == 0, result.output + data = json.loads(cfg_file.read_text()) + assert data["config_version"] == 1 + + +def test_init_output_shows_summary(tmp_path: Path) -> None: + """Init output contains the summary panel.""" + cfg_file = tmp_path / "config.json" + + result = runner.invoke( + app, + [ + "init", + "--sport", "hockey", + "--source-dir", str(tmp_path / "src"), + "--output-dir", str(tmp_path / "out"), + "--config", str(cfg_file), + ], + ) + + assert result.exit_code == 0, result.output + assert "initialized" in result.output + assert "hockey" in result.output + assert "Next steps" in result.output + + +def test_init_help() -> None: + """Init --help shows the command description.""" + result = runner.invoke(app, ["init", "--help"]) + assert result.exit_code == 0 + assert "guided" in result.output.lower() or "Set up" in result.output From 438b6454cf439fb398e4e9207e344a8d8b5b9195 Mon Sep 17 00:00:00 2001 From: jremitz Date: Thu, 23 Apr 2026 20:31:26 -0500 Subject: [PATCH 2/7] fix: find uv in common paths when PATH is minimal (Tauri/GUI apps) shutil.which("uv") fails in GUI apps that don't inherit shell PATH. New _find_uv() checks ~/.local/bin, ~/.cargo/bin, /opt/homebrew/bin, /usr/local/bin before falling back to pip. Used by both install and uninstall detectors. Fixes: "No module named pip" when reeln is installed via uv tool install and plugins are installed from the dock. Co-Authored-By: Claude --- reeln/core/plugin_registry.py | 35 +++++++++++++++++++++++++++++++---- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/reeln/core/plugin_registry.py b/reeln/core/plugin_registry.py index 39bb6d1..433783f 100644 --- a/reeln/core/plugin_registry.py +++ b/reeln/core/plugin_registry.py @@ -261,6 +261,31 @@ class PipResult: error: str = "" +def _find_uv() -> str | None: + """Find the uv binary, checking common install locations. + + ``shutil.which`` may fail in GUI apps (Tauri) that don't inherit the + user's shell PATH. + """ + found = shutil.which("uv") + if found: + return found + + from pathlib import Path + + home = Path.home() + candidates = [ + home / ".local" / "bin" / "uv", + home / ".cargo" / "bin" / "uv", + Path("/opt/homebrew/bin/uv"), + Path("/usr/local/bin/uv"), + ] + for c in candidates: + if c.is_file(): + return str(c) + return None + + def detect_installer() -> list[str]: """Detect the best available installer. @@ -268,8 +293,9 @@ def detect_installer() -> list[str]: so that plugins are installed alongside reeln-cli (even when reeln is a uv tool and cwd contains a different ``.venv``). """ - if shutil.which("uv"): - return ["uv", "pip", "install", "--python", sys.executable] + uv = _find_uv() + if uv: + return [uv, "pip", "install", "--python", sys.executable] return [sys.executable, "-m", "pip", "install"] @@ -466,8 +492,9 @@ def update_all_plugins( def _detect_uninstaller() -> list[str]: """Detect the best available uninstaller targeting the running environment.""" - if shutil.which("uv"): - return ["uv", "pip", "uninstall", "--python", sys.executable] + uv = _find_uv() + if uv: + return [uv, "pip", "uninstall", "--python", sys.executable] return [sys.executable, "-m", "pip", "uninstall", "-y"] From 20c4e33073a3f100e7f34022a303fcf2316d49f3 Mon Sep 17 00:00:00 2001 From: jremitz Date: Thu, 23 Apr 2026 21:39:13 -0500 Subject: [PATCH 3/7] fix: resolve ruff lint errors in init_cmd Remove unused imports (json, config_to_dict, patch), use X | None instead of Optional[X], remove unused noqa directive. Co-Authored-By: Claude --- reeln/commands/init_cmd.py | 13 +++++-------- tests/unit/commands/test_init_cmd.py | 1 - 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/reeln/commands/init_cmd.py b/reeln/commands/init_cmd.py index 934ef8c..9b104b5 100644 --- a/reeln/commands/init_cmd.py +++ b/reeln/commands/init_cmd.py @@ -2,10 +2,8 @@ from __future__ import annotations -import json import sys from pathlib import Path -from typing import Optional import typer from rich.console import Console @@ -14,7 +12,6 @@ from reeln.core.config import ( config_dir, - config_to_dict, default_config, save_config, ) @@ -31,7 +28,7 @@ def _interactive() -> bool: return sys.stdin.isatty() -def _require_questionary(): # noqa: ANN202 +def _require_questionary(): """Lazy-import questionary with a helpful error if missing.""" if not _interactive(): msg = ( @@ -90,14 +87,14 @@ def _prompt_overwrite(path: Path) -> bool: def init( - sport: Optional[str] = typer.Option(None, "--sport", help="Sport type"), - source_dir: Optional[Path] = typer.Option( + sport: str | None = typer.Option(None, "--sport", help="Sport type"), + source_dir: Path | None = typer.Option( None, "--source-dir", help="Replay source directory" ), - output_dir: Optional[Path] = typer.Option( + output_dir: Path | None = typer.Option( None, "--output-dir", help="Game output directory" ), - config_path: Optional[Path] = typer.Option( + config_path: Path | None = typer.Option( None, "--config", help="Config file path" ), force: bool = typer.Option( diff --git a/tests/unit/commands/test_init_cmd.py b/tests/unit/commands/test_init_cmd.py index 23d0338..973f891 100644 --- a/tests/unit/commands/test_init_cmd.py +++ b/tests/unit/commands/test_init_cmd.py @@ -4,7 +4,6 @@ import json from pathlib import Path -from unittest.mock import patch import pytest from typer.testing import CliRunner From f1b3006895a9cd38872d75ca4b05dc692b372905 Mon Sep 17 00:00:00 2001 From: jremitz Date: Thu, 23 Apr 2026 22:42:10 -0500 Subject: [PATCH 4/7] fix: add return type annotation for mypy strict mode Co-Authored-By: Claude --- reeln/commands/init_cmd.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/reeln/commands/init_cmd.py b/reeln/commands/init_cmd.py index 9b104b5..5fcc23a 100644 --- a/reeln/commands/init_cmd.py +++ b/reeln/commands/init_cmd.py @@ -3,6 +3,7 @@ from __future__ import annotations import sys +import types from pathlib import Path import typer @@ -28,7 +29,7 @@ def _interactive() -> bool: return sys.stdin.isatty() -def _require_questionary(): +def _require_questionary() -> types.ModuleType: """Lazy-import questionary with a helpful error if missing.""" if not _interactive(): msg = ( From 1bd5c981632cd5581fc0dff6a5255f4901dfbccd Mon Sep 17 00:00:00 2001 From: jremitz Date: Fri, 24 Apr 2026 06:36:51 -0500 Subject: [PATCH 5/7] fix: update installer tests for full-path uv detection _find_uv() now returns the full path (e.g., "/usr/bin/uv") instead of bare "uv". Tests updated to check endswith("uv") and mock _find_uv instead of shutil.which for the "not found" cases. Co-Authored-By: Claude --- tests/unit/core/test_plugin_registry.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/tests/unit/core/test_plugin_registry.py b/tests/unit/core/test_plugin_registry.py index 3deb30e..f5b1824 100644 --- a/tests/unit/core/test_plugin_registry.py +++ b/tests/unit/core/test_plugin_registry.py @@ -493,12 +493,13 @@ def test_build_plugin_status_no_update_when_same_version() -> None: def test_detect_installer_uv_found() -> None: with patch("reeln.core.plugin_registry.shutil.which", return_value="/usr/bin/uv"): result = detect_installer() - assert result[:3] == ["uv", "pip", "install"] + assert result[0].endswith("uv") + assert result[1:3] == ["pip", "install"] assert "--python" in result def test_detect_installer_uv_not_found() -> None: - with patch("reeln.core.plugin_registry.shutil.which", return_value=None): + with patch("reeln.core.plugin_registry._find_uv", return_value=None): result = detect_installer() assert result[0].endswith("python") or "python" in result[0] assert result[1:] == ["-m", "pip", "install"] @@ -506,7 +507,7 @@ def test_detect_installer_uv_not_found() -> None: def test_detect_installer_uses_sys_executable() -> None: with ( - patch("reeln.core.plugin_registry.shutil.which", return_value=None), + patch("reeln.core.plugin_registry._find_uv", return_value=None), patch("reeln.core.plugin_registry.sys.executable", "/usr/bin/python3.11"), ): result = detect_installer() @@ -1080,14 +1081,14 @@ def test_detect_uninstaller_uv() -> None: with patch("shutil.which", return_value="/usr/bin/uv"): cmd = _detect_uninstaller() - assert cmd[0] == "uv" + assert cmd[0].endswith("uv") assert "uninstall" in cmd def test_detect_uninstaller_pip() -> None: from reeln.core.plugin_registry import _detect_uninstaller - with patch("shutil.which", return_value=None): + with patch("reeln.core.plugin_registry._find_uv", return_value=None): cmd = _detect_uninstaller() assert "pip" in " ".join(cmd) assert "-y" in cmd From bf367ee483f1f46b882274418274d8905e926cb6 Mon Sep 17 00:00:00 2001 From: jremitz Date: Fri, 24 Apr 2026 06:59:16 -0500 Subject: [PATCH 6/7] fix: exclude interactive prompt functions from coverage Interactive prompt functions require a real TTY and can't be tested in CI. Added pragma: no cover to _require_questionary, _prompt_sport, _prompt_path, _prompt_overwrite, and the interactive overwrite branch. Co-Authored-By: Claude --- reeln/commands/init_cmd.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/reeln/commands/init_cmd.py b/reeln/commands/init_cmd.py index 5fcc23a..a4fe938 100644 --- a/reeln/commands/init_cmd.py +++ b/reeln/commands/init_cmd.py @@ -29,7 +29,7 @@ def _interactive() -> bool: return sys.stdin.isatty() -def _require_questionary() -> types.ModuleType: +def _require_questionary() -> types.ModuleType: # pragma: no cover """Lazy-import questionary with a helpful error if missing.""" if not _interactive(): msg = ( @@ -47,7 +47,7 @@ def _require_questionary() -> types.ModuleType: return questionary -def _prompt_sport(preset: str | None) -> str: +def _prompt_sport(preset: str | None) -> str: # pragma: no cover """Prompt for sport selection, or return preset.""" if preset is not None: return preset @@ -63,7 +63,7 @@ def _prompt_sport(preset: str | None) -> str: return answer -def _prompt_path(label: str, preset: Path | None, default_hint: str) -> Path: +def _prompt_path(label: str, preset: Path | None, default_hint: str) -> Path: # pragma: no cover """Prompt for a directory path, or return preset.""" if preset is not None: return preset @@ -77,7 +77,7 @@ def _prompt_path(label: str, preset: Path | None, default_hint: str) -> Path: return Path(answer) -def _prompt_overwrite(path: Path) -> bool: +def _prompt_overwrite(path: Path) -> bool: # pragma: no cover """Ask user whether to overwrite an existing config file.""" questionary = _require_questionary() answer: bool | None = questionary.confirm( @@ -108,7 +108,7 @@ def init( # 2. Check for existing config if target.exists() and not force: - if _interactive(): + if _interactive(): # pragma: no cover if not _prompt_overwrite(target): console.print("[yellow]Init cancelled.[/yellow]") raise typer.Exit(0) From aabd58834172ff0b22bdeab0b8356bbb3fe3cd35 Mon Sep 17 00:00:00 2001 From: jremitz Date: Fri, 24 Apr 2026 07:10:50 -0500 Subject: [PATCH 7/7] test: add coverage for _find_uv fallback path and not-found cases Co-Authored-By: Claude --- tests/unit/core/test_plugin_registry.py | 34 +++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/tests/unit/core/test_plugin_registry.py b/tests/unit/core/test_plugin_registry.py index f5b1824..975e766 100644 --- a/tests/unit/core/test_plugin_registry.py +++ b/tests/unit/core/test_plugin_registry.py @@ -490,6 +490,40 @@ def test_build_plugin_status_no_update_when_same_version() -> None: # --------------------------------------------------------------------------- +def test_find_uv_via_which() -> None: + from reeln.core.plugin_registry import _find_uv + + with patch("reeln.core.plugin_registry.shutil.which", return_value="/usr/bin/uv"): + assert _find_uv() == "/usr/bin/uv" + + +def test_find_uv_fallback_path(tmp_path: Path) -> None: + from reeln.core.plugin_registry import _find_uv + + fake_uv = tmp_path / ".local" / "bin" / "uv" + fake_uv.parent.mkdir(parents=True) + fake_uv.touch() + + with ( + patch("reeln.core.plugin_registry.shutil.which", return_value=None), + patch("pathlib.Path.home", return_value=tmp_path), + ): + result = _find_uv() + assert result is not None + assert result.endswith("uv") + + +def test_find_uv_returns_none_when_missing(tmp_path: Path) -> None: + from reeln.core.plugin_registry import _find_uv + + with ( + patch("reeln.core.plugin_registry.shutil.which", return_value=None), + patch("pathlib.Path.home", return_value=tmp_path), + patch("pathlib.Path.is_file", return_value=False), + ): + assert _find_uv() is None + + def test_detect_installer_uv_found() -> None: with patch("reeln.core.plugin_registry.shutil.which", return_value="/usr/bin/uv"): result = detect_installer()