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..a4fe938 --- /dev/null +++ b/reeln/commands/init_cmd.py @@ -0,0 +1,187 @@ +"""Guided first-time configuration.""" + +from __future__ import annotations + +import sys +import types +from pathlib import Path + +import typer +from rich.console import Console +from rich.panel import Panel +from rich.table import Table + +from reeln.core.config import ( + config_dir, + 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() -> types.ModuleType: # pragma: no cover + """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: # pragma: no cover + """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: # pragma: no cover + """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: # pragma: no cover + """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: str | None = typer.Option(None, "--sport", help="Sport type"), + source_dir: Path | None = typer.Option( + None, "--source-dir", help="Replay source directory" + ), + output_dir: Path | None = typer.Option( + None, "--output-dir", help="Game output directory" + ), + config_path: Path | None = 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(): # pragma: no cover + 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/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"] diff --git a/tests/unit/commands/test_init_cmd.py b/tests/unit/commands/test_init_cmd.py new file mode 100644 index 0000000..973f891 --- /dev/null +++ b/tests/unit/commands/test_init_cmd.py @@ -0,0 +1,251 @@ +"""Tests for the init command.""" + +from __future__ import annotations + +import json +from pathlib import Path + +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 diff --git a/tests/unit/core/test_plugin_registry.py b/tests/unit/core/test_plugin_registry.py index 3deb30e..975e766 100644 --- a/tests/unit/core/test_plugin_registry.py +++ b/tests/unit/core/test_plugin_registry.py @@ -490,15 +490,50 @@ 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() - 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 +541,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 +1115,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