diff --git a/.gitignore b/.gitignore index 3b59b144..fea80d19 100644 --- a/.gitignore +++ b/.gitignore @@ -52,7 +52,6 @@ config/backups/ # Starlark apps runtime storage (installed .star files and cached renders) /starlark-apps/ -skin_renders/ # JS test deps (test/js) node_modules/ diff --git a/scripts/validate_skin.py b/scripts/validate_skin.py deleted file mode 100644 index 53cb6ebe..00000000 --- a/scripts/validate_skin.py +++ /dev/null @@ -1,250 +0,0 @@ -#!/usr/bin/env python3 -""" -Headless skin validator — render a skin against bundled fixture games at -multiple panel sizes without hardware, a network, or a running service. - - python scripts/validate_skin.py --skin my-skin - python scripts/validate_skin.py --skin my-skin --sport baseball \ - --size 128x32 --size 64x32 --output-dir /tmp/skin_renders - -For each (mode x size) it checks: the manifest loads and its API version -matches, the render raises no exception, the canvas isn't blank, and the -render finishes inside a time budget (warn — the live renderer runs every -display-loop pass, and a Pi is far slower than your dev machine). PNGs are -saved (native plus 4x nearest-neighbor previews) so you can eyeball the -result. Exit code is non-zero when any check fails. -""" - -import argparse -import json -import logging -import sys -import time -from pathlib import Path - -PROJECT_ROOT = Path(__file__).resolve().parents[1] -sys.path.insert(0, str(PROJECT_ROOT)) - -from PIL import Image, ImageDraw, ImageFont # noqa: E402 - -from src.common.font_layout import load_truetype # noqa: E402 - -FIXTURES_DIR = PROJECT_ROOT / "src" / "skin_system" / "fixtures" -MODES = ("live", "recent", "upcoming") -SPORTS = ("baseball", "basketball", "football", "hockey") -RENDER_BUDGET_S = 0.100 - - -class FixtureHost: - """Stands in for a SportsCore instance: fonts, logger, logo loading, - outlined text — everything build_context needs, no network.""" - - def __init__(self, sport: str, skin_options: dict) -> None: - self.sport = sport - self.sport_key = sport - self.skin_options = skin_options - self.logger = logging.getLogger(f"validate_skin.{sport}") - self.fonts = self._load_fonts() - self._logo_cache = {} - self.display_manager = None # build_context is always given a size - - def _load_fonts(self) -> dict: - """Load the SportsCore font set (TTF, with PIL default fallback).""" - fonts = {} - try: - press = str(PROJECT_ROOT / "assets/fonts/PressStart2P-Regular.ttf") - small = str(PROJECT_ROOT / "assets/fonts/4x6-font.ttf") - fonts['score'] = load_truetype(press, 10) - fonts['time'] = load_truetype(press, 8) - fonts['team'] = load_truetype(press, 8) - fonts['status'] = load_truetype(small, 6) - fonts['detail'] = load_truetype(small, 6) - fonts['rank'] = load_truetype(press, 10) - except IOError: - default = ImageFont.load_default() - for key in ('score', 'time', 'team', 'status', 'detail', 'rank'): - fonts[key] = default - return fonts - - def _load_and_resize_logo(self, team_id: str, team_abbrev: str, - logo_path, logo_url) -> "Image.Image | None": - """Load a fixture logo from disk (no downloads), cached per team.""" - if team_abbrev in self._logo_cache: - return self._logo_cache[team_abbrev] - path = Path(logo_path) - if not path.is_absolute(): - path = PROJECT_ROOT / path - if not path.exists(): - return None - logo = Image.open(path).convert('RGBA') - self._logo_cache[team_abbrev] = logo - return logo - - def _draw_text_with_outline(self, draw: "ImageDraw.ImageDraw", text: str, - position: tuple, font, - fill: tuple = (255, 255, 255), - outline_color: tuple = (0, 0, 0)) -> None: - """Classic outlined scorebug text, same as SportsCore's helper.""" - x, y = position - for dx, dy in [(-1, -1), (-1, 0), (-1, 1), (0, -1), (0, 1), - (1, -1), (1, 0), (1, 1)]: - draw.text((x + dx, y + dy), text, font=font, fill=outline_color) - draw.text((x, y), text, font=font, fill=fill) - - -def load_fixture(sport: str, mode: str) -> dict: - with open(FIXTURES_DIR / f"{sport}_{mode}.json", encoding="utf-8") as f: - game = json.load(f) - # Real view models carry start_time_utc as a UTC datetime, not a string. - if isinstance(game.get("start_time_utc"), str): - from datetime import datetime - game["start_time_utc"] = datetime.fromisoformat(game["start_time_utc"]) - return game - - -def parse_size(value: str) -> "tuple[int, int]": - try: - w_text, h_text = value.lower().split("x") - w, h = int(w_text), int(h_text) - except ValueError as exc: - raise argparse.ArgumentTypeError(f"size must look like 128x32, got {value!r}") from exc - if w <= 0 or h <= 0: - raise argparse.ArgumentTypeError(f"size dimensions must be positive, got {value!r}") - return w, h - - -def parse_options(value: str) -> dict: - try: - options = json.loads(value) - except json.JSONDecodeError as exc: - raise argparse.ArgumentTypeError(f"options must be valid JSON: {exc.msg}") from exc - if not isinstance(options, dict): - raise argparse.ArgumentTypeError("options must be a JSON object") - return options - - -def display_path(path: Path) -> str: - """Repo-relative when inside the repo, absolute otherwise (--output-dir - may point anywhere, e.g. /tmp/skin_renders).""" - try: - return str(path.relative_to(PROJECT_ROOT)) - except ValueError: - return str(path) - - -def main() -> int: - parser = argparse.ArgumentParser(description=__doc__, - formatter_class=argparse.RawDescriptionHelpFormatter) - parser.add_argument("--skin", required=True, help="skin id (directory name under skins/)") - parser.add_argument("--sport", choices=SPORTS, - help="fixture sport (default: first sport the skin targets, else baseball)") - parser.add_argument("--size", action="append", type=parse_size, dest="sizes", - metavar="WxH", help="panel size to render at (repeatable; default 128x32 and 64x32)") - parser.add_argument("--output-dir", type=Path, - default=PROJECT_ROOT / "skin_renders", - help="where rendered PNGs are written") - parser.add_argument("--options", type=parse_options, default={}, - help="skin_options JSON to pass the skin") - args = parser.parse_args() - sizes = args.sizes or [(128, 32), (64, 32)] - - logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s") - - from src.skin_system import skin_runtime - from src.skin_system.skin_base import SKIN_API_VERSION - - skins = skin_runtime.discover_skins() - manifest = skins.get(args.skin) - if manifest is None: - print(f"FAIL: skin '{args.skin}' not found under {skin_runtime.get_skins_directory()}") - if skins: - print(f" installed skins: {', '.join(sorted(skins))}") - return 1 - - sport = args.sport - if sport is None: - declared = skin_runtime.skin_targets(manifest)[0] - sport = next((s for s in declared if s in SPORTS), "baseball") - - skin = skin_runtime.load_skin(args.skin, sport=sport, sport_key=sport, - options=args.options) - if skin is None: - print(f"FAIL: skin '{args.skin}' did not load " - f"(see log above; host API is {SKIN_API_VERSION})") - return 1 - - host = FixtureHost(sport, args.options) - args.output_dir.mkdir(parents=True, exist_ok=True) - failures = 0 - rendered = 0 - - for mode in MODES: - game = load_fixture(sport, mode) - render = getattr(skin, f"render_{mode}") - for width, height in sizes: - label = f"{mode}@{width}x{height}" - try: - # Warm-up render absorbs one-time font/image loads, second - # render is the one timed against the budget. - ctx = skin_runtime.build_context(host, game, size=(width, height)) - handled = render(ctx, dict(game)) - if handled: - ctx = skin_runtime.build_context(host, game, size=(width, height)) - started = time.monotonic() - handled = render(ctx, dict(game)) - elapsed = time.monotonic() - started - else: - elapsed = 0.0 - except Exception as e: - print(f"FAIL {label}: render raised {type(e).__name__}: {e}") - import traceback - traceback.print_exc() - failures += 1 - continue - - if not handled: - print(f"skip {label}: render_{mode} returned False (built-in renderer would be used)") - continue - - if ctx.canvas.size != (width, height): - print(f"FAIL {label}: canvas was replaced/resized to {ctx.canvas.size} — draw onto ctx.canvas, never reassign it") - failures += 1 - continue - if ctx.canvas.convert("L").getbbox() is None: - print(f"FAIL {label}: canvas is blank — render returned True but drew nothing") - failures += 1 - continue - if elapsed > RENDER_BUDGET_S: - print(f"WARN {label}: render took {elapsed * 1000:.0f}ms " - f"(budget {RENDER_BUDGET_S * 1000:.0f}ms; a Pi is much slower than this machine)") - - out = args.output_dir / f"{args.skin}_{sport}_{mode}_{width}x{height}.png" - ctx.canvas.save(out) - preview = ctx.canvas.resize((width * 4, height * 4), Image.NEAREST) - preview.save(out.with_name(out.stem + "_x4.png")) - print(f"ok {label}: {elapsed * 1000:.0f}ms -> {display_path(out)}") - rendered += 1 - - # Vegas card, once per mode at the first size (optional API) - try: - width, height = sizes[0] - ctx = skin_runtime.build_context(host, game, size=(width, height)) - card = skin.render_vegas_card(ctx, dict(game)) - if card is not None: - out = args.output_dir / f"{args.skin}_{sport}_{mode}_vegas.png" - card.save(out) - print(f"ok {mode} vegas card -> {display_path(out)}") - except Exception as e: - print(f"FAIL {mode} vegas card: {type(e).__name__}: {e}") - failures += 1 - - if rendered == 0 and failures == 0: - print(f"FAIL: skin '{args.skin}' rendered nothing — no render_ returned True") - return 1 - print(f"\n{'FAILED' if failures else 'PASSED'}: {rendered} renders, {failures} failures " - f"(PNGs in {args.output_dir})") - return 1 if failures else 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/skins/README.md b/skins/README.md deleted file mode 100644 index 78cb534e..00000000 --- a/skins/README.md +++ /dev/null @@ -1,28 +0,0 @@ -# skins/ - -> **Not supported yet.** The current scoreboard plugins don't render skins, -> so a skin placed here and selected in config has no effect, and the web UI -> and Plugin Store don't offer them. See -> [docs/SKIN_SYSTEM.md](../docs/SKIN_SYSTEM.md#status-not-supported-yet). - -User-installable **visual skins** for the sports scoreboards. Each -subdirectory is one skin: - -```text -skins// - skin.json # manifest - skin.py # renderer (a ScoreboardSkin subclass) - preview.png # optional -``` - -- Install a skin: `git clone skins/`. The Plugin Store - refuses registry entries with `"type": "skin"` while skins don't render. -- Select it: set `"skin": ""` in the plugin's section of - `config/config.json`. The web UI no longer shows a Visual Skin dropdown. -- Build one: start from `example-classic-baseball/` and read - [docs/CREATING_SKINS.md](../docs/CREATING_SKINS.md). Validate with - `python scripts/validate_skin.py --skin `. - -Skins survive plugin reinstalls/updates (that's why they live here and not in -the plugin's directory). A skin is Python at the same trust level as a -plugin — review before installing. diff --git a/skins/example-classic-baseball/preview.png b/skins/example-classic-baseball/preview.png deleted file mode 100644 index 37ad2dad..00000000 Binary files a/skins/example-classic-baseball/preview.png and /dev/null differ diff --git a/skins/example-classic-baseball/skin.json b/skins/example-classic-baseball/skin.json deleted file mode 100644 index ccec3071..00000000 --- a/skins/example-classic-baseball/skin.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "id": "example-classic-baseball", - "name": "Example: Classic Baseball", - "version": "1.0.0", - "author": "LEDMatrix", - "description": "Reference skin: a restyled baseball scorebug demonstrating the skin API. Copy this directory to start your own skin.", - "skin_api_version": "1.0.0", - "targets": { - "sports": [ - "baseball" - ], - "sport_keys": [ - "mlb", - "milb" - ] - }, - "entry_point": "skin.py", - "class_name": "ClassicBaseballSkin", - "modes": [ - "live", - "recent", - "upcoming" - ], - "preview": "preview.png" -} diff --git a/skins/example-classic-baseball/skin.py b/skins/example-classic-baseball/skin.py deleted file mode 100644 index 6882a52c..00000000 --- a/skins/example-classic-baseball/skin.py +++ /dev/null @@ -1,131 +0,0 @@ -""" -Example: Classic Baseball — the reference skin. - -Shows the whole skin API surface on purpose: adaptive regions -(scoreboard_regions), fitted text (ctx.layout.fit_text + ctx.draw_fit), -logos (ctx.load_logo + ctx.draw_image), raw PIL (ctx.draw for the bases -diamond), and per-user options (ctx.options). Everything is derived from -ctx and the game dict — a skin holds no state, does no I/O, and never -touches the display. - -Copy this directory to skins//, rename the class and the -manifest fields, and run: - - python scripts/validate_skin.py --skin -""" - -from src.adaptive_layout import LADDER_GRID, scoreboard_regions -from src.skin_system.skin_base import ScoreboardSkin, SkinContext - -DEFAULT_ACCENT = (255, 200, 0) - - -class ClassicBaseballSkin(ScoreboardSkin): - """Reference baseball skin: classic scorebug with bases/outs/count.""" - - def __init__(self, manifest: dict, options: dict): - super().__init__(manifest, options) - # Validate user options once at load time (fail fast, fall back - # gracefully) rather than surprising every render. - accent = self.options.get("accent_color", DEFAULT_ACCENT) - if (isinstance(accent, (list, tuple)) and len(accent) == 3 - and all(isinstance(c, int) and 0 <= c <= 255 for c in accent)): - self._accent_color = tuple(accent) - else: - import logging - logging.getLogger(__name__).error( - "accent_color must be three 0-255 integers, got %r; using default", accent) - self._accent_color = DEFAULT_ACCENT - - # -- shared pieces ---------------------------------------------------- - - def _accent(self, ctx: SkinContext) -> tuple: - """Users can recolor the skin from config via skin_options.""" - return self._accent_color - - def _draw_card(self, ctx: SkinContext, game: dict, status: str, - center_lines: list, detail: str) -> None: - """The common card: logos left/right, status on top, the given - center content, detail along the bottom.""" - regions = scoreboard_regions(ctx.layout.bounds, ctx=ctx.layout) - - ctx.draw_image(ctx.load_logo("away"), regions.away_slot, - cache_key=f"logo:{game.get('away_abbr')}") - ctx.draw_image(ctx.load_logo("home"), regions.home_slot, - cache_key=f"logo:{game.get('home_abbr')}") - - if status: - fit = ctx.layout.fit_text(status, regions.status_band, LADDER_GRID) - ctx.draw_fit(fit, regions.status_band, color=self._accent(ctx)) - - if center_lines: - rows = regions.score_area.split_v(*[1] * len(center_lines)) - for line, row in zip(center_lines, rows): - if line: - fit = ctx.layout.fit_text(line, row, LADDER_GRID) - ctx.draw_fit(fit, row) - - if detail: - fit = ctx.layout.fit_text(detail, regions.detail_band, LADDER_GRID) - ctx.draw_fit(fit, regions.detail_band, color=(160, 160, 160)) - - def _draw_bases_and_outs(self, ctx: SkinContext, game: dict) -> None: - """Raw-PIL escape hatch: a bases diamond + out dots in the bottom - band, sized from the layout scale so it works on any panel.""" - size = ctx.layout.px(3, minimum=2) # half-diagonal of one base - gap = ctx.layout.px(1) - cx = ctx.width // 2 - cy = ctx.height - (size * 2) - 1 - - bases = game.get("bases_occupied") or [False, False, False] - # (dx, dy) per base: first (right), second (top), third (left) - offsets = [(size + gap, 0), (0, -(size + gap)), (-(size + gap), 0)] - for occupied, (dx, dy) in zip(bases, offsets): - x, y = cx + dx, cy + dy - diamond = [(x, y - size), (x + size, y), (x, y + size), (x - size, y)] - if occupied: - ctx.draw.polygon(diamond, fill=self._accent(ctx)) - else: - ctx.draw.polygon(diamond, outline=(110, 110, 110)) - - outs = min(int(game.get("outs") or 0), 3) - r = max(1, size - 1) - for i in range(3): - x = cx + (i - 1) * (2 * r + 2 * gap) - y = ctx.height - r - 1 - dot = [x - r, y - r, x + r, y + r] - if i < outs: - ctx.draw.ellipse(dot, fill=(255, 255, 255)) - else: - ctx.draw.ellipse(dot, outline=(110, 110, 110)) - - # -- the three modes -------------------------------------------------- - - def render_live(self, ctx: SkinContext, game: dict) -> bool: - half = "▲" if game.get("inning_half") == "top" else "▼" - inning = game.get("inning") or "" - status = f"{half}{inning}" if inning else game.get("status_text", "") - score = f"{game.get('away_score', '0')}-{game.get('home_score', '0')}" - count = f"{game.get('balls', 0)}-{game.get('strikes', 0)}" - - self._draw_card(ctx, game, status, [score], "") - self._draw_bases_and_outs(ctx, game) - - # Ball-strike count in the top-left corner, over the away logo. - fit = ctx.layout.fit_text(count, (ctx.width // 4, ctx.layout.px(8, minimum=6)), LADDER_GRID) - ctx.draw_fit(fit, ctx.layout.bounds.top_band(fit.height + 1).left_col(fit.width + 2), - color=(200, 200, 200)) - return True - - def render_recent(self, ctx: SkinContext, game: dict) -> bool: - score = f"{game.get('away_score', '0')}-{game.get('home_score', '0')}" - self._draw_card(ctx, game, game.get("status_text", "Final"), - [score], game.get("series_summary", "")) - return True - - def render_upcoming(self, ctx: SkinContext, game: dict) -> bool: - matchup = f"{game.get('away_abbr', '')}@{game.get('home_abbr', '')}" - self._draw_card(ctx, game, game.get("game_date", ""), - [matchup, game.get("game_time", "")], - f"{game.get('away_record', '')} {game.get('home_record', '')}".strip()) - return True diff --git a/src/plugin_system/schema_manager.py b/src/plugin_system/schema_manager.py index d4eb70b8..9f5e59fe 100644 --- a/src/plugin_system/schema_manager.py +++ b/src/plugin_system/schema_manager.py @@ -658,53 +658,6 @@ class SchemaManager: self.logger.error(error_msg) return False, [error_msg] - def inject_skin_selector(self, schema: Dict[str, Any], plugin_id: str, - current_value: Any = None) -> Dict[str, Any]: - """Return a copy of a plugin's schema with a "skin" dropdown added - when installed skins target this plugin (docs/SKIN_SYSTEM.md). - - Serve-time only — validation never sees this enum, so a config - referencing an uninstalled skin stays valid (rendering falls back - to the built-in layout). The currently-configured value is always - included in the enum for the same reason: the dropdown must be able - to display a selection whose skin was removed. - """ - # A per-mode mapping ({"live": ..., "recent": ...}) can't be edited - # through a string dropdown — injecting one would let the form save - # a string over the mapping. Leave the schema alone; per-mode users - # edit via the raw JSON config editor. - if isinstance(current_value, dict): - return schema - - try: - from src.skin_system import skin_runtime - matching = skin_runtime.skins_for_plugin(plugin_id) - except Exception as e: - self.logger.debug(f"Skin discovery failed for {plugin_id}: {e}") - return schema - - choices = sorted(matching.keys()) - if isinstance(current_value, str) and current_value and \ - current_value != "built-in" and current_value not in choices: - choices.append(current_value) - if not choices: - return schema - - enhanced = copy.deepcopy(schema) - enhanced.setdefault("properties", {}) - if "skin" not in enhanced["properties"]: - names = {sid: (matching.get(sid, {}).get("name") or sid) for sid in choices} - enhanced["properties"]["skin"] = { - "type": "string", - "title": "Visual Skin", - "description": "Replace this scoreboard's look with an installed skin " - "(data, scheduling, and vegas mode are unaffected)", - "enum": ["built-in", *choices], - "enumNames": ["Built-in", *(names[sid] for sid in choices)], - "default": "built-in" - } - return enhanced - def _format_validation_error(self, error: ValidationError, plugin_id: Optional[str] = None) -> str: """ Format a validation error into a readable message. diff --git a/src/plugin_system/store_manager.py b/src/plugin_system/store_manager.py index d2fb5ab2..2158a1b6 100644 --- a/src/plugin_system/store_manager.py +++ b/src/plugin_system/store_manager.py @@ -1338,18 +1338,6 @@ class PluginStoreManager: self.logger.error(f"Plugin not found in registry: {plugin_id}") return False - # Visual skins share the registry. _install_skin_from_info can put one - # in skins/, but no current scoreboard plugin renders skins, so the - # store refuses them rather than installing something that does - # nothing (docs/SKIN_SYSTEM.md). Manual installs under skins/ and - # uninstall_skin are unaffected. - if (plugin_info.get('type') or 'plugin') == 'skin': - from src.skin_system import SKINS_RENDER_SUPPORTED, SKINS_UNSUPPORTED_MESSAGE - if not SKINS_RENDER_SUPPORTED: - self.logger.error(f"Not installing skin {plugin_id}: {SKINS_UNSUPPORTED_MESSAGE}") - return False - return self._install_skin_from_info(plugin_id, plugin_info, branch) - repo_url = plugin_info.get('repo') if not repo_url: self.logger.error(f"Plugin {plugin_id} missing repository URL") @@ -2502,152 +2490,6 @@ class PluginStoreManager: continue return None - _SKIN_ID_PATTERN = re.compile(r'^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$') - - def _resolve_skin_target(self, skin_id: str) -> Optional[Path]: - """Validate an externally-supplied skin id and resolve it to a path - strictly inside the skins directory. Returns None (after logging) - for ids that are malformed or would escape the directory — registry - entries and manifests are external input and must not be able to - write or delete outside skins/.""" - from src.skin_system import skin_runtime - - if not isinstance(skin_id, str) or not self._SKIN_ID_PATTERN.match(skin_id) \ - or '..' in skin_id: - self.logger.error(f"Rejecting unsafe skin id: {skin_id!r}") - return None - skins_dir = skin_runtime.get_skins_directory().resolve() - target = (skins_dir / skin_id).resolve() - if target.parent != skins_dir: - self.logger.error(f"Skin id {skin_id!r} escapes the skins directory; rejecting") - return None - return target - - def _install_skin_from_info(self, skin_id: str, skin_info: Dict, - branch: Optional[str] = None) -> bool: - """Install a registry entry of type "skin" into skins//. - - Reuses the plugin download machinery (git / monorepo zip / archive) - but validates skin.json instead of manifest.json and never installs - dependencies — skins are render-only (stdlib + PIL + the provided - SkinContext), which is also what keeps them safe to iterate on. - - Downloads into a staging directory and validates there; the - existing installation is only replaced after the new one passes, - so a failed download or bad manifest can't destroy a working skin. - """ - from src.skin_system import skin_runtime - from src.skin_system.skin_base import SKIN_API_VERSION - - repo_url = skin_info.get('repo') - if not repo_url: - self.logger.error(f"Skin {skin_id} missing repository URL") - return False - - target = self._resolve_skin_target(skin_id) - if target is None: - return False - skins_dir = target.parent - skins_dir.mkdir(parents=True, exist_ok=True) - # Leading "_" keeps staging invisible to skin discovery - staging = skins_dir / f"_staging-{skin_id}" - if staging.exists() and not self._safe_remove_directory(staging): - return False - - subpath = skin_info.get('plugin_path') - branch_candidates = self._distinct_sequence([ - branch, - skin_info.get('branch'), - skin_info.get('default_branch'), - skin_info.get('last_commit_branch'), - 'main', - 'master' - ]) - - try: - branch_used = None - if subpath: - for candidate in branch_candidates: - download_url = f"{repo_url}/archive/refs/heads/{candidate}.zip" - if self._install_from_monorepo(download_url, subpath, staging): - branch_used = candidate - break - else: - branch_used = self._install_via_git(repo_url, staging, branch_candidates) - if branch_used is None and not staging.exists(): - for candidate in branch_candidates: - download_url = f"{repo_url}/archive/refs/heads/{candidate}.zip" - if self._install_via_download(download_url, staging): - branch_used = candidate - break - - if branch_used is None and not staging.exists(): - self.logger.error(f"Failed to install skin {skin_id} via git or archive download") - return False - - try: - with open(staging / 'skin.json', 'r', encoding='utf-8') as f: - manifest = json.load(f) - except (OSError, json.JSONDecodeError) as e: - self.logger.error(f"Skin {skin_id} has no valid skin.json: {e}") - return False - - missing = [k for k in ('id', 'name', 'version', 'skin_api_version', 'class_name') - if not manifest.get(k)] - if missing: - self.logger.error(f"Skin {skin_id} manifest missing fields: {missing}") - return False - - # Unlike plugins, a mismatched id is rejected rather than - # renamed: the manifest id is external input, and the registry - # id is what the user asked to install. - if manifest['id'] != skin_id: - self.logger.error( - f"Skin manifest id {manifest['id']!r} doesn't match registry id " - f"{skin_id!r}; not installing") - return False - - def _api_major(v): - try: - return int(str(v).split('.')[0]) - except (ValueError, IndexError): - return None - - if _api_major(manifest['skin_api_version']) != _api_major(SKIN_API_VERSION): - self.logger.error( - f"Skin {skin_id} targets skin API {manifest['skin_api_version']} but this " - f"LEDMatrix provides {SKIN_API_VERSION}; not installing") - return False - - # Validated — swap into place - if target.exists() and not self._safe_remove_directory(target): - self.logger.error(f"Could not replace existing skin directory: {target}") - return False - shutil.move(str(staging), str(target)) - skin_runtime.discover_skins(force_refresh=True) - self.logger.info(f"Successfully installed skin: {skin_id} (branch: {branch_used})") - return True - finally: - if staging.exists(): - self._safe_remove_directory(staging) - - def uninstall_skin(self, skin_id: str) -> bool: - """Remove an installed skin. Plugin configs referencing it keep - validating; rendering falls back to the built-in layout.""" - from src.skin_system import skin_runtime - - target = self._resolve_skin_target(skin_id) - if target is None: - return False - if not target.exists(): - self.logger.info(f"Skin {skin_id} not found (already uninstalled)") - return True - if self._safe_remove_directory(target): - skin_runtime.discover_skins(force_refresh=True) - self.logger.info(f"Successfully uninstalled skin: {skin_id}") - return True - return False - def uninstall_plugin(self, plugin_id: str) -> bool: """ Uninstall a plugin by removing its directory. @@ -2661,12 +2503,6 @@ class PluginStoreManager: plugin_path = self._find_plugin_path(plugin_id) if plugin_path is None or not plugin_path.exists(): - # A skin id passed to the plugin uninstall path (the store UI - # uses one uninstall flow) removes the skin instead - skin_target = self._resolve_skin_target(plugin_id) \ - if self._SKIN_ID_PATTERN.match(str(plugin_id)) else None - if skin_target is not None and skin_target.exists(): - return self.uninstall_skin(plugin_id) self.logger.info(f"Plugin {plugin_id} not found (already uninstalled)") return True # Already uninstalled, consider this success diff --git a/src/skin_system/__init__.py b/src/skin_system/__init__.py deleted file mode 100644 index 422358da..00000000 --- a/src/skin_system/__init__.py +++ /dev/null @@ -1,43 +0,0 @@ -""" -Skin system: user-installable visual overlays for sports scoreboards. - -A skin replaces only the rendering of a scoreboard (live / recent / -upcoming) while the host plugin keeps doing data fetching, scheduling, -caching, live priority, and vegas mode. See docs/SKIN_SYSTEM.md. -""" - -# Skins are not offered to users yet. The only render hook is -# SportsCore._render_game in src/base_classes/sports/core.py, and none of the -# current scoreboard plugins (monorepo or third-party) build on -# src.base_classes, so a selected skin never draws. The web UI and store -# read these instead of offering install/selection; stored "skin" config -# values still load and save. See docs/SKIN_SYSTEM.md. -SKINS_RENDER_SUPPORTED = False -SKINS_UNSUPPORTED_MESSAGE = ( - "Skins aren't supported yet: the current scoreboard plugins don't render " - "them. Installed skins and saved skin settings are kept but have no effect." -) - -from src.skin_system.skin_base import ( # noqa: E402 - SKIN_API_VERSION, - VIEW_MODEL_VERSION, - ScoreboardSkin, - SkinContext, -) -from src.skin_system.skin_runtime import ( - build_context, - discover_skins, - get_skins_directory, - load_skin, -) - -__all__ = [ - "SKIN_API_VERSION", - "VIEW_MODEL_VERSION", - "ScoreboardSkin", - "SkinContext", - "build_context", - "discover_skins", - "get_skins_directory", - "load_skin", -] diff --git a/src/skin_system/fixtures/baseball_live.json b/src/skin_system/fixtures/baseball_live.json deleted file mode 100644 index c5bcec34..00000000 --- a/src/skin_system/fixtures/baseball_live.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "id": "401570001", - "game_time": "7:05PM", - "game_date": "Jul 16th", - "start_time_utc": "2026-07-16T23:05:00+00:00", - "status_text": "Bot 7th", - "is_live": true, - "is_final": false, - "is_upcoming": false, - "is_halftime": false, - "is_period_break": false, - "home_abbr": "LAD", - "home_id": "19", - "home_score": "5", - "home_logo_path": "src/skin_system/fixtures/placeholder_home.png", - "home_logo_url": null, - "home_record": "58-33", - "away_abbr": "SF", - "away_id": "26", - "away_score": "3", - "away_logo_path": "src/skin_system/fixtures/placeholder_away.png", - "away_logo_url": null, - "away_record": "49-42", - "is_within_window": true, - "status": "STATUS_IN_PROGRESS", - "status_state": "in", - "inning": 7, - "inning_half": "bottom", - "balls": 3, - "strikes": 2, - "outs": 2, - "bases_occupied": [ - true, - true, - true - ], - "start_time": "2026-07-16T23:05:00Z", - "series_summary": "LAD leads 2-1" -} \ No newline at end of file diff --git a/src/skin_system/fixtures/baseball_recent.json b/src/skin_system/fixtures/baseball_recent.json deleted file mode 100644 index 15ed238f..00000000 --- a/src/skin_system/fixtures/baseball_recent.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "id": "401570001", - "game_time": "7:05PM", - "game_date": "Jul 16th", - "start_time_utc": "2026-07-16T23:05:00+00:00", - "status_text": "Final", - "is_live": false, - "is_final": true, - "is_upcoming": false, - "is_halftime": false, - "is_period_break": false, - "home_abbr": "LAD", - "home_id": "19", - "home_score": "5", - "home_logo_path": "src/skin_system/fixtures/placeholder_home.png", - "home_logo_url": null, - "home_record": "58-33", - "away_abbr": "SF", - "away_id": "26", - "away_score": "3", - "away_logo_path": "src/skin_system/fixtures/placeholder_away.png", - "away_logo_url": null, - "away_record": "49-42", - "is_within_window": true, - "status": "STATUS_FINAL", - "status_state": "post", - "inning": 9, - "inning_half": "top", - "balls": 0, - "strikes": 0, - "outs": 3, - "bases_occupied": [ - false, - false, - false - ], - "start_time": "2026-07-16T23:05:00Z", - "series_summary": "Series tied 2-2" -} \ No newline at end of file diff --git a/src/skin_system/fixtures/baseball_upcoming.json b/src/skin_system/fixtures/baseball_upcoming.json deleted file mode 100644 index 48ccb857..00000000 --- a/src/skin_system/fixtures/baseball_upcoming.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "id": "401570001", - "game_time": "7:05PM", - "game_date": "Jul 16th", - "start_time_utc": "2026-07-16T23:05:00+00:00", - "status_text": "7:05 PM", - "is_live": false, - "is_final": false, - "is_upcoming": true, - "is_halftime": false, - "is_period_break": false, - "home_abbr": "LAD", - "home_id": "19", - "home_score": "0", - "home_logo_path": "src/skin_system/fixtures/placeholder_home.png", - "home_logo_url": null, - "home_record": "58-33", - "away_abbr": "SF", - "away_id": "26", - "away_score": "0", - "away_logo_path": "src/skin_system/fixtures/placeholder_away.png", - "away_logo_url": null, - "away_record": "49-42", - "is_within_window": true, - "status": "STATUS_SCHEDULED", - "status_state": "pre", - "inning": 0, - "inning_half": "top", - "balls": 0, - "strikes": 0, - "outs": 0, - "bases_occupied": [ - false, - false, - false - ], - "start_time": "2026-07-16T23:05:00Z", - "series_summary": "" -} \ No newline at end of file diff --git a/src/skin_system/fixtures/basketball_live.json b/src/skin_system/fixtures/basketball_live.json deleted file mode 100644 index 06e86880..00000000 --- a/src/skin_system/fixtures/basketball_live.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "id": "401570001", - "game_time": "7:05PM", - "game_date": "Jul 16th", - "start_time_utc": "2026-07-16T23:05:00+00:00", - "status_text": "Q4 2:34", - "is_live": true, - "is_final": false, - "is_upcoming": false, - "is_halftime": false, - "is_period_break": false, - "home_abbr": "OKC", - "home_id": "19", - "home_score": "5", - "home_logo_path": "src/skin_system/fixtures/placeholder_home.png", - "home_logo_url": null, - "home_record": "", - "away_abbr": "MIN", - "away_id": "26", - "away_score": "3", - "away_logo_path": "src/skin_system/fixtures/placeholder_away.png", - "away_logo_url": null, - "away_record": "", - "is_within_window": true, - "period": 4, - "period_text": "Q4", - "clock": "2:34" -} \ No newline at end of file diff --git a/src/skin_system/fixtures/basketball_recent.json b/src/skin_system/fixtures/basketball_recent.json deleted file mode 100644 index 96e15d06..00000000 --- a/src/skin_system/fixtures/basketball_recent.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "id": "401570001", - "game_time": "7:05PM", - "game_date": "Jul 16th", - "start_time_utc": "2026-07-16T23:05:00+00:00", - "status_text": "Final", - "is_live": false, - "is_final": true, - "is_upcoming": false, - "is_halftime": false, - "is_period_break": false, - "home_abbr": "OKC", - "home_id": "19", - "home_score": "5", - "home_logo_path": "src/skin_system/fixtures/placeholder_home.png", - "home_logo_url": null, - "home_record": "", - "away_abbr": "MIN", - "away_id": "26", - "away_score": "3", - "away_logo_path": "src/skin_system/fixtures/placeholder_away.png", - "away_logo_url": null, - "away_record": "", - "is_within_window": true, - "period": 4, - "period_text": "Final", - "clock": "0:00" -} \ No newline at end of file diff --git a/src/skin_system/fixtures/basketball_upcoming.json b/src/skin_system/fixtures/basketball_upcoming.json deleted file mode 100644 index 32cd7df6..00000000 --- a/src/skin_system/fixtures/basketball_upcoming.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "id": "401570001", - "game_time": "7:05PM", - "game_date": "Jul 16th", - "start_time_utc": "2026-07-16T23:05:00+00:00", - "status_text": "7:05 PM", - "is_live": false, - "is_final": false, - "is_upcoming": true, - "is_halftime": false, - "is_period_break": false, - "home_abbr": "OKC", - "home_id": "19", - "home_score": "0", - "home_logo_path": "src/skin_system/fixtures/placeholder_home.png", - "home_logo_url": null, - "home_record": "", - "away_abbr": "MIN", - "away_id": "26", - "away_score": "0", - "away_logo_path": "src/skin_system/fixtures/placeholder_away.png", - "away_logo_url": null, - "away_record": "", - "is_within_window": true, - "period": 0, - "period_text": "", - "clock": "0:00" -} \ No newline at end of file diff --git a/src/skin_system/fixtures/football_live.json b/src/skin_system/fixtures/football_live.json deleted file mode 100644 index b680e640..00000000 --- a/src/skin_system/fixtures/football_live.json +++ /dev/null @@ -1,36 +0,0 @@ -{ - "id": "401570001", - "game_time": "7:05PM", - "game_date": "Jul 16th", - "start_time_utc": "2026-07-16T23:05:00+00:00", - "status_text": "Q3 8:12", - "is_live": true, - "is_final": false, - "is_upcoming": false, - "is_halftime": false, - "is_period_break": false, - "home_abbr": "KC", - "home_id": "19", - "home_score": "21", - "home_logo_path": "src/skin_system/fixtures/placeholder_home.png", - "home_logo_url": null, - "home_record": "58-33", - "away_abbr": "BUF", - "away_id": "26", - "away_score": "17", - "away_logo_path": "src/skin_system/fixtures/placeholder_away.png", - "away_logo_url": null, - "away_record": "49-42", - "is_within_window": true, - "period": 3, - "period_text": "Q3", - "clock": "8:12", - "home_timeouts": 2, - "away_timeouts": 3, - "down_distance_text": "3rd & 4", - "down_distance_text_long": "3rd & 4 at KC 22", - "is_redzone": true, - "possession": "12", - "possession_indicator": "away", - "scoring_event": null -} \ No newline at end of file diff --git a/src/skin_system/fixtures/football_recent.json b/src/skin_system/fixtures/football_recent.json deleted file mode 100644 index 1f2efb5c..00000000 --- a/src/skin_system/fixtures/football_recent.json +++ /dev/null @@ -1,36 +0,0 @@ -{ - "id": "401570001", - "game_time": "7:05PM", - "game_date": "Jul 16th", - "start_time_utc": "2026-07-16T23:05:00+00:00", - "status_text": "Final", - "is_live": false, - "is_final": true, - "is_upcoming": false, - "is_halftime": false, - "is_period_break": false, - "home_abbr": "KC", - "home_id": "19", - "home_score": "21", - "home_logo_path": "src/skin_system/fixtures/placeholder_home.png", - "home_logo_url": null, - "home_record": "58-33", - "away_abbr": "BUF", - "away_id": "26", - "away_score": "17", - "away_logo_path": "src/skin_system/fixtures/placeholder_away.png", - "away_logo_url": null, - "away_record": "49-42", - "is_within_window": true, - "period": 4, - "period_text": "Final", - "clock": "0:00", - "home_timeouts": 0, - "away_timeouts": 0, - "down_distance_text": "", - "down_distance_text_long": "", - "is_redzone": false, - "possession": null, - "possession_indicator": null, - "scoring_event": null -} \ No newline at end of file diff --git a/src/skin_system/fixtures/football_upcoming.json b/src/skin_system/fixtures/football_upcoming.json deleted file mode 100644 index 7b28c0a9..00000000 --- a/src/skin_system/fixtures/football_upcoming.json +++ /dev/null @@ -1,36 +0,0 @@ -{ - "id": "401570001", - "game_time": "7:05PM", - "game_date": "Jul 16th", - "start_time_utc": "2026-07-16T23:05:00+00:00", - "status_text": "7:05 PM", - "is_live": false, - "is_final": false, - "is_upcoming": true, - "is_halftime": false, - "is_period_break": false, - "home_abbr": "KC", - "home_id": "19", - "home_score": "0", - "home_logo_path": "src/skin_system/fixtures/placeholder_home.png", - "home_logo_url": null, - "home_record": "58-33", - "away_abbr": "BUF", - "away_id": "26", - "away_score": "0", - "away_logo_path": "src/skin_system/fixtures/placeholder_away.png", - "away_logo_url": null, - "away_record": "49-42", - "is_within_window": true, - "period": 0, - "period_text": "", - "clock": "0:00", - "home_timeouts": 3, - "away_timeouts": 3, - "down_distance_text": "", - "down_distance_text_long": "", - "is_redzone": false, - "possession": null, - "possession_indicator": null, - "scoring_event": null -} \ No newline at end of file diff --git a/src/skin_system/fixtures/hockey_live.json b/src/skin_system/fixtures/hockey_live.json deleted file mode 100644 index 2d53425d..00000000 --- a/src/skin_system/fixtures/hockey_live.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "id": "401570001", - "game_time": "7:05PM", - "game_date": "Jul 16th", - "start_time_utc": "2026-07-16T23:05:00+00:00", - "status_text": "P3 14:55", - "is_live": true, - "is_final": false, - "is_upcoming": false, - "is_halftime": false, - "is_period_break": false, - "home_abbr": "COL", - "home_id": "19", - "home_score": "2", - "home_logo_path": "src/skin_system/fixtures/placeholder_home.png", - "home_logo_url": null, - "home_record": "58-33", - "away_abbr": "VGK", - "away_id": "26", - "away_score": "2", - "away_logo_path": "src/skin_system/fixtures/placeholder_away.png", - "away_logo_url": null, - "away_record": "49-42", - "is_within_window": true, - "period": 3, - "period_text": "P3", - "clock": "14:55", - "power_play": true, - "penalties": [], - "home_shots": 27, - "away_shots": 31 -} \ No newline at end of file diff --git a/src/skin_system/fixtures/hockey_recent.json b/src/skin_system/fixtures/hockey_recent.json deleted file mode 100644 index 0127162a..00000000 --- a/src/skin_system/fixtures/hockey_recent.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "id": "401570001", - "game_time": "7:05PM", - "game_date": "Jul 16th", - "start_time_utc": "2026-07-16T23:05:00+00:00", - "status_text": "Final/OT", - "is_live": false, - "is_final": true, - "is_upcoming": false, - "is_halftime": false, - "is_period_break": false, - "home_abbr": "COL", - "home_id": "19", - "home_score": "3", - "home_logo_path": "src/skin_system/fixtures/placeholder_home.png", - "home_logo_url": null, - "home_record": "58-33", - "away_abbr": "VGK", - "away_id": "26", - "away_score": "2", - "away_logo_path": "src/skin_system/fixtures/placeholder_away.png", - "away_logo_url": null, - "away_record": "49-42", - "is_within_window": true, - "period": 5, - "period_text": "Final/OT", - "clock": "0:00", - "power_play": false, - "penalties": [], - "home_shots": 35, - "away_shots": 33 -} \ No newline at end of file diff --git a/src/skin_system/fixtures/hockey_upcoming.json b/src/skin_system/fixtures/hockey_upcoming.json deleted file mode 100644 index 64caf089..00000000 --- a/src/skin_system/fixtures/hockey_upcoming.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "id": "401570001", - "game_time": "7:05PM", - "game_date": "Jul 16th", - "start_time_utc": "2026-07-16T23:05:00+00:00", - "status_text": "7:05 PM", - "is_live": false, - "is_final": false, - "is_upcoming": true, - "is_halftime": false, - "is_period_break": false, - "home_abbr": "COL", - "home_id": "19", - "home_score": "0", - "home_logo_path": "src/skin_system/fixtures/placeholder_home.png", - "home_logo_url": null, - "home_record": "58-33", - "away_abbr": "VGK", - "away_id": "26", - "away_score": "0", - "away_logo_path": "src/skin_system/fixtures/placeholder_away.png", - "away_logo_url": null, - "away_record": "49-42", - "is_within_window": true, - "period": 0, - "period_text": "", - "clock": "0:00", - "power_play": false, - "penalties": [], - "home_shots": 0, - "away_shots": 0 -} \ No newline at end of file diff --git a/src/skin_system/fixtures/placeholder_away.png b/src/skin_system/fixtures/placeholder_away.png deleted file mode 100644 index fc9ec4ee..00000000 Binary files a/src/skin_system/fixtures/placeholder_away.png and /dev/null differ diff --git a/src/skin_system/fixtures/placeholder_home.png b/src/skin_system/fixtures/placeholder_home.png deleted file mode 100644 index 7533f054..00000000 Binary files a/src/skin_system/fixtures/placeholder_home.png and /dev/null differ diff --git a/src/skin_system/skin_base.py b/src/skin_system/skin_base.py deleted file mode 100644 index e687014b..00000000 --- a/src/skin_system/skin_base.py +++ /dev/null @@ -1,171 +0,0 @@ -""" -Skin API: the classes a skin author works with. - -A skin is a directory under skins// containing a skin.json -manifest and a Python module exposing a ScoreboardSkin subclass. The -host (a sports scoreboard's base classes) builds a SkinContext per -render and calls render_live / render_recent / render_upcoming with the -game view model. The skin draws onto ctx.canvas and returns True; the -host composites the canvas onto the display. A skin never talks to the -display, the network, or the plugin directly. - -Skin API Version: 1.0.0 -View Model Version: 1.0 -""" - -from abc import ABC -from dataclasses import dataclass, field -from typing import Any, Callable, Dict, Optional, Tuple, Union - -from PIL import Image, ImageDraw - -try: - import freetype -except ImportError: # pragma: no cover - freetype ships with the project deps - freetype = None - -from src.adaptive_layout import FitResult, LayoutContext, Region - -# Major must match a skin manifest's skin_api_version major or the skin -# is refused at load time (renames/removals bump major; additions minor). -SKIN_API_VERSION = "1.0.0" - -# Version of the guaranteed `game` dict keys (see docs/CREATING_SKINS.md). -VIEW_MODEL_VERSION = "1.0" - - -def _draw_bdf_text_on(draw: ImageDraw.ImageDraw, text: str, x: int, y: int, - color: Tuple[int, int, int], face: Any, - clip_w: int, clip_h: int) -> None: - """Render a freetype BDF face glyph-by-glyph onto an arbitrary canvas. - - DisplayManager._draw_bdf_text only draws onto the panel image; skins - draw onto their own canvas, so the fitted-font path (fit_text can - return freetype faces) needs this standalone equivalent. - """ - try: - ascender_px = face.size.ascender >> 6 - except Exception: - ascender_px = 0 - baseline_y = y + ascender_px - for char in text: - face.load_char(char) - bitmap = face.glyph.bitmap - glyph_left = face.glyph.bitmap_left - glyph_top = face.glyph.bitmap_top - for i in range(bitmap.rows): - for j in range(bitmap.width): - byte_index = i * bitmap.pitch + (j // 8) - if byte_index < len(bitmap.buffer) and \ - bitmap.buffer[byte_index] & (1 << (7 - (j % 8))): - px = x + glyph_left + j - py = baseline_y - glyph_top + i - if 0 <= px < clip_w and 0 <= py < clip_h: - draw.point((px, py), fill=color) - x += face.glyph.advance.x >> 6 - - -@dataclass -class SkinContext: - """Everything a skin may touch during one render call. - - The canvas is a fresh RGB image sized to the current display (or - vegas card). Draw onto it via the helpers below or raw ``draw``; - never call display/update methods — the host composites the canvas. - """ - - canvas: Image.Image - draw: ImageDraw.ImageDraw - layout: LayoutContext - width: int - height: int - fonts: Dict[str, Any] - options: Dict[str, Any] - logger: Any - sport: Optional[str] = None - view_model_version: str = VIEW_MODEL_VERSION - # load_logo("home") / load_logo("away") -> RGBA PIL image or None. - # Bound to the current game; hits the host's logo cache (never loads - # from disk twice), downloads missing logos like the built-in layout. - load_logo: Callable[[str], Optional[Image.Image]] = field(default=lambda side: None) - # draw_text_outlined(text, (x, y), font, fill=..., outline_color=...) - # — the classic scorebug outlined text, drawn onto this canvas. - # TTF fonts only (ctx.fonts values are TTF); for ladder-fitted fonts - # use draw_fit / draw_text, which handle BDF faces too. - draw_text_outlined: Callable[..., None] = field(default=lambda *a, **k: None) - - def draw_text(self, text: str, x: int, y: int, - color: Tuple[int, int, int] = (255, 255, 255), - font: Any = None) -> None: - """Draw text at a top-left position, handling both PIL fonts and - the freetype BDF faces that layout.fit_text can return.""" - if font is None: - font = self.fonts.get('time') - if freetype is not None and isinstance(font, freetype.Face): - _draw_bdf_text_on(self.draw, text, int(x), int(y), color, font, - self.width, self.height) - else: - self.draw.text((int(x), int(y)), text, font=font, fill=color) - - def draw_fit(self, fit: FitResult, box: Union[Region, Tuple[int, int]], - color: Tuple[int, int, int] = (255, 255, 255), - align: str = "center", valign: str = "center") -> None: - """Draw a layout.fit_text() result aligned within a Region — the - canvas-local equivalent of adaptive_layout.draw_fitted_text.""" - region = box if isinstance(box, Region) else Region(0, 0, box[0], box[1]) - x, y = region.align_xy(fit.width, fit.height, align, valign) - self.draw_text(fit.text, x, y - fit.y_offset, color=color, font=fit.font) - - def draw_image(self, img: Optional[Image.Image], - box: Union[Region, Tuple[int, int]], *, - mode: str = "contain", align: str = "center", - valign: str = "center", cache_key: Any = None) -> None: - """Fit an image (a logo, art) into a Region and paste it, honoring - alpha. Silently no-ops on None so `ctx.draw_image(ctx.load_logo( - 'home'), ...)` stays safe when a logo is missing.""" - if img is None: - return - region = box if isinstance(box, Region) else Region(0, 0, box[0], box[1]) - fitted = self.layout.fit_image(img, region, mode=mode, - cache_key=cache_key) - result = fitted.image # fit_image returns an ImageFitResult (always RGBA) - if result is None: - return - x, y = region.align_xy(result.width, result.height, align, valign) - self.canvas.paste(result, (int(x), int(y)), result) - - -class ScoreboardSkin(ABC): - """Base class for scoreboard skins. - - Override only the modes you want to restyle; any mode you leave - unimplemented (or return False from) falls back to the plugin's - built-in renderer, so a live-only skin still gets recent/upcoming - screens for free. - - Skins should be stateless: three host instances (live, recent, - upcoming) each hold their own skin instance, and a render must be - derivable from (ctx, game) alone. - """ - - SKIN_API_VERSION = SKIN_API_VERSION - - def __init__(self, manifest: Dict[str, Any], options: Dict[str, Any]): - self.manifest = manifest - self.options = options or {} - - def render_live(self, ctx: SkinContext, game: Dict[str, Any]) -> bool: - return False - - def render_recent(self, ctx: SkinContext, game: Dict[str, Any]) -> bool: - return False - - def render_upcoming(self, ctx: SkinContext, game: Dict[str, Any]) -> bool: - return False - - def render_vegas_card(self, ctx: SkinContext, - game: Dict[str, Any]) -> Optional[Image.Image]: - """Render one vegas scroll card at ctx.width x ctx.height. Return - the finished image, or None to let the host use its default vegas - rendering (which captures the regular display output).""" - return None diff --git a/src/skin_system/skin_runtime.py b/src/skin_system/skin_runtime.py deleted file mode 100644 index 6c923eb0..00000000 --- a/src/skin_system/skin_runtime.py +++ /dev/null @@ -1,352 +0,0 @@ -""" -Skin runtime: discovery, validation, loading, and context building. - -Deliberately generic — this module knows nothing about sports beyond -passing a `sport` label through; the sports flavor lives in skin_base -(ScoreboardSkin) and in the hosts that call build_context. - -Every failure path here logs and returns None: a broken or missing skin -must never take down the plugin that references it — the host falls -back to its built-in renderer. -""" - -import importlib.util -import json -import sys -import threading -from pathlib import Path -from typing import Any, Dict, Optional, Tuple - -from PIL import Image, ImageDraw - -from src.adaptive_layout import LayoutContext -from src.logging_config import get_logger -from src.skin_system.skin_base import ( - SKIN_API_VERSION, - ScoreboardSkin, - SkinContext, -) - -logger = get_logger(__name__) - -_REQUIRED_MANIFEST_FIELDS = ("id", "name", "version", "skin_api_version", "class_name") -_DEFAULT_ENTRY_POINT = "skin.py" - -_lock = threading.RLock() -# skins_dir -> (fingerprint, {skin_id: manifest+path}) -_discovery_cache: Dict[str, Tuple[Tuple, Dict[str, Dict[str, Any]]]] = {} - -_shared_layout_font_manager: Optional[Any] = None - - -def _get_font_manager() -> Any: - """Shared FontManager for skin LayoutContexts. SportsCore hosts don't - carry a plugin_manager, so skins share one module-level FontManager — - the same shape as base_plugin._fallback_font_manager, constructed - directly so rendering never has to import the whole plugin system.""" - global _shared_layout_font_manager - if _shared_layout_font_manager is None: - from src.font_manager import FontManager - _shared_layout_font_manager = FontManager({}) - return _shared_layout_font_manager - - -def get_skins_directory() -> Path: - """Central skins directory: /skins. Lives outside the - plugin directories on purpose — plugin reinstall/update deletes the - whole plugin directory, and a skin must survive that.""" - return Path(__file__).resolve().parents[2] / "skins" - - -def _major(version: str) -> Optional[int]: - try: - return int(str(version).split(".")[0]) - except (ValueError, AttributeError, IndexError): - return None - - -def _read_manifest(skin_dir: Path) -> Optional[Dict[str, Any]]: - manifest_path = skin_dir / "skin.json" - if not manifest_path.is_file(): - return None - try: - with open(manifest_path, "r", encoding="utf-8") as f: - manifest = json.load(f) - except (OSError, json.JSONDecodeError) as e: - logger.error("Skin manifest %s is unreadable: %s", manifest_path, e) - return None - missing = [k for k in _REQUIRED_MANIFEST_FIELDS if not manifest.get(k)] - if missing: - logger.error("Skin manifest %s missing required fields: %s", - manifest_path, ", ".join(missing)) - return None - if manifest["id"] != skin_dir.name: - logger.warning("Skin manifest id %r does not match directory name %r", - manifest["id"], skin_dir.name) - manifest["_skin_dir"] = str(skin_dir) - return manifest - - -def _discovery_fingerprint(skins_dir: Path) -> Optional[Tuple]: - """Cache key for a skins directory: its mtime plus every skin.json's - (path, mtime). The directory mtime alone misses in-place manifest edits - (a skin updated without adding/removing entries).""" - try: - parts = [skins_dir.stat().st_mtime] - for manifest_path in sorted(skins_dir.glob("*/skin.json")): - parts.append((str(manifest_path), manifest_path.stat().st_mtime)) - return tuple(parts) - except OSError: - return None - - -def discover_skins(skins_dir: Optional[Path] = None, - force_refresh: bool = False) -> Dict[str, Dict[str, Any]]: - """Return {skin_id: manifest} for every valid skin package installed. - - Cached per directory and invalidated when the directory or any - skin.json changes; pass force_refresh to bypass. - """ - skins_dir = Path(skins_dir) if skins_dir else get_skins_directory() - cache_key = str(skins_dir) - fingerprint = _discovery_fingerprint(skins_dir) - if fingerprint is None: - return {} - - with _lock: - cached = _discovery_cache.get(cache_key) - if cached and not force_refresh and cached[0] == fingerprint: - return dict(cached[1]) - - skins: Dict[str, Dict[str, Any]] = {} - for entry in sorted(skins_dir.iterdir()): - if not entry.is_dir() or entry.name.startswith((".", "_")): - continue - manifest = _read_manifest(entry) - if manifest: - skins[manifest["id"]] = manifest - _discovery_cache[cache_key] = (fingerprint, skins) - return dict(skins) - - -def skin_targets(manifest: Dict[str, Any]) -> Tuple[list, list]: - """(sports, sport_keys) a skin declares it supports.""" - targets = manifest.get("targets") or {} - return (list(targets.get("sports") or []), - list(targets.get("sport_keys") or [])) - - -def skin_matches_target(manifest: Dict[str, Any], sport: Optional[str], - sport_key: Optional[str]) -> bool: - """True when the skin declares support for this sport family or exact - sport key. A skin with no targets at all matches everything.""" - sports, sport_keys = skin_targets(manifest) - if not sports and not sport_keys: - return True - if sport and sport in sports: - return True - if sport_key and sport_key in sport_keys: - return True - return False - - -def skins_for_plugin(plugin_id: str, - skins: Optional[Dict[str, Dict[str, Any]]] = None) -> Dict[str, Dict[str, Any]]: - """Installed skins that plausibly apply to a plugin, for UI dropdowns. - - A skin matches when the plugin id is listed in targets.plugins, or any - declared sport / sport_key appears as a token of the plugin id (so a - skin targeting sports=["baseball"] matches "baseball-scoreboard", and - sport_keys=["milb"] matches "milb-scoreboard").""" - if skins is None: - skins = discover_skins() - tokens = set(str(plugin_id).lower().replace("-", "_").split("_")) - matched = {} - for skin_id, manifest in skins.items(): - targets = manifest.get("targets") or {} - if plugin_id in (targets.get("plugins") or []): - matched[skin_id] = manifest - continue - sports, sport_keys = skin_targets(manifest) - if any(str(t).lower() in tokens for t in sports + sport_keys): - matched[skin_id] = manifest - return matched - - -def _load_skin_module(skin_id: str, skin_dir: Path, entry_point: str) -> Optional[Any]: - """Import the skin's entry module under a namespaced sys.modules key, - namespacing its sibling .py files the same way — the collision- - avoidance scheme plugins use (plugin_loader._namespace_plugin_modules), - so two skins can both ship a helpers.py. - - The entry module is cached: the live/recent/upcoming hosts all load - the same skin, and only the first load executes any code. (A skin - whose *code* changed on disk needs a service restart to take effect — - Python modules can't be safely hot-swapped.) - """ - entry_path = skin_dir / entry_point - if not entry_path.is_file(): - logger.error("Skin '%s' entry point not found: %s", skin_id, entry_path) - return None - - module_name = f"_skin_{skin_id}_{Path(entry_point).stem}" - with _lock: - cached_entry = sys.modules.get(module_name) - if cached_entry is not None: - return cached_entry - - # Import siblings under their namespaced alias, and *bind* the bare - # name (cached or fresh) so `import helpers` inside the entry module - # resolves to this skin's copy. The bare bindings are transient — - # restored below so another skin's identically-named sibling can't - # be shadowed by ours. - replaced_bare: Dict[str, Any] = {} - try: - for sibling in skin_dir.glob("*.py"): - if sibling.name == entry_point: - continue - alias = f"_skin_{skin_id}_{sibling.stem}" - module = sys.modules.get(alias) - if module is None: - spec = importlib.util.spec_from_file_location(alias, sibling) - if not spec or not spec.loader: - continue - module = importlib.util.module_from_spec(spec) - sys.modules[alias] = module - replaced_bare.setdefault(sibling.stem, sys.modules.get(sibling.stem)) - sys.modules[sibling.stem] = module - try: - spec.loader.exec_module(module) - except Exception as e: - logger.error("Skin '%s' sibling module %s failed to import: %s", - skin_id, sibling.name, e, exc_info=True) - sys.modules.pop(alias, None) - return None - else: - replaced_bare.setdefault(sibling.stem, sys.modules.get(sibling.stem)) - sys.modules[sibling.stem] = module - - try: - spec = importlib.util.spec_from_file_location(module_name, entry_path) - if not spec or not spec.loader: - logger.error("Skin '%s': could not create import spec for %s", - skin_id, entry_path) - return None - module = importlib.util.module_from_spec(spec) - sys.modules[module_name] = module - spec.loader.exec_module(module) - return module - except Exception as e: - sys.modules.pop(module_name, None) - logger.error("Skin '%s' failed to import: %s", skin_id, e, exc_info=True) - return None - finally: - for bare_name, previous in replaced_bare.items(): - if previous is None: - sys.modules.pop(bare_name, None) - else: - sys.modules[bare_name] = previous - - -def load_skin(skin_id: str, sport: Optional[str] = None, - sport_key: Optional[str] = None, - options: Optional[Dict[str, Any]] = None, - skins_dir: Optional[Path] = None) -> Optional[ScoreboardSkin]: - """Load and instantiate a skin. Returns None (after logging why) on - any failure — callers treat None as 'use the built-in renderer'.""" - skins = discover_skins(skins_dir) - manifest = skins.get(skin_id) - if manifest is None: - logger.warning("Skin '%s' is configured but not installed under %s; " - "using built-in renderer", - skin_id, skins_dir or get_skins_directory()) - return None - - manifest_major = _major(manifest.get("skin_api_version")) - api_major = _major(SKIN_API_VERSION) - if manifest_major != api_major: - logger.error("Skin '%s' targets skin API %s but this LEDMatrix " - "provides %s — the skin needs an update; using " - "built-in renderer", - skin_id, manifest.get("skin_api_version"), SKIN_API_VERSION) - return None - - if not skin_matches_target(manifest, sport, sport_key): - # Soft: the user explicitly configured it, so warn but load anyway - # (a baseball skin may render an acceptable generic scoreboard). - logger.warning("Skin '%s' does not declare support for sport=%r / " - "sport_key=%r; loading anyway", skin_id, sport, sport_key) - - skin_dir = Path(manifest["_skin_dir"]) - module = _load_skin_module(skin_id, skin_dir, - manifest.get("entry_point", _DEFAULT_ENTRY_POINT)) - if module is None: - return None - - class_name = manifest["class_name"] - skin_class = getattr(module, class_name, None) - if skin_class is None or not isinstance(skin_class, type) or \ - not issubclass(skin_class, ScoreboardSkin): - logger.error("Skin '%s': %s is missing or not a ScoreboardSkin subclass", - skin_id, class_name) - return None - - try: - return skin_class(manifest, options or {}) - except Exception as e: - logger.error("Skin '%s' failed to instantiate: %s", skin_id, e, exc_info=True) - return None - - -def build_context(host: Any, game: Dict[str, Any], - size: Optional[Tuple[int, int]] = None) -> SkinContext: - """Build a SkinContext for one render call. - - `host` is a SportsCore-style object: display_manager, fonts, logger, - sport, skin_options, _load_and_resize_logo, _draw_text_with_outline. - `size` overrides the canvas size (vegas cards); default is the - current display size read live from the display manager. - """ - if size is not None: - width, height = int(size[0]), int(size[1]) - else: - dm = host.display_manager - width = getattr(dm, "width", None) or dm.matrix.width - height = getattr(dm, "height", None) or dm.matrix.height - - canvas = Image.new("RGB", (width, height), (0, 0, 0)) - draw = ImageDraw.Draw(canvas) - layout = LayoutContext(width, height, _get_font_manager()) - - def load_logo(side: str) -> Optional[Image.Image]: - if side not in ("home", "away"): - return None - try: - logo_path = game.get(f"{side}_logo_path") - if logo_path is not None and not isinstance(logo_path, Path): - logo_path = Path(logo_path) - return host._load_and_resize_logo( - game.get(f"{side}_id"), game.get(f"{side}_abbr"), - logo_path, game.get(f"{side}_logo_url")) - except Exception as e: - host.logger.warning("Skin logo load failed for %s: %s", side, e) - return None - - def draw_text_outlined(text, position, font, fill=(255, 255, 255), - outline_color=(0, 0, 0)): - host._draw_text_with_outline(draw, text, position, font, - fill=fill, outline_color=outline_color) - - return SkinContext( - canvas=canvas, - draw=draw, - layout=layout, - width=width, - height=height, - fonts=dict(host.fonts), - options=dict(getattr(host, "skin_options", {}) or {}), - logger=host.logger, - sport=getattr(host, "sport", None), - load_logo=load_logo, - draw_text_outlined=draw_text_outlined, - ) diff --git a/test/fixtures/api_v3_url_map.json b/test/fixtures/api_v3_url_map.json index c5f496ac..f9557edf 100644 --- a/test/fixtures/api_v3_url_map.json +++ b/test/fixtures/api_v3_url_map.json @@ -671,15 +671,6 @@ "POST" ] ], - [ - "/api/v3/skins", - "api_v3.list_skins", - [ - "GET", - "HEAD", - "OPTIONS" - ] - ], [ "/api/v3/starlark/apps", "api_v3.get_starlark_apps", diff --git a/test/test_discovery_path_contract.py b/test/test_discovery_path_contract.py index 993797be..6e8e96ca 100644 --- a/test/test_discovery_path_contract.py +++ b/test/test_discovery_path_contract.py @@ -209,30 +209,3 @@ class TestStandaloneBackupContract: assert "'.standalone-backup-'" in pm_text.replace('"', "'") assert ".standalone-backup-" in sm_text - -class TestSkinTargetResolution: - def _store(self, tmp_path): - return PluginStoreManager( - plugins_dir=str(tmp_path / "plugins"), - uninstalled_registry_path=str(tmp_path / "uninstalled.json")) - - def test_valid_skin_id_resolves_inside_skins_dir(self, tmp_path): - from src.skin_system import skin_runtime - store = self._store(tmp_path) - target = store._resolve_skin_target("my-skin") - assert target is not None - assert target.parent == skin_runtime.get_skins_directory().resolve() - - @pytest.mark.parametrize("bad_id", [ - "../evil", - "..", - "a/../../etc", - "/etc/passwd", - "skin/../../outside", - "", - None, - 123, - ]) - def test_traversal_and_malformed_ids_rejected(self, tmp_path, bad_id): - store = self._store(tmp_path) - assert store._resolve_skin_target(bad_id) is None diff --git a/test/test_skin_runtime_cache.py b/test/test_skin_runtime_cache.py deleted file mode 100644 index 460e1902..00000000 --- a/test/test_skin_runtime_cache.py +++ /dev/null @@ -1,377 +0,0 @@ -"""Gap tests for src/skin_system/skin_runtime.py: the discovery cache, -module namespacing internals, API gating edge cases, and targeting. - -test/test_skin_system.py already covers discovery validation, load_skin -basics, and build_context — nothing here duplicates those. - -NOTE: every test uses a UNIQUE skin id. load_skin caches the entry -module in sys.modules per skin id and never re-executes it, so reusing -an id across tests would silently serve another test's module. -""" - -import builtins -import json -import os -import sys -import time -from pathlib import Path -from unittest.mock import MagicMock - -import pytest - -# skin_runtime -> skin_base can transitively reach hardware modules via -# sports imports in sibling tests' processes; stub the matrix driver -# before importing, matching test_skin_system.py. -sys.modules.setdefault("rgbmatrix", MagicMock()) - -from src.skin_system import skin_runtime -from src.skin_system.skin_base import SKIN_API_VERSION, ScoreboardSkin - - -DEFAULT_BODY = ( - "from src.skin_system.skin_base import ScoreboardSkin\n" - "class {cls}(ScoreboardSkin):\n" - " def render_live(self, ctx, game):\n" - " return True\n" -) - - -@pytest.fixture(autouse=True) -def _clean_runtime_state(): - """Clear the discovery cache and any skin modules this test creates.""" - skin_runtime._discovery_cache.clear() - before = {k for k in sys.modules if k.startswith("_skin_")} - yield - skin_runtime._discovery_cache.clear() - created = [k for k in sys.modules - if k.startswith("_skin_") and k not in before] - for k in created: - sys.modules.pop(k, None) - - -def make_skin(skins_dir: Path, skin_id: str, *, - api_version: str = SKIN_API_VERSION, - class_name: str = "TestSkin", - body: str = None, - extra_files: dict = None, - entry_point: str = None, - manifest_id: str = None, - manifest_extra: dict = None, - write_entry: bool = True) -> Path: - """Write a skin package directory and return its path.""" - skin_dir = skins_dir / skin_id - skin_dir.mkdir(parents=True, exist_ok=True) - manifest = { - "id": manifest_id or skin_id, - "name": skin_id, - "version": "1.0.0", - "skin_api_version": api_version, - "class_name": class_name, - } - if entry_point: - manifest["entry_point"] = entry_point - manifest.update(manifest_extra or {}) - (skin_dir / "skin.json").write_text(json.dumps(manifest)) - if write_entry: - entry_name = entry_point or "skin.py" - (skin_dir / entry_name).write_text( - body if body is not None else DEFAULT_BODY.format(cls=class_name)) - for name, content in (extra_files or {}).items(): - (skin_dir / name).write_text(content) - return skin_dir - - -def counting_read_manifest(monkeypatch): - """Wrap skin_runtime._read_manifest with a call counter.""" - original = skin_runtime._read_manifest - counter = {"count": 0} - - def wrapper(skin_dir): - counter["count"] += 1 - return original(skin_dir) - - monkeypatch.setattr(skin_runtime, "_read_manifest", wrapper) - return counter - - -def bump_mtime(path: Path, offset: float = 100.0): - """Set a distinct, strictly later mtime so the fingerprint changes.""" - t = time.time() + offset - os.utime(path, (t, t)) - - -# --------------------------------------------------------------------------- -# A. Discovery cache -# --------------------------------------------------------------------------- - -class TestDiscoveryCache: - def test_second_call_serves_cache(self, tmp_path, monkeypatch): - make_skin(tmp_path, "t01-cache-hit") - counter = counting_read_manifest(monkeypatch) - first = skin_runtime.discover_skins(tmp_path) - count_after_first = counter["count"] - assert count_after_first >= 1 - second = skin_runtime.discover_skins(tmp_path) - assert counter["count"] == count_after_first # no re-read - assert second == first - assert "t01-cache-hit" in second - - def test_manifest_edit_invalidates_without_force_refresh(self, tmp_path): - skin_dir = make_skin(tmp_path, "t02-edit") - skins = skin_runtime.discover_skins(tmp_path) - assert skins["t02-edit"]["name"] == "t02-edit" - - manifest_path = skin_dir / "skin.json" - manifest = json.loads(manifest_path.read_text()) - manifest["name"] = "renamed" - manifest_path.write_text(json.dumps(manifest)) - bump_mtime(manifest_path) - - skins = skin_runtime.discover_skins(tmp_path) # no force_refresh - assert skins["t02-edit"]["name"] == "renamed" - - def test_new_skin_dir_invalidates(self, tmp_path): - make_skin(tmp_path, "t03-first") - assert set(skin_runtime.discover_skins(tmp_path)) == {"t03-first"} - - new_dir = make_skin(tmp_path, "t03-second") - bump_mtime(new_dir / "skin.json") - bump_mtime(tmp_path) - - skins = skin_runtime.discover_skins(tmp_path) # no force_refresh - assert set(skins) == {"t03-first", "t03-second"} - - def test_py_file_change_does_not_invalidate(self, tmp_path, monkeypatch): - # PIN: the fingerprint only globs */skin.json — editing a skin's - # .py file alone does NOT invalidate the cache; the cached - # manifests are still served (a code change needs a restart). - skin_dir = make_skin(tmp_path, "t04-pyedit") - counter = counting_read_manifest(monkeypatch) - skin_runtime.discover_skins(tmp_path) - count_after_first = counter["count"] - - (skin_dir / "skin.py").write_text("# rewritten\n" + - DEFAULT_BODY.format(cls="TestSkin")) - bump_mtime(skin_dir / "skin.py") - - skins = skin_runtime.discover_skins(tmp_path) - assert counter["count"] == count_after_first # cache still served - assert "t04-pyedit" in skins - - def test_force_refresh_rereads_with_unchanged_fingerprint(self, tmp_path, - monkeypatch): - make_skin(tmp_path, "t05-force") - counter = counting_read_manifest(monkeypatch) - skin_runtime.discover_skins(tmp_path) - count_after_first = counter["count"] - skin_runtime.discover_skins(tmp_path, force_refresh=True) - assert counter["count"] > count_after_first - - def test_result_mapping_is_copy_but_manifests_shared(self, tmp_path): - make_skin(tmp_path, "t06-copy") - result = skin_runtime.discover_skins(tmp_path) - - # Mutating the returned mapping does not poison the cache... - del result["t06-copy"] - again = skin_runtime.discover_skins(tmp_path) # cache hit - assert "t06-copy" in again - - # ...but the inner manifest dicts ARE shared with the cache (pin). - again["t06-copy"]["name"] = "mutated-inner" - third = skin_runtime.discover_skins(tmp_path) # cache hit - assert third["t06-copy"]["name"] == "mutated-inner" - - def test_missing_directory_returns_empty_and_caches_nothing(self, tmp_path): - missing = tmp_path / "not-yet" - assert skin_runtime.discover_skins(missing) == {} - assert str(missing) not in skin_runtime._discovery_cache - - # Creating the directory later is picked up without force_refresh. - make_skin(missing, "t07-late") - skins = skin_runtime.discover_skins(missing) - assert "t07-late" in skins - - def test_hidden_underscore_and_plain_file_entries_skipped(self, tmp_path): - make_skin(tmp_path, ".hidden-skin") - make_skin(tmp_path, "_private-skin") - (tmp_path / "stray-file").write_text("not a directory") - make_skin(tmp_path, "t08-good") - skins = skin_runtime.discover_skins(tmp_path, force_refresh=True) - assert set(skins) == {"t08-good"} - - def test_manifest_id_mismatch_keys_by_manifest_id(self, tmp_path): - make_skin(tmp_path, "t09-dirname", manifest_id="t09-manifest-id") - skins = skin_runtime.discover_skins(tmp_path, force_refresh=True) - assert "t09-manifest-id" in skins - assert "t09-dirname" not in skins - assert skins["t09-manifest-id"]["_skin_dir"].endswith("t09-dirname") - - def test_falsy_required_field_drops_skin(self, tmp_path): - make_skin(tmp_path, "t10-empty-class", class_name="") - skins = skin_runtime.discover_skins(tmp_path, force_refresh=True) - assert skins == {} - - -# --------------------------------------------------------------------------- -# B. Module namespacing (_load_skin_module via load_skin) -# --------------------------------------------------------------------------- - -BODY_WITH_HELPERS = ( - "import helpers\n" - "from src.skin_system.skin_base import ScoreboardSkin\n" - "class TestSkin(ScoreboardSkin):\n" - " pass\n" -) - - -class TestModuleNamespacing: - def test_namespaced_sys_modules_keys(self, tmp_path): - make_skin(tmp_path, "t11-ns", body=BODY_WITH_HELPERS, - extra_files={"helpers.py": "VALUE = 11\n"}) - skin = skin_runtime.load_skin("t11-ns", skins_dir=tmp_path) - assert skin is not None - assert "_skin_t11-ns_skin" in sys.modules - assert "_skin_t11-ns_helpers" in sys.modules - - def test_preseeded_bare_name_restored(self, tmp_path, monkeypatch): - sentinel = object() - monkeypatch.setitem(sys.modules, "helpers", sentinel) - make_skin(tmp_path, "t12a-restore", body=BODY_WITH_HELPERS, - extra_files={"helpers.py": "VALUE = 'a'\n"}) - skin = skin_runtime.load_skin("t12a-restore", skins_dir=tmp_path) - assert skin is not None - assert sys.modules["helpers"] is sentinel - - def test_absent_bare_name_stays_absent(self, tmp_path): - saved = sys.modules.pop("helpers", None) - try: - assert "helpers" not in sys.modules - make_skin(tmp_path, "t12b-absent", body=BODY_WITH_HELPERS, - extra_files={"helpers.py": "VALUE = 'b'\n"}) - skin = skin_runtime.load_skin("t12b-absent", skins_dir=tmp_path) - assert skin is not None - assert "helpers" not in sys.modules - finally: - if saved is not None: - sys.modules["helpers"] = saved - - def test_stdlib_shadowing_sibling_leaves_real_module_intact(self, tmp_path): - real_json = sys.modules["json"] - make_skin(tmp_path, "t12c-json", - extra_files={"json.py": "SKIN_LOCAL = True\n"}) - skin = skin_runtime.load_skin("t12c-json", skins_dir=tmp_path) - assert skin is not None - assert sys.modules["json"] is real_json - assert not hasattr(sys.modules["json"], "SKIN_LOCAL") - assert json.loads('{"ok": 1}') == {"ok": 1} # stdlib still works - # The skin's copy lives only under its namespaced alias. - assert getattr(sys.modules["_skin_t12c-json_json"], "SKIN_LOCAL") is True - - def test_entry_module_executed_once_across_loads(self, tmp_path, - monkeypatch): - executions = [] - monkeypatch.setattr(builtins, "_t13_skin_executions", executions, - raising=False) - body = ( - "import builtins\n" - "builtins._t13_skin_executions.append(1)\n" - "from src.skin_system.skin_base import ScoreboardSkin\n" - "class TestSkin(ScoreboardSkin):\n" - " pass\n" - ) - make_skin(tmp_path, "t13-cached", body=body) - for _ in range(3): - skin = skin_runtime.load_skin("t13-cached", skins_dir=tmp_path) - assert skin is not None - assert len(executions) == 1 # module executed exactly once - - def test_sibling_import_failure_returns_none_and_restores_bare( - self, tmp_path, monkeypatch): - sentinel = object() - monkeypatch.setitem(sys.modules, "helpers", sentinel) - make_skin(tmp_path, "t14-sibfail", body=BODY_WITH_HELPERS, - extra_files={"helpers.py": "raise RuntimeError('sibling boom')\n"}) - assert skin_runtime.load_skin("t14-sibfail", skins_dir=tmp_path) is None - assert sys.modules["helpers"] is sentinel - - def test_missing_entry_point_file(self, tmp_path): - make_skin(tmp_path, "t15-noentry", write_entry=False) - assert skin_runtime.load_skin("t15-noentry", skins_dir=tmp_path) is None - - def test_custom_entry_point(self, tmp_path): - make_skin(tmp_path, "t16-custom", entry_point="render.py") - skin = skin_runtime.load_skin("t16-custom", skins_dir=tmp_path) - assert isinstance(skin, ScoreboardSkin) - assert "_skin_t16-custom_render" in sys.modules - assert "_skin_t16-custom_skin" not in sys.modules - - def test_class_name_pointing_at_unrelated_class(self, tmp_path): - body = "class NotASkin:\n pass\n" - make_skin(tmp_path, "t17a-wrongclass", body=body, - class_name="NotASkin") - assert skin_runtime.load_skin("t17a-wrongclass", - skins_dir=tmp_path) is None - - def test_class_name_pointing_at_instance(self, tmp_path): - body = ( - "from src.skin_system.skin_base import ScoreboardSkin\n" - "class MySkin(ScoreboardSkin):\n" - " pass\n" - "obj = MySkin({}, {})\n" - ) - make_skin(tmp_path, "t17b-instance", body=body, class_name="obj") - assert skin_runtime.load_skin("t17b-instance", - skins_dir=tmp_path) is None - - def test_constructor_raising_returns_none(self, tmp_path): - body = ( - "from src.skin_system.skin_base import ScoreboardSkin\n" - "class TestSkin(ScoreboardSkin):\n" - " def __init__(self, manifest, options):\n" - " raise ValueError('ctor boom')\n" - ) - make_skin(tmp_path, "t18-ctor", body=body) - assert skin_runtime.load_skin("t18-ctor", skins_dir=tmp_path) is None - - -# --------------------------------------------------------------------------- -# C. API gate + targeting -# --------------------------------------------------------------------------- - -class TestApiGateAndTargeting: - def test_same_major_higher_minor_loads(self, tmp_path): - make_skin(tmp_path, "t19-minor", api_version="1.9.0") - skin = skin_runtime.load_skin("t19-minor", skins_dir=tmp_path) - assert isinstance(skin, ScoreboardSkin) - - def test_malformed_api_version_refused(self, tmp_path): - make_skin(tmp_path, "t20-malformed", api_version="abc") - assert skin_runtime.load_skin("t20-malformed", - skins_dir=tmp_path) is None - - @pytest.mark.parametrize("manifest,sport,sport_key,expected", [ - # No targets key at all -> matches everything - ({"id": "x"}, "baseball", "mlb", True), - ({"id": "x"}, None, None, True), - # Empty targets dict -> matches everything - ({"id": "x", "targets": {}}, "hockey", None, True), - # sports family match - ({"id": "x", "targets": {"sports": ["baseball"]}}, - "baseball", None, True), - # sport_keys exact match - ({"id": "x", "targets": {"sport_keys": ["milb"]}}, - None, "milb", True), - # OR semantics: sport_keys matches even though sports excludes it - ({"id": "x", "targets": {"sports": ["hockey"], - "sport_keys": ["milb"]}}, - "baseball", "milb", True), - # Neither matches - ({"id": "x", "targets": {"sports": ["hockey"]}}, - "baseball", None, False), - ({"id": "x", "targets": {"sports": ["hockey"], - "sport_keys": ["nhl"]}}, - "baseball", "milb", False), - ]) - def test_skin_matches_target(self, manifest, sport, sport_key, expected): - assert skin_runtime.skin_matches_target( - manifest, sport, sport_key) is expected diff --git a/test/test_skin_system.py b/test/test_skin_system.py deleted file mode 100644 index 1a7138c2..00000000 --- a/test/test_skin_system.py +++ /dev/null @@ -1,571 +0,0 @@ -"""Tests for the skin system: discovery, version gating, fallback -semantics, module isolation, and the view-model contract.""" - -import json -import logging -import sys -from pathlib import Path -from unittest.mock import MagicMock - -import pytest -from PIL import Image, ImageFont - -# src.base_classes.sports transitively imports the hardware matrix driver; -# stub it so the fallback-semantics tests can import SportsCore off-device. -sys.modules.setdefault("rgbmatrix", MagicMock()) - -from src.skin_system import skin_runtime -from src.skin_system.skin_base import ( - SKIN_API_VERSION, - ScoreboardSkin, - SkinContext, -) - -PROJECT_ROOT = Path(__file__).resolve().parents[1] -FIXTURES_DIR = PROJECT_ROOT / "src" / "skin_system" / "fixtures" - -# The v1.0 guaranteed view-model keys (docs/CREATING_SKINS.md). Renaming -# or removing any of these is a breaking change to every published skin: -# it requires a VIEW_MODEL_VERSION major bump and a compat shim. -GUARANTEED_KEYS = [ - "id", "game_time", "game_date", "start_time_utc", "status_text", - "is_live", "is_final", "is_upcoming", "is_halftime", - "home_abbr", "home_id", "home_score", "home_logo_path", "home_record", - "away_abbr", "away_id", "away_score", "away_logo_path", "away_record", -] - - -def write_skin(skins_dir: Path, skin_id: str, *, api_version: str = SKIN_API_VERSION, - body: str = None, extra_files: dict = None, - class_name: str = "TestSkin") -> Path: - skin_dir = skins_dir / skin_id - skin_dir.mkdir(parents=True) - manifest = { - "id": skin_id, "name": skin_id, "version": "1.0.0", - "skin_api_version": api_version, "class_name": class_name, - "targets": {"sports": ["baseball"]}, - } - (skin_dir / "skin.json").write_text(json.dumps(manifest)) - if body is None: - body = ( - "from src.skin_system.skin_base import ScoreboardSkin\n" - f"class {class_name}(ScoreboardSkin):\n" - " def render_live(self, ctx, game):\n" - " ctx.draw.rectangle([0, 0, 4, 4], fill=(255, 0, 0))\n" - " return True\n" - ) - (skin_dir / "skin.py").write_text(body) - for name, content in (extra_files or {}).items(): - (skin_dir / name).write_text(content) - return skin_dir - - -class TestDiscovery: - def test_discovers_valid_skin(self, tmp_path): - write_skin(tmp_path, "my-skin") - skins = skin_runtime.discover_skins(tmp_path, force_refresh=True) - assert "my-skin" in skins - assert skins["my-skin"]["_skin_dir"].endswith("my-skin") - - def test_skips_manifest_missing_required_fields(self, tmp_path): - skin_dir = tmp_path / "broken" - skin_dir.mkdir() - (skin_dir / "skin.json").write_text(json.dumps({"id": "broken"})) - assert skin_runtime.discover_skins(tmp_path, force_refresh=True) == {} - - def test_skips_unreadable_manifest_and_non_skin_dirs(self, tmp_path): - (tmp_path / "not-a-skin").mkdir() - bad = tmp_path / "bad-json" - bad.mkdir() - (bad / "skin.json").write_text("{nope") - write_skin(tmp_path, "good-skin") - skins = skin_runtime.discover_skins(tmp_path, force_refresh=True) - assert list(skins) == ["good-skin"] - - def test_missing_directory_is_empty(self, tmp_path): - assert skin_runtime.discover_skins(tmp_path / "nope") == {} - - def test_example_skin_in_repo_is_discoverable(self): - skins = skin_runtime.discover_skins(force_refresh=True) - assert "example-classic-baseball" in skins - - -class TestLoadSkin: - def test_loads_and_instantiates(self, tmp_path): - write_skin(tmp_path, "my-skin") - skin = skin_runtime.load_skin("my-skin", sport="baseball", - skins_dir=tmp_path) - assert isinstance(skin, ScoreboardSkin) - - def test_unknown_skin_returns_none(self, tmp_path): - assert skin_runtime.load_skin("ghost", skins_dir=tmp_path) is None - - def test_api_major_mismatch_is_refused(self, tmp_path): - write_skin(tmp_path, "old-skin", api_version="99.0.0") - assert skin_runtime.load_skin("old-skin", skins_dir=tmp_path) is None - - def test_target_mismatch_still_loads(self, tmp_path): - write_skin(tmp_path, "my-skin") # targets baseball - skin = skin_runtime.load_skin("my-skin", sport="hockey", - skins_dir=tmp_path) - assert skin is not None # soft warning, not a hard block - - def test_import_error_returns_none(self, tmp_path): - write_skin(tmp_path, "crashy", body="raise RuntimeError('boom')\n") - assert skin_runtime.load_skin("crashy", skins_dir=tmp_path) is None - - def test_wrong_class_returns_none(self, tmp_path): - write_skin(tmp_path, "classless", body="x = 1\n") - assert skin_runtime.load_skin("classless", skins_dir=tmp_path) is None - - def test_options_are_passed_through(self, tmp_path): - write_skin(tmp_path, "my-skin") - skin = skin_runtime.load_skin("my-skin", skins_dir=tmp_path, - options={"accent": [1, 2, 3]}) - assert skin.options == {"accent": [1, 2, 3]} - - def test_sibling_modules_are_isolated_between_skins(self, tmp_path): - helper = "VALUE = {!r}\n" - body = ( - "import helpers\n" - "from src.skin_system.skin_base import ScoreboardSkin\n" - "class TestSkin(ScoreboardSkin):\n" - " def render_live(self, ctx, game):\n" - " ctx.logger.info(helpers.VALUE)\n" - " self.helper_value = helpers.VALUE\n" - " return False\n" - ) - write_skin(tmp_path, "skin-a", body=body, - extra_files={"helpers.py": helper.format("A")}) - write_skin(tmp_path, "skin-b", body=body, - extra_files={"helpers.py": helper.format("B")}) - skin_a = skin_runtime.load_skin("skin-a", skins_dir=tmp_path) - skin_b = skin_runtime.load_skin("skin-b", skins_dir=tmp_path) - ctx = _make_context() - skin_a.render_live(ctx, {}) - skin_b.render_live(ctx, {}) - assert skin_a.helper_value == "A" - assert skin_b.helper_value == "B" - - def test_same_skin_loads_repeatedly_with_siblings(self, tmp_path): - """The live/recent/upcoming hosts each load the same skin — the - 2nd and 3rd loads must still resolve sibling modules (regression: - cached siblings used to be skipped without rebinding).""" - body = ( - "import reload_helpers\n" - "from src.skin_system.skin_base import ScoreboardSkin\n" - "class TestSkin(ScoreboardSkin):\n" - " def render_live(self, ctx, game):\n" - " self.helper_value = reload_helpers.VALUE\n" - " return False\n" - ) - write_skin(tmp_path, "reload-skin", body=body, - extra_files={"reload_helpers.py": "VALUE = 'R'\n"}) - ctx = _make_context() - for _ in range(3): - skin = skin_runtime.load_skin("reload-skin", skins_dir=tmp_path) - assert skin is not None - skin.render_live(ctx, {}) - assert skin.helper_value == "R" - - -def _make_host(fonts=None): - host = MagicMock() - host.sport = "baseball" - host.sport_key = "mlb" - host.skin_options = {"accent": True} - host.fonts = fonts or {"time": ImageFont.load_default()} - host.logger = logging.getLogger("test_skin_system") - host.display_manager.width = 128 - host.display_manager.height = 32 - return host - - -def _make_context(width=128, height=32): - host = _make_host() - return skin_runtime.build_context(host, {}, size=(width, height)) - - -class TestBuildContext: - def test_context_shape(self): - host = _make_host() - game = {"home_abbr": "LAD", "away_abbr": "SF"} - ctx = skin_runtime.build_context(host, game) - assert (ctx.width, ctx.height) == (128, 32) - assert ctx.canvas.size == (128, 32) - assert ctx.sport == "baseball" - assert ctx.options == {"accent": True} - assert ctx.layout.bounds.w == 128 - - def test_explicit_size_overrides_display(self): - ctx = skin_runtime.build_context(_make_host(), {}, size=(64, 64)) - assert ctx.canvas.size == (64, 64) - - def test_load_logo_binds_game_and_survives_failure(self): - host = _make_host() - host._load_and_resize_logo.side_effect = RuntimeError("disk gone") - ctx = skin_runtime.build_context( - host, {"home_id": "1", "home_abbr": "LAD", - "home_logo_path": "x.png", "home_logo_url": None}) - assert ctx.load_logo("home") is None # exception swallowed - assert ctx.load_logo("elsewhere") is None # bad side rejected - - def test_draw_helpers_draw_on_canvas(self): - ctx = _make_context() - ctx.draw_text("HI", 2, 2, font=ImageFont.load_default()) - fit = ctx.layout.fit_text("42", ctx.layout.bounds) - ctx.draw_fit(fit, ctx.layout.bounds) - logo = Image.new("RGBA", (16, 16), (255, 0, 0, 255)) - ctx.draw_image(logo, ctx.layout.bounds.left_col(20)) - ctx.draw_image(None, ctx.layout.bounds) # None must no-op - assert ctx.canvas.convert("L").getbbox() is not None - - -class _FallbackProbe: - """Bare-bones SportsCore stand-in that exercises the real _render_game.""" - - def __init__(self, skin): - from src.base_classes.sports import SportsCore - self._cls = SportsCore - self.SKIN_MODE = "live" - self.logger = logging.getLogger("test_skin_system") - self.sport = "baseball" - self.sport_key = "mlb" - self.skin_options = {} - self.fonts = {"time": ImageFont.load_default()} - self._skin = skin - self._skin_load_attempted = True - self._skin_failures = 0 - self._skin_slow_renders = 0 - self._skin_config = "test-skin" - self.display_manager = MagicMock() - self.display_manager.width = 128 - self.display_manager.height = 32 - self.display_manager.image = Image.new("RGB", (128, 32)) - self.builtin_calls = 0 - - def _resolve_skin_id(self): - return "test-skin" - - def _draw_scorebug_layout(self, game, force_clear=False): - self.builtin_calls += 1 - - def _render_game(self, game, force_clear=False): - from src.base_classes.sports import SportsCore - SportsCore._render_game(self, game, force_clear) - - def _get_skin(self): - return self._skin - - -class TestRenderGameFallback: - def test_skin_handles_render(self): - class GoodSkin(ScoreboardSkin): - def render_live(self, ctx, game): - ctx.draw.rectangle([0, 0, 10, 10], fill=(0, 255, 0)) - return True - - probe = _FallbackProbe(GoodSkin({}, {})) - probe._render_game({"status_text": "Q1"}) - assert probe.builtin_calls == 0 - probe.display_manager.update_display.assert_called_once() - assert probe.display_manager.image.convert("L").getbbox() is not None - - def test_skin_declining_falls_back(self): - probe = _FallbackProbe(ScoreboardSkin({}, {})) # all renders -> False - probe._render_game({"status_text": "Q1"}) - assert probe.builtin_calls == 1 - - def test_no_skin_falls_back(self): - probe = _FallbackProbe(None) - probe._render_game({"status_text": "Q1"}) - assert probe.builtin_calls == 1 - - def test_three_strikes_disables_skin(self): - class BrokenSkin(ScoreboardSkin): - calls = 0 - - def render_live(self, ctx, game): - BrokenSkin.calls += 1 - raise ValueError("kaboom") - - probe = _FallbackProbe(BrokenSkin({}, {})) - for i in range(5): - probe._render_game({"status_text": "Q1"}) - # every render fell back to the built-in layout... - assert probe.builtin_calls == 5 - # ...and the skin stopped being called after the 3rd failure - assert BrokenSkin.calls == 3 - assert probe._skin_failures == 3 - - def test_skin_cannot_mutate_callers_game_dict(self): - class MutatingSkin(ScoreboardSkin): - def render_live(self, ctx, game): - game.clear() - game["hacked"] = True - return True - - probe = _FallbackProbe(MutatingSkin({}, {})) - game = {"status_text": "Q1", "home_score": "3"} - probe._render_game(game) - assert game == {"status_text": "Q1", "home_score": "3"} - - -class TestSkinModeResolution: - def _core(self, skin_config, mode="live"): - from src.base_classes.sports import SportsCore - probe = _FallbackProbe(None) - probe.SKIN_MODE = mode - probe._skin_config = skin_config - return SportsCore._resolve_skin_id(probe) - - def test_plain_id_applies_to_all_modes(self): - assert self._core("retro", "live") == "retro" - assert self._core("retro", "recent") == "retro" - - def test_per_mode_mapping(self): - cfg = {"live": "retro", "recent": "built-in"} - assert self._core(cfg, "live") == "retro" - assert self._core(cfg, "recent") is None - assert self._core(cfg, "upcoming") is None - - def test_builtin_and_empty_mean_none(self): - assert self._core("built-in") is None - assert self._core("") is None - assert self._core(None) is None - - -class TestViewModelContract: - @pytest.mark.parametrize("sport", ["baseball", "basketball", "football", "hockey"]) - @pytest.mark.parametrize("mode", ["live", "recent", "upcoming"]) - def test_fixtures_carry_all_guaranteed_keys(self, sport, mode): - with open(FIXTURES_DIR / f"{sport}_{mode}.json") as f: - game = json.load(f) - missing = [k for k in GUARANTEED_KEYS if k not in game] - assert not missing, f"{sport}_{mode} fixture missing {missing}" - - def test_extractor_produces_guaranteed_keys(self): - """The real extractor's output must be a superset of the documented - contract — this is the test that catches accidental renames.""" - import pytz - from src.base_classes.sports import SportsCore - - event = { - "id": "401570001", - "date": "2026-07-16T23:05:00Z", - "competitions": [{ - "status": {"type": {"name": "STATUS_IN_PROGRESS", "state": "in", - "shortDetail": "Bot 7th"}}, - "competitors": [ - {"homeAway": "home", "id": "19", - "team": {"abbreviation": "LAD"}, "score": "5", - "records": [{"summary": "58-33"}]}, - {"homeAway": "away", "id": "26", - "team": {"abbreviation": "SF"}, "score": "3", - "records": [{"summary": "49-42"}]}, - ], - }], - } - probe = MagicMock() - probe.logger = logging.getLogger("test_skin_system") - probe.favorite_teams = [] - probe.config = {} - probe.logo_dir = Path("assets/logos") - probe._get_timezone.return_value = pytz.utc - probe.display_manager.format_date_with_ordinal.return_value = "Jul 16th" - - details, _, _, _, _ = SportsCore._extract_game_details_common(probe, event) - assert details is not None - missing = [k for k in GUARANTEED_KEYS if k not in details] - assert not missing, ( - f"_extract_game_details_common no longer emits {missing}. " - "These keys are part of the frozen skin view-model contract " - "(VIEW_MODEL_VERSION) — renaming or removing them breaks every " - "published skin. Add a compat shim or bump the major version.") - - -class TestPluginMatching: - def test_matches_by_sport_token_and_sport_key(self, tmp_path): - write_skin(tmp_path, "bb-skin") # targets sports=["baseball"] - skins = skin_runtime.discover_skins(tmp_path, force_refresh=True) - assert "bb-skin" in skin_runtime.skins_for_plugin("baseball-scoreboard", skins) - assert "bb-skin" not in skin_runtime.skins_for_plugin("football-scoreboard", skins) - - def test_matches_by_explicit_plugin_list(self, tmp_path): - skin_dir = write_skin(tmp_path, "exact-skin") - manifest = json.loads((skin_dir / "skin.json").read_text()) - manifest["targets"] = {"plugins": ["my-custom-plugin"]} - (skin_dir / "skin.json").write_text(json.dumps(manifest)) - skins = skin_runtime.discover_skins(tmp_path, force_refresh=True) - assert "exact-skin" in skin_runtime.skins_for_plugin("my-custom-plugin", skins) - assert "exact-skin" not in skin_runtime.skins_for_plugin("baseball-scoreboard", skins) - - -class TestSchemaInjection: - def _manager(self): - from src.plugin_system.schema_manager import SchemaManager - return SchemaManager() - - def test_injects_enum_with_installed_and_configured_skins(self): - sm = self._manager() - schema = {"type": "object", "properties": {}} - out = sm.inject_skin_selector(schema, "baseball-scoreboard", - current_value="gone-skin") - enum = out["properties"]["skin"]["enum"] - assert enum[0] == "built-in" - assert "example-classic-baseball" in enum - # an uninstalled-but-configured skin must stay selectable so the - # saved config never becomes invalid in the UI - assert "gone-skin" in enum - assert "skin" not in schema["properties"] # source schema untouched - - def test_no_matching_skins_leaves_schema_alone(self): - sm = self._manager() - schema = {"type": "object", "properties": {}} - out = sm.inject_skin_selector(schema, "totally-unrelated-plugin") - assert "skin" not in out.get("properties", {}) - - def test_validation_accepts_skin_keys_without_enum(self): - sm = self._manager() - schema = {"type": "object", "properties": {"foo": {"type": "string"}}} - ok, errors = sm.validate_config_against_schema( - {"skin": "any-id-even-uninstalled", "skin_options": {"x": 1}}, - schema, "baseball-scoreboard") - assert ok, errors - ok, errors = sm.validate_config_against_schema( - {"skin": {"live": "a", "recent": "built-in"}}, schema, "p") - assert ok, errors - - -class TestExampleSkin: - @pytest.mark.parametrize("mode", ["live", "recent", "upcoming"]) - @pytest.mark.parametrize("size", [(128, 32), (64, 32), (128, 64)]) - def test_renders_all_modes_and_sizes(self, mode, size): - skin = skin_runtime.load_skin("example-classic-baseball", sport="baseball") - assert skin is not None - host = _make_host() - host._load_and_resize_logo.return_value = Image.new("RGBA", (32, 32), (200, 0, 0, 255)) - with open(FIXTURES_DIR / f"baseball_{mode}.json") as f: - game = json.load(f) - ctx = skin_runtime.build_context(host, game, size=size) - assert getattr(skin, f"render_{mode}")(ctx, game) is True - assert ctx.canvas.convert("L").getbbox() is not None - - -class TestRenderSkinCard: - """render_skin_card (vegas cards) shares _render_game's 3-strike counter. - - Both paths reset the counter on success — transient failures must not - accumulate across a session and disable a working skin. - """ - - def _probe(self, skin): - from src.base_classes.sports import SportsCore - probe = _FallbackProbe(skin) - probe.render_skin_card = ( - lambda game, size: SportsCore.render_skin_card(probe, game, size)) - return probe - - def test_vegas_card_returned_when_skin_provides_one(self): - card_img = Image.new("RGB", (96, 32), (0, 0, 255)) - - class CardSkin(ScoreboardSkin): - def render_vegas_card(self, ctx, game): - return card_img - - probe = self._probe(CardSkin({}, {})) - assert probe.render_skin_card({}, (96, 32)) is card_img - - def test_vegas_card_none_falls_through_to_mode_renderer(self): - class ModeOnlySkin(ScoreboardSkin): - def render_live(self, ctx, game): - ctx.draw.rectangle([0, 0, 5, 5], fill=(255, 0, 0)) - return True - - probe = self._probe(ModeOnlySkin({}, {})) - card = probe.render_skin_card({}, (96, 32)) - assert card is not None - assert card.size == (96, 32) - assert card.convert("L").getbbox() is not None - - def test_skin_declining_returns_none(self): - probe = self._probe(ScoreboardSkin({}, {})) # all renders -> False - assert probe.render_skin_card({}, (96, 32)) is None - assert probe._skin_failures == 0 # declining is not a failure - - def test_no_skin_returns_none(self): - probe = self._probe(None) - assert probe.render_skin_card({}, (96, 32)) is None - - def test_card_failures_count_toward_shared_disable(self): - class BrokenCardSkin(ScoreboardSkin): - calls = 0 - - def render_vegas_card(self, ctx, game): - BrokenCardSkin.calls += 1 - raise ValueError("kaboom") - - probe = self._probe(BrokenCardSkin({}, {})) - for _ in range(5): - assert probe.render_skin_card({}, (96, 32)) is None - # Skin stopped being consulted after the 3rd failure... - assert BrokenCardSkin.calls == 3 - assert probe._skin_failures == 3 - # ...and the shared counter also disables _render_game's skin path. - probe._render_game({"status_text": "Q1"}) - assert probe.builtin_calls == 1 - assert BrokenCardSkin.calls == 3 # not consulted again - - def test_card_success_resets_strikes(self): - """A successful card render clears accumulated strikes (mirroring - _render_game) — 2 failures + a success + 1 failure leaves the skin - enabled with a single strike, instead of disabling it.""" - card_img = Image.new("RGB", (96, 32), (0, 0, 255)) - - class FlakyCardSkin(ScoreboardSkin): - fail = True - - def render_vegas_card(self, ctx, game): - if FlakyCardSkin.fail: - raise ValueError("kaboom") - return card_img - - probe = self._probe(FlakyCardSkin({}, {})) - FlakyCardSkin.fail = True - probe.render_skin_card({}, (96, 32)) - probe.render_skin_card({}, (96, 32)) - assert probe._skin_failures == 2 - - FlakyCardSkin.fail = False - assert probe.render_skin_card({}, (96, 32)) is card_img - assert probe._skin_failures == 0 # success cleared the strikes - - FlakyCardSkin.fail = True - probe.render_skin_card({}, (96, 32)) - assert probe._skin_failures == 1 # counting from the reset state - FlakyCardSkin.fail = False - assert probe.render_skin_card({}, (96, 32)) is card_img # still enabled - - def test_card_success_via_mode_renderer_also_resets_strikes(self): - """The fallthrough path (render_vegas_card None -> mode renderer - True) resets the counter as well.""" - class ModeOnlySkin(ScoreboardSkin): - def render_live(self, ctx, game): - ctx.draw.rectangle([0, 0, 5, 5], fill=(255, 0, 0)) - return True - - probe = self._probe(ModeOnlySkin({}, {})) - probe._skin_failures = 2 - assert probe.render_skin_card({}, (96, 32)) is not None - assert probe._skin_failures == 0 - - def test_render_game_success_also_resets_strikes(self): - """Same reset contract on the display path, for symmetry.""" - class GoodSkin(ScoreboardSkin): - def render_live(self, ctx, game): - return True - - probe = _FallbackProbe(GoodSkin({}, {})) - probe._skin_failures = 2 - probe._render_game({"status_text": "Q1"}) - assert probe._skin_failures == 0 diff --git a/test/test_version_comparison_consistency.py b/test/test_version_comparison_consistency.py index 9a27b59c..d111c4de 100644 --- a/test/test_version_comparison_consistency.py +++ b/test/test_version_comparison_consistency.py @@ -9,9 +9,9 @@ can never disagree. (Historically the store used raw string equality, which reinstalled over cosmetic differences like "v1.2.0" vs "1.2.0" and even DOWNGRADED locally-ahead plugins; this file's tests killed that.) -Two other version parsers legitimately remain and are pinned here so they -don't drift: `compatibility.parse_semver` (the install-compatibility gate, -range-spec oriented) and `skin_runtime._major` (skin API major gate). +One other version parser legitimately remains and is pinned here so it +doesn't drift: `compatibility.parse_semver` (the install-compatibility gate, +range-spec oriented). """ import json @@ -21,7 +21,6 @@ import pytest from packaging.version import parse as pkg_parse from src.plugin_system.compatibility import is_update_available, parse_semver -from src.skin_system.skin_runtime import _major from src.plugin_system.store_manager import PluginStoreManager from web_interface.blueprints.api_v3 import _is_plugin_update_available @@ -142,25 +141,6 @@ class TestStoreManagerUsesSharedComparator: reinstall.assert_not_called() -class TestSkinRuntimeMajor: - def test_plain_versions(self): - assert _major("1.0.0") == 1 - assert _major("2.1") == 2 - - def test_int_input_tolerated(self): - assert _major(2) == 2 - - def test_garbage_returns_none(self): - assert _major("garbage") is None - assert _major(None) is None - - def test_v_prefix_not_tolerated(self): - # Unlike parse_semver, _major does NOT strip a leading 'v' — - # a skin.json declaring "v1.0.0" fails the API gate. Characterized - # so a manifest-format loosening elsewhere doesn't silently diverge. - assert _major("v1.0.0") is None - - class TestParseSemverAgreesWithPackaging: """parse_semver and packaging must agree on ordering for plain X.Y.Z — the region where the two ecosystems overlap and must never diverge.""" diff --git a/web_interface/blueprints/api_v3/misc.py b/web_interface/blueprints/api_v3/misc.py index baeddf59..ad9cc460 100644 --- a/web_interface/blueprints/api_v3/misc.py +++ b/web_interface/blueprints/api_v3/misc.py @@ -1,5 +1,5 @@ """Routes with no larger group of their own: errors, integrations, -cache, sync, skins, logs, health and hardware. +cache, sync, logs, health and hardware. Routes decorate the shared `api_v3` Blueprint from ._common, so their endpoint names are unchanged by living here. @@ -149,51 +149,6 @@ def get_hardware_status(): except Exception: logger.error("Unexpected error reading hardware status", exc_info=True) return jsonify({"status": "error", "message": "Unable to read hardware status"}), 500 -@api_v3.route('/skins', methods=['GET']) -def list_skins(): - """List installed visual skins (docs/SKIN_SYSTEM.md). - - Optional ?plugin_id=... filters to skins matching that plugin. - - The response carries ``supported: false`` and a ``message``: the current - scoreboard plugins don't render skins, so a client must not present - these as selectable. - """ - try: - from src.skin_system import ( - SKINS_RENDER_SUPPORTED, SKINS_UNSUPPORTED_MESSAGE, skin_runtime, - ) - - plugin_id = request.args.get('plugin_id') - if plugin_id: - skins = skin_runtime.skins_for_plugin(plugin_id) - else: - # The discovery cache self-invalidates on directory/manifest - # mtime changes, so no force_refresh — keeps Pi disk I/O down. - skins = skin_runtime.discover_skins() - - payload = [] - for skin_id, manifest in sorted(skins.items()): - skin_dir = Path(manifest['_skin_dir']) - preview = manifest.get('preview') - payload.append({ - 'id': skin_id, - 'name': manifest.get('name', skin_id), - 'version': manifest.get('version'), - 'author': manifest.get('author'), - 'description': manifest.get('description', ''), - 'skin_api_version': manifest.get('skin_api_version'), - 'targets': manifest.get('targets', {}), - 'modes': manifest.get('modes', []), - 'has_preview': bool(preview and (skin_dir / preview).is_file()), - }) - data = {'skins': payload, 'supported': SKINS_RENDER_SUPPORTED} - if not SKINS_RENDER_SUPPORTED: - data['message'] = SKINS_UNSUPPORTED_MESSAGE - return jsonify({'status': 'success', 'data': data}) - except Exception as e: - logger.error('Error in list_skins', exc_info=True) - return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 @api_v3.route('/logs', methods=['GET']) def get_logs(): """Get system logs from journalctl""" diff --git a/web_interface/blueprints/api_v3/plugins.py b/web_interface/blueprints/api_v3/plugins.py index fbe62390..ffd0e462 100644 --- a/web_interface/blueprints/api_v3/plugins.py +++ b/web_interface/blueprints/api_v3/plugins.py @@ -1294,16 +1294,6 @@ def install_plugin(): plugin_id = data['plugin_id'] branch = data.get('branch') # Optional branch parameter - # A registry skin would install but never render with the current - # scoreboards; refuse it with the reason rather than a generic failure - try: - registry_entry = api_v3.plugin_store_manager.get_registry_info(plugin_id) - except Exception: - registry_entry = None - if isinstance(registry_entry, dict) and (registry_entry.get('type') or 'plugin') == 'skin': - from src.skin_system import SKINS_UNSUPPORTED_MESSAGE - return jsonify({'status': 'error', 'message': SKINS_UNSUPPORTED_MESSAGE}), 400 - # Install the plugin # Log the plugins directory being used for debugging plugins_dir = api_v3.plugin_store_manager.plugins_dir @@ -1504,13 +1494,9 @@ def get_registry_from_url(): registry = api_v3.plugin_store_manager.fetch_registry_from_url(repo_url) if registry: - # Skins aren't offered: current scoreboards don't render them return jsonify({ 'status': 'success', - 'plugins': [ - p for p in registry.get('plugins', []) - if not (isinstance(p, dict) and (p.get('type') or 'plugin') == 'skin') - ], + 'plugins': registry.get('plugins', []), 'registry_url': repo_url }) else: @@ -1625,10 +1611,6 @@ def list_plugin_store(): # Format plugins for the web interface formatted_plugins = [] for plugin in plugins: - # Registry skins install but never render with the current - # scoreboards, so the store doesn't offer them - if (plugin.get('type') or 'plugin') == 'skin': - continue formatted_plugins.append({ 'id': plugin.get('id'), 'name': plugin.get('name'), @@ -2753,11 +2735,6 @@ def get_plugin_schema(): schema = schema_mgr.load_schema(plugin_id, use_cache=True) if schema: - # No "Visual Skin" dropdown: the current scoreboard plugins don't - # render skins (src.skin_system.SKINS_UNSUPPORTED_MESSAGE), so - # schema_mgr.inject_skin_selector is deliberately not called. A - # stored "skin" value is unaffected: validation still allows it - # and a form save deep-merges over the stored section, keeping it. return jsonify({'status': 'success', 'data': {'schema': schema}}) # Return a simple default schema if file not found