From 6e6a26b7c7538f1043ae5148a0928a8cdb346eb3 Mon Sep 17 00:00:00 2001 From: jremitz Date: Wed, 4 Mar 2026 19:14:40 -0600 Subject: [PATCH 1/3] =?UTF-8?q?Release=20v0.0.26=20=E2=80=94=20plugin=20in?= =?UTF-8?q?stall=20from=20git,=20config=20path=20fix,=20hook=20shared=20da?= =?UTF-8?q?ta?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Plugin install uses git+{homepage} for GitHub/GitLab repos - Post-install verification catches uv no-op bug - detect_installer() targets correct Python with --python flag - save_config() respects REELN_CONFIG/REELN_PROFILE env vars - HookContext.shared dict for plugin-to-core data passing - GameState.livestreams field persists URLs from hook plugins - resolve_config_path() extracted for reuse - Version strings removed from test assertions Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 14 ++++++ reeln/__init__.py | 2 +- reeln/core/config.py | 40 ++++++++++++----- reeln/core/highlights.py | 13 ++++-- reeln/core/plugin_registry.py | 7 +-- reeln/models/game.py | 3 ++ reeln/plugins/hooks.py | 1 + tests/unit/core/test_config.py | 59 +++++++++++++++++++++++++ tests/unit/core/test_highlights.py | 24 ++++++++++ tests/unit/core/test_plugin_registry.py | 3 +- tests/unit/models/test_game.py | 54 ++++++++++++++++++++++ tests/unit/plugins/test_hooks.py | 33 ++++++++++++++ 12 files changed, 233 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f177ac3..fefb0b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/). ## [Unreleased] +## [0.0.26] - 2026-03-04 + +### Added +- `HookContext.shared` dict for plugins to pass data back (e.g. livestream URLs) +- `GameState.livestreams` field — persists livestream URLs written by hook plugins +- `resolve_config_path()` — extracted config path resolution for reuse + +### Fixed +- Plugin install now uses `git+{homepage}` for GitHub/GitLab plugins instead of PyPI lookup +- Post-install verification catches silent `uv pip install` no-ops +- `save_config()` now respects `REELN_CONFIG` / `REELN_PROFILE` env vars (previously always wrote to default path) +- `detect_installer()` passes `--python sys.executable` to uv so plugins install into the correct environment +- Hardcoded version strings removed from tests (use `__version__` dynamically) + ## [0.0.25] - 2026-03-04 ### Fixed diff --git a/reeln/__init__.py b/reeln/__init__.py index 776edb0..a7faba0 100644 --- a/reeln/__init__.py +++ b/reeln/__init__.py @@ -2,4 +2,4 @@ from __future__ import annotations -__version__ = "0.0.25" +__version__ = "0.0.26" diff --git a/reeln/core/config.py b/reeln/core/config.py index 68c50a8..a46dccf 100644 --- a/reeln/core/config.py +++ b/reeln/core/config.py @@ -338,24 +338,19 @@ def validate_plugin_configs(settings: dict[str, dict[str, Any]]) -> list[str]: # --------------------------------------------------------------------------- -def load_config( +def resolve_config_path( path: Path | None = None, profile: str | None = None, -) -> AppConfig: - """Load config from disk with env var overrides. +) -> Path: + """Resolve the config file path using the standard priority order. - Loading order: bundled defaults → user config file → env vars. - - The config file path can be set via (in priority order): + Priority: 1. ``path`` argument (e.g. ``--config`` CLI flag) 2. ``REELN_CONFIG`` environment variable 3. ``profile`` argument (e.g. ``--profile`` CLI flag) 4. ``REELN_PROFILE`` environment variable 5. Default XDG path (``~/.config/reeln/config.json``) """ - base = config_to_dict(default_config()) - - # Resolve config file: explicit path → env var → profile → default if path is None: env_config = os.environ.get("REELN_CONFIG") if env_config: @@ -364,7 +359,27 @@ def load_config( env_profile = os.environ.get("REELN_PROFILE") if env_profile: profile = env_profile - file_path = path or default_config_path(profile) + return path or default_config_path(profile) + + +def load_config( + path: Path | None = None, + profile: str | None = None, +) -> AppConfig: + """Load config from disk with env var overrides. + + Loading order: bundled defaults → user config file → env vars. + + The config file path can be set via (in priority order): + 1. ``path`` argument (e.g. ``--config`` CLI flag) + 2. ``REELN_CONFIG`` environment variable + 3. ``profile`` argument (e.g. ``--profile`` CLI flag) + 4. ``REELN_PROFILE`` environment variable + 5. Default XDG path (``~/.config/reeln/config.json``) + """ + base = config_to_dict(default_config()) + + file_path = resolve_config_path(path, profile) if file_path.is_file(): try: raw = json.loads(file_path.read_text(encoding="utf-8")) @@ -392,8 +407,11 @@ def save_config(config: AppConfig, path: Path | None = None) -> Path: """Atomically write config to disk. Uses tempfile + ``Path.replace()`` to prevent corruption. + Respects ``REELN_CONFIG`` / ``REELN_PROFILE`` env vars when no + explicit *path* is given, matching the resolution order of + :func:`load_config`. """ - file_path = path or default_config_path() + file_path = resolve_config_path(path) file_path.parent.mkdir(parents=True, exist_ok=True) data = config_to_dict(config) diff --git a/reeln/core/highlights.py b/reeln/core/highlights.py index b211084..2833274 100644 --- a/reeln/core/highlights.py +++ b/reeln/core/highlights.py @@ -167,10 +167,15 @@ def init_game( if away_profile is not None: hook_data["away_profile"] = away_profile - get_registry().emit( - Hook.ON_GAME_INIT, - HookContext(hook=Hook.ON_GAME_INIT, data=hook_data), - ) + ctx = HookContext(hook=Hook.ON_GAME_INIT, data=hook_data) + get_registry().emit(Hook.ON_GAME_INIT, ctx) + + # Persist livestream URLs written by plugins (e.g. Google, Meta) + livestreams = ctx.shared.get("livestreams", {}) + if livestreams: + state = load_game_state(game_dir) + state.livestreams = dict(livestreams) + save_game_state(state, game_dir) messages.append(f"Created {_GAME_STATE_FILE}") log.info("Game initialized: %s", game_dir) diff --git a/reeln/core/plugin_registry.py b/reeln/core/plugin_registry.py index 76d16a5..c6c3871 100644 --- a/reeln/core/plugin_registry.py +++ b/reeln/core/plugin_registry.py @@ -264,11 +264,12 @@ class PipResult: def detect_installer() -> list[str]: """Detect the best available installer. - Returns the command prefix: ``["uv", "pip", "install"]`` if uv is - available, otherwise ``[sys.executable, "-m", "pip", "install"]``. + Returns the command prefix targeting the *running* Python environment + so that plugins are installed alongside reeln-cli (even when reeln is + a uv tool and cwd contains a different ``.venv``). """ if shutil.which("uv"): - return ["uv", "pip", "install"] + return ["uv", "pip", "install", "--python", sys.executable] return [sys.executable, "-m", "pip", "install"] diff --git a/reeln/models/game.py b/reeln/models/game.py index 2105b3b..deea4cc 100644 --- a/reeln/models/game.py +++ b/reeln/models/game.py @@ -58,6 +58,7 @@ class GameState: finished_at: str = "" renders: list[RenderEntry] = field(default_factory=list) events: list[GameEvent] = field(default_factory=list) + livestreams: dict[str, str] = field(default_factory=dict) # --------------------------------------------------------------------------- @@ -156,6 +157,7 @@ def game_state_to_dict(state: GameState) -> dict[str, Any]: "finished_at": state.finished_at, "renders": [render_entry_to_dict(r) for r in state.renders], "events": [game_event_to_dict(e) for e in state.events], + "livestreams": dict(state.livestreams), } @@ -172,4 +174,5 @@ def dict_to_game_state(data: dict[str, Any]) -> GameState: finished_at=str(data.get("finished_at", "")), renders=[dict_to_render_entry(r) for r in renders_raw], events=[dict_to_game_event(e) for e in events_raw], + livestreams=dict(data.get("livestreams", {})), ) diff --git a/reeln/plugins/hooks.py b/reeln/plugins/hooks.py index ce261a5..91db424 100644 --- a/reeln/plugins/hooks.py +++ b/reeln/plugins/hooks.py @@ -29,6 +29,7 @@ class HookContext: hook: Hook data: dict[str, Any] = field(default_factory=dict) + shared: dict[str, Any] = field(default_factory=dict) class HookHandler(Protocol): diff --git a/tests/unit/core/test_config.py b/tests/unit/core/test_config.py index d53f1a0..557a985 100644 --- a/tests/unit/core/test_config.py +++ b/tests/unit/core/test_config.py @@ -800,6 +800,65 @@ def test_save_config_cleans_up_on_error(tmp_path: Path) -> None: assert tmp_files == [] +def test_save_config_respects_reeln_config_env(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + env_path = tmp_path / "custom" / "config.json" + monkeypatch.setenv("REELN_CONFIG", str(env_path)) + + save_config(AppConfig()) + + assert env_path.is_file() + data = json.loads(env_path.read_text()) + assert "config_version" in data + + +def test_save_config_respects_reeln_profile_env(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("REELN_PROFILE", "game") + monkeypatch.delenv("REELN_CONFIG", raising=False) + + expected = default_config_path("game") + result = save_config(AppConfig(), path=expected) + + assert result == expected + + +# --------------------------------------------------------------------------- +# resolve_config_path +# --------------------------------------------------------------------------- + + +def test_resolve_config_path_explicit_wins(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + from reeln.core.config import resolve_config_path + + monkeypatch.setenv("REELN_CONFIG", "/should/be/ignored") + explicit = tmp_path / "explicit.json" + assert resolve_config_path(path=explicit) == explicit + + +def test_resolve_config_path_env_var(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + from reeln.core.config import resolve_config_path + + env_path = tmp_path / "env.json" + monkeypatch.setenv("REELN_CONFIG", str(env_path)) + assert resolve_config_path() == env_path + + +def test_resolve_config_path_profile(monkeypatch: pytest.MonkeyPatch) -> None: + from reeln.core.config import resolve_config_path + + monkeypatch.delenv("REELN_CONFIG", raising=False) + monkeypatch.delenv("REELN_PROFILE", raising=False) + result = resolve_config_path(profile="game") + assert result == default_config_path("game") + + +def test_resolve_config_path_default(monkeypatch: pytest.MonkeyPatch) -> None: + from reeln.core.config import resolve_config_path + + monkeypatch.delenv("REELN_CONFIG", raising=False) + monkeypatch.delenv("REELN_PROFILE", raising=False) + assert resolve_config_path() == default_config_path() + + # --------------------------------------------------------------------------- # validate_plugin_configs # --------------------------------------------------------------------------- diff --git a/tests/unit/core/test_highlights.py b/tests/unit/core/test_highlights.py index 57fa37a..cfa4a96 100644 --- a/tests/unit/core/test_highlights.py +++ b/tests/unit/core/test_highlights.py @@ -1031,6 +1031,30 @@ def test_init_game_dry_run_no_hook(tmp_path: Path) -> None: assert len(emitted) == 0 +def test_init_game_persists_livestreams(tmp_path: Path) -> None: + """Livestream URLs written to context.shared by plugins are saved to game.json.""" + + def fake_plugin(ctx: HookContext) -> None: + ctx.shared["livestreams"] = {"google": "https://youtube.com/live/abc123"} + + get_registry().register(Hook.ON_GAME_INIT, fake_plugin) + + info = GameInfo(date="2026-02-26", home_team="a", away_team="b", sport="hockey") + game_dir, _ = init_game(tmp_path, info) + + state = load_game_state(game_dir) + assert state.livestreams == {"google": "https://youtube.com/live/abc123"} + + +def test_init_game_no_livestreams_no_extra_save(tmp_path: Path) -> None: + """When no plugin writes livestreams, game.json is not re-saved.""" + info = GameInfo(date="2026-02-26", home_team="a", away_team="b", sport="hockey") + game_dir, _ = init_game(tmp_path, info) + + state = load_game_state(game_dir) + assert state.livestreams == {} + + def test_create_events_emits_on_event_created(tmp_path: Path) -> None: game_dir = tmp_path / "game" game_dir.mkdir() diff --git a/tests/unit/core/test_plugin_registry.py b/tests/unit/core/test_plugin_registry.py index f1c4673..a46e991 100644 --- a/tests/unit/core/test_plugin_registry.py +++ b/tests/unit/core/test_plugin_registry.py @@ -493,7 +493,8 @@ def test_build_plugin_status_no_update_when_same_version() -> None: def test_detect_installer_uv_found() -> None: with patch("reeln.core.plugin_registry.shutil.which", return_value="/usr/bin/uv"): result = detect_installer() - assert result == ["uv", "pip", "install"] + assert result[:3] == ["uv", "pip", "install"] + assert "--python" in result def test_detect_installer_uv_not_found() -> None: diff --git a/tests/unit/models/test_game.py b/tests/unit/models/test_game.py index a6b0944..7576093 100644 --- a/tests/unit/models/test_game.py +++ b/tests/unit/models/test_game.py @@ -145,6 +145,18 @@ def test_game_state_defaults() -> None: assert gs.events == [] +def test_game_state_livestreams_default() -> None: + gi = GameInfo(date="2026-02-26", home_team="a", away_team="b", sport="generic") + gs = GameState(game_info=gi) + assert gs.livestreams == {} + + +def test_game_state_with_livestreams() -> None: + gi = GameInfo(date="2026-02-26", home_team="a", away_team="b", sport="hockey") + gs = GameState(game_info=gi, livestreams={"google": "https://youtube.com/live/abc123"}) + assert gs.livestreams == {"google": "https://youtube.com/live/abc123"} + + def test_game_state_custom() -> None: gi = GameInfo(date="2026-02-26", home_team="a", away_team="b", sport="hockey") gs = GameState( @@ -511,6 +523,20 @@ def test_game_state_to_dict() -> None: assert d["events"] == [] +def test_game_state_to_dict_with_livestreams() -> None: + gi = GameInfo(date="2026-02-26", home_team="a", away_team="b", sport="hockey") + gs = GameState(game_info=gi, livestreams={"google": "https://youtube.com/live/abc123"}) + d = game_state_to_dict(gs) + assert d["livestreams"] == {"google": "https://youtube.com/live/abc123"} + + +def test_game_state_to_dict_livestreams_empty() -> None: + gi = GameInfo(date="2026-02-26", home_team="a", away_team="b", sport="hockey") + gs = GameState(game_info=gi) + d = game_state_to_dict(gs) + assert d["livestreams"] == {} + + def test_game_state_to_dict_with_finished_at() -> None: gi = GameInfo(date="2026-02-26", home_team="a", away_team="b", sport="hockey") gs = GameState( @@ -589,6 +615,33 @@ def test_dict_to_game_state_defaults() -> None: assert gs.events == [] +def test_dict_to_game_state_with_livestreams() -> None: + d = { + "game_info": { + "date": "2026-02-26", + "home_team": "a", + "away_team": "b", + "sport": "generic", + }, + "livestreams": {"google": "https://youtube.com/live/abc123"}, + } + gs = dict_to_game_state(d) + assert gs.livestreams == {"google": "https://youtube.com/live/abc123"} + + +def test_dict_to_game_state_livestreams_missing() -> None: + d = { + "game_info": { + "date": "2026-02-26", + "home_team": "a", + "away_team": "b", + "sport": "generic", + }, + } + gs = dict_to_game_state(d) + assert gs.livestreams == {} + + def test_dict_to_game_state_with_finished_at() -> None: d = { "game_info": { @@ -691,5 +744,6 @@ def test_game_state_round_trip() -> None: finished_at="2026-03-01T20:00:00+00:00", renders=[entry], events=[ev], + livestreams={"google": "https://youtube.com/live/abc123"}, ) assert dict_to_game_state(game_state_to_dict(gs)) == gs diff --git a/tests/unit/plugins/test_hooks.py b/tests/unit/plugins/test_hooks.py index b2daaa4..3c30f90 100644 --- a/tests/unit/plugins/test_hooks.py +++ b/tests/unit/plugins/test_hooks.py @@ -41,6 +41,7 @@ def test_hook_context_defaults() -> None: ctx = HookContext(hook=Hook.PRE_RENDER) assert ctx.hook is Hook.PRE_RENDER assert ctx.data == {} + assert ctx.shared == {} def test_hook_context_with_data() -> None: @@ -58,6 +59,38 @@ def test_hook_context_is_frozen() -> None: pass +def test_hook_context_shared_mutable_despite_frozen() -> None: + """Dict contents are mutable even though the dataclass is frozen.""" + ctx = HookContext(hook=Hook.ON_GAME_INIT) + ctx.shared["livestreams"] = {"google": "https://youtube.com/live/abc123"} + assert ctx.shared["livestreams"]["google"] == "https://youtube.com/live/abc123" + + +def test_hook_context_shared_survives_emit_cycle() -> None: + """Shared dict written by one handler is visible to subsequent handlers.""" + from reeln.plugins.registry import HookRegistry + + results: list[str] = [] + + def handler_a(context: HookContext) -> None: + context.shared["livestreams"] = context.shared.get("livestreams", {}) + context.shared["livestreams"]["google"] = "https://youtube.com/live/abc" + + def handler_b(context: HookContext) -> None: + url = context.shared.get("livestreams", {}).get("google", "") + results.append(url) + + registry = HookRegistry() + registry.register(Hook.ON_GAME_INIT, handler_a) + registry.register(Hook.ON_GAME_INIT, handler_b) + + ctx = HookContext(hook=Hook.ON_GAME_INIT) + registry.emit(Hook.ON_GAME_INIT, ctx) + + assert results == ["https://youtube.com/live/abc"] + assert ctx.shared["livestreams"]["google"] == "https://youtube.com/live/abc" + + # --------------------------------------------------------------------------- # HookHandler protocol # --------------------------------------------------------------------------- From 870c5f88f1499e45ae489fc524a0098166dd306e Mon Sep 17 00:00:00 2001 From: jremitz Date: Wed, 4 Mar 2026 19:17:33 -0600 Subject: [PATCH 2/3] Update docs for v0.0.26: plugin git install, HookContext.shared, config path Co-Authored-By: Claude Opus 4.6 --- docs/cli/plugins.md | 19 ++++++++++++++++++- docs/guide/configuration.md | 2 ++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/docs/cli/plugins.md b/docs/cli/plugins.md index 3bf8fde..75d5d84 100644 --- a/docs/cli/plugins.md +++ b/docs/cli/plugins.md @@ -84,7 +84,11 @@ reeln plugins install youtube --installer uv | `--dry-run` | Preview the install command without executing | | `--installer` | Force a specific installer (`pip` or `uv`) | -After installation, the plugin is automatically enabled in your config. The installer is auto-detected: `uv` is preferred when available, otherwise falls back to `pip`. On permission failures, the command auto-retries with `--user`. +After installation, the plugin is automatically enabled in your config. + +**Install source:** When a plugin's registry entry has a GitHub or GitLab homepage, the install uses `git+{homepage}` (e.g. `git+https://github.com/StreamnDad/reeln-plugin-streamn-scoreboard`). Plugins without a git homepage fall back to the PyPI package name. + +The installer is auto-detected: `uv` is preferred when available, otherwise falls back to `pip`. On permission failures, the command auto-retries with `--user`. ### `reeln plugins update` @@ -181,6 +185,19 @@ reeln exposes lifecycle hooks that plugins can subscribe to: | `ON_SEGMENT_COMPLETE` | After segment merge and state update | | `ON_ERROR` | When an error occurs in core operations | +Hooks receive a `HookContext` with three fields: + +- `hook` — the hook type (e.g. `Hook.ON_GAME_INIT`) +- `data` — read-only data from core (game directory, team profiles, etc.) +- `shared` — writable dict for plugins to pass data back to core + +```python +def on_game_init(context: HookContext) -> None: + game_dir = context.data["game_dir"] + # Write data back — core persists shared["livestreams"] to game.json + context.shared["livestreams"] = {"youtube": "https://..."} +``` + ## Capability protocols Plugins can implement typed capability interfaces: diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index e200ad6..d393b82 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -289,6 +289,8 @@ Priority order (highest wins): 4. `REELN_PROFILE` env var 5. Default XDG path (`config.json`) +This priority applies to both reading and writing. When a command modifies config (e.g. `reeln plugins enable`), the changes are written back to the same resolved path. + ```bash # Use a specific config file export REELN_CONFIG=~/projects/tournament/reeln.json From f1098bee98dc42c35083a4a059996bba94e8d9c8 Mon Sep 17 00:00:00 2001 From: jremitz Date: Wed, 4 Mar 2026 19:19:31 -0600 Subject: [PATCH 3/3] Fix ruff SIM102: combine nested if statements Co-Authored-By: Claude Opus 4.6 --- reeln/core/plugin_registry.py | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/reeln/core/plugin_registry.py b/reeln/core/plugin_registry.py index c6c3871..7a0581b 100644 --- a/reeln/core/plugin_registry.py +++ b/reeln/core/plugin_registry.py @@ -404,15 +404,14 @@ def install_plugin( result = _run_pip([target], dry_run=dry_run, installer=installer) # Verify the package is actually installed (uv can return 0 for no-ops) - if result.success and not dry_run: - if not get_installed_version(entry.package): - return PipResult( - success=False, - package=entry.package, - action="install", - error=f"Package '{entry.package}' not found after install. " - f"Check that the repository has a valid Python package.", - ) + if result.success and not dry_run and not get_installed_version(entry.package): + return PipResult( + success=False, + package=entry.package, + action="install", + error=f"Package '{entry.package}' not found after install. " + f"Check that the repository has a valid Python package.", + ) return result