mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-10 17:16:36 +00:00
refactor: remove the skin system
Skins never rendered with the current scoreboard plugins: the only hook was SportsCore._render_game in src/base_classes, which no plugin builds on, so the UI and store already treated them as unsupported. The owner decided on 2026-09-23 to remove them outright. Removed src/skin_system/ (runtime, base class, fixtures), skins/, scripts/validate_skin.py and their tests; the store's "type": "skin" installer, uninstaller and hide/refuse filters (the official registry lists no skins); SchemaManager.inject_skin_selector; and GET /api/v3/skins. Stored skin/skin_options config values are handled in the next commit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -52,7 +52,6 @@ config/backups/
|
|||||||
|
|
||||||
# Starlark apps runtime storage (installed .star files and cached renders)
|
# Starlark apps runtime storage (installed .star files and cached renders)
|
||||||
/starlark-apps/
|
/starlark-apps/
|
||||||
skin_renders/
|
|
||||||
|
|
||||||
# JS test deps (test/js)
|
# JS test deps (test/js)
|
||||||
node_modules/
|
node_modules/
|
||||||
|
|||||||
@@ -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_<mode> 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())
|
|
||||||
@@ -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-id>/
|
|
||||||
skin.json # manifest
|
|
||||||
skin.py # renderer (a ScoreboardSkin subclass)
|
|
||||||
preview.png # optional
|
|
||||||
```
|
|
||||||
|
|
||||||
- Install a skin: `git clone <skin repo> skins/<skin-id>`. The Plugin Store
|
|
||||||
refuses registry entries with `"type": "skin"` while skins don't render.
|
|
||||||
- Select it: set `"skin": "<skin-id>"` 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 <skin-id>`.
|
|
||||||
|
|
||||||
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.
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 5.3 KiB |
@@ -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"
|
|
||||||
}
|
|
||||||
@@ -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/<your-skin-id>/, rename the class and the
|
|
||||||
manifest fields, and run:
|
|
||||||
|
|
||||||
python scripts/validate_skin.py --skin <your-skin-id>
|
|
||||||
"""
|
|
||||||
|
|
||||||
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
|
|
||||||
@@ -658,53 +658,6 @@ class SchemaManager:
|
|||||||
self.logger.error(error_msg)
|
self.logger.error(error_msg)
|
||||||
return False, [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:
|
def _format_validation_error(self, error: ValidationError, plugin_id: Optional[str] = None) -> str:
|
||||||
"""
|
"""
|
||||||
Format a validation error into a readable message.
|
Format a validation error into a readable message.
|
||||||
|
|||||||
@@ -1338,18 +1338,6 @@ class PluginStoreManager:
|
|||||||
self.logger.error(f"Plugin not found in registry: {plugin_id}")
|
self.logger.error(f"Plugin not found in registry: {plugin_id}")
|
||||||
return False
|
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')
|
repo_url = plugin_info.get('repo')
|
||||||
if not repo_url:
|
if not repo_url:
|
||||||
self.logger.error(f"Plugin {plugin_id} missing repository URL")
|
self.logger.error(f"Plugin {plugin_id} missing repository URL")
|
||||||
@@ -2502,152 +2490,6 @@ class PluginStoreManager:
|
|||||||
continue
|
continue
|
||||||
return None
|
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/<id>/.
|
|
||||||
|
|
||||||
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:
|
def uninstall_plugin(self, plugin_id: str) -> bool:
|
||||||
"""
|
"""
|
||||||
Uninstall a plugin by removing its directory.
|
Uninstall a plugin by removing its directory.
|
||||||
@@ -2661,12 +2503,6 @@ class PluginStoreManager:
|
|||||||
plugin_path = self._find_plugin_path(plugin_id)
|
plugin_path = self._find_plugin_path(plugin_id)
|
||||||
|
|
||||||
if plugin_path is None or not plugin_path.exists():
|
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)")
|
self.logger.info(f"Plugin {plugin_id} not found (already uninstalled)")
|
||||||
return True # Already uninstalled, consider this success
|
return True # Already uninstalled, consider this success
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
|
||||||
]
|
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
@@ -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": ""
|
|
||||||
}
|
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 444 B |
Binary file not shown.
|
Before Width: | Height: | Size: 446 B |
@@ -1,171 +0,0 @@
|
|||||||
"""
|
|
||||||
Skin API: the classes a skin author works with.
|
|
||||||
|
|
||||||
A skin is a directory under skins/<skin-id>/ 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
|
|
||||||
@@ -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: <project_root>/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,
|
|
||||||
)
|
|
||||||
Vendored
-9
@@ -671,15 +671,6 @@
|
|||||||
"POST"
|
"POST"
|
||||||
]
|
]
|
||||||
],
|
],
|
||||||
[
|
|
||||||
"/api/v3/skins",
|
|
||||||
"api_v3.list_skins",
|
|
||||||
[
|
|
||||||
"GET",
|
|
||||||
"HEAD",
|
|
||||||
"OPTIONS"
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
[
|
||||||
"/api/v3/starlark/apps",
|
"/api/v3/starlark/apps",
|
||||||
"api_v3.get_starlark_apps",
|
"api_v3.get_starlark_apps",
|
||||||
|
|||||||
@@ -209,30 +209,3 @@ class TestStandaloneBackupContract:
|
|||||||
assert "'.standalone-backup-'" in pm_text.replace('"', "'")
|
assert "'.standalone-backup-'" in pm_text.replace('"', "'")
|
||||||
assert ".standalone-backup-" in sm_text
|
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
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
reinstalled over cosmetic differences like "v1.2.0" vs "1.2.0" and even
|
||||||
DOWNGRADED locally-ahead plugins; this file's tests killed that.)
|
DOWNGRADED locally-ahead plugins; this file's tests killed that.)
|
||||||
|
|
||||||
Two other version parsers legitimately remain and are pinned here so they
|
One other version parser legitimately remains and is pinned here so it
|
||||||
don't drift: `compatibility.parse_semver` (the install-compatibility gate,
|
doesn't drift: `compatibility.parse_semver` (the install-compatibility gate,
|
||||||
range-spec oriented) and `skin_runtime._major` (skin API major gate).
|
range-spec oriented).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
@@ -21,7 +21,6 @@ import pytest
|
|||||||
from packaging.version import parse as pkg_parse
|
from packaging.version import parse as pkg_parse
|
||||||
|
|
||||||
from src.plugin_system.compatibility import is_update_available, parse_semver
|
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 src.plugin_system.store_manager import PluginStoreManager
|
||||||
from web_interface.blueprints.api_v3 import _is_plugin_update_available
|
from web_interface.blueprints.api_v3 import _is_plugin_update_available
|
||||||
|
|
||||||
@@ -142,25 +141,6 @@ class TestStoreManagerUsesSharedComparator:
|
|||||||
reinstall.assert_not_called()
|
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:
|
class TestParseSemverAgreesWithPackaging:
|
||||||
"""parse_semver and packaging must agree on ordering for plain X.Y.Z —
|
"""parse_semver and packaging must agree on ordering for plain X.Y.Z —
|
||||||
the region where the two ecosystems overlap and must never diverge."""
|
the region where the two ecosystems overlap and must never diverge."""
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
"""Routes with no larger group of their own: errors, integrations,
|
"""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
|
Routes decorate the shared `api_v3` Blueprint from ._common, so their
|
||||||
endpoint names are unchanged by living here.
|
endpoint names are unchanged by living here.
|
||||||
@@ -149,51 +149,6 @@ def get_hardware_status():
|
|||||||
except Exception:
|
except Exception:
|
||||||
logger.error("Unexpected error reading hardware status", exc_info=True)
|
logger.error("Unexpected error reading hardware status", exc_info=True)
|
||||||
return jsonify({"status": "error", "message": "Unable to read hardware status"}), 500
|
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'])
|
@api_v3.route('/logs', methods=['GET'])
|
||||||
def get_logs():
|
def get_logs():
|
||||||
"""Get system logs from journalctl"""
|
"""Get system logs from journalctl"""
|
||||||
|
|||||||
@@ -1294,16 +1294,6 @@ def install_plugin():
|
|||||||
plugin_id = data['plugin_id']
|
plugin_id = data['plugin_id']
|
||||||
branch = data.get('branch') # Optional branch parameter
|
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
|
# Install the plugin
|
||||||
# Log the plugins directory being used for debugging
|
# Log the plugins directory being used for debugging
|
||||||
plugins_dir = api_v3.plugin_store_manager.plugins_dir
|
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)
|
registry = api_v3.plugin_store_manager.fetch_registry_from_url(repo_url)
|
||||||
|
|
||||||
if registry:
|
if registry:
|
||||||
# Skins aren't offered: current scoreboards don't render them
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'success',
|
'status': 'success',
|
||||||
'plugins': [
|
'plugins': registry.get('plugins', []),
|
||||||
p for p in registry.get('plugins', [])
|
|
||||||
if not (isinstance(p, dict) and (p.get('type') or 'plugin') == 'skin')
|
|
||||||
],
|
|
||||||
'registry_url': repo_url
|
'registry_url': repo_url
|
||||||
})
|
})
|
||||||
else:
|
else:
|
||||||
@@ -1625,10 +1611,6 @@ def list_plugin_store():
|
|||||||
# Format plugins for the web interface
|
# Format plugins for the web interface
|
||||||
formatted_plugins = []
|
formatted_plugins = []
|
||||||
for plugin in 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({
|
formatted_plugins.append({
|
||||||
'id': plugin.get('id'),
|
'id': plugin.get('id'),
|
||||||
'name': plugin.get('name'),
|
'name': plugin.get('name'),
|
||||||
@@ -2753,11 +2735,6 @@ def get_plugin_schema():
|
|||||||
schema = schema_mgr.load_schema(plugin_id, use_cache=True)
|
schema = schema_mgr.load_schema(plugin_id, use_cache=True)
|
||||||
|
|
||||||
if schema:
|
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 jsonify({'status': 'success', 'data': {'schema': schema}})
|
||||||
|
|
||||||
# Return a simple default schema if file not found
|
# Return a simple default schema if file not found
|
||||||
|
|||||||
Reference in New Issue
Block a user