Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 18 additions & 1 deletion docs/cli/plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`

Expand Down Expand Up @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions docs/guide/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion reeln/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@

from __future__ import annotations

__version__ = "0.0.25"
__version__ = "0.0.26"
40 changes: 29 additions & 11 deletions reeln/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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"))
Expand Down Expand Up @@ -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)
Expand Down
13 changes: 9 additions & 4 deletions reeln/core/highlights.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
24 changes: 12 additions & 12 deletions reeln/core/plugin_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]


Expand Down Expand Up @@ -403,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


Expand Down
3 changes: 3 additions & 0 deletions reeln/models/game.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)


# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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),
}


Expand All @@ -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", {})),
)
1 change: 1 addition & 0 deletions reeln/plugins/hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
59 changes: 59 additions & 0 deletions tests/unit/core/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
# ---------------------------------------------------------------------------
Expand Down
24 changes: 24 additions & 0 deletions tests/unit/core/test_highlights.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
3 changes: 2 additions & 1 deletion tests/unit/core/test_plugin_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading