From 4077e3088cbf2c0fe812e055ae8542b5df390d39 Mon Sep 17 00:00:00 2001 From: jremitz Date: Thu, 2 Apr 2026 18:15:32 -0500 Subject: [PATCH 1/3] chore: bump openai plugin to v0.8.2 in registry Co-Authored-By: Claude Opus 4.6 (1M context) --- registry/plugins.json | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/registry/plugins.json b/registry/plugins.json index 692f8b1..66451b7 100644 --- a/registry/plugins.json +++ b/registry/plugins.json @@ -19,7 +19,20 @@ "homepage": "https://github.com/StreamnDad/reeln-plugin-google", "min_reeln_version": "0.0.31", "author": "StreamnDad", - "license": "AGPL-3.0" + "license": "AGPL-3.0", + "ui_contributions": { + "input_fields": { + "game_init": [ + { + "id": "thumbnail_image", + "label": "Thumbnail Image", + "type": "file", + "required": false, + "description": "Thumbnail image for YouTube livestream" + } + ] + } + } }, { "name": "meta", @@ -44,6 +57,7 @@ { "name": "openai", "package": "reeln-plugin-openai", + "version": "0.8.2", "description": "OpenAI-powered LLM integration — livestream metadata, render metadata, game thumbnails, and translation", "capabilities": ["hook:ON_GAME_INIT", "hook:POST_RENDER", "hook:ON_FRAMES_EXTRACTED"], "homepage": "https://github.com/StreamnDad/reeln-plugin-openai", From 44950a63ce8d82c9d607cfefd1a26a754f6ac682 Mon Sep 17 00:00:00 2001 From: jremitz Date: Fri, 3 Apr 2026 16:21:59 -0500 Subject: [PATCH 2/3] feat: plugin input contributions, game list/info/delete, CLI styling (v0.0.37) Plugins can now declare additional user inputs via input_schema or get_input_schema() that are collected during existing commands (game init, render short/preview). Inputs are prompted interactively or passed via --plugin-input KEY=VALUE. Registry fallback provides inputs for plugins that haven't adopted the class-level declaration yet. Also adds game list/info/delete commands, colored CLI output, fail-fast unfinished game check before prompts, reliable game directory resolution using created_at instead of filesystem mtime, and plugin uninstall. Co-Authored-By: Claude --- .claude/rules/plugin-development.md | 86 +++- CHANGELOG.md | 27 + reeln/__init__.py | 2 +- reeln/cli.py | 70 ++- reeln/commands/game.py | 249 +++++++++- reeln/commands/plugins_cmd.py | 246 +++++++-- reeln/commands/render.py | 31 +- reeln/commands/style.py | 39 ++ reeln/core/config.py | 12 +- reeln/core/ffmpeg.py | 42 +- reeln/core/highlights.py | 6 + reeln/core/iterations.py | 10 +- reeln/core/plugin_registry.py | 70 +++ reeln/core/profiles.py | 4 +- reeln/core/prompts.py | 17 +- reeln/core/shorts.py | 15 +- reeln/core/zoom.py | 5 +- reeln/models/plugin.py | 26 +- reeln/models/plugin_input.py | 252 ++++++++++ reeln/models/profile.py | 12 +- reeln/plugins/__init__.py | 7 + reeln/plugins/inputs.py | 304 ++++++++++++ reeln/plugins/loader.py | 39 ++ tests/unit/commands/test_cli.py | 39 +- tests/unit/commands/test_game.py | 392 ++++++++++++++- tests/unit/commands/test_hooks_cmd.py | 56 ++- tests/unit/commands/test_plugins_cmd.py | 256 +++++++++- tests/unit/commands/test_render.py | 35 ++ tests/unit/core/test_config.py | 36 +- tests/unit/core/test_iterations.py | 32 +- tests/unit/core/test_plugin_registry.py | 127 +++++ tests/unit/core/test_prompts.py | 76 +++ tests/unit/core/test_shorts.py | 4 +- tests/unit/models/test_plugin.py | 83 ++++ tests/unit/models/test_plugin_input.py | 430 ++++++++++++++++ tests/unit/plugins/test_init.py | 5 + tests/unit/plugins/test_inputs.py | 635 ++++++++++++++++++++++++ tests/unit/plugins/test_loader.py | 208 ++++++++ 38 files changed, 3763 insertions(+), 222 deletions(-) create mode 100644 reeln/commands/style.py create mode 100644 reeln/models/plugin_input.py create mode 100644 reeln/plugins/inputs.py create mode 100644 tests/unit/models/test_plugin_input.py create mode 100644 tests/unit/plugins/test_inputs.py diff --git a/.claude/rules/plugin-development.md b/.claude/rules/plugin-development.md index d52168a..ce45628 100644 --- a/.claude/rules/plugin-development.md +++ b/.claude/rules/plugin-development.md @@ -264,4 +264,88 @@ def on_game_init(self, context: HookContext) -> None: - **Tests:** use `tmp_path` for file I/O, mock external API clients - **Package naming:** `reeln-plugin-` (PyPI), `reeln__plugin` (Python package) - **Entry point:** `reeln.plugins` group, plugin name as key -- **No CLI arg registration:** plugins do not register CLI arguments. Use feature flags in plugin config (`smart_zoom_enabled`, etc.). Core CLI flags (`--smart`) trigger hooks; plugins decide behavior via their own config +- **Input contributions:** plugins declare additional user inputs via `input_schema: PluginInputSchema` class attribute. These are prompted interactively or passed via `--plugin-input KEY=VALUE`. See "Plugin Input Contributions" section below +- **No direct CLI arg registration:** plugins do not register CLI arguments directly. Use `input_schema` for user inputs and feature flags in plugin config for capability toggles. Core CLI flags (`--smart`) trigger hooks; plugins decide behavior via their own config + +## Plugin Input Contributions + +Plugins declare additional user inputs via `input_schema`, a class attribute of type `PluginInputSchema`. Each `InputField` is scoped to a CLI command and collected automatically. + +### Declaration (plugin class) + +```python +from reeln.models.plugin_input import InputField, PluginInputSchema + +class GooglePlugin: + input_schema = PluginInputSchema(fields=( + InputField( + id="thumbnail_image", + label="Thumbnail Image", + field_type="file", + command="game_init", + plugin_name="google", + required=False, + description="Thumbnail image for YouTube livestream", + ), + )) +``` + +### Declaration (registry JSON) + +For reeln-dock to render UI fields without loading Python code: + +```json +{ + "ui_contributions": { + "input_fields": { + "game_init": [ + { + "id": "thumbnail_image", + "label": "Thumbnail Image", + "type": "file", + "required": false, + "description": "Thumbnail image for YouTube livestream" + } + ] + } + } +} +``` + +### How inputs are collected + +- **Interactive mode:** prompted via questionary after core prompts +- **Non-interactive mode:** passed via `--plugin-input KEY=VALUE` (repeatable, aliased `-I`) +- **reeln-dock:** rendered as form fields based on registry `input_fields` + +### How plugins read collected inputs + +Inputs are passed via `HookContext.data["plugin_inputs"]`: + +```python +def on_game_init(self, context: HookContext) -> None: + inputs = context.data.get("plugin_inputs", {}) + thumbnail = inputs.get("thumbnail_image") +``` + +### Valid command scopes + +`game_init`, `game_finish`, `game_segment`, `render_short`, `render_preview` + +### Valid field types + +`str`, `int`, `float`, `bool`, `file`, `select` + +### Conflict resolution + +When two plugins declare the same `id` for the same command: +- **Same type:** first-registered wins (plugin load order), duplicate logged +- **Different type:** second field namespaced as `{plugin_name}.{id}`, warning logged + +### Introspection + +```bash +reeln plugins inputs # all registered fields +reeln plugins inputs --command game_init # filter by command +reeln plugins inputs --json # JSON output for tooling +``` diff --git a/CHANGELOG.md b/CHANGELOG.md index acac079..aa44d01 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,33 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/), and this project adheres to [Semantic Versioning](https://semver.org/). +## [0.0.37] - 2026-04-03 + +### Added +- `reeln game list` — list games in output directory with status badges (finished/in progress) +- `reeln game info` — show detailed game information (teams, venue, progress, livestreams) +- `reeln game delete` — delete a game directory with confirmation prompt (`--force` to skip) +- Colored CLI output: `--version`, `plugins list`, `plugins search`, `plugins info`, `plugins inputs`, `game list`, `game info`, `game delete` all use consistent styled output (bold names, green/red/yellow badges, dim labels) +- Shared `reeln.commands.style` module for consistent CLI formatting +- Plugin Input Contributions: plugins declare additional user inputs via `input_schema` class attribute (`PluginInputSchema`, `InputField`) +- `--plugin-input KEY=VALUE` (`-I`) repeatable option on `game init`, `render short`, and `render preview` +- Interactive prompts for plugin-contributed inputs (questionary-based, preset-first pattern) +- Conditional prompting: `thumbnail` and `tournament` only prompted when a plugin declares them +- `InputCollector` with conflict resolution (same-type dedup, cross-type namespacing) +- `get_input_schema()` method support: plugins can conditionally declare inputs based on feature flags (e.g., only prompt for thumbnail when `create_livestream` is enabled) +- Registry fallback: plugins without `input_schema` / `get_input_schema()` get inputs from `ui_contributions.input_fields` in registry JSON +- `reeln plugins inputs` introspection command (text + JSON output for reeln-dock) +- `input_contributions` field on `RegistryEntry` model, parsed from `ui_contributions.input_fields` +- Google plugin registry entry updated with `thumbnail_image` input field for `game_init` + +### Changed +- `init_game()` accepts `plugin_inputs` kwarg, included in `ON_GAME_INIT` / `ON_GAME_READY` hook data +- `PRE_RENDER` / `POST_RENDER` hook data includes `plugin_inputs` when present +- `activate_plugins()` now registers plugin input schemas with the `InputCollector` singleton +- `game init` checks for unfinished games **before** interactive prompts (fail fast instead of prompting then failing) +- `_resolve_game_dir` now sorts by `created_at` from `game.json` instead of filesystem mtime (which is unreliable due to Spotlight/Time Machine) +- `_resolve_game_dir` prefers unfinished games over finished ones, so `reeln game finish` finds the right game + ## [0.0.36] - 2026-04-02 ### Fixed diff --git a/reeln/__init__.py b/reeln/__init__.py index 348a069..8ecf366 100644 --- a/reeln/__init__.py +++ b/reeln/__init__.py @@ -2,4 +2,4 @@ from __future__ import annotations -__version__ = "0.0.36" +__version__ = "0.0.37" diff --git a/reeln/cli.py b/reeln/cli.py index b95f35d..c9ff1ec 100644 --- a/reeln/cli.py +++ b/reeln/cli.py @@ -26,45 +26,41 @@ app.add_typer(hooks_cmd.app, name="hooks") -def _build_version_lines() -> list[str]: - """Build version output lines: reeln version, ffmpeg info, plugins.""" - lines: list[str] = [f"reeln {__version__}"] - - # FFmpeg info - try: - from reeln.core.ffmpeg import check_version, discover_ffmpeg - - ffmpeg_path = discover_ffmpeg() - version = check_version(ffmpeg_path) - lines.append(f"ffmpeg {version} ({ffmpeg_path})") - except Exception: - lines.append("ffmpeg: not found") - - # Plugin versions - try: - from reeln.core.plugin_registry import get_installed_version - from reeln.plugins.loader import discover_plugins - - plugins = discover_plugins() - plugin_lines: list[str] = [] - for p in plugins: - if p.package: - ver = get_installed_version(p.package) - if ver: - plugin_lines.append(f" {p.name} {ver}") - if plugin_lines: - lines.append("plugins:") - lines.extend(plugin_lines) - except Exception: - pass - - return lines - - def _version_callback(value: bool) -> None: if value: - for line in _build_version_lines(): - typer.echo(line) + from reeln.commands.style import bold, error, label, success + + typer.echo(f" {bold('reeln')} {success(__version__)}") + + # FFmpeg info + try: + from reeln.core.ffmpeg import check_version, discover_ffmpeg + + ffmpeg_path = discover_ffmpeg() + version = check_version(ffmpeg_path) + typer.echo(f" {bold('ffmpeg')} {version} {label(str(ffmpeg_path))}") + except Exception: + typer.echo(f" {bold('ffmpeg')} {error('not found')}") + + # Plugin versions + try: + from reeln.core.plugin_registry import get_installed_version + from reeln.plugins.loader import discover_plugins + + plugins = discover_plugins() + plugin_lines: list[str] = [] + for p in plugins: + if p.package: + ver = get_installed_version(p.package) + if ver: + plugin_lines.append(f" {p.name} {label(ver)}") + if plugin_lines: + typer.echo(f" {bold('plugins:')}") + for pl in plugin_lines: + typer.echo(pl) + except Exception: + pass + raise typer.Exit() diff --git a/reeln/commands/game.py b/reeln/commands/game.py index 3c2a3c0..cdd17a8 100644 --- a/reeln/commands/game.py +++ b/reeln/commands/game.py @@ -1,17 +1,19 @@ -"""Game command group: init, segment, highlights, finish, compile, event.""" +"""Game command group: init, segment, highlights, finish, compile, event, list, info, delete.""" from __future__ import annotations +import shutil from datetime import date from pathlib import Path import typer from reeln.commands import event +from reeln.commands.style import bold, error, label, success, warn from reeln.core.config import load_config from reeln.core.errors import PromptAborted, ReelnError from reeln.core.ffmpeg import discover_ffmpeg -from reeln.core.highlights import init_game, merge_game_highlights, process_segment +from reeln.core.highlights import find_unfinished_games, init_game, merge_game_highlights, process_segment from reeln.core.prompts import collect_game_info_interactive from reeln.core.teams import slugify from reeln.models.game import GameInfo @@ -30,12 +32,45 @@ def _resolve_output_dir(output_dir: Path | None, config_output_dir: Path | None) return Path.cwd() +def _read_game_state_raw(game_dir: Path) -> dict[str, object]: + """Read and parse ``game.json`` from *game_dir*, returning ``{}`` on failure.""" + import json + + state_file = game_dir / "game.json" + try: + return dict(json.loads(state_file.read_text(encoding="utf-8"))) + except (json.JSONDecodeError, OSError): + return {} + + +def _get_sub_dict(state: dict[str, object], key: str) -> dict[str, object]: + """Safely extract a nested dict from *state*, returning ``{}`` on type mismatch.""" + val = state.get(key) + return dict(val) if isinstance(val, dict) else {} + + +def _get_sub_list(state: dict[str, object], key: str) -> list[object]: + """Safely extract a nested list from *state*, returning ``[]`` on type mismatch.""" + val = state.get(key) + return list(val) if isinstance(val, list) else [] + + +def _game_created_at(state: dict[str, object]) -> str: + """Extract ``created_at`` from game state, returning ``""`` on failure.""" + return str(state.get("created_at", "")) + + def _resolve_game_dir(output_dir: Path | None, config_output_dir: Path | None) -> Path: """Resolve game directory for segment/highlights commands. If the resolved directory contains ``game.json``, use it directly. - Otherwise, search subdirectories for the most recently modified - ``game.json`` and use its parent. + Otherwise, search subdirectories for the best candidate: + + 1. Prefer **unfinished** games (most likely what the user wants to + work on). + 2. Within each group (unfinished / finished), sort by the + ``created_at`` timestamp inside ``game.json`` — not filesystem + mtime, which is unreliable (Spotlight, Time Machine, etc.). """ base = _resolve_output_dir(output_dir, config_output_dir) if (base / "game.json").is_file(): @@ -50,7 +85,13 @@ def _resolve_game_dir(output_dir: Path | None, config_output_dir: Path | None) - err=True, ) raise typer.Exit(code=1) - return max(candidates, key=lambda p: (p / "game.json").stat().st_mtime) + + # Read state once per candidate + states = {c: _read_game_state_raw(c) for c in candidates} + unfinished = [c for c in candidates if not states[c].get("finished", False)] + pool = unfinished if unfinished else candidates + + return max(pool, key=lambda p: _game_created_at(states[p])) def _apply_profile_post( @@ -145,6 +186,7 @@ def init( profile: str | None = typer.Option(None, "--profile", help="Named config profile."), config_path: Path | None = typer.Option(None, "--config", help="Explicit config file path."), dry_run: bool = typer.Option(False, "--dry-run", help="Preview without creating."), + plugin_input: list[str] = typer.Option([], "--plugin-input", "-I", help="Plugin input as KEY=VALUE (repeatable)."), ) -> None: """Initialize a new game workspace.""" try: @@ -157,6 +199,17 @@ def init( base = _resolve_output_dir(output_dir, config.paths.output_dir) + # Fail fast before prompting if unfinished games exist + unfinished = find_unfinished_games(base) + if unfinished: + names = ", ".join(d.name for d in unfinished) + typer.echo( + f"Error: Unfinished game(s) found: {names}. " + "Run 'reeln game finish' before starting a new game.", + err=True, + ) + raise typer.Exit(code=1) + home_profile = None away_profile = None @@ -240,8 +293,33 @@ def init( away_slug=away_slug, ) + # Collect plugin-contributed inputs + from reeln.plugins.inputs import get_input_collector + + collector = get_input_collector() + presets = collector.collect_noninteractive("game_init", plugin_input) + + # Bridge well-known CLI args to plugin input presets + if thumbnail and "thumbnail_image" not in presets: + presets["thumbnail_image"] = thumbnail + if tournament and "tournament" not in presets: + presets["tournament"] = tournament + + if home is None or away is None: + # Interactive — prompt for remaining plugin inputs + collected_inputs = collector.collect_interactive("game_init", presets=presets) + else: + collected_inputs = presets + try: - _, messages = init_game(base, game_info, dry_run=dry_run, home_profile=home_profile, away_profile=away_profile) + _, messages = init_game( + base, + game_info, + dry_run=dry_run, + home_profile=home_profile, + away_profile=away_profile, + plugin_inputs=collected_inputs if collected_inputs else None, + ) except ReelnError as exc: typer.echo(f"Error: {exc}", err=True) raise typer.Exit(code=1) from exc @@ -506,3 +584,162 @@ def prune( for msg in messages: typer.echo(msg) + + +# --------------------------------------------------------------------------- +# game list +# --------------------------------------------------------------------------- + + +@app.command(name="list") +def list_games( + output_dir: Path | None = typer.Option(None, "--output-dir", "-o", help="Base output directory."), + profile: str | None = typer.Option(None, "--profile", help="Named config profile."), + config_path: Path | None = typer.Option(None, "--config", help="Explicit config file path."), +) -> None: + """List games in the output directory.""" + try: + config = load_config(path=config_path, profile=profile) + except ReelnError as exc: + typer.echo(f"Error: {exc}", err=True) + raise typer.Exit(code=1) from exc + + base = _resolve_output_dir(output_dir, config.paths.output_dir) + if not base.is_dir(): + typer.echo("No games found.") + return + + candidates = sorted( + (d for d in base.iterdir() if d.is_dir() and (d / "game.json").is_file()), + key=lambda p: p.name, + ) + if not candidates: + typer.echo("No games found.") + return + + for game_dir in candidates: + state_raw = _read_game_state_raw(game_dir) + gi = _get_sub_dict(state_raw, "game_info") + + home = str(gi.get("home_team", "?")) + away = str(gi.get("away_team", "?")) + sport = str(gi.get("sport", "")) + game_date = str(gi.get("date", "")) + finished = state_raw.get("finished", False) + + status = success("finished") if finished else warn("in progress") + sport_tag = f" {label(sport)}" if sport else "" + + typer.echo(f" {bold(f'{home} vs {away}')} {game_date} {status}{sport_tag}") + typer.echo(f" {label(str(game_dir))}") + + +# --------------------------------------------------------------------------- +# game info +# --------------------------------------------------------------------------- + + +@app.command() +def info( + output_dir: Path | None = typer.Option(None, "--output-dir", "-o", help="Game directory."), + profile: str | None = typer.Option(None, "--profile", help="Named config profile."), + config_path: Path | None = typer.Option(None, "--config", help="Explicit config file path."), +) -> None: + """Show detailed information about a game.""" + try: + config = load_config(path=config_path, profile=profile) + except ReelnError as exc: + typer.echo(f"Error: {exc}", err=True) + raise typer.Exit(code=1) from exc + + game_dir = _resolve_game_dir(output_dir, config.paths.output_dir) + state_raw = _read_game_state_raw(game_dir) + gi = _get_sub_dict(state_raw, "game_info") + + home = gi.get("home_team", "?") + away = gi.get("away_team", "?") + finished = state_raw.get("finished", False) + status = success("finished") if finished else warn("in progress") + + typer.echo(f"\n {bold(f'{home} vs {away}')} {status}") + if gi.get("description"): + typer.echo(f" {gi['description']}\n") + else: + typer.echo() + + typer.echo(f" {label('Date:')} {gi.get('date', 'N/A')}") + typer.echo(f" {label('Sport:')} {gi.get('sport', 'N/A')}") + if gi.get("venue"): + typer.echo(f" {label('Venue:')} {gi['venue']}") + if gi.get("game_time"): + typer.echo(f" {label('Game time:')} {gi['game_time']}") + if gi.get("level"): + typer.echo(f" {label('Level:')} {gi['level']}") + if gi.get("tournament"): + typer.echo(f" {label('Tournament:')} {gi['tournament']}") + typer.echo(f" {label('Directory:')} {game_dir}") + + # Progress + segments = _get_sub_list(state_raw, "segments_processed") + events = _get_sub_list(state_raw, "events") + renders = _get_sub_list(state_raw, "renders") + livestreams = _get_sub_dict(state_raw, "livestreams") + + typer.echo(f"\n {label('Segments:')} {len(segments)} processed") + typer.echo(f" {label('Events:')} {len(events)}") + typer.echo(f" {label('Renders:')} {len(renders)}") + + if livestreams: + typer.echo(f" {label('Livestreams:')}") + for platform, url in livestreams.items(): + typer.echo(f" {platform}: {url}") + + created = state_raw.get("created_at", "") + finished_at = state_raw.get("finished_at", "") + if created: + typer.echo(f"\n {label('Created:')} {created}") + if finished_at: + typer.echo(f" {label('Finished:')} {finished_at}") + typer.echo() + + +# --------------------------------------------------------------------------- +# game delete +# --------------------------------------------------------------------------- + + +@app.command() +def delete( + output_dir: Path | None = typer.Option(None, "--output-dir", "-o", help="Game directory."), + force: bool = typer.Option(False, "--force", "-f", help="Skip confirmation prompt."), + profile: str | None = typer.Option(None, "--profile", help="Named config profile."), + config_path: Path | None = typer.Option(None, "--config", help="Explicit config file path."), +) -> None: + """Delete a game directory and all its contents.""" + try: + config = load_config(path=config_path, profile=profile) + except ReelnError as exc: + typer.echo(f"Error: {exc}", err=True) + raise typer.Exit(code=1) from exc + + game_dir = _resolve_game_dir(output_dir, config.paths.output_dir) + state_raw = _read_game_state_raw(game_dir) + gi = _get_sub_dict(state_raw, "game_info") + + home = gi.get("home_team", "?") + away = gi.get("away_team", "?") + game_date = gi.get("date", "") + + typer.echo(f"\n {bold(f'{home} vs {away}')} {game_date}") + typer.echo(f" {label(str(game_dir))}\n") + + if not force: + confirm = typer.confirm( + typer.style(" Delete this game and all its contents?", fg=typer.colors.RED, bold=True), + ) + if not confirm: + typer.echo("Cancelled.") + raise typer.Exit() + + shutil.rmtree(game_dir) + typer.echo(f"Deleted {game_dir.name}") diff --git a/reeln/commands/plugins_cmd.py b/reeln/commands/plugins_cmd.py index 3f6f6b4..aad7e85 100644 --- a/reeln/commands/plugins_cmd.py +++ b/reeln/commands/plugins_cmd.py @@ -1,4 +1,4 @@ -"""Plugin management commands: list, enable, disable, search, info, install, update.""" +"""Plugin management commands: list, enable, disable, search, info, install, update, inputs.""" from __future__ import annotations @@ -11,16 +11,48 @@ fetch_registry, get_installed_version, install_plugin, + uninstall_plugin, update_all_plugins, update_plugin, ) +from reeln.models.plugin import PluginStatus from reeln.plugins.loader import ( discover_plugins, ) +from reeln.commands.style import bold, error, label, success, warn + app = typer.Typer(no_args_is_help=True, help="Plugin management commands.") +# --------------------------------------------------------------------------- +# Formatting helpers +# --------------------------------------------------------------------------- + + +def _status_badge(enabled: bool, installed: bool) -> str: + """Colored status badge.""" + if not installed: + return label("not installed") + if enabled: + return success("enabled") + return error("disabled") + + +def _version_str(status: PluginStatus) -> str: + """Format version with optional upgrade indicator.""" + if not status.installed or not status.installed_version: + return "" + if status.update_available and status.available_version: + return f"{status.installed_version} {warn(f'-> {status.available_version}')}" + return status.installed_version + + +# --------------------------------------------------------------------------- +# plugins list +# --------------------------------------------------------------------------- + + @app.command(name="list") def list_plugins( refresh: bool = typer.Option(False, "--refresh", help="Force registry refresh."), @@ -35,33 +67,25 @@ def list_plugins( entries = [] statuses = build_plugin_status(entries, plugins, config.plugins.enabled, config.plugins.disabled) + installed = [st for st in statuses if st.installed] - if not statuses: - typer.echo("No plugins installed or available.") + if not installed: + typer.echo("No plugins installed.") return - for st in statuses: - # Name - parts: list[str] = [f" {st.name}"] + for st in installed: + badge = _status_badge(st.enabled, st.installed) + ver = _version_str(st) + ver_part = f" {ver}" if ver else "" - # Version info - if st.installed and st.installed_version: - if st.update_available and st.available_version: - parts.append(f"{st.installed_version} -> {st.available_version}") - else: - parts.append(st.installed_version) - elif not st.installed: - parts.append("not installed") + typer.echo(f" {bold(st.name)} {badge}{ver_part}") + if st.description: + typer.echo(f" {label(st.description)}") - # Status - if st.installed: - parts.append("enabled" if st.enabled else "disabled") - # Capabilities - if st.capabilities: - parts.append(f"[{', '.join(st.capabilities)}]") - - typer.echo(" ".join(parts)) +# --------------------------------------------------------------------------- +# plugins search +# --------------------------------------------------------------------------- @app.command() @@ -94,8 +118,16 @@ def search( return for entry in matches: - status = "installed" if entry.name in installed_names else "available" - typer.echo(f" {entry.name} {status} {entry.description}") + installed = entry.name in installed_names + badge = success("installed") if installed else label("available") + typer.echo(f" {bold(entry.name)} {badge}") + if entry.description: + typer.echo(f" {label(entry.description)}") + + +# --------------------------------------------------------------------------- +# plugins info +# --------------------------------------------------------------------------- @app.command() @@ -124,29 +156,34 @@ def info( installed_version = get_installed_version(entry.package) if entry.package else "" is_installed = bool(installed_version) - typer.echo(f"Name: {entry.name}") - typer.echo(f"Package: {entry.package}") - typer.echo(f"Description: {entry.description}") - typer.echo(f"Capabilities: {', '.join(entry.capabilities) if entry.capabilities else 'none'}") - typer.echo(f"Homepage: {entry.homepage or 'N/A'}") - typer.echo(f"Author: {entry.author or 'N/A'}") - typer.echo(f"License: {entry.license or 'N/A'}") - typer.echo(f"Installed: {'yes' if is_installed else 'no'}") + typer.echo(f"\n {bold(entry.name)} {_status_badge(True, is_installed)}") + typer.echo(f" {entry.description}\n") + + typer.echo(f" {label('Package:')} {entry.package}") if is_installed: - typer.echo(f"Version: {installed_version}") + typer.echo(f" {label('Version:')} {installed_version}") + typer.echo(f" {label('Author:')} {entry.author or 'N/A'}") + typer.echo(f" {label('License:')} {entry.license or 'N/A'}") + if entry.homepage: + typer.echo(f" {label('Homepage:')} {entry.homepage}") from reeln.core.plugin_config import extract_schema_by_name schema = extract_schema_by_name(name) - if schema is not None: - typer.echo("Config schema:") + if schema is not None and schema.fields: + typer.echo(f"\n {label('Settings:')}") for f in schema.fields: - req = " (required)" if f.required else "" - default = f" [default: {f.default}]" if f.default is not None else "" - desc = f" — {f.description}" if f.description else "" - typer.echo(f" {f.name}: {f.field_type}{req}{default}{desc}") - else: - typer.echo("Config schema: none declared") + req = warn(" (required)") if f.required else "" + default = f" [{f.default}]" if f.default is not None else "" + typer.echo(f" {f.name}: {f.field_type}{req}{default}") + if f.description: + typer.echo(f" {label(f.description)}") + typer.echo() + + +# --------------------------------------------------------------------------- +# plugins install +# --------------------------------------------------------------------------- @app.command() @@ -190,6 +227,11 @@ def install( raise typer.Exit(1) +# --------------------------------------------------------------------------- +# plugins update +# --------------------------------------------------------------------------- + + @app.command() def update( name: str = typer.Argument("", help="Plugin to update (empty = all)."), @@ -237,6 +279,11 @@ def update( typer.echo(f"Failed to update '{r.package}': {r.error}", err=True) +# --------------------------------------------------------------------------- +# plugins enable / disable +# --------------------------------------------------------------------------- + + @app.command() def enable(name: str = typer.Argument(..., help="Plugin name to enable.")) -> None: """Enable a plugin.""" @@ -269,3 +316,120 @@ def disable(name: str = typer.Argument(..., help="Plugin name to disable.")) -> save_config(config) typer.echo(f"Plugin '{name}' disabled.") + + +@app.command() +def uninstall( + name: str = typer.Argument(..., help="Plugin name to uninstall."), + dry_run: bool = typer.Option(False, "--dry-run", help="Preview without uninstalling."), + force: bool = typer.Option(False, "--force", "-f", help="Skip confirmation prompt."), + installer: str = typer.Option("", "--installer", help="Force installer (pip, uv)."), +) -> None: + """Uninstall a plugin and remove it from config.""" + config = load_config() + + installed_version = "" + try: + entries = fetch_registry(config.plugins.registry_url) + except RegistryError as exc: + typer.echo(f"Error: {exc}", err=True) + raise typer.Exit(1) from exc + + # Find package name from registry + entry = None + for e in entries: + if e.name == name: + entry = e + break + + if entry is not None and entry.package: + installed_version = get_installed_version(entry.package) + + if not installed_version: + typer.echo(f"Plugin '{name}' is not installed.") + raise typer.Exit(1) + + if not force and not dry_run: + confirm = typer.confirm(f"Uninstall plugin '{name}' ({installed_version})?") + if not confirm: + typer.echo("Cancelled.") + raise typer.Exit() + + try: + result = uninstall_plugin(name, entries, dry_run=dry_run, installer=installer) + except RegistryError as exc: + typer.echo(f"Error: {exc}", err=True) + raise typer.Exit(1) from exc + + if result.success: + typer.echo(result.output if dry_run else f"Plugin '{name}' uninstalled.") + if not dry_run: + if name in config.plugins.enabled: + config.plugins.enabled.remove(name) + if name not in config.plugins.disabled: + config.plugins.disabled.append(name) + save_config(config) + else: + typer.echo(f"Failed to uninstall '{name}': {result.error}", err=True) + raise typer.Exit(1) + + +# --------------------------------------------------------------------------- +# plugins inputs +# --------------------------------------------------------------------------- + + +@app.command() +def inputs( + command: str = typer.Option("", "--command", "-c", help="Filter by command scope (e.g. game_init)."), + json_output: bool = typer.Option(False, "--json", help="Output as JSON."), +) -> None: + """Show plugin-contributed input fields for CLI commands.""" + import json + + from reeln.models.plugin_input import input_field_to_dict + from reeln.plugins.inputs import get_input_collector + from reeln.plugins.loader import activate_plugins + + config = load_config() + activate_plugins(config.plugins) + + collector = get_input_collector() + + if command: + commands = [command] + else: + from reeln.models.plugin_input import InputCommand + + commands = sorted(InputCommand._ALL) + + all_fields: list[dict[str, object]] = [] + for cmd in commands: + fields = collector.fields_for_command(cmd) + for f in fields: + all_fields.append(input_field_to_dict(f)) + + if json_output: + typer.echo(json.dumps({"fields": all_fields}, indent=2)) + return + + if not all_fields: + typer.echo("No plugin input contributions registered.") + return + + # Group by command for readability + by_command: dict[str, list[dict[str, object]]] = {} + for f_dict in all_fields: + cmd_name = str(f_dict.get("command", "")) + by_command.setdefault(cmd_name, []).append(f_dict) + + for cmd_name, cmd_fields in sorted(by_command.items()): + typer.echo(f"\n {typer.style(cmd_name, bold=True)}") + for f_dict in cmd_fields: + req = warn(" (required)") if f_dict.get("required") else "" + plugin = f_dict.get("plugin_name", "") + plugin_str = f" {label(f'[{plugin}]')}" if plugin else "" + typer.echo(f" {f_dict['id']} {label(str(f_dict.get('type', '')))}{req}{plugin_str}") + if f_dict.get("description"): + typer.echo(f" {label(str(f_dict['description']))}") + typer.echo() diff --git a/reeln/commands/render.py b/reeln/commands/render.py index 2f65466..8ac7e97 100644 --- a/reeln/commands/render.py +++ b/reeln/commands/render.py @@ -264,6 +264,7 @@ def _do_short( player_numbers: str | None = None, event_type: str | None = None, no_branding: bool = False, + plugin_input: list[str] | None = None, ) -> None: """Shared implementation for short and preview commands.""" from reeln.core.ffmpeg import discover_ffmpeg @@ -278,6 +279,13 @@ def _do_short( activate_plugins(config.plugins) + # Collect plugin-contributed inputs + from reeln.plugins.inputs import get_input_collector as _get_input_collector + + _input_collector = _get_input_collector() + _render_command = "render_preview" if is_preview else "render_short" + _plugin_inputs = _input_collector.collect_noninteractive(_render_command, plugin_input or []) + # Resolve --player-numbers before anything else _scoring_team_name: str | None = None if player_numbers is not None: @@ -501,9 +509,7 @@ def _do_short( if isinstance(smart_zoom_data, dict): zoom_error = smart_zoom_data.get("error") if zoom_error is not None: - raise RenderError( - f"Smart zoom analysis failed after retries: {zoom_error}" - ) + raise RenderError(f"Smart zoom analysis failed after retries: {zoom_error}") zoom_path = smart_zoom_data.get("zoom_path") debug_from_plugin = smart_zoom_data.get("debug") if isinstance(debug_from_plugin, dict): @@ -569,8 +575,7 @@ def _do_short( profile_list = profiles_for_event(config, render_game_event) if profile_list: iter_ctx: TemplateContext | None = ( - build_base_context(render_game_info, render_game_event) - if render_game_info else None + build_base_context(render_game_info, render_game_event) if render_game_info else None ) if player is not None and iter_ctx is not None: iter_ctx = TemplateContext(variables={**iter_ctx.variables, "player": player}) @@ -663,9 +668,12 @@ def _do_short( from reeln.plugins.hooks import HookContext as _RHookCtx from reeln.plugins.registry import get_registry as _get_reg + _pre_data: dict[str, Any] = {"plan": plan} + if _plugin_inputs: + _pre_data["plugin_inputs"] = _plugin_inputs _get_reg().emit( _RHook.PRE_RENDER, - _RHookCtx(hook=_RHook.PRE_RENDER, data={"plan": plan}), + _RHookCtx(hook=_RHook.PRE_RENDER, data=_pre_data), ) try: ffmpeg_path = discover_ffmpeg() @@ -684,6 +692,8 @@ def _do_short( _post_data["player"] = player if assists is not None: _post_data["assists"] = assists + if _plugin_inputs: + _post_data["plugin_inputs"] = _plugin_inputs _get_reg().emit( _RHook.POST_RENDER, _RHookCtx(hook=_RHook.POST_RENDER, data=_post_data), @@ -806,6 +816,7 @@ def short( debug_flag: bool = typer.Option(False, "--debug", help="Write debug artifacts to game debug directory."), no_branding: bool = typer.Option(False, "--no-branding", help="Disable branding overlay."), dry_run: bool = typer.Option(False, "--dry-run", help="Show plan without executing."), + plugin_input: list[str] = typer.Option([], "--plugin-input", "-I", help="Plugin input as KEY=VALUE (repeatable)."), ) -> None: """Render a 9:16 short from a clip.""" _do_short( @@ -836,6 +847,7 @@ def short( player_numbers=player_numbers_str, event_type=event_type, no_branding=no_branding, + plugin_input=plugin_input, ) @@ -877,6 +889,7 @@ def preview( debug_flag: bool = typer.Option(False, "--debug", help="Write debug artifacts to game debug directory."), no_branding: bool = typer.Option(False, "--no-branding", help="Disable branding overlay."), dry_run: bool = typer.Option(False, "--dry-run", help="Show plan without executing."), + plugin_input: list[str] = typer.Option([], "--plugin-input", "-I", help="Plugin input as KEY=VALUE (repeatable)."), ) -> None: """Fast low-res preview render.""" _do_short( @@ -906,6 +919,7 @@ def preview( player_numbers=player_numbers_str, event_type=event_type, no_branding=no_branding, + plugin_input=plugin_input, ) @@ -1113,10 +1127,7 @@ def apply_profile( rendered_subtitle: Path | None = None try: if rp.subtitle_template is not None: - ctx = ( - build_base_context(apply_game_info, apply_game_event) - if apply_game_info else TemplateContext() - ) + ctx = build_base_context(apply_game_info, apply_game_event) if apply_game_info else TemplateContext() if player_name is not None: ctx = TemplateContext(variables={**ctx.variables, "player": player_name}) diff --git a/reeln/commands/style.py b/reeln/commands/style.py new file mode 100644 index 0000000..c8c6538 --- /dev/null +++ b/reeln/commands/style.py @@ -0,0 +1,39 @@ +"""Shared CLI styling helpers for consistent output formatting.""" + +from __future__ import annotations + +import typer + +GREEN = typer.colors.GREEN +RED = typer.colors.RED +YELLOW = typer.colors.YELLOW +DIM = typer.colors.BRIGHT_BLACK + + +def label(text: str) -> str: + """Dim label text for secondary information.""" + return typer.style(text, fg=DIM) + + +def bold(text: str) -> str: + """Bold text for names and headings.""" + return typer.style(text, bold=True) + + +def success(text: str) -> str: + """Green text for positive status.""" + return typer.style(text, fg=GREEN) + + +def error(text: str) -> str: + """Red text for negative status.""" + return typer.style(text, fg=RED) + + +def warn(text: str) -> str: + """Yellow text for warnings and upgrades.""" + return typer.style(text, fg=YELLOW) + + + + diff --git a/reeln/core/config.py b/reeln/core/config.py index 58a8981..8bde22d 100644 --- a/reeln/core/config.py +++ b/reeln/core/config.py @@ -135,9 +135,7 @@ def config_to_dict(config: AppConfig, *, full: bool = False) -> dict[str, Any]: if full or config.event_types: d["event_types"] = [ - {"name": et.name, "team_specific": et.team_specific} - if et.team_specific - else et.name + {"name": et.name, "team_specific": et.team_specific} if et.team_specific else et.name for et in config.event_types ] @@ -157,9 +155,7 @@ def config_to_dict(config: AppConfig, *, full: bool = False) -> dict[str, Any]: } has_branding = ( - not config.branding.enabled - or config.branding.template != "builtin:branding" - or config.branding.duration != 5.0 + not config.branding.enabled or config.branding.template != "builtin:branding" or config.branding.duration != 5.0 ) if full or has_branding: d["branding"] = { @@ -421,9 +417,7 @@ def validate_config(data: dict[str, Any]) -> list[str]: if isinstance(mappings, dict): for key in mappings: if key != "default" and key not in type_set: - issues.append( - f"iterations references type '{key}' not listed in event_types" - ) + issues.append(f"iterations references type '{key}' not listed in event_types") return issues diff --git a/reeln/core/ffmpeg.py b/reeln/core/ffmpeg.py index 2f1a37c..64accbf 100644 --- a/reeln/core/ffmpeg.py +++ b/reeln/core/ffmpeg.py @@ -388,9 +388,7 @@ def build_xfade_command( duration of the corresponding input in seconds. """ if len(files) != len(durations): - raise FFmpegError( - f"files and durations must have the same length: {len(files)} vs {len(durations)}" - ) + raise FFmpegError(f"files and durations must have the same length: {len(files)} vs {len(durations)}") if len(files) < 2: raise FFmpegError("xfade requires at least 2 input files") @@ -410,31 +408,36 @@ def build_xfade_command( for i in range(1, n): v_in = f"[{i - 1}:v]" if i == 1 else f"[xf{i - 2}]" v_out = f"[xf{i - 1}]" if i < n - 1 else "[vout]" - v_parts.append( - f"{v_in}[{i}:v]xfade=transition=fade:duration={fade}:offset={offset:.6f}{v_out}" - ) + v_parts.append(f"{v_in}[{i}:v]xfade=transition=fade:duration={fade}:offset={offset:.6f}{v_out}") a_in = f"[{i - 1}:a]" if i == 1 else f"[af{i - 2}]" a_out = f"[af{i - 1}]" if i < n - 1 else "[aout]" - a_parts.append( - f"{a_in}[{i}:a]acrossfade=d={fade}:c1=tri:c2=tri{a_out}" - ) + a_parts.append(f"{a_in}[{i}:a]acrossfade=d={fade}:c1=tri:c2=tri{a_out}") if i < n - 1: offset += durations[i] - fade filter_complex = ";".join(v_parts + a_parts) - cmd.extend([ - "-filter_complex", filter_complex, - "-map", "[vout]", - "-map", "[aout]", - "-c:v", video_codec, - "-crf", str(crf), - "-c:a", audio_codec, - "-ar", str(audio_rate), - str(output), - ]) + cmd.extend( + [ + "-filter_complex", + filter_complex, + "-map", + "[vout]", + "-map", + "[aout]", + "-c:v", + video_codec, + "-crf", + str(crf), + "-c:a", + audio_codec, + "-ar", + str(audio_rate), + str(output), + ] + ) return cmd @@ -513,7 +516,6 @@ def concat_files( concat_file.unlink(missing_ok=True) - def build_extract_frame_command( ffmpeg_path: Path, input_path: Path, diff --git a/reeln/core/highlights.py b/reeln/core/highlights.py index 11383f8..dbacd77 100644 --- a/reeln/core/highlights.py +++ b/reeln/core/highlights.py @@ -151,6 +151,7 @@ def init_game( dry_run: bool = False, home_profile: object | None = None, away_profile: object | None = None, + plugin_inputs: dict[str, Any] | None = None, ) -> tuple[Path, list[str]]: """Initialize a new game workspace. @@ -160,6 +161,9 @@ def init_game( When *home_profile* / *away_profile* are provided they are included in the ``ON_GAME_INIT`` hook context so plugins can access team metadata. + *plugin_inputs* is an optional dict of values collected from the user + via plugin input contributions, passed through to hook contexts. + In dry-run mode, no files or directories are created. """ # Block if unfinished games exist @@ -202,6 +206,8 @@ def init_game( hook_data["home_profile"] = home_profile if away_profile is not None: hook_data["away_profile"] = away_profile + if plugin_inputs: + hook_data["plugin_inputs"] = plugin_inputs ctx = HookContext(hook=Hook.ON_GAME_INIT, data=hook_data) get_registry().emit(Hook.ON_GAME_INIT, ctx) diff --git a/reeln/core/iterations.py b/reeln/core/iterations.py index c6b74c5..fb4a3e1 100644 --- a/reeln/core/iterations.py +++ b/reeln/core/iterations.py @@ -125,10 +125,13 @@ def render_iterations( iter_dur = source_dur if profile.speed_segments is not None: iter_dur = compute_speed_segments_duration( - profile.speed_segments, source_dur, + profile.speed_segments, + source_dur, ) ctx = build_overlay_context( - base_ctx, duration=iter_dur, event_metadata=event_metadata, + base_ctx, + duration=iter_dur, + event_metadata=event_metadata, ) # Resolve subtitle template @@ -158,7 +161,8 @@ def render_iterations( from reeln.core.zoom import remap_zoom_path_for_speed_segments iter_zoom = remap_zoom_path_for_speed_segments( - iter_zoom, modified.speed_segments, + iter_zoom, + modified.speed_segments, ) plan = plan_short(modified, zoom_path=iter_zoom, source_fps=source_fps) else: diff --git a/reeln/core/plugin_registry.py b/reeln/core/plugin_registry.py index a44aa2c..39bb6d1 100644 --- a/reeln/core/plugin_registry.py +++ b/reeln/core/plugin_registry.py @@ -462,3 +462,73 @@ def update_all_plugins( result = update_plugin(plugin.name, entries, dry_run=dry_run, installer=installer) results.append(result) return results + + +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] + return [sys.executable, "-m", "pip", "uninstall", "-y"] + + +def uninstall_plugin( + name: str, + entries: list[RegistryEntry], + *, + dry_run: bool = False, + installer: str = "", +) -> PipResult: + """Uninstall a plugin by name using the registry to resolve the package.""" + entry = _resolve_entry(name, entries) + + if installer == "uv": + cmd = ["uv", "pip", "uninstall"] + elif installer == "pip": + cmd = [sys.executable, "-m", "pip", "uninstall", "-y"] + else: + cmd = _detect_uninstaller() + + full_cmd = [*cmd, entry.package] + + if dry_run: + return PipResult( + success=True, + package=entry.package, + action="dry-run", + output=f"Would run: {' '.join(full_cmd)}", + ) + + try: + proc = subprocess.run( + full_cmd, + capture_output=True, + text=True, + timeout=120, + ) + if proc.returncode == 0: + return PipResult( + success=True, + package=entry.package, + action="uninstall", + output=proc.stdout, + ) + return PipResult( + success=False, + package=entry.package, + action="uninstall", + error=proc.stderr, + ) + except subprocess.TimeoutExpired: + return PipResult( + success=False, + package=entry.package, + action="uninstall", + error="Uninstall timed out", + ) + except Exception as exc: + return PipResult( + success=False, + package=entry.package, + action="uninstall", + error=str(exc), + ) diff --git a/reeln/core/profiles.py b/reeln/core/profiles.py index 94cc664..a3ac5f6 100644 --- a/reeln/core/profiles.py +++ b/reeln/core/profiles.py @@ -133,9 +133,7 @@ def build_profile_filter_chain( if has_segments: assert profile.speed_segments is not None validate_speed_segments(profile.speed_segments) - return _build_profile_speed_segments_chain( - profile, rendered_subtitle=rendered_subtitle - ) + return _build_profile_speed_segments_chain(profile, rendered_subtitle=rendered_subtitle) filters: list[str] = [] diff --git a/reeln/core/prompts.py b/reeln/core/prompts.py index ae851d1..685e98b 100644 --- a/reeln/core/prompts.py +++ b/reeln/core/prompts.py @@ -327,7 +327,20 @@ def collect_game_info_interactive( result["game_time"] = prompt_game_time(preset=game_time) result["period_length"] = prompt_period_length(preset=period_length) result["description"] = prompt_description(preset=description) - result["thumbnail"] = prompt_thumbnail(preset=thumbnail) - result["tournament"] = prompt_tournament(preset=tournament) + + # Thumbnail and tournament are only prompted when a plugin declares the + # corresponding input ID, or when a preset was provided via CLI arg. + from reeln.plugins.inputs import get_input_collector + + collector = get_input_collector() + if thumbnail is not None or collector.has_field("game_init", "thumbnail_image"): + result["thumbnail"] = prompt_thumbnail(preset=thumbnail) + else: + result["thumbnail"] = thumbnail or "" + + if tournament is not None or collector.has_field("game_init", "tournament"): + result["tournament"] = prompt_tournament(preset=tournament) + else: + result["tournament"] = tournament or "" return result diff --git a/reeln/core/shorts.py b/reeln/core/shorts.py index b32e366..fc76556 100644 --- a/reeln/core/shorts.py +++ b/reeln/core/shorts.py @@ -78,8 +78,7 @@ def validate_speed_segments(segments: tuple[SpeedSegment, ...]) -> None: assert seg.until is not None if seg.until <= prev_until: raise RenderError( - f"speed_segments[{i}]: 'until' values must be strictly increasing, " - f"got {seg.until} after {prev_until}" + f"speed_segments[{i}]: 'until' values must be strictly increasing, got {seg.until} after {prev_until}" ) prev_until = seg.until @@ -266,7 +265,7 @@ def build_speed_segments_filters( chain = [trim, "setpts=PTS-STARTPTS"] if seg.speed != 1.0: chain.append(f"setpts=PTS/{seg.speed}") - video_parts.append(f"{v_labels[i]}{',' .join(chain)}{sv_labels[i]}") + video_parts.append(f"{v_labels[i]}{','.join(chain)}{sv_labels[i]}") video_parts.append(f"{''.join(sv_labels)}concat=n={n}:v=1:a=0[_vout]") @@ -280,7 +279,7 @@ def build_speed_segments_filters( chain = [atrim, "asetpts=PTS-STARTPTS"] if seg.speed != 1.0: chain.append(_build_atempo_chain(seg.speed)) - audio_parts.append(f"{a_labels[i]}{',' .join(chain)}{sa_labels[i]}") + audio_parts.append(f"{a_labels[i]}{','.join(chain)}{sa_labels[i]}") audio_parts.append(f"{''.join(sa_labels)}concat=n={n}:v=0:a=1[_aout]") @@ -530,7 +529,8 @@ def _build_speed_segments_chain( # Wire post-filters (scale) after concat, label [_fg] video_graph = video_graph.replace( - "[_vout]", f"[_vout];[_vout]{','.join(post)}[_fg]", + "[_vout]", + f"[_vout];[_vout]{','.join(post)}[_fg]", ) # Colour background and overlay @@ -538,7 +538,10 @@ def _build_speed_segments_chain( fps_frac = _fps_to_fraction(source_fps) overlay_expr = build_smart_pad_filter( - zoom_path, config.width, config.height, config.pad_color, + zoom_path, + config.width, + config.height, + config.pad_color, ) color_part = f"color=c={config.pad_color}:s={config.width}x{config.height}:r={fps_frac}[_bg]" diff --git a/reeln/core/zoom.py b/reeln/core/zoom.py index 5462b82..922bb51 100644 --- a/reeln/core/zoom.py +++ b/reeln/core/zoom.py @@ -190,10 +190,7 @@ def _source_to_output(source_t: float) -> float: prev = end return output_t - remapped = tuple( - replace(p, timestamp=_source_to_output(p.timestamp)) - for p in zoom_path.points - ) + remapped = tuple(replace(p, timestamp=_source_to_output(p.timestamp)) for p in zoom_path.points) new_duration = _source_to_output(zoom_path.duration) return replace(zoom_path, points=remapped, duration=new_duration) diff --git a/reeln/models/plugin.py b/reeln/models/plugin.py index e83eec6..c5e0538 100644 --- a/reeln/models/plugin.py +++ b/reeln/models/plugin.py @@ -4,6 +4,7 @@ from dataclasses import dataclass, field from pathlib import Path +from typing import Any @dataclass(frozen=True) @@ -39,6 +40,7 @@ class RegistryEntry: min_reeln_version: str = "" author: str = "" license: str = "" + input_contributions: dict[str, list[dict[str, Any]]] = field(default_factory=dict) @dataclass(frozen=True) @@ -64,6 +66,24 @@ def _parse_string_list(value: object) -> list[str]: return [] +def _parse_input_contributions(data: dict[str, object]) -> dict[str, list[dict[str, Any]]]: + """Extract ``input_fields`` from ``ui_contributions``. + + Returns ``{command: [field_dict, ...]}`` mapping. + """ + ui = data.get("ui_contributions") + if not isinstance(ui, dict): + return {} + raw = ui.get("input_fields") + if not isinstance(raw, dict): + return {} + result: dict[str, list[dict[str, Any]]] = {} + for command, fields in raw.items(): + if isinstance(fields, list): + result[str(command)] = [dict(f) for f in fields if isinstance(f, dict)] + return result + + def dict_to_registry_entry(data: dict[str, object]) -> RegistryEntry: """Deserialize a dict into a ``RegistryEntry``, ignoring unknown keys.""" return RegistryEntry( @@ -75,12 +95,13 @@ def dict_to_registry_entry(data: dict[str, object]) -> RegistryEntry: min_reeln_version=str(data.get("min_reeln_version", "")), author=str(data.get("author", "")), license=str(data.get("license", "")), + input_contributions=_parse_input_contributions(data), ) def registry_entry_to_dict(entry: RegistryEntry) -> dict[str, object]: """Serialize a ``RegistryEntry`` to a JSON-compatible dict.""" - return { + d: dict[str, object] = { "name": entry.name, "package": entry.package, "description": entry.description, @@ -90,6 +111,9 @@ def registry_entry_to_dict(entry: RegistryEntry) -> dict[str, object]: "author": entry.author, "license": entry.license, } + if entry.input_contributions: + d["input_contributions"] = dict(entry.input_contributions) + return d @dataclass diff --git a/reeln/models/plugin_input.py b/reeln/models/plugin_input.py new file mode 100644 index 0000000..7d25f55 --- /dev/null +++ b/reeln/models/plugin_input.py @@ -0,0 +1,252 @@ +"""Plugin input contribution models. + +Plugins declare additional inputs they need during specific CLI commands +via a ``PluginInputSchema`` class attribute. These inputs are collected +interactively (questionary prompts), non-interactively (``--plugin-input +KEY=VALUE``), or via reeln-dock UI fields, then passed to plugins through +``HookContext.data["plugin_inputs"]``. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +# --------------------------------------------------------------------------- +# Command scope constants +# --------------------------------------------------------------------------- + + +class InputCommand: + """Valid command scopes for plugin input contributions.""" + + GAME_INIT: str = "game_init" + GAME_FINISH: str = "game_finish" + GAME_SEGMENT: str = "game_segment" + RENDER_SHORT: str = "render_short" + RENDER_PREVIEW: str = "render_preview" + + _ALL: frozenset[str] = frozenset( + { + "game_init", + "game_finish", + "game_segment", + "render_short", + "render_preview", + } + ) + + @classmethod + def is_valid(cls, command: str) -> bool: + """Return ``True`` if *command* is a recognised scope.""" + return command in cls._ALL + + +# --------------------------------------------------------------------------- +# Valid field types +# --------------------------------------------------------------------------- + +VALID_INPUT_TYPES: frozenset[str] = frozenset({"str", "int", "float", "bool", "file", "select"}) + +_TYPE_COERCERS: dict[str, type] = { + "str": str, + "int": int, + "float": float, + "bool": bool, + "file": str, +} + + +# --------------------------------------------------------------------------- +# Data models +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class InputOption: + """A selectable option for ``select``-type input fields.""" + + value: str + label: str + + +@dataclass(frozen=True) +class InputField: + """A single plugin input field declaration. + + Each field is scoped to a specific CLI command (``command``) and owned + by the declaring plugin (``plugin_name``). + """ + + id: str + label: str + field_type: str + command: str + plugin_name: str = "" + default: Any = None + required: bool = False + description: str = "" + min_value: float | None = None + max_value: float | None = None + step: float | None = None + options: tuple[InputOption, ...] = () + maps_to: str = "" + + @property + def effective_maps_to(self) -> str: + """Return the mapping key, falling back to *id*.""" + return self.maps_to or self.id + + +@dataclass(frozen=True) +class PluginInputSchema: + """Collection of input field declarations for a plugin.""" + + fields: tuple[InputField, ...] = () + + def fields_for_command(self, command: str) -> list[InputField]: + """Return fields scoped to *command*.""" + return [f for f in self.fields if f.command == command] + + +# --------------------------------------------------------------------------- +# Serialization helpers +# --------------------------------------------------------------------------- + + +def input_option_to_dict(option: InputOption) -> dict[str, str]: + """Serialize an ``InputOption`` to a JSON-compatible dict.""" + return {"value": option.value, "label": option.label} + + +def dict_to_input_option(data: dict[str, Any]) -> InputOption: + """Deserialize a dict into an ``InputOption``.""" + return InputOption(value=str(data.get("value", "")), label=str(data.get("label", ""))) + + +def input_field_to_dict(f: InputField) -> dict[str, Any]: + """Serialize an ``InputField`` to a JSON-compatible dict.""" + d: dict[str, Any] = { + "id": f.id, + "label": f.label, + "type": f.field_type, + "command": f.command, + } + if f.plugin_name: + d["plugin_name"] = f.plugin_name + if f.default is not None: + d["default"] = f.default + if f.required: + d["required"] = f.required + if f.description: + d["description"] = f.description + if f.min_value is not None: + d["min"] = f.min_value + if f.max_value is not None: + d["max"] = f.max_value + if f.step is not None: + d["step"] = f.step + if f.options: + d["options"] = [input_option_to_dict(o) for o in f.options] + if f.maps_to: + d["maps_to"] = f.maps_to + return d + + +def dict_to_input_field( + data: dict[str, Any], + *, + command: str = "", + plugin_name: str = "", +) -> InputField: + """Deserialize a dict into an ``InputField``. + + *command* and *plugin_name* can be provided as overrides when parsing + from the registry JSON where they are not embedded in each field dict. + """ + raw_options = data.get("options", ()) + options: tuple[InputOption, ...] = () + if raw_options: + options = tuple(dict_to_input_option(o) for o in raw_options) + + return InputField( + id=str(data.get("id", "")), + label=str(data.get("label", "")), + field_type=str(data.get("type", data.get("field_type", "str"))), + command=str(data.get("command", command)), + plugin_name=str(data.get("plugin_name", plugin_name)), + default=data.get("default"), + required=bool(data.get("required", False)), + description=str(data.get("description", "")), + min_value=data.get("min", data.get("min_value")), + max_value=data.get("max", data.get("max_value")), + step=data.get("step"), + options=options, + maps_to=str(data.get("maps_to", "")), + ) + + +def input_schema_to_dict(schema: PluginInputSchema) -> dict[str, Any]: + """Serialize a ``PluginInputSchema`` to a JSON-compatible dict.""" + return {"fields": [input_field_to_dict(f) for f in schema.fields]} + + +def dict_to_input_schema( + data: dict[str, Any], + *, + plugin_name: str = "", +) -> PluginInputSchema: + """Deserialize a dict into a ``PluginInputSchema``.""" + raw_fields = data.get("fields", ()) + fields = tuple(dict_to_input_field(f, plugin_name=plugin_name) for f in raw_fields) + return PluginInputSchema(fields=fields) + + +# --------------------------------------------------------------------------- +# Type coercion / validation +# --------------------------------------------------------------------------- + + +def coerce_value(raw: str, f: InputField) -> Any: + """Coerce a raw string value to the declared field type. + + Raises ``ValueError`` on type mismatch or constraint violation. + """ + ft = f.field_type + + if ft == "bool": + lowered = raw.lower() + if lowered in ("true", "1", "yes", "on"): + return True + if lowered in ("false", "0", "no", "off"): + return False + msg = f"Cannot coerce {raw!r} to bool for input {f.id!r}" + raise ValueError(msg) + + if ft == "select": + valid = {o.value for o in f.options} + if valid and raw not in valid: + msg = f"Invalid selection {raw!r} for input {f.id!r}; valid: {sorted(valid)}" + raise ValueError(msg) + return raw + + coercer = _TYPE_COERCERS.get(ft) + if coercer is None: + return raw + + try: + value = coercer(raw) + except (ValueError, TypeError) as exc: + msg = f"Cannot coerce {raw!r} to {ft} for input {f.id!r}" + raise ValueError(msg) from exc + + # Range validation for numeric types + if ft in ("int", "float"): + if f.min_value is not None and value < f.min_value: + msg = f"Value {value} below minimum {f.min_value} for input {f.id!r}" + raise ValueError(msg) + if f.max_value is not None and value > f.max_value: + msg = f"Value {value} above maximum {f.max_value} for input {f.id!r}" + raise ValueError(msg) + + return value diff --git a/reeln/models/profile.py b/reeln/models/profile.py index ad39987..a2f74b2 100644 --- a/reeln/models/profile.py +++ b/reeln/models/profile.py @@ -100,8 +100,7 @@ def render_profile_to_dict(profile: RenderProfile) -> dict[str, Any]: result[field_name] = value if profile.speed_segments is not None: result["speed_segments"] = [ - {"speed": s.speed, **({"until": s.until} if s.until is not None else {})} - for s in profile.speed_segments + {"speed": s.speed, **({"until": s.until} if s.until is not None else {})} for s in profile.speed_segments ] return result @@ -169,13 +168,8 @@ def _opt_str(data: dict[str, Any], key: str) -> str | None: return str(v) if v is not None else None -def _opt_speed_segments( - data: dict[str, Any], key: str -) -> tuple[SpeedSegment, ...] | None: +def _opt_speed_segments(data: dict[str, Any], key: str) -> tuple[SpeedSegment, ...] | None: v = data.get(key) if v is None or not isinstance(v, list): return None - return tuple( - SpeedSegment(speed=float(s["speed"]), until=s.get("until")) - for s in v - ) + return tuple(SpeedSegment(speed=float(s["speed"]), until=s.get("until")) for s in v) diff --git a/reeln/plugins/__init__.py b/reeln/plugins/__init__.py index 7f39de7..ccc8218 100644 --- a/reeln/plugins/__init__.py +++ b/reeln/plugins/__init__.py @@ -3,8 +3,10 @@ from __future__ import annotations from reeln.models.plugin import GeneratorResult +from reeln.models.plugin_input import InputField, PluginInputSchema from reeln.plugins.capabilities import Generator, MetadataEnricher, Notifier, Uploader from reeln.plugins.hooks import Hook, HookContext, HookHandler +from reeln.plugins.inputs import InputCollector, get_input_collector, reset_input_collector from reeln.plugins.loader import activate_plugins from reeln.plugins.registry import HookRegistry, get_registry, reset_registry @@ -15,10 +17,15 @@ "HookContext", "HookHandler", "HookRegistry", + "InputCollector", + "InputField", "MetadataEnricher", "Notifier", + "PluginInputSchema", "Uploader", "activate_plugins", + "get_input_collector", "get_registry", + "reset_input_collector", "reset_registry", ] diff --git a/reeln/plugins/inputs.py b/reeln/plugins/inputs.py new file mode 100644 index 0000000..fe5c759 --- /dev/null +++ b/reeln/plugins/inputs.py @@ -0,0 +1,304 @@ +"""Plugin input collection and conflict resolution. + +The :class:`InputCollector` gathers :class:`InputField` declarations from +loaded plugins, resolves conflicts when two plugins declare the same input ID, +and collects values from the user (interactively or via CLI arguments). +""" + +from __future__ import annotations + +import dataclasses +import logging +from typing import Any + +from reeln.core.log import get_logger +from reeln.models.plugin_input import ( + InputField, + PluginInputSchema, + coerce_value, +) + +log: logging.Logger = get_logger(__name__) + + +# --------------------------------------------------------------------------- +# InputCollector +# --------------------------------------------------------------------------- + + +class InputCollector: + """Gather, validate, and collect plugin-contributed inputs.""" + + def __init__(self) -> None: + self._fields: dict[str, InputField] = {} # key = "{command}:{id}" + self._conflicts: list[str] = [] + + # -- registration ------------------------------------------------------- + + def register_plugin_inputs(self, plugin: object, plugin_name: str) -> None: + """Extract input schema from *plugin* and register its fields. + + Prefers ``get_input_schema()`` (config-aware, called at runtime) over + the static ``input_schema`` class attribute. This lets plugins gate + inputs on feature flags — e.g. only prompt for ``thumbnail_image`` + when ``create_livestream`` is enabled. + + Plugins that expose neither are silently skipped. + """ + get_fn = getattr(plugin, "get_input_schema", None) + if callable(get_fn): + try: + schema = get_fn() + except Exception: + log.warning( + "Plugin %s get_input_schema() failed, skipping", + plugin_name, + exc_info=True, + ) + return + else: + schema = getattr(plugin, "input_schema", None) + if not isinstance(schema, PluginInputSchema): + return + + for field in schema.fields: + # Stamp the plugin name if not already set + stamped = field + if not field.plugin_name: + stamped = dataclasses.replace(field, plugin_name=plugin_name) + self._register_field(stamped) + + def _register_field(self, field: InputField) -> None: + """Register a single field, resolving conflicts.""" + key = f"{field.command}:{field.id}" + + if key not in self._fields: + self._fields[key] = field + return + + existing = self._fields[key] + + if existing.field_type == field.field_type: + # Same type — first wins, log info + msg = ( + f"Input '{field.id}' for command '{field.command}' already " + f"registered by '{existing.plugin_name}', skipping duplicate " + f"from '{field.plugin_name}'" + ) + log.info(msg) + self._conflicts.append(msg) + return + + # Type conflict — namespace the second field + namespaced_id = f"{field.plugin_name}.{field.id}" + namespaced_key = f"{field.command}:{namespaced_id}" + msg = ( + f"Input conflict: '{field.id}' declared by '{field.plugin_name}' " + f"(type {field.field_type}) conflicts with '{existing.plugin_name}' " + f"(type {existing.field_type}). Namespacing to '{namespaced_id}'" + ) + log.warning(msg) + self._conflicts.append(msg) + self._fields[namespaced_key] = dataclasses.replace(field, id=namespaced_id) + + def register_registry_inputs( + self, + plugin_name: str, + contributions: dict[str, list[dict[str, object]]], + ) -> None: + """Register input fields from the remote registry as a fallback. + + Only registers fields for *plugin_name* if that plugin did not + already contribute fields via ``input_schema`` or + ``get_input_schema()``. This lets the registry serve as a + fallback for plugins that haven't yet adopted the class-level + declaration, while plugins that do declare their own schema + always win. + """ + from reeln.models.plugin_input import dict_to_input_field + + for command, field_dicts in contributions.items(): + for fd in field_dicts: + field = dict_to_input_field( + fd, + command=command, + plugin_name=plugin_name, + ) + key = f"{field.command}:{field.id}" + if key not in self._fields: + self._register_field(field) + + # -- queries ------------------------------------------------------------ + + def fields_for_command(self, command: str) -> list[InputField]: + """Return all registered fields for *command*, sorted by id.""" + return sorted( + (f for f in self._fields.values() if f.command == command), + key=lambda f: f.id, + ) + + def has_field(self, command: str, field_id: str) -> bool: + """Return ``True`` if a field with *field_id* is registered for *command*.""" + return f"{command}:{field_id}" in self._fields + + @property + def conflicts(self) -> list[str]: + """Return logged conflict messages (for testing/debugging).""" + return list(self._conflicts) + + # -- collection --------------------------------------------------------- + + def collect_noninteractive( + self, + command: str, + raw_inputs: list[str], + ) -> dict[str, Any]: + """Parse ``KEY=VALUE`` pairs and validate against registered fields. + + Unknown keys (no matching field) are passed through as strings. + Returns ``{field_id: coerced_value}``. + """ + fields = {f.id: f for f in self.fields_for_command(command)} + result: dict[str, Any] = {} + + for item in raw_inputs: + if "=" not in item: + log.warning("Ignoring malformed plugin input (expected KEY=VALUE): %r", item) + continue + key, _, raw_value = item.partition("=") + key = key.strip() + raw_value = raw_value.strip() + + field = fields.get(key) + if field is None: + # Pass through unknown keys as strings + result[key] = raw_value + continue + + try: + result[key] = coerce_value(raw_value, field) + except ValueError as exc: + log.warning("Plugin input validation: %s", exc) + # Still store the raw value so the plugin gets something + result[key] = raw_value + + return result + + def collect_interactive( + self, + command: str, + presets: dict[str, Any] | None = None, + ) -> dict[str, Any]: + """Prompt for plugin inputs, skipping those already in *presets*. + + Returns ``{field_id: value}``. Fields with defaults that the user + skips (empty input) get the default value. + """ + presets = presets or {} + fields = self.fields_for_command(command) + result: dict[str, Any] = {} + + for field in fields: + if field.id in presets: + result[field.id] = presets[field.id] + continue + result[field.id] = _prompt_for_field(field) + + return result + + # -- lifecycle ---------------------------------------------------------- + + def clear(self) -> None: + """Reset all registered fields and conflicts.""" + self._fields.clear() + self._conflicts.clear() + + +# --------------------------------------------------------------------------- +# Interactive prompt dispatch +# --------------------------------------------------------------------------- + + +def _prompt_for_field(field: InputField) -> Any: + """Prompt the user for a single input field value via questionary. + + Returns the collected (and coerced) value, or the field's default + when the user provides no input and a default exists. + """ + import sys + + if not sys.stdin.isatty(): + return field.default + + try: + import questionary + except ImportError: + log.debug("questionary not installed, using default for %s", field.id) + return field.default + + ft = field.field_type + plugin_tag = f"[{field.plugin_name}] " if field.plugin_name else "" + label = f"{plugin_tag}{field.label}" + if field.description: + label = f"{label} ({field.description})" + + if ft == "bool": + default_bool = bool(field.default) if field.default is not None else False + answer = questionary.confirm(f"{label}:", default=default_bool).ask() + if answer is None: + return field.default + return answer + + if ft == "select" and field.options: + choices = [questionary.Choice(title=o.label, value=o.value) for o in field.options] + answer = questionary.select(f"{label}:", choices=choices).ask() + if answer is None: + return field.default + return answer + + # str, int, float, file — all use text prompt + default_str = str(field.default) if field.default is not None else "" + answer = questionary.text( + f"{label}:", + default=default_str, + ).ask() + + if answer is None: + return field.default + if answer == "" and not field.required: + return field.default + + # Coerce typed fields + if ft in ("int", "float"): + try: + return coerce_value(answer, field) + except ValueError: + return field.default + + return answer + + +# --------------------------------------------------------------------------- +# Module-level singleton +# --------------------------------------------------------------------------- + +_collector: InputCollector | None = None + + +def get_input_collector() -> InputCollector: + """Return the module-level :class:`InputCollector` singleton.""" + global _collector + if _collector is None: + _collector = InputCollector() + return _collector + + +def reset_input_collector() -> InputCollector: + """Clear and return a fresh :class:`InputCollector` singleton. + + Used at the start of ``activate_plugins()`` for idempotency and + by tests for isolation. + """ + global _collector + _collector = InputCollector() + return _collector diff --git a/reeln/plugins/loader.py b/reeln/plugins/loader.py index 9ecdefd..4adfda6 100644 --- a/reeln/plugins/loader.py +++ b/reeln/plugins/loader.py @@ -11,7 +11,9 @@ from reeln.models.config import PluginsConfig from reeln.models.doctor import DoctorCheck from reeln.models.plugin import PluginInfo +from reeln.models.plugin_input import PluginInputSchema from reeln.plugins.hooks import Hook +from reeln.plugins.inputs import reset_input_collector from reeln.plugins.registry import FilteredRegistry, HookRegistry, get_registry log: logging.Logger = get_logger(__name__) @@ -47,6 +49,10 @@ def _detect_capabilities(plugin: object) -> list[str]: for cap_name, method_name in _CAPABILITY_CHECKS: if callable(getattr(plugin, method_name, None)): caps.append(cap_name) + if callable(getattr(plugin, "get_input_schema", None)) or isinstance( + getattr(plugin, "input_schema", None), PluginInputSchema + ): + caps.append("inputs") return caps @@ -213,6 +219,27 @@ def _register_plugin_hooks( effective_registry.register(hook, handler) +def _fetch_registry_input_contributions( + registry_url: str, +) -> dict[str, dict[str, list[dict[str, object]]]]: + """Fetch the plugin registry and return a name → input_contributions mapping. + + Returns an empty dict on any error. + """ + try: + from reeln.core.plugin_registry import fetch_registry + + entries = fetch_registry(registry_url) + return { + e.name: e.input_contributions + for e in entries + if e.input_contributions + } + except Exception: + log.debug("Could not fetch registry for input contributions", exc_info=True) + return {} + + def _fetch_registry_capabilities(registry_url: str) -> dict[str, list[str]]: """Fetch the plugin registry and return a name → capabilities mapping. @@ -262,6 +289,18 @@ def activate_plugins(plugins_config: PluginsConfig) -> dict[str, object]: allowed = _parse_allowed_hooks(caps.get(name, [])) _register_plugin_hooks(name, plugin, registry, allowed_hooks=allowed) + # Register plugin input contributions (class-level first, registry fallback) + collector = reset_input_collector() + for name, plugin in loaded.items(): + collector.register_plugin_inputs(plugin, name) + + # Fallback: use registry input_contributions for plugins that didn't + # declare their own input_schema / get_input_schema() + registry_inputs = _fetch_registry_input_contributions(plugins_config.registry_url) + for name, contributions in registry_inputs.items(): + if name in loaded: + collector.register_registry_inputs(name, contributions) + return loaded diff --git a/tests/unit/commands/test_cli.py b/tests/unit/commands/test_cli.py index f7c2592..ac5dc51 100644 --- a/tests/unit/commands/test_cli.py +++ b/tests/unit/commands/test_cli.py @@ -26,6 +26,13 @@ def test_help_shows_all_groups() -> None: assert "plugins" in result.output +def _unstyle(text: str) -> str: + """Strip ANSI escape codes for assertion matching.""" + import re + + return re.sub(r"\x1b\[[0-9;]*m", "", text) + + def test_version() -> None: with ( patch("reeln.core.ffmpeg.discover_ffmpeg", return_value=Path("/usr/bin/ffmpeg")), @@ -34,8 +41,12 @@ def test_version() -> None: ): result = runner.invoke(app, ["--version"]) assert result.exit_code == 0 - assert f"reeln {__version__}" in result.output - assert "ffmpeg 7.1 (/usr/bin/ffmpeg)" in result.output + plain = _unstyle(result.output) + assert "reeln" in plain + assert __version__ in plain + assert "ffmpeg" in plain + assert "7.1" in plain + assert "/usr/bin/ffmpeg" in plain def test_version_ffmpeg_not_found() -> None: @@ -45,8 +56,10 @@ def test_version_ffmpeg_not_found() -> None: ): result = runner.invoke(app, ["--version"]) assert result.exit_code == 0 - assert f"reeln {__version__}" in result.output - assert "ffmpeg: not found" in result.output + plain = _unstyle(result.output) + assert "reeln" in plain + assert __version__ in plain + assert "not found" in plain def test_version_with_plugins() -> None: @@ -64,9 +77,12 @@ def test_version_with_plugins() -> None: ): result = runner.invoke(app, ["--version"]) assert result.exit_code == 0 - assert "plugins:" in result.output - assert " scoreboard 1.2.0" in result.output - assert " youtube 0.3.1" in result.output + plain = _unstyle(result.output) + assert "plugins:" in plain + assert "scoreboard" in plain + assert "1.2.0" in plain + assert "youtube" in plain + assert "0.3.1" in plain def test_version_no_plugins() -> None: @@ -77,7 +93,8 @@ def test_version_no_plugins() -> None: ): result = runner.invoke(app, ["--version"]) assert result.exit_code == 0 - assert "plugins:" not in result.output + plain = _unstyle(result.output) + assert "plugins:" not in plain def test_version_plugin_discovery_error() -> None: @@ -88,8 +105,10 @@ def test_version_plugin_discovery_error() -> None: ): result = runner.invoke(app, ["--version"]) assert result.exit_code == 0 - assert f"reeln {__version__}" in result.output - assert "plugins:" not in result.output + plain = _unstyle(result.output) + assert "reeln" in plain + assert __version__ in plain + assert "plugins:" not in plain def test_version_plugin_no_package_or_no_version() -> None: diff --git a/tests/unit/commands/test_game.py b/tests/unit/commands/test_game.py index 540fc6a..85598f9 100644 --- a/tests/unit/commands/test_game.py +++ b/tests/unit/commands/test_game.py @@ -52,17 +52,16 @@ def test_resolve_game_dir_direct(tmp_path: Path) -> None: def test_resolve_game_dir_discovers_latest(tmp_path: Path) -> None: - """When resolved path is the parent, discover the latest game dir.""" - import time + """When resolved path is the parent, discover the latest game dir by created_at.""" + import json old_game = tmp_path / "2026-01-01_a_vs_b" old_game.mkdir() - (old_game / "game.json").write_text("{}") - time.sleep(0.05) + (old_game / "game.json").write_text(json.dumps({"created_at": "2026-01-01T00:00:00"})) new_game = tmp_path / "2026-02-28_c_vs_d" new_game.mkdir() - (new_game / "game.json").write_text("{}") + (new_game / "game.json").write_text(json.dumps({"created_at": "2026-02-28T00:00:00"})) result = _resolve_game_dir(tmp_path, None) assert result == new_game @@ -86,6 +85,89 @@ def test_resolve_game_dir_skips_non_game_dirs(tmp_path: Path) -> None: assert result == game +def test_resolve_game_dir_prefers_unfinished(tmp_path: Path) -> None: + """Unfinished games are preferred over finished ones regardless of timestamp.""" + import json + + finished = tmp_path / "2026-03-15_a_vs_b" + finished.mkdir() + (finished / "game.json").write_text(json.dumps({ + "finished": True, + "created_at": "2026-03-15T18:00:00", + })) + + unfinished = tmp_path / "2026-03-10_c_vs_d" + unfinished.mkdir() + (unfinished / "game.json").write_text(json.dumps({ + "finished": False, + "created_at": "2026-03-10T12:00:00", + })) + + result = _resolve_game_dir(tmp_path, None) + assert result == unfinished + + +def test_resolve_game_dir_latest_unfinished_when_multiple(tmp_path: Path) -> None: + """When multiple unfinished games exist, pick the one with latest created_at.""" + import json + + older = tmp_path / "2026-03-01_a_vs_b" + older.mkdir() + (older / "game.json").write_text(json.dumps({ + "finished": False, + "created_at": "2026-03-01T00:00:00", + })) + + newer = tmp_path / "2026-03-10_c_vs_d" + newer.mkdir() + (newer / "game.json").write_text(json.dumps({ + "finished": False, + "created_at": "2026-03-10T00:00:00", + })) + + result = _resolve_game_dir(tmp_path, None) + assert result == newer + + +def test_resolve_game_dir_falls_back_to_finished(tmp_path: Path) -> None: + """When all games are finished, pick the latest by created_at.""" + import json + + old = tmp_path / "2026-01-01_a_vs_b" + old.mkdir() + (old / "game.json").write_text(json.dumps({ + "finished": True, + "created_at": "2026-01-01T00:00:00", + })) + + new = tmp_path / "2026-02-01_c_vs_d" + new.mkdir() + (new / "game.json").write_text(json.dumps({ + "finished": True, + "created_at": "2026-02-01T00:00:00", + })) + + result = _resolve_game_dir(tmp_path, None) + assert result == new + + +def test_resolve_game_dir_handles_bad_json(tmp_path: Path) -> None: + """Malformed game.json is treated as unfinished with empty created_at.""" + good = tmp_path / "2026-02-01_a_vs_b" + good.mkdir() + (good / "game.json").write_text('{"created_at": "2026-02-01T00:00:00"}') + + bad = tmp_path / "2026-03-01_c_vs_d" + bad.mkdir() + (bad / "game.json").write_text("not json") + + result = _resolve_game_dir(tmp_path, None) + # bad json has empty created_at ("") which sorts before "2026-..." + # but it's treated as unfinished, so it's in the preferred pool + # Both are unfinished; good has a later-sorting created_at + assert result == good + + def test_resolve_game_dir_uses_config_output_dir(tmp_path: Path) -> None: """Falls through to config output_dir for discovery.""" game = tmp_path / "2026-02-28_a_vs_b" @@ -99,6 +181,9 @@ def test_resolve_game_dir_uses_config_output_dir(tmp_path: Path) -> None: def test_game_help_lists_commands() -> None: result = runner.invoke(app, ["game", "--help"]) assert result.exit_code == 0 + assert "list" in result.output + assert "info" in result.output + assert "delete" in result.output assert "init" in result.output assert "segment" in result.output assert "highlights" in result.output @@ -258,6 +343,19 @@ def test_game_init_config_error_exits() -> None: assert "bad config" in result.output +def test_game_init_unfinished_blocks_before_prompts(tmp_path: Path) -> None: + """Unfinished games are detected before interactive prompts begin.""" + unfinished = tmp_path / "2026-01-01_a_vs_b" + unfinished.mkdir() + state = unfinished / "game.json" + state.write_text('{"finished": false}') + + result = runner.invoke(app, ["game", "init", "-o", str(tmp_path)]) + assert result.exit_code == 1 + assert "Unfinished game(s)" in result.output + assert "reeln game finish" in result.output + + def test_game_init_help_shows_arguments() -> None: result = runner.invoke(app, ["game", "init", "--help"]) assert result.exit_code == 0 @@ -340,9 +438,12 @@ def test_game_init_interactive_creates_directory_with_sport(tmp_path: Path) -> N def test_game_init_interactive_abort_exits() -> None: - with patch( - "reeln.commands.game.collect_game_info_interactive", - side_effect=PromptAborted("cancelled"), + with ( + patch("reeln.commands.game.find_unfinished_games", return_value=[]), + patch( + "reeln.commands.game.collect_game_info_interactive", + side_effect=PromptAborted("cancelled"), + ), ): result = runner.invoke(app, ["game", "init"]) @@ -379,9 +480,12 @@ def test_game_init_interactive_passes_sport_preset(tmp_path: Path) -> None: def test_game_init_interactive_missing_questionary() -> None: """ReelnError from missing dependency shows clean error, not traceback.""" - with patch( - "reeln.commands.game.collect_game_info_interactive", - side_effect=ReelnError("Interactive prompts require the 'questionary' package."), + with ( + patch("reeln.commands.game.find_unfinished_games", return_value=[]), + patch( + "reeln.commands.game.collect_game_info_interactive", + side_effect=ReelnError("Interactive prompts require the 'questionary' package."), + ), ): result = runner.invoke(app, ["game", "init"]) @@ -2028,6 +2132,272 @@ def test_game_compile_debug_dry_run_skipped(tmp_path: Path) -> None: assert "Debug:" not in result.output +# --------------------------------------------------------------------------- +# game init — plugin inputs +# --------------------------------------------------------------------------- + + +def test_game_init_thumbnail_bridges_to_plugin_input(tmp_path: Path) -> None: + """--thumbnail value is bridged to plugin_inputs as thumbnail_image.""" + from unittest.mock import ANY + + captured_kwargs: dict[str, object] = {} + + def _capture_init(base: object, info: object, **kw: object) -> tuple[Path, list[str]]: + captured_kwargs.update(kw) + return tmp_path / "game", ["ok"] + + with ( + patch("reeln.commands.game.init_game", side_effect=_capture_init), + patch("reeln.commands.game.load_config", return_value=AppConfig()), + patch("reeln.commands.game.activate_plugins"), + ): + result = runner.invoke( + app, + ["game", "init", "a", "b", "--thumbnail", "/path/thumb.jpg", "-o", str(tmp_path)], + ) + + assert result.exit_code == 0 + pi = captured_kwargs.get("plugin_inputs") + assert pi is not None + assert pi["thumbnail_image"] == "/path/thumb.jpg" # type: ignore[index] + + +def test_game_init_plugin_input_flag(tmp_path: Path) -> None: + """--plugin-input KEY=VALUE is parsed and passed to init_game.""" + captured_kwargs: dict[str, object] = {} + + def _capture_init(base: object, info: object, **kw: object) -> tuple[Path, list[str]]: + captured_kwargs.update(kw) + return tmp_path / "game", ["ok"] + + with ( + patch("reeln.commands.game.init_game", side_effect=_capture_init), + patch("reeln.commands.game.load_config", return_value=AppConfig()), + patch("reeln.commands.game.activate_plugins"), + ): + result = runner.invoke( + app, + ["game", "init", "a", "b", "-I", "mykey=myval", "-o", str(tmp_path)], + ) + + assert result.exit_code == 0 + pi = captured_kwargs.get("plugin_inputs") + assert pi is not None + assert pi["mykey"] == "myval" # type: ignore[index] + + +# --------------------------------------------------------------------------- +# game list +# --------------------------------------------------------------------------- + + +def test_game_list_shows_games(tmp_path: Path) -> None: + import json as _json + + g1 = tmp_path / "2026-03-15_a_vs_b" + g1.mkdir() + (g1 / "game.json").write_text( + _json.dumps({"game_info": {"home_team": "Eagles", "away_team": "Bears", "sport": "hockey", "date": "2026-03-15"}, "finished": True}) + ) + g2 = tmp_path / "2026-03-20_c_vs_d" + g2.mkdir() + (g2 / "game.json").write_text( + _json.dumps({"game_info": {"home_team": "Ducks", "away_team": "Hawks", "sport": "hockey", "date": "2026-03-20"}, "finished": False}) + ) + + result = runner.invoke(app, ["game", "list", "-o", str(tmp_path)]) + assert result.exit_code == 0 + assert "Eagles vs Bears" in result.output + assert "Ducks vs Hawks" in result.output + assert "finished" in result.output + assert "in progress" in result.output + + +def test_game_list_no_games(tmp_path: Path) -> None: + result = runner.invoke(app, ["game", "list", "-o", str(tmp_path)]) + assert result.exit_code == 0 + assert "No games found" in result.output + + +def test_game_list_no_dir(tmp_path: Path) -> None: + result = runner.invoke(app, ["game", "list", "-o", str(tmp_path / "nonexistent")]) + assert result.exit_code == 0 + assert "No games found" in result.output + + +def test_game_list_config_error() -> None: + with patch("reeln.commands.game.load_config", side_effect=ReelnError("bad")): + result = runner.invoke(app, ["game", "list"]) + assert result.exit_code == 1 + + +def test_game_list_bad_json(tmp_path: Path) -> None: + g = tmp_path / "2026-01-01_a_vs_b" + g.mkdir() + (g / "game.json").write_text("not json") + result = runner.invoke(app, ["game", "list", "-o", str(tmp_path)]) + assert result.exit_code == 0 + assert "? vs ?" in result.output + + +# --------------------------------------------------------------------------- +# game info +# --------------------------------------------------------------------------- + + +def test_game_info_shows_details(tmp_path: Path) -> None: + import json as _json + + game_dir = tmp_path / "2026-03-15_a_vs_b" + game_dir.mkdir() + (game_dir / "game.json").write_text( + _json.dumps({ + "game_info": { + "home_team": "Eagles", + "away_team": "Bears", + "sport": "hockey", + "date": "2026-03-15", + "venue": "OVAL", + "game_time": "7:00 PM", + "level": "11u", + "description": "Big game", + }, + "finished": False, + "segments_processed": [1, 2], + "events": [{"id": "1", "clip": "c.mkv", "segment_number": 1}], + "renders": [], + "livestreams": {"google": "https://youtube.com/live/abc"}, + "created_at": "2026-03-15T18:00:00", + }) + ) + + result = runner.invoke(app, ["game", "info", "-o", str(game_dir)]) + assert result.exit_code == 0 + assert "Eagles vs Bears" in result.output + assert "hockey" in result.output + assert "OVAL" in result.output + assert "7:00 PM" in result.output + assert "11u" in result.output + assert "Big game" in result.output + assert "2 processed" in result.output + assert "1" in result.output # 1 event + assert "google" in result.output + assert "youtube.com" in result.output + + +def test_game_info_finished_with_tournament(tmp_path: Path) -> None: + import json as _json + + game_dir = tmp_path / "2026-03-15_a_vs_b" + game_dir.mkdir() + (game_dir / "game.json").write_text( + _json.dumps({ + "game_info": { + "home_team": "Eagles", + "away_team": "Bears", + "sport": "hockey", + "date": "2026-03-15", + "tournament": "Stars Cup", + }, + "finished": True, + "segments_processed": [], + "events": [], + "renders": [{"input": "a.mkv", "output": "a_short.mp4", "segment_number": 1, "format": "vertical", "crop_mode": "pad", "rendered_at": "2026-03-15T19:00:00"}], + "livestreams": {}, + "created_at": "2026-03-15T18:00:00", + "finished_at": "2026-03-15T20:00:00", + }) + ) + + result = runner.invoke(app, ["game", "info", "-o", str(game_dir)]) + assert result.exit_code == 0 + assert "finished" in result.output + assert "Stars Cup" in result.output + assert "2026-03-15T20:00:00" in result.output + assert "1" in result.output # 1 render + + +def test_game_info_minimal_state(tmp_path: Path) -> None: + """Info works with minimal game.json (no timestamps, no optional fields).""" + import json as _json + + game_dir = tmp_path / "2026-01-01_a_vs_b" + game_dir.mkdir() + (game_dir / "game.json").write_text( + _json.dumps({ + "game_info": {"home_team": "A", "away_team": "B", "sport": "generic", "date": "2026-01-01"}, + "finished": False, + }) + ) + result = runner.invoke(app, ["game", "info", "-o", str(game_dir)]) + assert result.exit_code == 0 + assert "A vs B" in result.output + assert "Created:" not in result.output + + +def test_game_info_config_error() -> None: + with patch("reeln.commands.game.load_config", side_effect=ReelnError("bad")): + result = runner.invoke(app, ["game", "info"]) + assert result.exit_code == 1 + + +def test_game_delete_config_error() -> None: + with patch("reeln.commands.game.load_config", side_effect=ReelnError("bad")): + result = runner.invoke(app, ["game", "delete"]) + assert result.exit_code == 1 + + +# --------------------------------------------------------------------------- +# game delete +# --------------------------------------------------------------------------- + + +def test_game_delete_with_force(tmp_path: Path) -> None: + import json as _json + + game_dir = tmp_path / "2026-03-15_a_vs_b" + game_dir.mkdir() + (game_dir / "game.json").write_text( + _json.dumps({"game_info": {"home_team": "Eagles", "away_team": "Bears", "sport": "hockey", "date": "2026-03-15"}, "finished": True}) + ) + + result = runner.invoke(app, ["game", "delete", "-o", str(game_dir), "--force"]) + assert result.exit_code == 0 + assert "Deleted" in result.output + assert not game_dir.exists() + + +def test_game_delete_cancelled(tmp_path: Path) -> None: + import json as _json + + game_dir = tmp_path / "2026-03-15_a_vs_b" + game_dir.mkdir() + (game_dir / "game.json").write_text( + _json.dumps({"game_info": {"home_team": "Eagles", "away_team": "Bears", "sport": "hockey", "date": "2026-03-15"}, "finished": True}) + ) + + result = runner.invoke(app, ["game", "delete", "-o", str(game_dir)], input="n\n") + assert result.exit_code == 0 + assert "Cancelled" in result.output + assert game_dir.exists() + + +def test_game_delete_confirmed(tmp_path: Path) -> None: + import json as _json + + game_dir = tmp_path / "2026-03-15_a_vs_b" + game_dir.mkdir() + (game_dir / "game.json").write_text( + _json.dumps({"game_info": {"home_team": "Eagles", "away_team": "Bears", "sport": "hockey", "date": "2026-03-15"}, "finished": True}) + ) + + result = runner.invoke(app, ["game", "delete", "-o", str(game_dir)], input="y\n") + assert result.exit_code == 0 + assert "Deleted" in result.output + assert not game_dir.exists() + + # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- diff --git a/tests/unit/commands/test_hooks_cmd.py b/tests/unit/commands/test_hooks_cmd.py index bf7b51f..2e6e19c 100644 --- a/tests/unit/commands/test_hooks_cmd.py +++ b/tests/unit/commands/test_hooks_cmd.py @@ -32,8 +32,13 @@ def test_log_capture_info_goes_to_records() -> None: capture = _LogCapture() capture.setFormatter(logging.Formatter("%(message)s")) record = logging.LogRecord( - name="test", level=logging.INFO, pathname="", lineno=0, - msg="hello", args=(), exc_info=None, + name="test", + level=logging.INFO, + pathname="", + lineno=0, + msg="hello", + args=(), + exc_info=None, ) capture.emit(record) assert capture.records == ["hello"] @@ -44,8 +49,13 @@ def test_log_capture_warning_goes_to_records() -> None: capture = _LogCapture() capture.setFormatter(logging.Formatter("%(message)s")) record = logging.LogRecord( - name="test", level=logging.WARNING, pathname="", lineno=0, - msg="warn", args=(), exc_info=None, + name="test", + level=logging.WARNING, + pathname="", + lineno=0, + msg="warn", + args=(), + exc_info=None, ) capture.emit(record) assert capture.records == ["warn"] @@ -56,8 +66,13 @@ def test_log_capture_error_goes_to_errors() -> None: capture = _LogCapture() capture.setFormatter(logging.Formatter("%(message)s")) record = logging.LogRecord( - name="test", level=logging.ERROR, pathname="", lineno=0, - msg="bad", args=(), exc_info=None, + name="test", + level=logging.ERROR, + pathname="", + lineno=0, + msg="bad", + args=(), + exc_info=None, ) capture.emit(record) assert capture.records == [] @@ -68,8 +83,13 @@ def test_log_capture_critical_goes_to_errors() -> None: capture = _LogCapture() capture.setFormatter(logging.Formatter("%(message)s")) record = logging.LogRecord( - name="test", level=logging.CRITICAL, pathname="", lineno=0, - msg="fatal", args=(), exc_info=None, + name="test", + level=logging.CRITICAL, + pathname="", + lineno=0, + msg="fatal", + args=(), + exc_info=None, ) capture.emit(record) assert capture.errors == ["fatal"] @@ -329,11 +349,18 @@ def test_run_with_file_references(tmp_path: Path) -> None: shared_file.write_text('{"key": "val"}') with patch("reeln.commands.hooks_cmd.load_config", return_value=AppConfig()): - result = runner.invoke(app, [ - "hooks", "run", "on_game_init", - "--context-json", f"@{ctx_file}", - "--shared-json", f"@{shared_file}", - ]) + result = runner.invoke( + app, + [ + "hooks", + "run", + "on_game_init", + "--context-json", + f"@{ctx_file}", + "--shared-json", + f"@{shared_file}", + ], + ) assert result.exit_code == 0 output = json.loads(result.output.strip()) @@ -354,6 +381,7 @@ def fake_handler(ctx: HookContext) -> None: def fake_activate(plugins_config: object) -> dict[str, object]: from reeln.plugins.registry import get_registry + registry = get_registry() registry.register(Hook.ON_GAME_INIT, fake_handler) return {} @@ -389,6 +417,7 @@ def logging_handler(ctx: HookContext) -> None: def fake_activate(plugins_config: object) -> dict[str, object]: from reeln.plugins.registry import get_registry + registry = get_registry() registry.register(Hook.ON_GAME_INIT, logging_handler) return {} @@ -413,6 +442,7 @@ def bad_handler(ctx: HookContext) -> None: def fake_activate(plugins_config: object) -> dict[str, object]: from reeln.plugins.registry import get_registry + registry = get_registry() registry.register(Hook.ON_GAME_INIT, bad_handler) return {} diff --git a/tests/unit/commands/test_plugins_cmd.py b/tests/unit/commands/test_plugins_cmd.py index fe5700e..6583033 100644 --- a/tests/unit/commands/test_plugins_cmd.py +++ b/tests/unit/commands/test_plugins_cmd.py @@ -32,7 +32,7 @@ def test_plugins_list_no_plugins_no_registry() -> None: ): result = runner.invoke(app, ["plugins", "list"]) assert result.exit_code == 0 - assert "No plugins installed or available" in result.output + assert "No plugins installed" in result.output def test_plugins_list_shows_installed() -> None: @@ -45,6 +45,7 @@ def test_plugins_list_shows_installed() -> None: enabled=True, capabilities=["uploader"], update_available=True, + description="YouTube video uploader", ), PluginStatus( name="llm", @@ -65,11 +66,11 @@ def test_plugins_list_shows_installed() -> None: assert "youtube" in result.output assert "1.0.0" in result.output assert "1.1.0" in result.output - assert "uploader" in result.output assert "llm" in result.output -def test_plugins_list_shows_not_installed() -> None: +def test_plugins_list_hides_not_installed() -> None: + """Uninstalled plugins are not shown in list — use search instead.""" statuses = [ PluginStatus(name="meta", installed=False, capabilities=["uploader"]), ] @@ -81,7 +82,8 @@ def test_plugins_list_shows_not_installed() -> None: ): result = runner.invoke(app, ["plugins", "list"]) assert result.exit_code == 0 - assert "not installed" in result.output + assert "meta" not in result.output + assert "No plugins installed" in result.output def test_plugins_list_registry_error_graceful() -> None: @@ -133,6 +135,7 @@ def test_plugins_search_all() -> None: entries = [ RegistryEntry(name="youtube", package="reeln-youtube", description="YouTube uploader"), RegistryEntry(name="llm", package="reeln-llm", description="LLM enricher"), + RegistryEntry(name="bare", package="reeln-bare"), ] with ( patch("reeln.commands.plugins_cmd.load_config", return_value=AppConfig()), @@ -740,6 +743,7 @@ def test_plugins_info_shows_schema() -> None: name="youtube", package="reeln-youtube", description="YouTube uploader", + homepage="https://github.com/example/reeln-youtube", ), ] with ( @@ -750,12 +754,13 @@ def test_plugins_info_shows_schema() -> None: ): result = runner.invoke(app, ["plugins", "info", "youtube"]) assert result.exit_code == 0 - assert "Config schema:" in result.output + assert "Settings:" in result.output assert "api_key" in result.output assert "(required)" in result.output assert "region" in result.output - assert "[default: us-east-1]" in result.output + assert "us-east-1" in result.output assert "API key" in result.output + assert "Homepage:" in result.output def test_plugins_info_no_schema() -> None: @@ -770,7 +775,8 @@ def test_plugins_info_no_schema() -> None: ): result = runner.invoke(app, ["plugins", "info", "youtube"]) assert result.exit_code == 0 - assert "Config schema: none declared" in result.output + # No settings section when schema is None + assert "Settings:" not in result.output def test_plugins_info_shows_required_field() -> None: @@ -787,3 +793,239 @@ def test_plugins_info_shows_required_field() -> None: result = runner.invoke(app, ["plugins", "info", "meta"]) assert "token: str (required)" in result.output assert "Auth token" in result.output + + +# --------------------------------------------------------------------------- +# plugins uninstall +# --------------------------------------------------------------------------- + + +def test_plugins_uninstall_success() -> None: + entries = [RegistryEntry(name="google", package="reeln-plugin-google")] + pip_result = PipResult(success=True, package="reeln-plugin-google", action="uninstall") + config = AppConfig(plugins=PluginsConfig(enabled=["google"])) + with ( + patch("reeln.commands.plugins_cmd.load_config", return_value=config), + patch("reeln.commands.plugins_cmd.fetch_registry", return_value=entries), + patch("reeln.commands.plugins_cmd.get_installed_version", return_value="1.0.0"), + patch("reeln.commands.plugins_cmd.uninstall_plugin", return_value=pip_result), + patch("reeln.commands.plugins_cmd.save_config") as mock_save, + ): + result = runner.invoke(app, ["plugins", "uninstall", "google", "--force"]) + assert result.exit_code == 0 + assert "uninstalled" in result.output + mock_save.assert_called_once() + saved_config = mock_save.call_args[0][0] + assert "google" not in saved_config.plugins.enabled + assert "google" in saved_config.plugins.disabled + + +def test_plugins_uninstall_not_installed() -> None: + entries = [RegistryEntry(name="google", package="reeln-plugin-google")] + with ( + patch("reeln.commands.plugins_cmd.load_config", return_value=AppConfig()), + patch("reeln.commands.plugins_cmd.fetch_registry", return_value=entries), + patch("reeln.commands.plugins_cmd.get_installed_version", return_value=""), + ): + result = runner.invoke(app, ["plugins", "uninstall", "google", "--force"]) + assert result.exit_code == 1 + assert "not installed" in result.output + + +def test_plugins_uninstall_cancelled() -> None: + entries = [RegistryEntry(name="google", package="reeln-plugin-google")] + with ( + patch("reeln.commands.plugins_cmd.load_config", return_value=AppConfig()), + patch("reeln.commands.plugins_cmd.fetch_registry", return_value=entries), + patch("reeln.commands.plugins_cmd.get_installed_version", return_value="1.0.0"), + ): + result = runner.invoke(app, ["plugins", "uninstall", "google"], input="n\n") + assert result.exit_code == 0 + assert "Cancelled" in result.output + + +def test_plugins_uninstall_confirmed() -> None: + entries = [RegistryEntry(name="google", package="reeln-plugin-google")] + pip_result = PipResult(success=True, package="reeln-plugin-google", action="uninstall") + with ( + patch("reeln.commands.plugins_cmd.load_config", return_value=AppConfig()), + patch("reeln.commands.plugins_cmd.fetch_registry", return_value=entries), + patch("reeln.commands.plugins_cmd.get_installed_version", return_value="1.0.0"), + patch("reeln.commands.plugins_cmd.uninstall_plugin", return_value=pip_result), + patch("reeln.commands.plugins_cmd.save_config"), + ): + result = runner.invoke(app, ["plugins", "uninstall", "google"], input="y\n") + assert result.exit_code == 0 + assert "uninstalled" in result.output + + +def test_plugins_uninstall_dry_run() -> None: + entries = [RegistryEntry(name="google", package="reeln-plugin-google")] + pip_result = PipResult(success=True, package="reeln-plugin-google", action="dry-run", output="Would run: uv pip uninstall reeln-plugin-google") + with ( + patch("reeln.commands.plugins_cmd.load_config", return_value=AppConfig()), + patch("reeln.commands.plugins_cmd.fetch_registry", return_value=entries), + patch("reeln.commands.plugins_cmd.get_installed_version", return_value="1.0.0"), + patch("reeln.commands.plugins_cmd.uninstall_plugin", return_value=pip_result), + ): + result = runner.invoke(app, ["plugins", "uninstall", "google", "--dry-run"]) + assert result.exit_code == 0 + assert "Would run" in result.output + + +def test_plugins_uninstall_failure() -> None: + entries = [RegistryEntry(name="google", package="reeln-plugin-google")] + pip_result = PipResult(success=False, package="reeln-plugin-google", action="uninstall", error="permission denied") + with ( + patch("reeln.commands.plugins_cmd.load_config", return_value=AppConfig()), + patch("reeln.commands.plugins_cmd.fetch_registry", return_value=entries), + patch("reeln.commands.plugins_cmd.get_installed_version", return_value="1.0.0"), + patch("reeln.commands.plugins_cmd.uninstall_plugin", return_value=pip_result), + ): + result = runner.invoke(app, ["plugins", "uninstall", "google", "--force"]) + assert result.exit_code == 1 + assert "permission denied" in result.output + + +def test_plugins_uninstall_registry_error() -> None: + with ( + patch("reeln.commands.plugins_cmd.load_config", return_value=AppConfig()), + patch("reeln.commands.plugins_cmd.fetch_registry", side_effect=RegistryError("offline")), + ): + result = runner.invoke(app, ["plugins", "uninstall", "google", "--force"]) + assert result.exit_code == 1 + + +# --------------------------------------------------------------------------- +# plugins inputs +# --------------------------------------------------------------------------- + + +def test_plugins_inputs_no_plugins() -> None: + with ( + patch("reeln.commands.plugins_cmd.load_config", return_value=AppConfig()), + patch("reeln.plugins.loader.discover_plugins", return_value=[]), + ): + result = runner.invoke(app, ["plugins", "inputs"]) + assert result.exit_code == 0 + assert "No plugin input contributions" in result.output + + +def test_plugins_inputs_with_fields() -> None: + from reeln.models.plugin_input import InputField, PluginInputSchema + from reeln.plugins.inputs import reset_input_collector + + class FakePlugin: + input_schema = PluginInputSchema( + fields=( + InputField( + id="thumb", + label="Thumbnail", + field_type="file", + command="game_init", + plugin_name="google", + description="Thumbnail for livestream", + ), + ) + ) + + def _fake_activate(cfg: object) -> dict[str, object]: + collector = reset_input_collector() + p = FakePlugin() + collector.register_plugin_inputs(p, "google") + return {"google": p} + + with ( + patch("reeln.commands.plugins_cmd.load_config", return_value=AppConfig()), + patch("reeln.plugins.loader.activate_plugins", side_effect=_fake_activate), + ): + result = runner.invoke(app, ["plugins", "inputs", "--command", "game_init"]) + assert result.exit_code == 0 + assert "game_init" in result.output + assert "thumb" in result.output + assert "google" in result.output + assert "Thumbnail for livestream" in result.output + + +def test_plugins_inputs_json_output() -> None: + from reeln.models.plugin_input import InputField, PluginInputSchema + from reeln.plugins.inputs import reset_input_collector + + class FakePlugin: + input_schema = PluginInputSchema( + fields=( + InputField( + id="thumb", + label="Thumbnail", + field_type="file", + command="game_init", + plugin_name="google", + ), + ) + ) + + def _fake_activate(cfg: object) -> dict[str, object]: + collector = reset_input_collector() + p = FakePlugin() + collector.register_plugin_inputs(p, "google") + return {"google": p} + + with ( + patch("reeln.commands.plugins_cmd.load_config", return_value=AppConfig()), + patch("reeln.plugins.loader.activate_plugins", side_effect=_fake_activate), + ): + result = runner.invoke(app, ["plugins", "inputs", "--json"]) + assert result.exit_code == 0 + data = json.loads(result.output) + assert len(data["fields"]) == 1 + assert data["fields"][0]["id"] == "thumb" + + +def test_plugins_inputs_all_commands() -> None: + """Without --command, all command scopes are queried.""" + from reeln.plugins.inputs import reset_input_collector + + def _fake_activate(cfg: object) -> dict[str, object]: + reset_input_collector() + return {} + + with ( + patch("reeln.commands.plugins_cmd.load_config", return_value=AppConfig()), + patch("reeln.plugins.loader.activate_plugins", side_effect=_fake_activate), + ): + result = runner.invoke(app, ["plugins", "inputs"]) + assert result.exit_code == 0 + assert "No plugin input contributions" in result.output + + +def test_plugins_inputs_required_field() -> None: + from reeln.models.plugin_input import InputField, PluginInputSchema + from reeln.plugins.inputs import reset_input_collector + + class FakePlugin: + input_schema = PluginInputSchema( + fields=( + InputField( + id="api_key", + label="API Key", + field_type="str", + command="game_init", + plugin_name="test", + required=True, + ), + ) + ) + + def _fake_activate(cfg: object) -> dict[str, object]: + collector = reset_input_collector() + p = FakePlugin() + collector.register_plugin_inputs(p, "test") + return {"test": p} + + with ( + patch("reeln.commands.plugins_cmd.load_config", return_value=AppConfig()), + patch("reeln.plugins.loader.activate_plugins", side_effect=_fake_activate), + ): + result = runner.invoke(app, ["plugins", "inputs", "--command", "game_init"]) + assert result.exit_code == 0 + assert "(required)" in result.output diff --git a/tests/unit/commands/test_render.py b/tests/unit/commands/test_render.py index 5077ac7..a002b8b 100644 --- a/tests/unit/commands/test_render.py +++ b/tests/unit/commands/test_render.py @@ -5080,3 +5080,38 @@ def test_render_short_branding_error_continues(tmp_path: Path) -> None: assert result.exit_code == 0 assert "Warning: Failed to resolve branding" in result.output assert "Dry run" in result.output + + +def test_render_short_plugin_input_in_hook_data(tmp_path: Path) -> None: + """--plugin-input values are included in PRE_RENDER and POST_RENDER hook data.""" + clip = tmp_path / "clip.mkv" + clip.touch() + mock_result = _mock_result(tmp_path) + + captured_pre: dict[str, object] = {} + captured_post: dict[str, object] = {} + + def _capture_emit(hook: object, ctx: object) -> None: + from reeln.plugins.hooks import Hook + + if hasattr(ctx, "data"): + if hook == Hook.PRE_RENDER: + captured_pre.update(ctx.data) + elif hook == Hook.POST_RENDER: + captured_post.update(ctx.data) + + with ( + patch("reeln.core.ffmpeg.discover_ffmpeg", return_value=Path("/usr/bin/ffmpeg")), + patch("reeln.core.renderer.FFmpegRenderer") as mock_cls, + patch("reeln.plugins.registry.get_registry") as mock_reg, + ): + mock_cls.return_value.render.return_value = mock_result + mock_reg.return_value.emit.side_effect = _capture_emit + result = runner.invoke( + app, + ["render", "short", str(clip), "-I", "mykey=myval"], + ) + + assert result.exit_code == 0 + assert captured_pre.get("plugin_inputs") == {"mykey": "myval"} + assert captured_post.get("plugin_inputs") == {"mykey": "myval"} diff --git a/tests/unit/core/test_config.py b/tests/unit/core/test_config.py index f4d7b99..5a675c2 100644 --- a/tests/unit/core/test_config.py +++ b/tests/unit/core/test_config.py @@ -823,31 +823,37 @@ def test_validate_config_event_types_valid() -> None: def test_validate_config_iterations_references_unknown_event_type() -> None: - issues = validate_config({ - "config_version": 1, - "event_types": ["goal"], - "iterations": {"mappings": {"goal": ["slowmo"], "save": ["replay"]}}, - }) + issues = validate_config( + { + "config_version": 1, + "event_types": ["goal"], + "iterations": {"mappings": {"goal": ["slowmo"], "save": ["replay"]}}, + } + ) assert any("save" in i for i in issues) def test_validate_config_iterations_nested_mappings_key() -> None: """iterations dict without a 'mappings' key uses the dict itself as mappings.""" - issues = validate_config({ - "config_version": 1, - "event_types": ["goal"], - "iterations": {"goal": ["slowmo"], "default": ["normal"]}, - }) + issues = validate_config( + { + "config_version": 1, + "event_types": ["goal"], + "iterations": {"goal": ["slowmo"], "default": ["normal"]}, + } + ) assert issues == [] def test_validate_config_iterations_mappings_not_dict() -> None: """mappings value that is not a dict is silently skipped.""" - issues = validate_config({ - "config_version": 1, - "event_types": ["goal"], - "iterations": {"mappings": ["not", "a", "dict"]}, - }) + issues = validate_config( + { + "config_version": 1, + "event_types": ["goal"], + "iterations": {"mappings": ["not", "a", "dict"]}, + } + ) assert issues == [] diff --git a/tests/unit/core/test_iterations.py b/tests/unit/core/test_iterations.py index 22bce6b..ba9fda5 100644 --- a/tests/unit/core/test_iterations.py +++ b/tests/unit/core/test_iterations.py @@ -889,7 +889,10 @@ def test_speed_segments_overlay_duration_adjusted(tmp_path: Path) -> None: config = _make_config(slowmo=slowmo_profile) short_cfg = ShortConfig( - input=clip, output=output, width=1080, height=1920, + input=clip, + output=output, + width=1080, + height=1920, ) iter0 = _iteration_temp(output, 0) @@ -914,8 +917,13 @@ def fake_render(plan: object, **kwargs: object) -> RenderResult: MockRenderer.return_value = mock_instance render_iterations( - clip, ["slowmo"], config, Path("/usr/bin/ffmpeg"), output, - is_short=True, short_config=short_cfg, + clip, + ["slowmo"], + config, + Path("/usr/bin/ffmpeg"), + output, + is_short=True, + short_config=short_cfg, event_metadata={"assists": "A, B"}, ) @@ -1069,7 +1077,10 @@ def fake_run_ffmpeg(cmd: list[str], **kwargs: object) -> None: @patch(f"{_MOD}.FFmpegRenderer") @patch(f"{_MOD}.plan_short") def test_render_iterations_branding_first_only( - mock_plan: MagicMock, MockRenderer: MagicMock, mock_run: MagicMock, tmp_path: Path, + mock_plan: MagicMock, + MockRenderer: MagicMock, + mock_run: MagicMock, + tmp_path: Path, ) -> None: """Branding should only appear on the first iteration.""" config = _make_config() @@ -1093,9 +1104,7 @@ def test_render_iterations_branding_first_only( branding=branding_file, ) - mock_plan.return_value = RenderPlan( - inputs=[clip], output=output, filter_complex="scale=1080:-2" - ) + mock_plan.return_value = RenderPlan(inputs=[clip], output=output, filter_complex="scale=1080:-2") mock_instance = MagicMock() mock_instance.render.side_effect = lambda plan, **kw: ( plan.output.touch(), @@ -1126,7 +1135,8 @@ def test_render_iterations_branding_first_only( def test_game_info_included_in_post_render_hook( - tmp_path: Path, _mock_hook_registry: MagicMock, + tmp_path: Path, + _mock_hook_registry: MagicMock, ) -> None: """When game_info is provided, it appears in POST_RENDER hook data.""" clip = tmp_path / "clip.mkv" @@ -1167,7 +1177,8 @@ def fake_render(plan: object, **kwargs: object) -> RenderResult: def test_game_info_omitted_when_none( - tmp_path: Path, _mock_hook_registry: MagicMock, + tmp_path: Path, + _mock_hook_registry: MagicMock, ) -> None: """When game_info is None, it is not included in POST_RENDER data.""" clip = tmp_path / "clip.mkv" @@ -1204,7 +1215,8 @@ def fake_render(plan: object, **kwargs: object) -> RenderResult: def test_event_context_included_in_post_render_hook( - tmp_path: Path, _mock_hook_registry: MagicMock, + tmp_path: Path, + _mock_hook_registry: MagicMock, ) -> None: """When game_event/player/assists are provided, they appear in POST_RENDER data.""" clip = tmp_path / "clip.mkv" diff --git a/tests/unit/core/test_plugin_registry.py b/tests/unit/core/test_plugin_registry.py index b3f7ba1..3deb30e 100644 --- a/tests/unit/core/test_plugin_registry.py +++ b/tests/unit/core/test_plugin_registry.py @@ -964,3 +964,130 @@ def test_pip_result_defaults() -> None: assert r.action == "" assert r.output == "" assert r.error == "" + + +# --------------------------------------------------------------------------- +# uninstall_plugin +# --------------------------------------------------------------------------- + + +def test_uninstall_plugin_success() -> None: + from reeln.core.plugin_registry import uninstall_plugin + + entries = [RegistryEntry(name="google", package="reeln-plugin-google")] + mock_proc = MagicMock() + mock_proc.returncode = 0 + mock_proc.stdout = "Uninstalled reeln-plugin-google" + + with patch("reeln.core.plugin_registry.subprocess.run", return_value=mock_proc): + result = uninstall_plugin("google", entries) + assert result.success is True + assert result.action == "uninstall" + + +def test_uninstall_plugin_failure() -> None: + from reeln.core.plugin_registry import uninstall_plugin + + entries = [RegistryEntry(name="google", package="reeln-plugin-google")] + mock_proc = MagicMock() + mock_proc.returncode = 1 + mock_proc.stderr = "permission denied" + + with patch("reeln.core.plugin_registry.subprocess.run", return_value=mock_proc): + result = uninstall_plugin("google", entries) + assert result.success is False + assert "permission denied" in result.error + + +def test_uninstall_plugin_dry_run() -> None: + from reeln.core.plugin_registry import uninstall_plugin + + entries = [RegistryEntry(name="google", package="reeln-plugin-google")] + result = uninstall_plugin("google", entries, dry_run=True) + assert result.success is True + assert result.action == "dry-run" + assert "Would run" in result.output + + +def test_uninstall_plugin_timeout() -> None: + import subprocess + + from reeln.core.plugin_registry import uninstall_plugin + + entries = [RegistryEntry(name="google", package="reeln-plugin-google")] + with patch( + "reeln.core.plugin_registry.subprocess.run", + side_effect=subprocess.TimeoutExpired("cmd", 120), + ): + result = uninstall_plugin("google", entries) + assert result.success is False + assert "timed out" in result.error + + +def test_uninstall_plugin_exception() -> None: + from reeln.core.plugin_registry import uninstall_plugin + + entries = [RegistryEntry(name="google", package="reeln-plugin-google")] + with patch( + "reeln.core.plugin_registry.subprocess.run", + side_effect=RuntimeError("unexpected"), + ): + result = uninstall_plugin("google", entries) + assert result.success is False + assert "unexpected" in result.error + + +def test_uninstall_plugin_not_in_registry() -> None: + from reeln.core.errors import RegistryError + from reeln.core.plugin_registry import uninstall_plugin + + with pytest.raises(RegistryError, match="not found"): + uninstall_plugin("nonexistent", []) + + +def test_uninstall_plugin_with_installer_uv() -> None: + from reeln.core.plugin_registry import uninstall_plugin + + entries = [RegistryEntry(name="google", package="reeln-plugin-google")] + mock_proc = MagicMock() + mock_proc.returncode = 0 + mock_proc.stdout = "ok" + + with patch("reeln.core.plugin_registry.subprocess.run", return_value=mock_proc) as mock_run: + uninstall_plugin("google", entries, installer="uv") + cmd = mock_run.call_args[0][0] + assert cmd[0] == "uv" + assert "uninstall" in cmd + + +def test_uninstall_plugin_with_installer_pip() -> None: + from reeln.core.plugin_registry import uninstall_plugin + + entries = [RegistryEntry(name="google", package="reeln-plugin-google")] + mock_proc = MagicMock() + mock_proc.returncode = 0 + mock_proc.stdout = "ok" + + with patch("reeln.core.plugin_registry.subprocess.run", return_value=mock_proc) as mock_run: + uninstall_plugin("google", entries, installer="pip") + cmd = mock_run.call_args[0][0] + assert "pip" in " ".join(cmd) + assert "-y" in cmd + + +def test_detect_uninstaller_uv() -> None: + from reeln.core.plugin_registry import _detect_uninstaller + + with patch("shutil.which", return_value="/usr/bin/uv"): + cmd = _detect_uninstaller() + assert cmd[0] == "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): + cmd = _detect_uninstaller() + assert "pip" in " ".join(cmd) + assert "-y" in cmd diff --git a/tests/unit/core/test_prompts.py b/tests/unit/core/test_prompts.py index d0a00a0..34c7673 100644 --- a/tests/unit/core/test_prompts.py +++ b/tests/unit/core/test_prompts.py @@ -25,7 +25,24 @@ prompt_tournament, prompt_venue, ) +from reeln.models.plugin_input import InputField from reeln.models.team import TeamProfile +from reeln.plugins.inputs import InputCollector + + +def _collector_with_plugin_inputs() -> InputCollector: + """Return an InputCollector with thumbnail_image and tournament fields registered.""" + collector = InputCollector() + collector._register_field( + InputField( + id="thumbnail_image", label="Thumbnail", field_type="file", command="game_init", plugin_name="google" + ) + ) + collector._register_field( + InputField(id="tournament", label="Tournament", field_type="str", command="game_init", plugin_name="google") + ) + return collector + # --------------------------------------------------------------------------- # Fixtures @@ -641,6 +658,7 @@ def test_collect_with_profiles(mock_questionary: MagicMock) -> None: away_prof = TeamProfile(team_name="Bears", short_name="BRS", level="bantam") with ( + patch("reeln.plugins.inputs.get_input_collector", return_value=_collector_with_plugin_inputs()), patch("reeln.core.prompts.prompt_sport", return_value="hockey"), patch("reeln.core.prompts.prompt_level", return_value="bantam"), patch("reeln.core.prompts.prompt_team", side_effect=[home_prof, away_prof]), @@ -773,6 +791,7 @@ def test_collect_all_interactive() -> None: away_prof = TeamProfile(team_name="Bears", short_name="BRS", level="bantam") with ( + patch("reeln.plugins.inputs.get_input_collector", return_value=_collector_with_plugin_inputs()), patch("reeln.core.prompts.prompt_sport", return_value="hockey"), patch("reeln.core.prompts.prompt_level", return_value="bantam"), patch("reeln.core.prompts.prompt_team", side_effect=[home_prof, away_prof]), @@ -798,3 +817,60 @@ def test_collect_all_interactive() -> None: assert result["tournament"] == "Stars Cup" assert result["home_profile"] is home_prof assert result["away_profile"] is away_prof + + +def test_collect_no_plugin_inputs_skips_thumbnail_and_tournament() -> None: + """Without plugins declaring thumbnail_image/tournament, prompts are skipped.""" + from reeln.plugins.inputs import reset_input_collector + + # Empty collector — no plugin inputs registered + reset_input_collector() + + home_prof = TeamProfile(team_name="Eagles", short_name="EGL", level="bantam") + away_prof = TeamProfile(team_name="Bears", short_name="BRS", level="bantam") + + with ( + patch("reeln.core.prompts.prompt_sport", return_value="hockey"), + patch("reeln.core.prompts.prompt_level", return_value="bantam"), + patch("reeln.core.prompts.prompt_team", side_effect=[home_prof, away_prof]), + patch("reeln.core.prompts.prompt_date", return_value="2026-03-15"), + patch("reeln.core.prompts.prompt_venue", return_value="OVAL"), + patch("reeln.core.prompts.prompt_game_time", return_value="7:00 PM"), + patch("reeln.core.prompts.prompt_period_length", return_value=15), + patch("reeln.core.prompts.prompt_description", return_value="Test desc"), + patch("reeln.core.prompts.prompt_thumbnail") as mock_thumb, + patch("reeln.core.prompts.prompt_tournament") as mock_tourney, + ): + result = collect_game_info_interactive() + + # Neither prompt function should have been called + mock_thumb.assert_not_called() + mock_tourney.assert_not_called() + assert result["thumbnail"] == "" + assert result["tournament"] == "" + + +def test_collect_thumbnail_preset_always_returned() -> None: + """Thumbnail preset is always included even without plugin inputs.""" + from reeln.plugins.inputs import reset_input_collector + + reset_input_collector() + + with ( + patch("reeln.core.prompts.prompt_sport", return_value="hockey"), + patch("reeln.core.prompts.prompt_date", return_value="2026-03-15"), + patch("reeln.core.prompts.prompt_venue", return_value=""), + patch("reeln.core.prompts.prompt_game_time", return_value=""), + patch("reeln.core.prompts.prompt_period_length", return_value=15), + patch("reeln.core.prompts.prompt_description", return_value=""), + patch("reeln.core.prompts.prompt_thumbnail", return_value="/preset/thumb.jpg") as mock_thumb, + ): + result = collect_game_info_interactive( + home="Eagles", + away="Bears", + thumbnail="/preset/thumb.jpg", + ) + + # preset was provided, so prompt_thumbnail is called with it and returns immediately + mock_thumb.assert_called_once_with(preset="/preset/thumb.jpg") + assert result["thumbnail"] == "/preset/thumb.jpg" diff --git a/tests/unit/core/test_shorts.py b/tests/unit/core/test_shorts.py index 4e06c7c..9e6ce09 100644 --- a/tests/unit/core/test_shorts.py +++ b/tests/unit/core/test_shorts.py @@ -426,9 +426,7 @@ def test_build_filter_chain_pad_full(tmp_path: Path) -> None: sub = tmp_path / "subs.ass" cfg = _cfg(tmp_path, speed=0.5, lut=lut, subtitle=sub) chain, audio = build_filter_chain(cfg) - expected = ( - f"lut3d={lut},setpts=PTS/0.5,scale=1080:-2:flags=lanczos,pad=1080:1920:(ow-iw)/2:(oh-ih)/2:black,subtitles=f={sub}" - ) + expected = f"lut3d={lut},setpts=PTS/0.5,scale=1080:-2:flags=lanczos,pad=1080:1920:(ow-iw)/2:(oh-ih)/2:black,subtitles=f={sub}" assert chain == expected assert audio == "atempo=0.5" diff --git a/tests/unit/models/test_plugin.py b/tests/unit/models/test_plugin.py index deb3850..e1d885c 100644 --- a/tests/unit/models/test_plugin.py +++ b/tests/unit/models/test_plugin.py @@ -278,3 +278,86 @@ def test_registry_entry_to_dict_defaults() -> None: assert d["capabilities"] == [] assert d["author"] == "" assert d["license"] == "" + assert "input_contributions" not in d + + +# --------------------------------------------------------------------------- +# input_contributions parsing +# --------------------------------------------------------------------------- + + +def test_dict_to_registry_entry_with_input_contributions() -> None: + data: dict[str, object] = { + "name": "google", + "ui_contributions": { + "input_fields": { + "game_init": [ + { + "id": "thumbnail_image", + "label": "Thumbnail Image", + "type": "file", + "required": False, + "description": "Thumbnail for livestream", + } + ] + } + }, + } + entry = dict_to_registry_entry(data) + assert "game_init" in entry.input_contributions + assert len(entry.input_contributions["game_init"]) == 1 + assert entry.input_contributions["game_init"][0]["id"] == "thumbnail_image" + + +def test_dict_to_registry_entry_no_ui_contributions() -> None: + entry = dict_to_registry_entry({"name": "plain"}) + assert entry.input_contributions == {} + + +def test_dict_to_registry_entry_ui_contributions_no_input_fields() -> None: + data: dict[str, object] = { + "name": "openai", + "ui_contributions": {"render_options": {"fields": []}}, + } + entry = dict_to_registry_entry(data) + assert entry.input_contributions == {} + + +def test_dict_to_registry_entry_input_fields_non_list_ignored() -> None: + """Non-list values under input_fields are silently skipped.""" + data: dict[str, object] = { + "name": "weird", + "ui_contributions": { + "input_fields": { + "game_init": "not a list", + "render_short": [{"id": "x", "type": "str"}], + } + }, + } + entry = dict_to_registry_entry(data) + assert "game_init" not in entry.input_contributions + assert "render_short" in entry.input_contributions + + +def test_dict_to_registry_entry_input_fields_non_dict_items_filtered() -> None: + """Non-dict items within a field list are filtered out.""" + data: dict[str, object] = { + "name": "weird", + "ui_contributions": { + "input_fields": { + "game_init": [{"id": "ok", "type": "str"}, "not a dict", 42], + } + }, + } + entry = dict_to_registry_entry(data) + assert len(entry.input_contributions["game_init"]) == 1 + assert entry.input_contributions["game_init"][0]["id"] == "ok" + + +def test_registry_entry_to_dict_with_input_contributions() -> None: + entry = RegistryEntry( + name="google", + input_contributions={"game_init": [{"id": "thumb", "type": "file"}]}, + ) + d = registry_entry_to_dict(entry) + assert d["input_contributions"] == {"game_init": [{"id": "thumb", "type": "file"}]} diff --git a/tests/unit/models/test_plugin_input.py b/tests/unit/models/test_plugin_input.py new file mode 100644 index 0000000..68267f6 --- /dev/null +++ b/tests/unit/models/test_plugin_input.py @@ -0,0 +1,430 @@ +"""Tests for plugin input contribution models.""" + +from __future__ import annotations + +import pytest + +from reeln.models.plugin_input import ( + InputCommand, + InputField, + InputOption, + PluginInputSchema, + coerce_value, + dict_to_input_field, + dict_to_input_option, + dict_to_input_schema, + input_field_to_dict, + input_option_to_dict, + input_schema_to_dict, +) + +# --------------------------------------------------------------------------- +# InputCommand +# --------------------------------------------------------------------------- + + +class TestInputCommand: + def test_constants(self) -> None: + assert InputCommand.GAME_INIT == "game_init" + assert InputCommand.GAME_FINISH == "game_finish" + assert InputCommand.GAME_SEGMENT == "game_segment" + assert InputCommand.RENDER_SHORT == "render_short" + assert InputCommand.RENDER_PREVIEW == "render_preview" + + def test_is_valid_known(self) -> None: + assert InputCommand.is_valid("game_init") is True + assert InputCommand.is_valid("render_short") is True + assert InputCommand.is_valid("game_finish") is True + assert InputCommand.is_valid("game_segment") is True + assert InputCommand.is_valid("render_preview") is True + + def test_is_valid_unknown(self) -> None: + assert InputCommand.is_valid("unknown") is False + assert InputCommand.is_valid("") is False + + +# --------------------------------------------------------------------------- +# InputOption +# --------------------------------------------------------------------------- + + +class TestInputOption: + def test_creation(self) -> None: + o = InputOption(value="hd", label="HD 1080p") + assert o.value == "hd" + assert o.label == "HD 1080p" + + def test_frozen(self) -> None: + o = InputOption(value="a", label="b") + with pytest.raises(AttributeError): + o.value = "c" # type: ignore[misc] + + +# --------------------------------------------------------------------------- +# InputField +# --------------------------------------------------------------------------- + + +class TestInputField: + def test_defaults(self) -> None: + f = InputField(id="thumb", label="Thumbnail", field_type="file", command="game_init") + assert f.id == "thumb" + assert f.label == "Thumbnail" + assert f.field_type == "file" + assert f.command == "game_init" + assert f.plugin_name == "" + assert f.default is None + assert f.required is False + assert f.description == "" + assert f.min_value is None + assert f.max_value is None + assert f.step is None + assert f.options == () + assert f.maps_to == "" + + def test_full_construction(self) -> None: + opts = (InputOption(value="a", label="A"), InputOption(value="b", label="B")) + f = InputField( + id="quality", + label="Quality", + field_type="select", + command="render_short", + plugin_name="myplugin", + default="a", + required=True, + description="Output quality", + min_value=1.0, + max_value=10.0, + step=0.5, + options=opts, + maps_to="output_quality", + ) + assert f.plugin_name == "myplugin" + assert f.required is True + assert f.options == opts + assert f.maps_to == "output_quality" + + def test_frozen(self) -> None: + f = InputField(id="x", label="X", field_type="str", command="game_init") + with pytest.raises(AttributeError): + f.id = "y" # type: ignore[misc] + + def test_effective_maps_to_with_maps_to(self) -> None: + f = InputField(id="thumb", label="T", field_type="str", command="game_init", maps_to="thumbnail_image") + assert f.effective_maps_to == "thumbnail_image" + + def test_effective_maps_to_fallback(self) -> None: + f = InputField(id="thumb", label="T", field_type="str", command="game_init") + assert f.effective_maps_to == "thumb" + + +# --------------------------------------------------------------------------- +# PluginInputSchema +# --------------------------------------------------------------------------- + + +class TestPluginInputSchema: + def test_empty(self) -> None: + s = PluginInputSchema() + assert s.fields == () + assert s.fields_for_command("game_init") == [] + + def test_fields_for_command(self) -> None: + f1 = InputField(id="a", label="A", field_type="str", command="game_init") + f2 = InputField(id="b", label="B", field_type="int", command="render_short") + f3 = InputField(id="c", label="C", field_type="bool", command="game_init") + s = PluginInputSchema(fields=(f1, f2, f3)) + + game_fields = s.fields_for_command("game_init") + assert len(game_fields) == 2 + assert {f.id for f in game_fields} == {"a", "c"} + + render_fields = s.fields_for_command("render_short") + assert len(render_fields) == 1 + assert render_fields[0].id == "b" + + assert s.fields_for_command("unknown") == [] + + def test_frozen(self) -> None: + s = PluginInputSchema() + with pytest.raises(AttributeError): + s.fields = () # type: ignore[misc] + + +# --------------------------------------------------------------------------- +# Serialization: InputOption +# --------------------------------------------------------------------------- + + +class TestInputOptionSerialization: + def test_round_trip(self) -> None: + o = InputOption(value="hd", label="HD 1080p") + d = input_option_to_dict(o) + assert d == {"value": "hd", "label": "HD 1080p"} + + restored = dict_to_input_option(d) + assert restored == o + + def test_dict_to_input_option_missing_keys(self) -> None: + o = dict_to_input_option({}) + assert o.value == "" + assert o.label == "" + + +# --------------------------------------------------------------------------- +# Serialization: InputField +# --------------------------------------------------------------------------- + + +class TestInputFieldSerialization: + def test_to_dict_full(self) -> None: + f = InputField( + id="quality", + label="Quality", + field_type="select", + command="render_short", + plugin_name="myplugin", + default="a", + required=True, + description="Output quality", + min_value=1.0, + max_value=10.0, + step=0.5, + options=(InputOption(value="a", label="A"),), + maps_to="output_quality", + ) + d = input_field_to_dict(f) + assert d["id"] == "quality" + assert d["label"] == "Quality" + assert d["type"] == "select" + assert d["command"] == "render_short" + assert d["plugin_name"] == "myplugin" + assert d["default"] == "a" + assert d["required"] is True + assert d["description"] == "Output quality" + assert d["min"] == 1.0 + assert d["max"] == 10.0 + assert d["step"] == 0.5 + assert d["options"] == [{"value": "a", "label": "A"}] + assert d["maps_to"] == "output_quality" + + def test_to_dict_minimal(self) -> None: + f = InputField(id="x", label="X", field_type="str", command="game_init") + d = input_field_to_dict(f) + assert d == {"id": "x", "label": "X", "type": "str", "command": "game_init"} + assert "plugin_name" not in d + assert "default" not in d + assert "required" not in d + assert "description" not in d + assert "min" not in d + assert "max" not in d + assert "step" not in d + assert "options" not in d + assert "maps_to" not in d + + def test_dict_to_input_field_basic(self) -> None: + d = {"id": "thumb", "label": "Thumbnail", "type": "file"} + f = dict_to_input_field(d, command="game_init", plugin_name="google") + assert f.id == "thumb" + assert f.label == "Thumbnail" + assert f.field_type == "file" + assert f.command == "game_init" + assert f.plugin_name == "google" + + def test_dict_to_input_field_inline_command(self) -> None: + d = {"id": "x", "label": "X", "type": "str", "command": "render_short", "plugin_name": "p"} + f = dict_to_input_field(d) + assert f.command == "render_short" + assert f.plugin_name == "p" + + def test_dict_to_input_field_field_type_key(self) -> None: + """Accept ``field_type`` as alternate key for ``type``.""" + d = {"id": "x", "label": "X", "field_type": "int"} + f = dict_to_input_field(d, command="game_init") + assert f.field_type == "int" + + def test_dict_to_input_field_with_options(self) -> None: + d = { + "id": "q", + "label": "Q", + "type": "select", + "options": [{"value": "a", "label": "A"}, {"value": "b", "label": "B"}], + } + f = dict_to_input_field(d, command="game_init") + assert len(f.options) == 2 + assert f.options[0].value == "a" + assert f.options[1].label == "B" + + def test_dict_to_input_field_with_constraints(self) -> None: + d = { + "id": "z", + "label": "Zoom", + "type": "int", + "min": 1, + "max": 30, + "step": 1, + "required": True, + "description": "Zoom frames", + "default": 5, + "maps_to": "zoom_frames", + } + f = dict_to_input_field(d, command="render_short") + assert f.min_value == 1 + assert f.max_value == 30 + assert f.step == 1 + assert f.required is True + assert f.default == 5 + assert f.maps_to == "zoom_frames" + + def test_dict_to_input_field_min_value_key(self) -> None: + """Accept ``min_value`` as alternate key for ``min``.""" + d = {"id": "x", "label": "X", "type": "int", "min_value": 0, "max_value": 100} + f = dict_to_input_field(d, command="game_init") + assert f.min_value == 0 + assert f.max_value == 100 + + def test_dict_to_input_field_empty(self) -> None: + f = dict_to_input_field({}) + assert f.id == "" + assert f.field_type == "str" + + def test_round_trip(self) -> None: + original = InputField( + id="thumb", + label="Thumbnail", + field_type="file", + command="game_init", + plugin_name="google", + default="/path/to/img.png", + required=True, + description="Thumbnail for livestream", + maps_to="thumbnail_image", + ) + d = input_field_to_dict(original) + restored = dict_to_input_field(d) + assert restored.id == original.id + assert restored.field_type == original.field_type + assert restored.command == original.command + assert restored.plugin_name == original.plugin_name + assert restored.default == original.default + assert restored.maps_to == original.maps_to + + +# --------------------------------------------------------------------------- +# Serialization: PluginInputSchema +# --------------------------------------------------------------------------- + + +class TestInputSchemaSerialization: + def test_schema_to_dict(self) -> None: + s = PluginInputSchema( + fields=( + InputField(id="a", label="A", field_type="str", command="game_init"), + InputField(id="b", label="B", field_type="int", command="game_init"), + ) + ) + d = input_schema_to_dict(s) + assert len(d["fields"]) == 2 + assert d["fields"][0]["id"] == "a" + assert d["fields"][1]["type"] == "int" + + def test_schema_to_dict_empty(self) -> None: + d = input_schema_to_dict(PluginInputSchema()) + assert d == {"fields": []} + + def test_dict_to_input_schema(self) -> None: + d = { + "fields": [ + {"id": "a", "label": "A", "type": "str", "command": "game_init"}, + {"id": "b", "label": "B", "type": "int", "command": "render_short"}, + ] + } + s = dict_to_input_schema(d, plugin_name="test") + assert len(s.fields) == 2 + assert s.fields[0].plugin_name == "test" + assert s.fields[1].field_type == "int" + + def test_dict_to_input_schema_empty(self) -> None: + s = dict_to_input_schema({}) + assert s.fields == () + + +# --------------------------------------------------------------------------- +# Type coercion +# --------------------------------------------------------------------------- + + +class TestCoerceValue: + def test_str(self) -> None: + f = InputField(id="x", label="X", field_type="str", command="game_init") + assert coerce_value("hello", f) == "hello" + + def test_int_valid(self) -> None: + f = InputField(id="x", label="X", field_type="int", command="game_init") + assert coerce_value("42", f) == 42 + + def test_int_invalid(self) -> None: + f = InputField(id="x", label="X", field_type="int", command="game_init") + with pytest.raises(ValueError, match="Cannot coerce"): + coerce_value("abc", f) + + def test_float_valid(self) -> None: + f = InputField(id="x", label="X", field_type="float", command="game_init") + assert coerce_value("3.14", f) == pytest.approx(3.14) + + def test_float_invalid(self) -> None: + f = InputField(id="x", label="X", field_type="float", command="game_init") + with pytest.raises(ValueError, match="Cannot coerce"): + coerce_value("abc", f) + + def test_bool_true_variants(self) -> None: + f = InputField(id="x", label="X", field_type="bool", command="game_init") + for val in ("true", "True", "TRUE", "1", "yes", "on"): + assert coerce_value(val, f) is True + + def test_bool_false_variants(self) -> None: + f = InputField(id="x", label="X", field_type="bool", command="game_init") + for val in ("false", "False", "FALSE", "0", "no", "off"): + assert coerce_value(val, f) is False + + def test_bool_invalid(self) -> None: + f = InputField(id="x", label="X", field_type="bool", command="game_init") + with pytest.raises(ValueError, match="Cannot coerce"): + coerce_value("maybe", f) + + def test_file(self) -> None: + f = InputField(id="x", label="X", field_type="file", command="game_init") + assert coerce_value("/path/to/file.png", f) == "/path/to/file.png" + + def test_select_valid(self) -> None: + opts = (InputOption(value="a", label="A"), InputOption(value="b", label="B")) + f = InputField(id="x", label="X", field_type="select", command="game_init", options=opts) + assert coerce_value("a", f) == "a" + + def test_select_invalid(self) -> None: + opts = (InputOption(value="a", label="A"), InputOption(value="b", label="B")) + f = InputField(id="x", label="X", field_type="select", command="game_init", options=opts) + with pytest.raises(ValueError, match="Invalid selection"): + coerce_value("c", f) + + def test_select_no_options(self) -> None: + f = InputField(id="x", label="X", field_type="select", command="game_init") + assert coerce_value("anything", f) == "anything" + + def test_int_min_violation(self) -> None: + f = InputField(id="x", label="X", field_type="int", command="game_init", min_value=5) + with pytest.raises(ValueError, match="below minimum"): + coerce_value("3", f) + + def test_int_max_violation(self) -> None: + f = InputField(id="x", label="X", field_type="int", command="game_init", max_value=10) + with pytest.raises(ValueError, match="above maximum"): + coerce_value("15", f) + + def test_float_range_valid(self) -> None: + f = InputField(id="x", label="X", field_type="float", command="game_init", min_value=0.0, max_value=1.0) + assert coerce_value("0.5", f) == pytest.approx(0.5) + + def test_unknown_type(self) -> None: + f = InputField(id="x", label="X", field_type="custom", command="game_init") + assert coerce_value("anything", f) == "anything" diff --git a/tests/unit/plugins/test_init.py b/tests/unit/plugins/test_init.py index c90927b..07c5d01 100644 --- a/tests/unit/plugins/test_init.py +++ b/tests/unit/plugins/test_init.py @@ -57,11 +57,16 @@ def test_all_matches_expected() -> None: "HookContext", "HookHandler", "HookRegistry", + "InputCollector", + "InputField", "MetadataEnricher", "Notifier", + "PluginInputSchema", "Uploader", "activate_plugins", + "get_input_collector", "get_registry", + "reset_input_collector", "reset_registry", } assert set(plugins.__all__) == expected diff --git a/tests/unit/plugins/test_inputs.py b/tests/unit/plugins/test_inputs.py new file mode 100644 index 0000000..f78de4d --- /dev/null +++ b/tests/unit/plugins/test_inputs.py @@ -0,0 +1,635 @@ +"""Tests for plugin input collection and conflict resolution.""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +from reeln.models.plugin_input import InputField, InputOption, PluginInputSchema +from reeln.plugins.inputs import ( + InputCollector, + _prompt_for_field, + get_input_collector, + reset_input_collector, +) + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_field( + id: str = "thumb", + label: str = "Thumbnail", + field_type: str = "file", + command: str = "game_init", + plugin_name: str = "google", + **kwargs: object, +) -> InputField: + return InputField( + id=id, + label=label, + field_type=field_type, + command=command, + plugin_name=plugin_name, + **kwargs, # type: ignore[arg-type] + ) + + +class _FakePlugin: + """Fake plugin with an input_schema.""" + + def __init__(self, schema: PluginInputSchema) -> None: + self.input_schema = schema + + +class _NoSchemaPlugin: + """Fake plugin without input_schema.""" + + name = "bare" + + +# --------------------------------------------------------------------------- +# Registration +# --------------------------------------------------------------------------- + + +class TestRegisterPluginInputs: + def test_register_from_plugin(self) -> None: + field = _make_field() + plugin = _FakePlugin(PluginInputSchema(fields=(field,))) + collector = InputCollector() + collector.register_plugin_inputs(plugin, "google") + + fields = collector.fields_for_command("game_init") + assert len(fields) == 1 + assert fields[0].id == "thumb" + assert fields[0].plugin_name == "google" + + def test_stamps_plugin_name(self) -> None: + """Plugin name is stamped if not already set on the field.""" + field = InputField(id="x", label="X", field_type="str", command="game_init") + plugin = _FakePlugin(PluginInputSchema(fields=(field,))) + collector = InputCollector() + collector.register_plugin_inputs(plugin, "myplugin") + + result = collector.fields_for_command("game_init") + assert result[0].plugin_name == "myplugin" + + def test_preserves_existing_plugin_name(self) -> None: + field = _make_field(plugin_name="original") + plugin = _FakePlugin(PluginInputSchema(fields=(field,))) + collector = InputCollector() + collector.register_plugin_inputs(plugin, "other") + + result = collector.fields_for_command("game_init") + assert result[0].plugin_name == "original" + + def test_skip_no_schema(self) -> None: + collector = InputCollector() + collector.register_plugin_inputs(_NoSchemaPlugin(), "bare") + assert collector.fields_for_command("game_init") == [] + + def test_skip_non_schema_attribute(self) -> None: + """Ignore input_schema if it's not a PluginInputSchema instance.""" + + class Bad: + input_schema = "not a schema" + + collector = InputCollector() + collector.register_plugin_inputs(Bad(), "bad") + assert collector.fields_for_command("game_init") == [] + + def test_prefers_get_input_schema_method(self) -> None: + """get_input_schema() is preferred over static input_schema attribute.""" + static_field = InputField(id="static", label="S", field_type="str", command="game_init") + dynamic_field = InputField(id="dynamic", label="D", field_type="str", command="game_init") + + class MethodPlugin: + input_schema = PluginInputSchema(fields=(static_field,)) + + def get_input_schema(self) -> PluginInputSchema: + return PluginInputSchema(fields=(dynamic_field,)) + + collector = InputCollector() + collector.register_plugin_inputs(MethodPlugin(), "test") + fields = collector.fields_for_command("game_init") + assert len(fields) == 1 + assert fields[0].id == "dynamic" + + def test_get_input_schema_empty_when_disabled(self) -> None: + """Plugin returns empty schema when feature flag is off.""" + + class ConditionalPlugin: + def __init__(self, enabled: bool) -> None: + self._enabled = enabled + + def get_input_schema(self) -> PluginInputSchema: + if not self._enabled: + return PluginInputSchema() + return PluginInputSchema( + fields=(InputField(id="thumb", label="T", field_type="file", command="game_init"),) + ) + + collector = InputCollector() + collector.register_plugin_inputs(ConditionalPlugin(enabled=False), "google") + assert collector.fields_for_command("game_init") == [] + + collector.clear() + collector.register_plugin_inputs(ConditionalPlugin(enabled=True), "google") + assert len(collector.fields_for_command("game_init")) == 1 + + def test_get_input_schema_failure_logged(self) -> None: + """If get_input_schema() raises, the plugin is skipped.""" + + class BrokenPlugin: + def get_input_schema(self) -> PluginInputSchema: + raise RuntimeError("boom") + + collector = InputCollector() + collector.register_plugin_inputs(BrokenPlugin(), "broken") + assert collector.fields_for_command("game_init") == [] + + def test_get_input_schema_returns_non_schema(self) -> None: + """If get_input_schema() returns non-PluginInputSchema, skip.""" + + class WeirdPlugin: + def get_input_schema(self) -> object: + return "not a schema" + + collector = InputCollector() + collector.register_plugin_inputs(WeirdPlugin(), "weird") # type: ignore[arg-type] + assert collector.fields_for_command("game_init") == [] + + +# --------------------------------------------------------------------------- +# Conflict resolution +# --------------------------------------------------------------------------- + + +class TestConflictResolution: + def test_same_type_first_wins(self) -> None: + f1 = _make_field(plugin_name="alpha", description="from alpha") + f2 = _make_field(plugin_name="beta", description="from beta") + collector = InputCollector() + collector._register_field(f1) + collector._register_field(f2) + + fields = collector.fields_for_command("game_init") + assert len(fields) == 1 + assert fields[0].plugin_name == "alpha" + assert len(collector.conflicts) == 1 + assert "already registered" in collector.conflicts[0] + + def test_different_type_namespaces(self) -> None: + f1 = _make_field(plugin_name="alpha", field_type="str") + f2 = _make_field(plugin_name="beta", field_type="int") + collector = InputCollector() + collector._register_field(f1) + collector._register_field(f2) + + fields = collector.fields_for_command("game_init") + assert len(fields) == 2 + ids = {f.id for f in fields} + assert "thumb" in ids + assert "beta.thumb" in ids + assert len(collector.conflicts) == 1 + assert "Namespacing" in collector.conflicts[0] + + def test_different_commands_no_conflict(self) -> None: + f1 = _make_field(command="game_init", plugin_name="alpha") + f2 = _make_field(command="render_short", plugin_name="beta") + collector = InputCollector() + collector._register_field(f1) + collector._register_field(f2) + + assert len(collector.fields_for_command("game_init")) == 1 + assert len(collector.fields_for_command("render_short")) == 1 + assert collector.conflicts == [] + + +# --------------------------------------------------------------------------- +# Queries +# --------------------------------------------------------------------------- + + +class TestQueries: + def test_has_field(self) -> None: + collector = InputCollector() + collector._register_field(_make_field()) + assert collector.has_field("game_init", "thumb") is True + assert collector.has_field("game_init", "missing") is False + assert collector.has_field("render_short", "thumb") is False + + def test_fields_for_command_sorted(self) -> None: + collector = InputCollector() + collector._register_field(_make_field(id="z", plugin_name="a")) + collector._register_field(_make_field(id="a", plugin_name="b")) + collector._register_field(_make_field(id="m", plugin_name="c")) + + fields = collector.fields_for_command("game_init") + assert [f.id for f in fields] == ["a", "m", "z"] + + def test_fields_for_unknown_command(self) -> None: + collector = InputCollector() + collector._register_field(_make_field()) + assert collector.fields_for_command("unknown") == [] + + +# --------------------------------------------------------------------------- +# Non-interactive collection +# --------------------------------------------------------------------------- + + +class TestCollectNoninteractive: + def test_basic_parsing(self) -> None: + collector = InputCollector() + collector._register_field(_make_field(id="name", field_type="str")) + result = collector.collect_noninteractive("game_init", ["name=hello"]) + assert result == {"name": "hello"} + + def test_int_coercion(self) -> None: + collector = InputCollector() + collector._register_field(_make_field(id="count", field_type="int")) + result = collector.collect_noninteractive("game_init", ["count=42"]) + assert result == {"count": 42} + + def test_bool_coercion(self) -> None: + collector = InputCollector() + collector._register_field(_make_field(id="flag", field_type="bool")) + result = collector.collect_noninteractive("game_init", ["flag=true"]) + assert result == {"flag": True} + + def test_unknown_key_passthrough(self) -> None: + collector = InputCollector() + result = collector.collect_noninteractive("game_init", ["unknown=value"]) + assert result == {"unknown": "value"} + + def test_malformed_input_skipped(self) -> None: + collector = InputCollector() + result = collector.collect_noninteractive("game_init", ["noequals"]) + assert result == {} + + def test_invalid_value_falls_back_to_raw(self) -> None: + collector = InputCollector() + collector._register_field(_make_field(id="count", field_type="int")) + result = collector.collect_noninteractive("game_init", ["count=abc"]) + assert result == {"count": "abc"} + + def test_multiple_inputs(self) -> None: + collector = InputCollector() + collector._register_field(_make_field(id="a", field_type="str")) + collector._register_field(_make_field(id="b", field_type="int")) + result = collector.collect_noninteractive("game_init", ["a=hello", "b=5"]) + assert result == {"a": "hello", "b": 5} + + def test_value_with_equals(self) -> None: + """Values containing '=' should be preserved.""" + collector = InputCollector() + collector._register_field(_make_field(id="url", field_type="str")) + result = collector.collect_noninteractive("game_init", ["url=https://example.com?a=1&b=2"]) + assert result == {"url": "https://example.com?a=1&b=2"} + + def test_whitespace_trimmed(self) -> None: + collector = InputCollector() + collector._register_field(_make_field(id="name", field_type="str")) + result = collector.collect_noninteractive("game_init", [" name = hello "]) + assert result == {"name": "hello"} + + def test_empty_inputs(self) -> None: + collector = InputCollector() + result = collector.collect_noninteractive("game_init", []) + assert result == {} + + +# --------------------------------------------------------------------------- +# Interactive collection +# --------------------------------------------------------------------------- + + +class TestCollectInteractive: + def test_presets_bypass_prompting(self) -> None: + collector = InputCollector() + collector._register_field(_make_field(id="name", field_type="str")) + result = collector.collect_interactive("game_init", presets={"name": "preset_value"}) + assert result == {"name": "preset_value"} + + @patch("reeln.plugins.inputs._prompt_for_field", return_value="prompted_value") + def test_prompts_for_missing(self, mock_prompt: MagicMock) -> None: + collector = InputCollector() + collector._register_field(_make_field(id="name", field_type="str")) + result = collector.collect_interactive("game_init") + assert result == {"name": "prompted_value"} + mock_prompt.assert_called_once() + + @patch("reeln.plugins.inputs._prompt_for_field", return_value="val") + def test_mixed_presets_and_prompts(self, mock_prompt: MagicMock) -> None: + collector = InputCollector() + collector._register_field(_make_field(id="a", field_type="str", plugin_name="p1")) + collector._register_field(_make_field(id="b", field_type="str", plugin_name="p2")) + result = collector.collect_interactive("game_init", presets={"a": "preset"}) + assert result == {"a": "preset", "b": "val"} + assert mock_prompt.call_count == 1 + + def test_no_fields_returns_empty(self) -> None: + collector = InputCollector() + result = collector.collect_interactive("game_init") + assert result == {} + + +# --------------------------------------------------------------------------- +# _prompt_for_field +# --------------------------------------------------------------------------- + + +class TestPromptForField: + def test_non_tty_returns_default(self) -> None: + f = _make_field(default="/default.png") + with patch("sys.stdin") as mock_stdin: + mock_stdin.isatty.return_value = False + result = _prompt_for_field(f) + assert result == "/default.png" + + def test_no_questionary_returns_default(self) -> None: + f = _make_field(default="/default.png") + import builtins + + real_import = builtins.__import__ + + def _fake_import(name: str, *args: object, **kwargs: object) -> object: + if name == "questionary": + raise ImportError("no questionary") + return real_import(name, *args, **kwargs) + + with ( + patch("sys.stdin") as mock_stdin, + patch("builtins.__import__", side_effect=_fake_import), + ): + mock_stdin.isatty.return_value = True + result = _prompt_for_field(f) + assert result == "/default.png" + + def test_bool_field(self) -> None: + f = _make_field(field_type="bool", default=False) + mock_q = MagicMock() + mock_q.confirm.return_value.ask.return_value = True + with ( + patch("sys.stdin") as mock_stdin, + patch.dict("sys.modules", {"questionary": mock_q}), + ): + mock_stdin.isatty.return_value = True + result = _prompt_for_field(f) + assert result is True + + def test_bool_field_cancelled(self) -> None: + f = _make_field(field_type="bool", default=False) + mock_q = MagicMock() + mock_q.confirm.return_value.ask.return_value = None + with ( + patch("sys.stdin") as mock_stdin, + patch.dict("sys.modules", {"questionary": mock_q}), + ): + mock_stdin.isatty.return_value = True + result = _prompt_for_field(f) + assert result is False # default + + def test_select_field(self) -> None: + opts = (InputOption(value="a", label="A"), InputOption(value="b", label="B")) + f = _make_field(field_type="select", options=opts) + mock_q = MagicMock() + mock_q.select.return_value.ask.return_value = "b" + mock_q.Choice = MagicMock(side_effect=lambda title, value: MagicMock(title=title, value=value)) + with ( + patch("sys.stdin") as mock_stdin, + patch.dict("sys.modules", {"questionary": mock_q}), + ): + mock_stdin.isatty.return_value = True + result = _prompt_for_field(f) + assert result == "b" + + def test_select_field_cancelled(self) -> None: + opts = (InputOption(value="a", label="A"),) + f = _make_field(field_type="select", options=opts, default="a") + mock_q = MagicMock() + mock_q.select.return_value.ask.return_value = None + mock_q.Choice = MagicMock(side_effect=lambda title, value: MagicMock(title=title, value=value)) + with ( + patch("sys.stdin") as mock_stdin, + patch.dict("sys.modules", {"questionary": mock_q}), + ): + mock_stdin.isatty.return_value = True + result = _prompt_for_field(f) + assert result == "a" # default + + def test_text_field(self) -> None: + f = _make_field(field_type="str") + mock_q = MagicMock() + mock_q.text.return_value.ask.return_value = "hello" + with ( + patch("sys.stdin") as mock_stdin, + patch.dict("sys.modules", {"questionary": mock_q}), + ): + mock_stdin.isatty.return_value = True + result = _prompt_for_field(f) + assert result == "hello" + + def test_text_field_cancelled(self) -> None: + f = _make_field(field_type="str", default="fallback") + mock_q = MagicMock() + mock_q.text.return_value.ask.return_value = None + with ( + patch("sys.stdin") as mock_stdin, + patch.dict("sys.modules", {"questionary": mock_q}), + ): + mock_stdin.isatty.return_value = True + result = _prompt_for_field(f) + assert result == "fallback" + + def test_text_field_empty_non_required(self) -> None: + f = _make_field(field_type="str", default="fallback", required=False) + mock_q = MagicMock() + mock_q.text.return_value.ask.return_value = "" + with ( + patch("sys.stdin") as mock_stdin, + patch.dict("sys.modules", {"questionary": mock_q}), + ): + mock_stdin.isatty.return_value = True + result = _prompt_for_field(f) + assert result == "fallback" + + def test_int_field_valid(self) -> None: + f = _make_field(field_type="int") + mock_q = MagicMock() + mock_q.text.return_value.ask.return_value = "42" + with ( + patch("sys.stdin") as mock_stdin, + patch.dict("sys.modules", {"questionary": mock_q}), + ): + mock_stdin.isatty.return_value = True + result = _prompt_for_field(f) + assert result == 42 + + def test_int_field_invalid_falls_back(self) -> None: + f = _make_field(field_type="int", default=10) + mock_q = MagicMock() + mock_q.text.return_value.ask.return_value = "abc" + with ( + patch("sys.stdin") as mock_stdin, + patch.dict("sys.modules", {"questionary": mock_q}), + ): + mock_stdin.isatty.return_value = True + result = _prompt_for_field(f) + assert result == 10 # default + + def test_file_field(self) -> None: + f = _make_field(field_type="file") + mock_q = MagicMock() + mock_q.text.return_value.ask.return_value = "/path/to/file.png" + with ( + patch("sys.stdin") as mock_stdin, + patch.dict("sys.modules", {"questionary": mock_q}), + ): + mock_stdin.isatty.return_value = True + result = _prompt_for_field(f) + assert result == "/path/to/file.png" + + def test_description_in_label(self) -> None: + f = _make_field(field_type="str", description="some help text") + mock_q = MagicMock() + mock_q.text.return_value.ask.return_value = "val" + with ( + patch("sys.stdin") as mock_stdin, + patch.dict("sys.modules", {"questionary": mock_q}), + ): + mock_stdin.isatty.return_value = True + _prompt_for_field(f) + # Verify the label includes description + call_args = mock_q.text.call_args + assert "some help text" in call_args[0][0] + + def test_plugin_name_in_label(self) -> None: + f = _make_field(field_type="str", plugin_name="google") + mock_q = MagicMock() + mock_q.text.return_value.ask.return_value = "val" + with ( + patch("sys.stdin") as mock_stdin, + patch.dict("sys.modules", {"questionary": mock_q}), + ): + mock_stdin.isatty.return_value = True + _prompt_for_field(f) + call_args = mock_q.text.call_args + assert "[google]" in call_args[0][0] + + def test_no_plugin_name_no_tag(self) -> None: + f = _make_field(field_type="str", plugin_name="") + mock_q = MagicMock() + mock_q.text.return_value.ask.return_value = "val" + with ( + patch("sys.stdin") as mock_stdin, + patch.dict("sys.modules", {"questionary": mock_q}), + ): + mock_stdin.isatty.return_value = True + _prompt_for_field(f) + call_args = mock_q.text.call_args + assert "[" not in call_args[0][0] + + +# --------------------------------------------------------------------------- +# Singleton +# --------------------------------------------------------------------------- + + +# --------------------------------------------------------------------------- +# Registry fallback +# --------------------------------------------------------------------------- + + +class TestRegisterRegistryInputs: + def test_registers_from_registry(self) -> None: + collector = InputCollector() + contributions: dict[str, list[dict[str, object]]] = { + "game_init": [ + {"id": "thumb", "label": "Thumbnail", "type": "file", "description": "For livestream"} + ] + } + collector.register_registry_inputs("google", contributions) + fields = collector.fields_for_command("game_init") + assert len(fields) == 1 + assert fields[0].id == "thumb" + assert fields[0].plugin_name == "google" + + def test_skips_when_class_already_registered(self) -> None: + """Registry fallback does not override class-level declarations.""" + collector = InputCollector() + # Class-level registration + collector._register_field(_make_field(id="thumb", plugin_name="google", description="from class")) + # Registry fallback — same id, should be skipped + contributions: dict[str, list[dict[str, object]]] = { + "game_init": [{"id": "thumb", "label": "T", "type": "file", "description": "from registry"}] + } + collector.register_registry_inputs("google", contributions) + fields = collector.fields_for_command("game_init") + assert len(fields) == 1 + assert fields[0].description == "from class" + + def test_adds_new_fields_from_registry(self) -> None: + """Registry can add fields not declared by the class.""" + collector = InputCollector() + collector._register_field(_make_field(id="thumb", plugin_name="google")) + contributions: dict[str, list[dict[str, object]]] = { + "game_init": [ + {"id": "thumb", "label": "T", "type": "file"}, # already registered + {"id": "title", "label": "Title", "type": "str"}, # new + ] + } + collector.register_registry_inputs("google", contributions) + fields = collector.fields_for_command("game_init") + assert len(fields) == 2 + ids = {f.id for f in fields} + assert "thumb" in ids + assert "title" in ids + + def test_multiple_commands(self) -> None: + collector = InputCollector() + contributions: dict[str, list[dict[str, object]]] = { + "game_init": [{"id": "thumb", "label": "T", "type": "file"}], + "render_short": [{"id": "quality", "label": "Q", "type": "str"}], + } + collector.register_registry_inputs("google", contributions) + assert len(collector.fields_for_command("game_init")) == 1 + assert len(collector.fields_for_command("render_short")) == 1 + + +# --------------------------------------------------------------------------- +# Singleton +# --------------------------------------------------------------------------- + + +class TestSingleton: + def test_get_lazy_init(self) -> None: + """get_input_collector() creates one when the global is None.""" + import reeln.plugins.inputs as mod + + mod._collector = None + c = get_input_collector() + assert c is not None + assert c is get_input_collector() + + def test_get_returns_same_instance(self) -> None: + reset_input_collector() + a = get_input_collector() + b = get_input_collector() + assert a is b + + def test_reset_returns_fresh(self) -> None: + a = get_input_collector() + b = reset_input_collector() + assert a is not b + assert b is get_input_collector() + + def test_clear(self) -> None: + collector = reset_input_collector() + collector._register_field(_make_field()) + assert len(collector.fields_for_command("game_init")) == 1 + collector.clear() + assert len(collector.fields_for_command("game_init")) == 0 + assert collector.conflicts == [] diff --git a/tests/unit/plugins/test_loader.py b/tests/unit/plugins/test_loader.py index 3cd4c0d..9b8693c 100644 --- a/tests/unit/plugins/test_loader.py +++ b/tests/unit/plugins/test_loader.py @@ -706,3 +706,211 @@ def doctor_checks(self) -> list[object]: caps = _detect_capabilities(PluginWithDoctor()) assert "doctor" in caps + + +def test_detect_capabilities_includes_inputs() -> None: + """Plugins with input_schema are detected as having the inputs capability.""" + from reeln.models.plugin_input import InputField, PluginInputSchema + + class PluginWithInputs: + name = "test" + input_schema = PluginInputSchema(fields=(InputField(id="x", label="X", field_type="str", command="game_init"),)) + + caps = _detect_capabilities(PluginWithInputs()) + assert "inputs" in caps + + +def test_detect_capabilities_no_inputs_without_schema() -> None: + """input_schema that isn't a PluginInputSchema doesn't count.""" + + class PluginBadSchema: + name = "test" + input_schema = "not a schema" + + caps = _detect_capabilities(PluginBadSchema()) + assert "inputs" not in caps + + +def test_activate_plugins_registry_input_fallback() -> None: + """Plugins without input_schema get inputs from registry fallback.""" + from reeln.models.plugin import RegistryEntry + from reeln.plugins.inputs import get_input_collector + + entries = [ + RegistryEntry( + name="test", + input_contributions={ + "game_init": [{"id": "thumb", "label": "Thumbnail", "type": "file"}] + }, + ), + ] + + ep = _make_entry_point("test", _NoConfigPlugin) + with ( + patch("reeln.plugins.loader.importlib.metadata.entry_points", return_value=[ep]), + patch("reeln.core.plugin_registry.fetch_registry", return_value=entries), + ): + activate_plugins(PluginsConfig(enabled=["test"])) + + collector = get_input_collector() + fields = collector.fields_for_command("game_init") + assert len(fields) == 1 + assert fields[0].id == "thumb" + assert fields[0].plugin_name == "test" + reset_registry() + + +def test_activate_plugins_class_input_wins_over_registry() -> None: + """Class-level input_schema takes precedence over registry fallback.""" + from reeln.models.plugin import RegistryEntry + from reeln.models.plugin_input import InputField, PluginInputSchema + from reeln.plugins.inputs import get_input_collector + + class PluginWithInputs: + input_schema = PluginInputSchema( + fields=(InputField(id="thumb", label="From Class", field_type="file", command="game_init"),) + ) + + entries = [ + RegistryEntry( + name="google", + input_contributions={ + "game_init": [{"id": "thumb", "label": "From Registry", "type": "file"}] + }, + ), + ] + + ep = _make_entry_point("google", type(PluginWithInputs())) + with ( + patch("reeln.plugins.loader.importlib.metadata.entry_points", return_value=[ep]), + patch("reeln.core.plugin_registry.fetch_registry", return_value=entries), + ): + activate_plugins(PluginsConfig(enabled=["google"])) + + collector = get_input_collector() + fields = collector.fields_for_command("game_init") + assert len(fields) == 1 + assert fields[0].label == "From Class" + reset_registry() + + +def test_activate_plugins_registry_input_skips_unloaded() -> None: + """Registry input_contributions for unloaded plugins are skipped.""" + from reeln.models.plugin import RegistryEntry + from reeln.plugins.inputs import get_input_collector + + entries = [ + RegistryEntry( + name="not_loaded", + input_contributions={ + "game_init": [{"id": "thumb", "label": "T", "type": "file"}] + }, + ), + ] + + with ( + patch("reeln.plugins.loader.discover_plugins", return_value=[]), + patch("reeln.core.plugin_registry.fetch_registry", return_value=entries), + ): + activate_plugins(PluginsConfig()) + + collector = get_input_collector() + assert collector.fields_for_command("game_init") == [] + reset_registry() + + +def test_fetch_registry_input_contributions_failure() -> None: + """Registry fetch failure returns empty dict.""" + from reeln.plugins.loader import _fetch_registry_input_contributions + + with patch( + "reeln.core.plugin_registry.fetch_registry", + side_effect=Exception("network error"), + ): + result = _fetch_registry_input_contributions("https://example.com/registry.json") + assert result == {} + + +def test_fetch_registry_input_contributions_success() -> None: + """Fetches and returns input_contributions from registry entries.""" + from reeln.models.plugin import RegistryEntry + from reeln.plugins.loader import _fetch_registry_input_contributions + + entries = [ + RegistryEntry( + name="google", + input_contributions={"game_init": [{"id": "thumb", "type": "file"}]}, + ), + RegistryEntry(name="plain"), # no contributions + ] + with patch("reeln.core.plugin_registry.fetch_registry", return_value=entries): + result = _fetch_registry_input_contributions("https://example.com/registry.json") + assert "google" in result + assert "plain" not in result + + +def test_detect_capabilities_inputs_via_method() -> None: + """Plugins with get_input_schema() method are detected as having inputs capability.""" + + class PluginWithMethod: + name = "test" + + def get_input_schema(self) -> object: + return None # pragma: no cover + + caps = _detect_capabilities(PluginWithMethod()) + assert "inputs" in caps + + +# --------------------------------------------------------------------------- +# activate_plugins registers plugin inputs +# --------------------------------------------------------------------------- + + +def test_activate_plugins_registers_input_schema() -> None: + """activate_plugins() populates the InputCollector with plugin input_schema.""" + from reeln.models.plugin_input import InputField, PluginInputSchema + from reeln.plugins.inputs import get_input_collector + + class PluginWithInputs: + input_schema = PluginInputSchema( + fields=(InputField(id="thumb", label="Thumbnail", field_type="file", command="game_init"),) + ) + + ep = _make_entry_point("google", type(PluginWithInputs())) + with ( + patch("reeln.plugins.loader.importlib.metadata.entry_points", return_value=[ep]), + patch("reeln.plugins.loader._fetch_registry_input_contributions", return_value={}), + ): + activate_plugins(PluginsConfig(enabled=["google"])) + + collector = get_input_collector() + fields = collector.fields_for_command("game_init") + assert len(fields) == 1 + assert fields[0].id == "thumb" + assert fields[0].plugin_name == "google" + reset_registry() + + +def test_activate_plugins_clears_input_collector_on_reactivation() -> None: + """Double activation doesn't accumulate inputs.""" + from reeln.models.plugin_input import InputField, PluginInputSchema + from reeln.plugins.inputs import get_input_collector + + class PluginWithInputs: + input_schema = PluginInputSchema( + fields=(InputField(id="thumb", label="Thumbnail", field_type="file", command="game_init"),) + ) + + ep = _make_entry_point("google", type(PluginWithInputs())) + with ( + patch("reeln.plugins.loader.importlib.metadata.entry_points", return_value=[ep]), + patch("reeln.plugins.loader._fetch_registry_input_contributions", return_value={}), + ): + activate_plugins(PluginsConfig(enabled=["google"])) + activate_plugins(PluginsConfig(enabled=["google"])) + + collector = get_input_collector() + fields = collector.fields_for_command("game_init") + assert len(fields) == 1 # Not 2 + reset_registry() From 2b1b3ec2122e3509c673931b324f742e6190aeb3 Mon Sep 17 00:00:00 2001 From: jremitz Date: Fri, 3 Apr 2026 16:47:10 -0500 Subject: [PATCH 3/3] fix: lint violations (E501, F401, I001) and add pre-commit lint hooks Fix all ruff E501 line-too-long violations in tests. Add .claude/hooks/ with format-check.sh (PostToolUse) and lint-check.sh (Stop) to catch lint issues before they reach CI. Co-Authored-By: Claude --- .claude/hooks/format-check.sh | 13 ++++++ .claude/hooks/lint-check.sh | 9 ++++ reeln/commands/game.py | 2 +- reeln/commands/plugins_cmd.py | 3 +- tests/unit/commands/test_game.py | 60 ++++++++++++++++++++++--- tests/unit/commands/test_plugins_cmd.py | 7 ++- tests/unit/core/test_shorts.py | 5 ++- 7 files changed, 87 insertions(+), 12 deletions(-) create mode 100755 .claude/hooks/format-check.sh create mode 100755 .claude/hooks/lint-check.sh diff --git a/.claude/hooks/format-check.sh b/.claude/hooks/format-check.sh new file mode 100755 index 0000000..e1bc8ca --- /dev/null +++ b/.claude/hooks/format-check.sh @@ -0,0 +1,13 @@ +#!/bin/bash +file="$CLAUDE_FILE_PATH" +if [ -n "$file" ] && echo "$file" | grep -q '\.py$'; then + cd /Users/jremitz/workspace/reeln-cli + .venv/bin/ruff format "$file" 2>/dev/null + .venv/bin/ruff check --fix "$file" 2>/dev/null + remaining=$(.venv/bin/ruff check "$file" 2>&1 | grep -v '^All checks passed') + if [ -n "$remaining" ]; then + echo "LINT ISSUES (fix before commit):" >&2 + echo "$remaining" >&2 + fi +fi +exit 0 diff --git a/.claude/hooks/lint-check.sh b/.claude/hooks/lint-check.sh new file mode 100755 index 0000000..5528e52 --- /dev/null +++ b/.claude/hooks/lint-check.sh @@ -0,0 +1,9 @@ +#!/bin/bash +cd /Users/jremitz/workspace/reeln-cli +result=$(.venv/bin/ruff check reeln/ tests/ 2>&1 | grep -v '^All checks passed') +if [ -n "$result" ]; then + echo "LINT FAILURES - fix before committing:" >&2 + echo "$result" >&2 + exit 1 +fi +exit 0 diff --git a/reeln/commands/game.py b/reeln/commands/game.py index cdd17a8..04cbb46 100644 --- a/reeln/commands/game.py +++ b/reeln/commands/game.py @@ -9,7 +9,7 @@ import typer from reeln.commands import event -from reeln.commands.style import bold, error, label, success, warn +from reeln.commands.style import bold, label, success, warn from reeln.core.config import load_config from reeln.core.errors import PromptAborted, ReelnError from reeln.core.ffmpeg import discover_ffmpeg diff --git a/reeln/commands/plugins_cmd.py b/reeln/commands/plugins_cmd.py index aad7e85..06dc127 100644 --- a/reeln/commands/plugins_cmd.py +++ b/reeln/commands/plugins_cmd.py @@ -4,6 +4,7 @@ import typer +from reeln.commands.style import bold, error, label, success, warn from reeln.core.config import load_config, save_config from reeln.core.errors import RegistryError from reeln.core.plugin_registry import ( @@ -20,8 +21,6 @@ discover_plugins, ) -from reeln.commands.style import bold, error, label, success, warn - app = typer.Typer(no_args_is_help=True, help="Plugin management commands.") diff --git a/tests/unit/commands/test_game.py b/tests/unit/commands/test_game.py index 85598f9..474f559 100644 --- a/tests/unit/commands/test_game.py +++ b/tests/unit/commands/test_game.py @@ -2139,7 +2139,6 @@ def test_game_compile_debug_dry_run_skipped(tmp_path: Path) -> None: def test_game_init_thumbnail_bridges_to_plugin_input(tmp_path: Path) -> None: """--thumbnail value is bridged to plugin_inputs as thumbnail_image.""" - from unittest.mock import ANY captured_kwargs: dict[str, object] = {} @@ -2198,12 +2197,28 @@ def test_game_list_shows_games(tmp_path: Path) -> None: g1 = tmp_path / "2026-03-15_a_vs_b" g1.mkdir() (g1 / "game.json").write_text( - _json.dumps({"game_info": {"home_team": "Eagles", "away_team": "Bears", "sport": "hockey", "date": "2026-03-15"}, "finished": True}) + _json.dumps({ + "game_info": { + "home_team": "Eagles", + "away_team": "Bears", + "sport": "hockey", + "date": "2026-03-15", + }, + "finished": True, + }) ) g2 = tmp_path / "2026-03-20_c_vs_d" g2.mkdir() (g2 / "game.json").write_text( - _json.dumps({"game_info": {"home_team": "Ducks", "away_team": "Hawks", "sport": "hockey", "date": "2026-03-20"}, "finished": False}) + _json.dumps({ + "game_info": { + "home_team": "Ducks", + "away_team": "Hawks", + "sport": "hockey", + "date": "2026-03-20", + }, + "finished": False, + }) ) result = runner.invoke(app, ["game", "list", "-o", str(tmp_path)]) @@ -2303,7 +2318,14 @@ def test_game_info_finished_with_tournament(tmp_path: Path) -> None: "finished": True, "segments_processed": [], "events": [], - "renders": [{"input": "a.mkv", "output": "a_short.mp4", "segment_number": 1, "format": "vertical", "crop_mode": "pad", "rendered_at": "2026-03-15T19:00:00"}], + "renders": [{ + "input": "a.mkv", + "output": "a_short.mp4", + "segment_number": 1, + "format": "vertical", + "crop_mode": "pad", + "rendered_at": "2026-03-15T19:00:00", + }], "livestreams": {}, "created_at": "2026-03-15T18:00:00", "finished_at": "2026-03-15T20:00:00", @@ -2359,7 +2381,15 @@ def test_game_delete_with_force(tmp_path: Path) -> None: game_dir = tmp_path / "2026-03-15_a_vs_b" game_dir.mkdir() (game_dir / "game.json").write_text( - _json.dumps({"game_info": {"home_team": "Eagles", "away_team": "Bears", "sport": "hockey", "date": "2026-03-15"}, "finished": True}) + _json.dumps({ + "game_info": { + "home_team": "Eagles", + "away_team": "Bears", + "sport": "hockey", + "date": "2026-03-15", + }, + "finished": True, + }) ) result = runner.invoke(app, ["game", "delete", "-o", str(game_dir), "--force"]) @@ -2374,7 +2404,15 @@ def test_game_delete_cancelled(tmp_path: Path) -> None: game_dir = tmp_path / "2026-03-15_a_vs_b" game_dir.mkdir() (game_dir / "game.json").write_text( - _json.dumps({"game_info": {"home_team": "Eagles", "away_team": "Bears", "sport": "hockey", "date": "2026-03-15"}, "finished": True}) + _json.dumps({ + "game_info": { + "home_team": "Eagles", + "away_team": "Bears", + "sport": "hockey", + "date": "2026-03-15", + }, + "finished": True, + }) ) result = runner.invoke(app, ["game", "delete", "-o", str(game_dir)], input="n\n") @@ -2389,7 +2427,15 @@ def test_game_delete_confirmed(tmp_path: Path) -> None: game_dir = tmp_path / "2026-03-15_a_vs_b" game_dir.mkdir() (game_dir / "game.json").write_text( - _json.dumps({"game_info": {"home_team": "Eagles", "away_team": "Bears", "sport": "hockey", "date": "2026-03-15"}, "finished": True}) + _json.dumps({ + "game_info": { + "home_team": "Eagles", + "away_team": "Bears", + "sport": "hockey", + "date": "2026-03-15", + }, + "finished": True, + }) ) result = runner.invoke(app, ["game", "delete", "-o", str(game_dir)], input="y\n") diff --git a/tests/unit/commands/test_plugins_cmd.py b/tests/unit/commands/test_plugins_cmd.py index 6583033..59428ac 100644 --- a/tests/unit/commands/test_plugins_cmd.py +++ b/tests/unit/commands/test_plugins_cmd.py @@ -861,7 +861,12 @@ def test_plugins_uninstall_confirmed() -> None: def test_plugins_uninstall_dry_run() -> None: entries = [RegistryEntry(name="google", package="reeln-plugin-google")] - pip_result = PipResult(success=True, package="reeln-plugin-google", action="dry-run", output="Would run: uv pip uninstall reeln-plugin-google") + pip_result = PipResult( + success=True, + package="reeln-plugin-google", + action="dry-run", + output="Would run: uv pip uninstall reeln-plugin-google", + ) with ( patch("reeln.commands.plugins_cmd.load_config", return_value=AppConfig()), patch("reeln.commands.plugins_cmd.fetch_registry", return_value=entries), diff --git a/tests/unit/core/test_shorts.py b/tests/unit/core/test_shorts.py index 9e6ce09..63e1272 100644 --- a/tests/unit/core/test_shorts.py +++ b/tests/unit/core/test_shorts.py @@ -426,7 +426,10 @@ def test_build_filter_chain_pad_full(tmp_path: Path) -> None: sub = tmp_path / "subs.ass" cfg = _cfg(tmp_path, speed=0.5, lut=lut, subtitle=sub) chain, audio = build_filter_chain(cfg) - expected = f"lut3d={lut},setpts=PTS/0.5,scale=1080:-2:flags=lanczos,pad=1080:1920:(ow-iw)/2:(oh-ih)/2:black,subtitles=f={sub}" + expected = ( + f"lut3d={lut},setpts=PTS/0.5,scale=1080:-2:flags=lanczos," + f"pad=1080:1920:(ow-iw)/2:(oh-ih)/2:black,subtitles=f={sub}" + ) assert chain == expected assert audio == "atempo=0.5"