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 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-09-11 10:04:17 -04:00
co-authored by Claude Opus 5
parent c5858f1e5e
commit 6cd886cd78
2 changed files with 388 additions and 0 deletions
+164
View File
@@ -12,11 +12,47 @@ from abc import ABC, abstractmethod
from enum import Enum from enum import Enum
from typing import Dict, Any, Optional, List from typing import Dict, Any, Optional, List
import logging import logging
import os
import sys
from src.logging_config import get_logger from src.logging_config import get_logger
_shared_fallback_font_manager: Optional[Any] = None _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: def _fallback_font_manager() -> Any:
"""Shared FontManager for environments (unit tests, mocks) where the """Shared FontManager for environments (unit tests, mocks) where the
@@ -63,6 +99,12 @@ class BasePlugin(ABC):
API_VERSION = "1.0.0" API_VERSION = "1.0.0"
#: Which ``customization.modes.<mode>`` 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__( def __init__(
self, self,
plugin_id: str, plugin_id: str,
@@ -260,6 +302,128 @@ class BasePlugin(ABC):
self._layout_font_generation = generation self._layout_font_generation = generation
return context 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.<mode>`` 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, def draw_fit(self, text: str, box: Any,
color: tuple = (255, 255, 255), color: tuple = (255, 255, 255),
ladder: Optional[Any] = None, ladder: Optional[Any] = None,
+224
View File
@@ -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)