From 6cd886cd78a40d48013a558ef451900f20f82eb5 Mon Sep 17 00:00:00 2001 From: Chuck <33324927+ChuckBuilds@users.noreply.github.com> Date: Fri, 11 Sep 2026 10:04:17 -0400 Subject: [PATCH] feat(plugins): BasePlugin.styles -- per-element styling every plugin inherits Adopting the element-style system meant repeating three things in every plugin: a guarded import, finding its own config_schema.json, and rebuilding the resolver when on_config_change swapped the config dict. This is those three things once, on the class all 45 plugins already inherit from. title = self.styles.style('title_text', classic_font='PressStart2P-Regular.ttf', classic_size=8, classic_color=(255, 255, 255)) The classic_* arguments are the adoption contract: with nothing configured they come back verbatim, so a plugin that switches to this renders exactly as before until a user changes something. A plugin with one instance per display mode sets STYLE_MODE on the class and every existing lookup becomes mode-aware without a call site changing -- which is the point of binding the mode to the resolver rather than passing it per call. styles_for() covers a plugin that renders several modes from one instance. Schema discovery reads the concrete class's own module rather than this file, because this file lives in src/plugin_system where no plugin schema exists -- the same trap SportsCore._config_schema_path documents. The first mutation test for that passed anyway: an installed plugin's module directory and its entry under plugins_dir are the same path, so the test could not tell the two apart. The case where they diverge is a plugin symlinked in for development, and the test now forces that shape. Getting discovery wrong is silent rather than loud: with no schema the resolver has no defaults to compare against, so every configured value reads as a deliberate override and the plugin quietly stops honouring its own shipped styling. Co-Authored-By: Claude Opus 5 --- src/plugin_system/base_plugin.py | 164 ++++++++++++++++++++++ test/test_base_plugin_styles.py | 224 +++++++++++++++++++++++++++++++ 2 files changed, 388 insertions(+) create mode 100644 test/test_base_plugin_styles.py diff --git a/src/plugin_system/base_plugin.py b/src/plugin_system/base_plugin.py index dbe14a49..f27eae89 100644 --- a/src/plugin_system/base_plugin.py +++ b/src/plugin_system/base_plugin.py @@ -12,11 +12,47 @@ from abc import ABC, abstractmethod from enum import Enum from typing import Dict, Any, Optional, List import logging +import os +import sys from src.logging_config import get_logger _shared_fallback_font_manager: Optional[Any] = None +#: Distinguishes "not looked up yet" from "looked up and not found", so a +#: plugin with no schema does not re-scan the disk on every frame. +_UNSET_SCHEMA_PATH = object() + + +class _NullStyleResolver: + """Stand-in for ElementStyleResolver when the module is unavailable. + + Only reachable on a core that predates src.element_style, which + ``styles`` degrades to rather than raising: every lookup returns the + caller's classic values, which is what the plugin drew before styling + existed. + """ + + def __init__(self, config: Any) -> None: + self._config = config + + def style(self, element_key: str, classic_font: str = None, + classic_size: int = 8, classic_color: Any = None, + mode: Optional[str] = None) -> Any: + from types import SimpleNamespace + return SimpleNamespace( + font=None, color=classic_color or (255, 255, 255), offset=(0, 0), + font_name=classic_font, font_size=classic_size, + user_forced=False, user_forced_color=False, + visible=True, align=None, scale=1.0) + + def offset(self, element_key: str, mode: Optional[str] = None) -> tuple: + return (0, 0) + + def offset_value(self, element_key: str, axis: str, default: int = 0, + mode: Optional[str] = None) -> int: + return default + def _fallback_font_manager() -> Any: """Shared FontManager for environments (unit tests, mocks) where the @@ -63,6 +99,12 @@ class BasePlugin(ABC): API_VERSION = "1.0.0" + #: Which ``customization.modes.`` overrides :attr:`styles` applies. + #: A plugin with one instance per display mode (the scoreboards' live / + #: upcoming / recent classes) sets this and every existing style lookup + #: becomes mode-aware without changing a call site. + STYLE_MODE: Optional[str] = None + def __init__( self, plugin_id: str, @@ -260,6 +302,128 @@ class BasePlugin(ABC): self._layout_font_generation = generation return context + @property + def styles(self) -> Any: + """ + The user's per-element styling: fonts, sizes, colours, offsets, + visibility, alignment and scale, resolved against this plugin's own + config_schema.json. + + Every consumer of src.element_style used to repeat the same three + things -- a guarded import, finding its own schema file, and + rebuilding the resolver when on_config_change swapped the config + dict. This is those three things, once. + + Ask for a style by element name, passing what the plugin drew before + the user could customise anything:: + + title = self.styles.style('title_text', + classic_font='PressStart2P-Regular.ttf', + classic_size=8, + classic_color=(255, 255, 255)) + x, y = title.offset + self.display_manager.draw_text(text, x=x, y=y, + font=title.font, color=title.color) + + The classic_* arguments matter: when the user has chosen nothing, + they come back verbatim, so a plugin that adopts this renders + identically until someone actually changes a setting. + + A plugin whose display has modes (a scoreboard's live/upcoming/ + recent, weather's current/hourly/daily) sets ``STYLE_MODE`` on the + class, and every lookup here honours the matching + ``customization.modes.`` overrides without any call site + passing a mode. Use :meth:`styles_for` for a one-off mode. + + Never raises: with no schema on disk, or with the element-style + module unavailable, lookups fall back to the classic values. + """ + resolver = getattr(self, "_style_resolver", None) + # The config dict is swapped wholesale by on_config_change, so + # identity is the invalidation signal -- the same check the sports + # base classes use. + if resolver is not None and resolver._config is self.config: + return resolver + resolver = self._build_style_resolver(getattr(self, "STYLE_MODE", None)) + self._style_resolver = resolver + return resolver + + def styles_for(self, mode: Optional[str]) -> Any: + """:attr:`styles`, bound to ``mode`` instead of ``STYLE_MODE``. + + For a plugin that renders more than one mode from one instance. A + plugin with an instance per mode should set ``STYLE_MODE`` instead + and leave its call sites alone. + """ + cache = getattr(self, "_style_resolvers_by_mode", None) + if cache is None or getattr(self, "_style_resolver_config", None) is not self.config: + cache = {} + self._style_resolvers_by_mode = cache + self._style_resolver_config = self.config + if mode not in cache: + cache[mode] = self._build_style_resolver(mode) + return cache[mode] + + def _build_style_resolver(self, mode: Optional[str]) -> Any: + """Construct a resolver for this plugin's config and schema.""" + try: + from src.element_style import (ElementStyleResolver, + defaults_from_schema_file) + except ImportError: # pragma: no cover - core always ships it + return _NullStyleResolver(self.config) + + schema_path = self._config_schema_path() + defaults = (defaults_from_schema_file(schema_path) if schema_path + else {}) + return ElementStyleResolver(self.config, defaults, mode=mode) + + def _config_schema_path(self) -> Optional[str]: + """This plugin's config_schema.json, or None. + + Looked up from the concrete class's own module rather than from this + file: a plugin's subclass lives in its plugin directory, while this + module lives in src/plugin_system, where no plugin schema exists. + Falls back to the configured plugins directory, including the + ledmatrix- prefix form the loader accepts. + + Returning None is safe, not fatal -- the resolver then has no + defaults to compare against, so every configured value counts as a + deliberate override, which is the conservative reading. + """ + cached = getattr(self, "_config_schema_path_cache", _UNSET_SCHEMA_PATH) + if cached is not _UNSET_SCHEMA_PATH: + return cached + + path = None + try: + for candidate in self._schema_path_candidates(): + if candidate and os.path.isfile(candidate): + path = candidate + break + except Exception as exc: # pragma: no cover - defensive + self.logger.debug("Could not locate config_schema.json: %s", exc) + self._config_schema_path_cache = path + return path + + def _schema_path_candidates(self) -> list: + """Where a plugin's schema might be, best guess first.""" + candidates = [] + + module = sys.modules.get(type(self).__module__) + module_file = getattr(module, "__file__", None) + if module_file: + candidates.append(os.path.join( + os.path.dirname(os.path.abspath(module_file)), + "config_schema.json")) + + plugins_dir = getattr(self.plugin_manager, "plugins_dir", None) + if plugins_dir: + for plugin_id in (self.plugin_id, f"ledmatrix-{self.plugin_id}"): + candidates.append(os.path.join( + str(plugins_dir), os.path.basename(plugin_id), + "config_schema.json")) + return candidates + def draw_fit(self, text: str, box: Any, color: tuple = (255, 255, 255), ladder: Optional[Any] = None, diff --git a/test/test_base_plugin_styles.py b/test/test_base_plugin_styles.py new file mode 100644 index 00000000..3bc77ef1 --- /dev/null +++ b/test/test_base_plugin_styles.py @@ -0,0 +1,224 @@ +"""BasePlugin.styles -- the per-element styling seam every plugin inherits. + +Before this, each consumer of src.element_style repeated the same three +things: a guarded import, finding its own config_schema.json, and rebuilding +the resolver when on_config_change swapped the config dict. Getting the +second one wrong is silent -- the resolver simply has no defaults to compare +against, so every configured value reads as a deliberate override and the +plugin quietly stops honouring its own shipped styling. +""" + +import json +import sys +import types + +import pytest +from unittest.mock import MagicMock + +from src.plugin_system.base_plugin import BasePlugin + +SCHEMA = { + "type": "object", + "properties": { + "customization": { + "type": "object", + "x-style-modes": ["live", "recent"], + "x-style-elements": { + "score_text": { + "title": "Score", + "font": {"default": "PressStart2P-Regular.ttf"}, + "size": {"default": 10, "min": 4, "max": 16}, + "color": {"default": [255, 255, 255]}, + "offsets": True, + }, + }, + }, + }, +} + + +def _plugin_class(module_name, module_file, **attrs): + """A plugin class whose module sits in a plugin directory, as a real + one does -- that is what schema discovery keys on.""" + module = types.ModuleType(module_name) + module.__file__ = str(module_file) + sys.modules[module_name] = module + + namespace = dict(attrs) + namespace["update"] = lambda self: None + namespace["display"] = lambda self, force_clear=False: None + cls = type("DemoPlugin", (BasePlugin,), namespace) + cls.__module__ = module_name + return cls + + +@pytest.fixture +def plugin_dir(tmp_path): + d = tmp_path / "demo" + d.mkdir() + (d / "config_schema.json").write_text(json.dumps(SCHEMA), encoding="utf-8") + return d + + +@pytest.fixture +def make(tmp_path, plugin_dir, request): + created = [] + + def _make(config, plugin_id="demo", schema_in_module_dir=True, **attrs): + name = f"demo_module_{len(created)}_{id(request)}" + module_file = (plugin_dir if schema_in_module_dir + else tmp_path / "elsewhere") / "manager.py" + module_file.parent.mkdir(parents=True, exist_ok=True) + cls = _plugin_class(name, module_file, **attrs) + created.append(name) + pm = MagicMock() + pm.plugins_dir = str(tmp_path) + return cls(plugin_id, config, MagicMock(), MagicMock(), pm) + + try: + yield _make + finally: + for name in created: + sys.modules.pop(name, None) + + +CONFIG = {"customization": { + "score_text": {"font_size": 12}, + "layout": {"score_text": {"y_offset": -3}}, + "modes": {"live": {"score_text": {"font_size": 16}}}, +}} + + +class TestSchemaDiscovery: + def test_it_finds_the_schema_beside_the_plugin_module(self, make, plugin_dir): + """Not beside base_plugin.py, which lives in src/plugin_system where + no plugin schema exists.""" + p = make({}) + assert p._config_schema_path() == str(plugin_dir / "config_schema.json") + + def test_it_falls_back_to_the_plugins_directory(self, make): + p = make({}, schema_in_module_dir=False) + assert p._config_schema_path() is not None + + def test_it_accepts_the_ledmatrix_prefix_form(self, tmp_path, make): + (tmp_path / "ledmatrix-music").mkdir() + (tmp_path / "ledmatrix-music" / "config_schema.json").write_text( + json.dumps(SCHEMA), encoding="utf-8") + p = make({}, plugin_id="music", schema_in_module_dir=False) + assert "ledmatrix-music" in p._config_schema_path() + + def test_a_missing_schema_is_not_fatal(self, tmp_path, make): + p = make({}, plugin_id="ghost", schema_in_module_dir=False) + # 'elsewhere' has no schema and neither does tmp_path/ghost + assert p._config_schema_path() is None + assert p.styles.style("score_text", classic_size=8).font_size == 8 + + def test_the_module_directory_wins_over_the_plugins_directory( + self, tmp_path, plugin_dir): + """These two are the same path for an installed plugin, so the test + has to force them apart -- and they genuinely diverge for a plugin + symlinked in for development, where the module lives outside the + configured plugins directory. Sourcing the path from this module's + own __file__ instead would find neither, then quietly fall through + to whatever the plugins directory happened to hold. + """ + dev_dir = tmp_path / "dev-checkout" + dev_dir.mkdir() + dev_schema = json.loads(json.dumps(SCHEMA)) + (dev_schema["properties"]["customization"]["x-style-elements"] + ["score_text"]["size"]["default"]) = 99 + (dev_dir / "config_schema.json").write_text(json.dumps(dev_schema), + encoding="utf-8") + + cls = _plugin_class("demo_dev_checkout", dev_dir / "manager.py") + try: + pm = MagicMock() + pm.plugins_dir = str(tmp_path) # holds demo/config_schema.json + p = cls("demo", {}, MagicMock(), MagicMock(), pm) + assert p._config_schema_path() == str(dev_dir / "config_schema.json") + # and the difference is observable: 99 is this schema's default, + # so configuring 99 must read as "not a choice". + p.on_config_change({"customization": {"score_text": {"font_size": 99}}}) + assert p.styles.style("score_text", classic_size=10).font_size == 10 + finally: + sys.modules.pop("demo_dev_checkout", None) + + def test_the_lookup_is_cached(self, make): + """A miss must not re-scan the disk on every frame.""" + p = make({}) + first = p._config_schema_path() + assert p._config_schema_path() is first + + +class TestResolution: + def test_an_untouched_config_gets_the_classic_values(self, make): + """The invariant that makes adopting this safe.""" + style = make({}).styles.style( + "score_text", classic_font="4x6-font.ttf", classic_size=6, + classic_color=(1, 2, 3)) + assert (style.font_name, style.font_size, style.color) == ( + "4x6-font.ttf", 6, (1, 2, 3)) + assert style.user_forced is False + + def test_a_users_choice_comes_through(self, make): + style = make(CONFIG).styles.style("score_text", classic_size=10) + assert style.font_size == 12 + assert style.offset == (0, -3) + + def test_style_mode_binds_without_touching_call_sites(self, make): + """The whole point: a per-mode subclass sets one attribute and its + existing lookups become mode-aware.""" + assert make(CONFIG).styles.style( + "score_text", classic_size=10).font_size == 12 + assert make(CONFIG, STYLE_MODE="live").styles.style( + "score_text", classic_size=10).font_size == 16 + + def test_styles_for_handles_a_one_off_mode(self, make): + p = make(CONFIG) + assert p.styles_for("live").style("score_text", classic_size=10).font_size == 16 + assert p.styles_for("recent").style("score_text", classic_size=10).font_size == 12 + assert p.styles_for(None).style("score_text", classic_size=10).font_size == 12 + + +class TestInvalidation: + def test_the_resolver_is_reused(self, make): + p = make(CONFIG) + assert p.styles is p.styles + + def test_a_config_swap_rebuilds_it(self, make): + """on_config_change replaces the dict rather than mutating it, so + identity is the signal.""" + p = make(CONFIG) + assert p.styles.style("score_text", classic_size=10).font_size == 12 + p.on_config_change({"customization": {"score_text": {"font_size": 6}}}) + assert p.styles.style("score_text", classic_size=10).font_size == 6 + + def test_a_config_swap_rebuilds_the_per_mode_cache_too(self, make): + p = make(CONFIG) + assert p.styles_for("live").style("score_text", classic_size=10).font_size == 16 + p.on_config_change({"customization": { + "modes": {"live": {"score_text": {"font_size": 5}}}}}) + assert p.styles_for("live").style("score_text", classic_size=10).font_size == 5 + + +class TestDegradation: + # config=None is not in this list on purpose: BasePlugin.__init__ reads + # config.get("enabled") before styles exists, so a None config is a + # constructor precondition rather than something styles should absorb. + @pytest.mark.parametrize("config", [ + {}, {"customization": None}, {"customization": "nonsense"}, + {"customization": {"score_text": "nonsense"}}, + {"customization": {"layout": "nonsense"}}, + {"customization": {"modes": "nonsense"}}, + ]) + def test_a_hostile_config_still_resolves(self, config, make): + style = make(config).styles.style("score_text", classic_size=7) + assert style.font_size == 7 + + def test_it_works_without_a_plugin_manager(self, plugin_dir): + cls = _plugin_class("demo_no_pm", plugin_dir / "manager.py") + try: + p = cls("demo", {}, MagicMock(), MagicMock(), None) + assert p.styles.style("score_text", classic_size=8).font_size == 8 + finally: + sys.modules.pop("demo_no_pm", None)