Compare commits

...
2 Commits
Author SHA1 Message Date
ChuckandClaude Opus 5 bc2dbf3824 feat(sports): share the sports.py surface that is identical in all eight scoreboards (#515)
* feat(sports): share the sports.py surface that is identical in all eight

Nine scoreboards ship their own sports.py -- 41,326 lines. Comparing executable
ASTs across the eight that share a lineage, 48 method bodies are byte-identical
in every one: 1,007 lines carried eight times, so 8,056 lines that must be
edited eight times to fix once.

They are the parts with no sport in them: the selection and rotation engine
(_round_robin_favorites, _favorites_first, _compose_selection,
_check_ranking_coverage, _game_divisions, _normalise_quality), the font/colour/
date subsystem (_scale_headline_fonts, _scorebug_font, _resolve_font_size,
_format_game_date, _font_color), and the switch-mode upcoming card
(_draw_upcoming_center_switch). Nothing here knows what an inning is.

Mixins rather than free functions: every one of these reads host state, so
rewriting 48 bodies into free functions would be a rewrite rather than a move,
and it is the move that keeps the renders identical.

Three of the 48 are deliberately left in the plugins, because a byte-identical
body is not automatically safe to move:

- _get_timezone calls resolve_timezone, imported from a per-plugin module
  (hockey_timezone, soccer_timezone, ...). All eight of those differ -- each
  carries its own _WRITEBACK_FIXED_IN -- so hoisting the caller would silently
  bind every scoreboard to one plugin's copy.
- _extract_game_details and _fetch_data are @abstractmethod stubs. They are the
  sport contract; satisfying them from a mixin would let a plugin instantiate
  without implementing its own sport.

_resolve_font_path went the other way: a module-level function, identical in all
eight, that _scale_headline_fonts needs -- so it is inlined here.

_schema_font_size needed a real change rather than a move. It located the
plugin's config_schema.json with __file__, which here is src/common/, so the
load failed silently, the cache stayed empty and every element fell back to an
unsnapped size -- measured at 81% anti-aliased edges on a panel that should be
pixel-crisp. It now recovers the plugin directory from the instance. Note that
type(self).__module__ alone is not enough: SportsCore is an ABC, so a subclass
built with type(name, bases, ns) -- which the plugins' own tests do -- reports
its module as "abc". _plugin_dir walks the MRO past those synthetic classes to
the first module sitting beside a config_schema.json.

Worth recording: the 176 harness renders did NOT catch that regression. The
plugin's own test_fonts_are_crisp.py did. Renders alone were not a sufficient
gate here.

Not merged with src/common/sports_card.py despite fourteen same-named twins.
Only five are provably equivalent by source comparison; the other nine differ in
ways inspection cannot settle, and a wrong guess silently changes what every
scoreboard draws. That merge needs differential testing and is its own change.

* fix(sports): declare the constants the mixins read, and test the contract

CodeRabbit found _QUALITY_CHOICES and _RANKING_COVERAGE_SECONDS read by
_normalise_quality and _check_ranking_coverage but never defined on a mixin.
Confirmed: both are declared by all eight scoreboards, so nothing fails today --
it would only have bitten the ninth plugin to adopt this, at runtime, mid-render.
Both are identical everywhere, so they get defaults here; each plugin's own copy
still shadows them.

Auditing for others showed those two were the only ones, but also that the
host-contract docstring was substantially incomplete: it listed 21 attributes
where the mixins actually read about 40, and omitted five hooks
(_is_favorite_game, _is_game_really_over, _is_ranked_game,
_passes_other_filters, _get_timezone). The section is now derived from that
audit rather than remembered.

test_sports_shared.py covers what is genuinely new, not the moved bodies:

- The contract itself. It parses the module for every ALL-CAPS `self.X` the
  mixins read and asserts each is defined, so the next omission fails here
  rather than in the field.
- _plugin_dir, the only new logic in the move. Including the case that made it
  necessary: SportsCore is an ABC, so a subclass built with type(name, bases,
  ns) -- which the plugins' own tests build -- reports __module__ as "abc". The
  test asserts that precondition before asserting the walk steps past it.
- The three SportsLive bodies. Hockey and lacrosse disable live mode in their
  harness fixtures, so the 176 renders never reach this path; testing the mixin
  directly means coverage no longer depends on which plugin happens to have a
  unit test.

Two of those tests pin things that would otherwise be silently undone.
SportsRecentSharedMixin does carry an __init__ -- SportsRecent.__init__ was one
of the 48 byte-identical bodies. Its bare super() binds to where it is defined,
now the mixin, so it only reaches the host because the mixin is listed first in
the bases. One test proves the chain runs; the next proves that reversing the
order silently skips the host constructor.

* fix(sports): drop three unused imports and let the matcher narrow

Codacy flagged five issues on this file.

Three are unused imports: math, abc.abstractmethod and zoneinfo.ZoneInfo.
Nothing in the module references any of them -- the timezone work goes through
pytz, and the @abstractmethod mention in the module docstring describes the
two stubs that deliberately stayed behind in each plugin, not anything
declared here. pyflakes agrees; all three are removed.

The other two are "team_in is not callable" on the round-robin favourite
matcher. That call is already guarded by callable(), so it cannot raise at
runtime, but callable() is not a narrowing construct a static analyser
follows: the name still carries the None from getattr's default. Normalising a
non-callable to None and branching on `is None` gives the analyser a test it
does understand, and keeps the guard.

Behaviour is unchanged. _round_robin_favorites has no test coverage, so I
exercised it directly on both paths -- a host with _team_in (id matching, the
NRL case) and one without (abbreviation matching) -- across limits 1 to 4, and
the selections are identical before and after. A host whose _team_in is
present but not callable still falls back to abbreviation matching rather than
raising.

test/test_sports_shared.py: 27 passed. The 9 collection errors under
`pytest test/ -k sport` reproduce identically on the unmodified branch and are
not from this change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 13:29:37 -04:00
Chuck cb0545ecb3 release: report 3.3.0, so the version the gate reads matches the tag (#516)
v3.3.0 is tagged, but src/__init__.py still says "3.2.0" -- and that string,
not the git tag, is what the compatibility gate compares
(store_manager.py: `from src import __version__ as core_version`).

The effect is that every plugin flooring at 3.3.0 is refused on a device
running 3.3.0. Checked against the real gate and the real manifest:

    core __version__ reported to the gate : 3.2.0
    hockey floor                          : 3.3.0
    verdict                               : REFUSE
      "supports LEDMatrix >=3.3.0, but this system is running 3.2.0"

That is all eight sports scoreboards plus calendar 1.2.3, and it would read as
a broken plugin store rather than a stale constant.

The TRUSTWORTHY_FLOOR escape hatch does not cover this: it exempts cores
reporting below 2.0.0 as "unknown rather than old", and 3.2.0 is above it, so
the number is trusted and compared.

This is the same slip as v3.1.0, which was tagged six weeks before its version
string was bumped and shipped __version__ = "1.0.0" -- the reason that escape
hatch exists at all.

With the bump, the same gate call returns ALLOW for hockey at both the
sports_card and sports_shared floors, and for calendar 1.2.3.

CHANGELOG.md gains the 3.3.0 section. That file is what plugin authors read to
decide which release to floor on, so it records the three new modules --
src/common/sports_card.py, sports_game_renderer.py and sports_shared.py --
against this version, along with the three traps in adopting the mixins.

No test pinned the old literal; the ones that care monkeypatch __version__.
135 compatibility tests pass.
2026-09-03 10:32:08 -04:00
4 changed files with 1650 additions and 1 deletions
+75
View File
@@ -17,6 +17,81 @@ release that ships it.
accepts both, but the store flags the old spelling as deprecated accepts both, but the store flags the old spelling as deprecated
(`store_manager.py`) and only the new one is in `schema/manifest_schema.json`. (`store_manager.py`) and only the new one is in `schema/manifest_schema.json`.
## 3.3.0
**The release the sports scoreboards floor on to delete their bundled copies.**
3.2.0 shipped the unified sports library and made `ledmatrix_min_version`
enforceable; this ships the last three shared modules and completes the store
gate, so a scoreboard can now floor here and carry no fallback at all.
New modules a plugin may import via `src.*` and floor on 3.3.0 for:
- `src/common/sports_card.py` — settings, colour, font and date helpers for a
scoreboard's `game_renderer.py`. Free functions taking `config`/`fonts`
explicitly, so nothing about the caller's class is assumed.
- `src/common/sports_game_renderer.py` — `SportsGameRendererMixin`: scroll/Vegas
card geometry (centre gap, logo slot and cache key, layout offsets, the
upcoming-card date and time layout). No `__init__` and no state, so adoption
is one line on the class statement.
- `src/common/sports_shared.py` — `SportsCoreSharedMixin`,
`SportsLiveSharedMixin`, `SportsRecentSharedMixin`: the `sports.py` bodies
byte-identical in all eight lineage-sharing scoreboards.
All three sit under `src/common/` rather than `src/base_classes/sports/`,
deliberately: importing that package pulls `core.py` → `DisplayManager` →
`rgbmatrix`, and these are pure logic. Plugins importing them must not acquire a
hardware dependency.
Three notes for anyone adopting `sports_shared`:
- `SportsRecentSharedMixin` defines `__init__`. Its bare `super()` binds to the
mixin, so it reaches the host only when the mixin is listed **first** in the
bases. Reversing that order silently skips the host constructor.
- Methods that resolve the plugin's `config_schema.json` use `_plugin_dir()`,
which walks the MRO rather than reading `__file__` — `__file__` is now
`src/common/`. It walks because `SportsCore` is an ABC: a subclass built with
`type(name, bases, ns)` reports `__module__` as `"abc"`.
- `_get_timezone`, `_extract_game_details` and `_fetch_data` are byte-identical
across the eight but stay in the plugins. The first binds a per-plugin
timezone module whose contents differ; the other two are the abstract stubs
that define the sport.
**The store's compatibility gate is now on every registry-managed route.** 3.2.0
gated `install_plugin`. This release gates the git-pull update path and
`install_from_url`, so a plugin whose floor the core cannot meet is refused
after download with no partial directory left behind.
### Fixed
- **ESPN 403s.** `site.api` began rejecting the User-Agent strings this repo
sent on 2026-08-04; every shared-data-source scoreboard returned
`403 Forbidden`. Requests now send an identifying token with a project URL —
browser-style strings and bare custom tokens are both refused.
- **Low-memory boards becoming unreachable under load** while still answering
pings and serving the web UI. Fetched payloads are released after delivery
rather than pinned on the completed request for up to an hour, malloc arenas
are capped, and log volume and SD writes are reduced. Available memory is now
reported in Tools diagnostics.
- **A failed logo download pinning a team to a grey box**: the placeholder was
written under the real logo's filename, so later attempts found a file and
reported success without retrying.
- **A plugin enabled but never loaded is retried** rather than staying absent
with `error = null`.
- `ttl` now controls cache expiry; abandoned cache writes no longer leave temp
files; one cache-cleanup thread per directory rather than per manager.
- Odds are fetched for the games displayed, not the whole schedule window, and a
stalled ESPN no longer stalls the whole plugin update.
- Array-item secrets are no longer wiped or logged.
- A restored backup matches the device it was taken from.
- The web UI reports the real error instead of "unknown", rejects non-finite
JSON numbers, and stops checkbox groups posting back hidden options.
### Added
- `display.hardware.orientation` for panels mounted upside down.
- Vegas keeps live content in the ticker rather than being preempted by it.
- Schemas can label enum dropdown options.
## 3.2.0 ## 3.2.0
**The first release shipping the unified sports library.** This is the version **The first release shipping the unified sports library.** This is the version
+1 -1
View File
@@ -4,5 +4,5 @@ LEDMatrix Display System
Core source package for the LED Matrix Display project. Core source package for the LED Matrix Display project.
""" """
__version__ = "3.2.0" __version__ = "3.3.0"
File diff suppressed because it is too large Load Diff
+321
View File
@@ -0,0 +1,321 @@
"""The shared sports.py mixins: their host contract, and _plugin_dir.
Two things are worth testing here and the rest is not. The 45 method bodies
moved verbatim from the plugins, so they are covered by the plugins' own tests
and by 176 byte-identical safety-harness renders. What is genuinely new is:
1. The contract. Every ``self.<CONSTANT>`` a mixin reads must be defined on the
mixin, or a host that does not happen to declare it raises AttributeError at
runtime. Two were missed on the first pass (_QUALITY_CHOICES and
_RANKING_COVERAGE_SECONDS); the eight plugins all declare them, so nothing
failed -- it would only have bitten a ninth. The test derives the list rather
than restating it, so the next omission fails here instead of in the field.
2. ``_plugin_dir``. This is the only line of genuinely new logic in the move. In
sports.py these methods found config_schema.json with ``__file__``; here that
is src/common/, so the plugin directory has to be recovered from the
instance -- and getting it wrong is silent, costing grid-snapped font sizes
(measured at 81% anti-aliased edges) rather than raising.
"""
import ast
import os
import sys
import types
from abc import ABC
import pytest
from src.common import sports_shared
from src.common.sports_shared import (
SportsCoreSharedMixin, SportsLiveSharedMixin, SportsRecentSharedMixin)
MIXINS = (SportsCoreSharedMixin, SportsLiveSharedMixin, SportsRecentSharedMixin)
def _constants_read_by_mixins():
"""Every ALL-CAPS ``self.X`` the mixin bodies read, found by parsing them."""
tree = ast.parse(open(sports_shared.__file__).read())
names = set()
for node in ast.walk(tree):
if (isinstance(node, ast.Attribute)
and isinstance(node.value, ast.Name)
and node.value.id == "self"
and node.attr.upper() == node.attr):
names.add(node.attr)
return names
class TestHostContract:
def test_every_constant_read_is_also_defined(self):
# Otherwise a host that does not declare it raises AttributeError the
# first time the code path runs -- which for these is mid-render.
missing = sorted(
name for name in _constants_read_by_mixins()
if not any(hasattr(m, name) for m in MIXINS))
assert missing == [], (
f"read but never defined on a mixin: {missing}. Give each a default "
f"on SportsCoreSharedMixin and document it in the module docstring.")
@pytest.mark.parametrize("name,expected", [
("_QUALITY_CHOICES", frozenset({"any", "ranked"})),
("_RANKING_COVERAGE_SECONDS", 3600),
("_SCORE_PROBE_TEXT", "00-00"),
("_FONT_DESIGN_HEIGHT", 32),
])
def test_defaults_match_what_the_plugins_ship(self, name, expected):
# The eight plugins declare their own copies, which shadow these. The
# values must still agree, or a ninth plugin inheriting the default
# behaves differently from the eight.
assert getattr(SportsCoreSharedMixin, name) == expected
def test_only_the_recent_mixin_carries_a_constructor(self):
# SportsCore and SportsLive keep their own __init__ -- those differ per
# plugin. SportsRecent.__init__ was one of the 48 byte-identical bodies,
# so it moved with the rest; that is deliberate, not an oversight.
assert "__init__" not in SportsCoreSharedMixin.__dict__
assert "__init__" not in SportsLiveSharedMixin.__dict__
assert "__init__" in SportsRecentSharedMixin.__dict__
def test_the_recent_constructor_still_chains_to_the_host(self):
"""Its zero-arg super() binds to where it is DEFINED, not where it is used.
Moving a body containing bare ``super()`` is the one move that can
change meaning: the compiler closes over __class__ = the defining class,
so after the move that is SportsRecentSharedMixin rather than the
plugin's SportsRecent. It still works only because the mixin is listed
first, leaving the host class next in the MRO -- adopt it in the other
order and the chain silently skips the host's __init__.
"""
calls = []
class Host:
def __init__(self, config, display_manager, cache_manager, logger, sport_key):
calls.append(sport_key)
self.mode_config = {}
class Recent(SportsRecentSharedMixin, Host):
pass
inst = Recent({}, None, None, None, "nhl")
assert calls == ["nhl"], "the host constructor must still run"
assert inst.current_game_index == 0
assert inst.update_interval == 3600
assert inst._zero_clock_timestamps == {}
def test_adopting_the_recent_mixin_second_would_skip_the_host(self):
# The failure mode the ordering above prevents, pinned so nobody
# "tidies" the base list.
calls = []
class Host:
def __init__(self, *a):
calls.append(a)
self.mode_config = {}
class Wrong(Host, SportsRecentSharedMixin):
pass
Wrong({}, None, None, None, "nhl")
# Host.__init__ wins and the mixin's setup never runs at all.
assert not hasattr(Wrong({}, None, None, None, "nhl"), "current_game_index")
class _Host(SportsCoreSharedMixin):
pass
def _write_plugin(tmp_path, name="fakeplug", schema=True):
"""A throwaway package on sys.path, with or without a config_schema.json."""
d = tmp_path / name
d.mkdir()
(d / "__init__.py").write_text("")
(d / "mod.py").write_text("class Leaf:\n pass\n")
if schema:
(d / "config_schema.json").write_text(
'{"properties": {"customization": {"properties": '
'{"score": {"properties": {"font_size": {"default": 16}}}}}}}')
return d
class TestPluginDir:
def test_it_finds_the_directory_holding_config_schema_json(self, tmp_path, monkeypatch):
d = _write_plugin(tmp_path)
monkeypatch.syspath_prepend(str(tmp_path))
mod = __import__("fakeplug.mod", fromlist=["Leaf"])
host = type("H", (mod.Leaf, SportsCoreSharedMixin), {})()
assert host._plugin_dir() == str(d)
def test_a_class_built_by_type_still_resolves(self, tmp_path, monkeypatch):
# SportsCore is an ABC, so type(name, bases, ns) reports __module__ as
# "abc" rather than the plugin -- which is exactly what the plugins'
# own tests build. Walking the MRO is what steps past it.
d = _write_plugin(tmp_path, "abcplug")
monkeypatch.syspath_prepend(str(tmp_path))
mod = __import__("abcplug.mod", fromlist=["Leaf"])
class Base(SportsCoreSharedMixin, mod.Leaf, ABC):
pass
synthetic = type("Probe", (Base,), {})
assert synthetic.__module__ == "abc", "precondition: the trap this guards"
assert synthetic.__new__(synthetic)._plugin_dir() == str(d)
def test_it_returns_none_when_no_schema_is_anywhere_on_the_mro(self, tmp_path, monkeypatch):
d = _write_plugin(tmp_path, "noschema", schema=False)
monkeypatch.syspath_prepend(str(tmp_path))
mod = __import__("noschema.mod", fromlist=["Leaf"])
host = type("H", (mod.Leaf, SportsCoreSharedMixin), {})()
# None rather than a wrong guess: _schema_font_size then caches empty
# and every element keeps its own default.
assert host._plugin_dir() is None
def test_it_never_returns_the_core_module_directory(self):
# The bug this replaced: __file__ pointed at src/common/, so the schema
# was never found and font sizes silently stopped snapping to the grid.
host = _Host()
core_common = os.path.dirname(os.path.abspath(sports_shared.__file__))
assert host._plugin_dir() != core_common
def test_a_module_with_no_file_is_skipped_not_crashed_on(self, monkeypatch):
# Namespace packages and some frozen/dynamic modules have no __file__.
ghost = types.ModuleType("ghost_no_file")
if hasattr(ghost, "__file__"):
del ghost.__file__
monkeypatch.setitem(sys.modules, "ghost_no_file", ghost)
cls = type("H", (SportsCoreSharedMixin,), {"__module__": "ghost_no_file"})
assert cls.__new__(cls)._plugin_dir() is None
class TestSchemaFontSize:
def test_it_reads_the_plugin_schema_not_the_cores(self, tmp_path, monkeypatch):
d = _write_plugin(tmp_path, "sizeplug")
monkeypatch.syspath_prepend(str(tmp_path))
mod = __import__("sizeplug.mod", fromlist=["Leaf"])
host = type("H", (mod.Leaf, SportsCoreSharedMixin), {})()
assert host._schema_font_size("score") == 16
def test_an_unknown_element_is_none(self, tmp_path, monkeypatch):
_write_plugin(tmp_path, "unkplug")
monkeypatch.syspath_prepend(str(tmp_path))
mod = __import__("unkplug.mod", fromlist=["Leaf"])
host = type("H", (mod.Leaf, SportsCoreSharedMixin), {})()
assert host._schema_font_size("nonesuch") is None
def test_an_empty_key_is_none_without_touching_the_disk(self):
assert _Host()._schema_font_size("") is None
def test_a_missing_schema_degrades_to_none_rather_than_raising(self, tmp_path, monkeypatch):
_write_plugin(tmp_path, "bareplug", schema=False)
monkeypatch.syspath_prepend(str(tmp_path))
mod = __import__("bareplug.mod", fromlist=["Leaf"])
host = type("H", (mod.Leaf, SportsCoreSharedMixin), {})()
assert host._schema_font_size("score") is None
class _LiveHost(SportsLiveSharedMixin):
"""The documented contract for the live mixin, and nothing else."""
def __init__(self, no_data_interval=300, stale_game_timeout=600, over=()):
self.no_data_interval = no_data_interval
self.stale_game_timeout = stale_game_timeout
self.game_update_timestamps = {}
self._over = set(over)
class _L:
def __getattr__(self, _n):
return lambda *a, **k: None
self.logger = _L()
def _is_game_really_over(self, game):
return game.get("id") in self._over
class TestLiveMixin:
"""These three moved to the core, so they are tested here.
They were already covered by hockey's and lacrosse's own tests, but those
two plugins disable live mode in their safety-harness fixtures, so the 176
renders never exercise this path. Testing the mixin directly means the
coverage no longer depends on which plugin happens to have a unit test.
"""
def test_a_stale_game_is_dropped_and_forgotten(self):
h = _LiveHost(stale_game_timeout=600)
import time as _t
h.game_update_timestamps["g1"] = {"last_seen": _t.time() - 5000}
games = [{"id": "g1", "home_abbr": "H", "away_abbr": "A"}]
h._detect_stale_games(games)
assert games == []
assert "g1" not in h.game_update_timestamps, "its timestamp must go too"
def test_a_fresh_game_survives(self):
h = _LiveHost(stale_game_timeout=600)
import time as _t
h.game_update_timestamps["g1"] = {"last_seen": _t.time() - 5}
games = [{"id": "g1"}]
h._detect_stale_games(games)
assert len(games) == 1
def test_a_game_never_seen_is_not_treated_as_stale(self):
# last_seen 0 means "no reading", not "seen at the epoch".
h = _LiveHost()
games = [{"id": "g1"}]
h._detect_stale_games(games)
assert len(games) == 1
def test_a_game_with_no_id_is_left_alone(self):
h = _LiveHost()
games = [{"home_abbr": "H"}]
h._detect_stale_games(games)
assert len(games) == 1
def test_a_finished_game_is_dropped_even_when_fresh(self):
h = _LiveHost(over=("g2",))
games = [{"id": "g1"}, {"id": "g2"}]
h._detect_stale_games(games)
assert [g["id"] for g in games] == ["g1"]
def test_removing_several_does_not_skip_any(self):
# It iterates a copy for exactly this reason; mutating the live list
# while looping would step over the element after each removal.
h = _LiveHost(over=("g1", "g2", "g3"))
games = [{"id": "g1"}, {"id": "g2"}, {"id": "g3"}]
h._detect_stale_games(games)
assert games == []
def test_the_idle_interval_escalates_with_the_empty_streak(self):
h = _LiveHost(no_data_interval=60)
h.live_idle_max_interval = 100000
base = h._idle_live_interval()
h._empty_live_streak = 6
short = h._idle_live_interval()
h._empty_live_streak = 24
long = h._idle_live_interval()
assert base < short < long
def test_the_ceiling_bounds_even_the_unescalated_interval(self):
# base > ceiling is a reachable config: the two settings are
# independent integers with no cross-validation. Returning base
# unclamped made the wait SHRINK as the streak grew.
h = _LiveHost(no_data_interval=3600)
h.live_idle_max_interval = 900
h._empty_live_streak = 0
assert h._idle_live_interval() == 900
h._empty_live_streak = 24
assert h._idle_live_interval() == 900
def test_finding_a_live_game_resets_the_streak(self):
h = _LiveHost()
h._note_live_fetch(False)
h._note_live_fetch(False)
assert h._empty_live_streak == 2
h._note_live_fetch(True)
assert h._empty_live_streak == 0
def test_the_streak_starts_from_absent_state(self):
# The host is not required to pre-declare _empty_live_streak.
h = _LiveHost()
assert not hasattr(h, "_empty_live_streak")
h._note_live_fetch(False)
assert h._empty_live_streak == 1