mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-14 02:56:36 +00:00
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:
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user