feat(common): sports_helpers — the helpers all nine scoreboards carry identical copies of (#583)

* feat(common): sports_helpers, the helpers all nine scoreboards copy verbatim

Add src/common/sports_helpers.py: the helpers the scoreboard plugins'
sports.py carry byte-identical copies of (docstring-stripped AST, checked at
ledmatrix-plugins f09bff2), so a later plugins PR can delete its copies once
it floors on the core release that ships this.

- Free functions: clamp_window, clamp_seconds, logo_needs_refresh (lazy
  src.logo_downloader import, as in the plugins), spread_weighted_order,
  MIN_WINDOW_DAYS / MAX_WINDOW_DAYS. All nine plugins.
- SportsHelpersMixin (no __init__, stateless): _mode_customization,
  _setting_int, _reset_dwell_on_reentry, _next_switch_index,
  _spread_weighted_order (all nine), _odds_color and
  _upcoming_date_and_time_text (all but ufc), plus the _favorite_key seam
  from base_classes core.py for later phases.

A new module rather than more methods on sports_shared: a plugin that
deletes a copy and relies on an existing module having grown the method
fails at runtime with AttributeError on an older core, which neither the
loader nor check_min_core_version.py can see; a missing module fails at load.

Tests: behaviour for every helper, a derived host contract, and a parity
test that AST-compares every body against every plugin copy when
LEDMATRIX_PLUGINS points at a checkout (skipped otherwise).
test_common_is_hardware_free.py imports src.common and every sports_* module
with rgbmatrix blocked and scans src/common for module-level imports of
src.base_classes, src.display_manager and src.plugin_system (no existing
violations).

Nothing in core imports the new module; no behaviour change. CHANGELOG
Unreleased entry and a converging note in docs/SPORTS_UNIFICATION.md.
__version__ is not bumped.

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

* fix(common): address review on sports_helpers and the hardware-free test

- SportsHelpersMixin docstring and CHANGELOG: constructor-free, but it keeps
  lazy state on its host (_reset_dwell_on_reentry, _next_switch_index).
- test_common_is_hardware_free: the runtime check now filters every
  FORBIDDEN package, src.plugin_system included; the AST scan resolves
  relative imports against src.common, so `from .. import plugin_system`
  and `from ..plugin_system import x` are caught. Guard tests for both.
- Parity skip reason names the CI guard that runs the same comparison:
  ledmatrix-plugins scripts/check_sports_helpers_parity.py (#495).
- _odds_color: line-level pylint disable for a not-callable false positive
  (getter is None-checked); the AST is unchanged, parity still passes.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-09-15 15:32:21 -04:00
committed by GitHub
co-authored by Claude Opus 5
parent 9e3f184d81
commit 8360220809
5 changed files with 1213 additions and 0 deletions
+408
View File
@@ -0,0 +1,408 @@
"""Sports helpers every scoreboard's ``sports.py`` carries an identical copy of.
Ten helpers are byte-identical (executable AST, docstrings stripped) in the
scoreboard plugins' ``sports.py`` and have no equivalent elsewhere in
``src/common``. The bodies below were copied from those plugin copies -- not
from ``src/base_classes`` -- at ledmatrix-plugins ``f09bff2`` (origin/main,
2026-09-14):
- In all nine (afl, baseball, basketball, football, hockey, lacrosse, nrl,
soccer, ufc): ``_clamp_window``, ``_clamp_seconds``, ``_logo_needs_refresh``
(with ``_MIN_WINDOW_DAYS`` / ``_MAX_WINDOW_DAYS``), and the ``SportsCore``
methods ``_mode_customization``, ``_setting_int``,
``_reset_dwell_on_reentry`` (with ``_DWELL_REENTRY_GAP_SECONDS``),
``_next_switch_index`` and ``_spread_weighted_order``.
- In the eight that share a lineage (all but ufc, which has neither):
``_odds_color`` and ``_upcoming_date_and_time_text``.
The module-level helpers become public free functions here (``clamp_window``,
``clamp_seconds``, ``logo_needs_refresh``, ``spread_weighted_order``,
``MIN_WINDOW_DAYS``, ``MAX_WINDOW_DAYS``); the methods keep the plugins' exact
names and signatures on ``SportsHelpersMixin`` so adopting it is deleting the
copies and adding one base. ``test/test_sports_helpers.py`` compares every body
here against every plugin copy when a plugins checkout is available
(``LEDMATRIX_PLUGINS``), so drift in either direction fails a test.
WHY A NEW MODULE, NOT MORE METHODS ON sports_shared
---------------------------------------------------
A plugin that deletes its copy of a method and relies on an *existing* module
having grown it fails at runtime on an older core: ``AttributeError`` mid-render,
usually swallowed into a blank board. The plugin loader cannot see that, and
neither can the plugins' ``scripts/check_min_core_version.py``, which checks
modules. A plugin importing a module that does not exist yet fails at load,
where both can. Plugins import this and floor ``ledmatrix_min_version`` on the
first core release that ships it (see ``CHANGELOG.md``).
``_favorite_key`` is the one method not taken from the plugins: it is the
override point from ``src/base_classes/sports/core.py``, carried here so later
phases (shared celebrations and game selection) have a hardware-free home for
the seam. No plugin defines it today and nothing in this module calls it.
WHAT A HOST MUST PROVIDE
------------------------
Derived by walking every ``self.<attr>`` the mixin reads; the host-contract
test in ``test/test_sports_helpers.py`` fails if a read is added without being
listed here.
- ``config`` (dict) -- ``_mode_customization``.
- ``mode_config`` (dict) and ``logger`` -- ``_setting_int``. ``league`` is
read with ``getattr`` for the warning text only.
- ``games_list`` and ``current_game_index`` -- ``_next_switch_index``; plus
``_is_favorite_game`` (called with a game), which stays per-plugin and is
only called when
``favorite_rotation_boost`` is above 1. ``favorite_rotation_boost`` itself
defaults to 1 on the mixin.
- ``last_game_switch`` -- ``_reset_dwell_on_reentry``, read with ``getattr``
and written back.
- ``_card_option``, ``_format_game_date`` and ``_format_game_time`` --
``_upcoming_date_and_time_text``. All three are on
``SportsCoreSharedMixin`` (``src/common/sports_shared.py``).
- ``_element_color`` -- ``_odds_color``, looked up with ``getattr`` and falling
back to green, so a host without it still works. Also on
``SportsCoreSharedMixin``.
- ``SKIN_MODE`` -- optional, read with ``getattr`` by ``_mode_customization``;
without it the mode overrides are simply not merged.
State the mixin keeps on the host, created on first use (all read with
``getattr``, so nothing needs initialising): ``_last_display_call_monotonic``,
``_switch_order``, ``_switch_order_key``, ``_switch_position``.
Class-level defaults: ``_DWELL_REENTRY_GAP_SECONDS = 5.0`` and
``favorite_rotation_boost = 1``, matching the plugins. A host's own value
shadows them.
MRO
---
No ``__init__`` and no bare ``super()``, so base order does not change what
runs. By convention list it after ``SportsCoreSharedMixin``::
class SportsCore(SportsCoreSharedMixin, SportsHelpersMixin, ABC):
HARDWARE-FREE
-------------
Nothing here may import ``src.display_manager``, ``src.base_classes``,
``src.plugin_system`` or anything else that reaches ``rgbmatrix`` at module
level; ``test/test_common_is_hardware_free.py`` enforces that for all of
``src/common``. ``logo_needs_refresh`` imports ``src.logo_downloader`` lazily,
exactly as the plugin copy does.
"""
from __future__ import annotations
import time
from typing import Any, ClassVar, Dict, List, Optional, Tuple
__all__ = [
"MIN_WINDOW_DAYS",
"MAX_WINDOW_DAYS",
"clamp_window",
"clamp_seconds",
"logo_needs_refresh",
"spread_weighted_order",
"SportsHelpersMixin",
]
MIN_WINDOW_DAYS = 1
MAX_WINDOW_DAYS = 60
def clamp_window(value: Any, fallback: int) -> int:
"""Days for one side of the schedule window, or the default if unusable."""
try:
days = int(value)
except (TypeError, ValueError, OverflowError):
# OverflowError: json parses a bare Infinity and int(inf) raises,
# which crashed manager init from a hand-edited config.
return fallback
return max(MIN_WINDOW_DAYS, min(MAX_WINDOW_DAYS, days))
def clamp_seconds(value: Any, fallback: int, low: int = 5,
high: int = 86400) -> int:
"""An interval in seconds, or the fallback when the value is unusable."""
try:
seconds = int(value)
except (TypeError, ValueError, OverflowError):
# OverflowError: json parses bare Infinity by default and int(inf)
# raises -- the same gap clamp_window above already covers.
return fallback
return max(low, min(high, seconds))
def logo_needs_refresh(logo_file) -> bool:
"""True if this file is a placeholder stale enough to retry the real logo.
A failed logo download is cached as a placeholder wearing the real logo's
filename, so "the file exists" is not proof the logo was ever fetched.
Without this check one transient failure leaves a team a grey box forever.
Returns False on a core that predates placeholder marking, which keeps the
previous behaviour rather than breaking the load.
"""
# Imported from the core by its full path, never as a bare name: a
# deferred bare-name import can bind another plugin's vendored
# logo_downloader once the core isolates top-level plugin modules.
try:
from src.logo_downloader import (
PLACEHOLDER_RETRY_SECONDS,
is_placeholder_logo,
placeholder_age_seconds,
)
except ImportError:
return False
try:
if not is_placeholder_logo(logo_file):
return False
age = placeholder_age_seconds(logo_file)
return age is None or age >= PLACEHOLDER_RETRY_SECONDS
except Exception:
return False
def spread_weighted_order(weights: List[int]) -> List[int]:
"""Indices into ``weights``, each repeated by its weight and spread out.
Each index keeps its own slot and places its extra turns at even
fractions of the rotation after it, wrapping round. That keeps the
list's schedule order for everything else and spaces a favourite's
repeats evenly *around the loop* -- the live rotation's smooth
weighted round-robin schedules a boosted game first and last, so a
rotation that wraps shows it back to back. Equal weights come back in
plain order, so a boost that applies to no card changes nothing.
Repeats are kept apart only where the ratio leaves room: once one
weight exceeds all the others combined, no cyclic order can separate
its turns ([3, 1, 1] gives [0, 1, 0, 2, 0]). Each index still gets
exactly its weight in turns -- the configured ratio wins over spacing.
"""
count = len(weights)
slots = []
for index, weight in enumerate(weights):
for turn in range(weight):
slots.append(((index + turn * count / weight) % count, turn > 0, index))
return [index for _, _, index in sorted(slots)]
class SportsHelpersMixin:
"""The ``SportsCore`` methods identical across the scoreboards' sports.py.
Constructor-free; keeps lazy state on its host (see the module docstring
for that state, the host contract and base-order guidance).
"""
#: Longest gap between two display() calls that still counts as one
#: on-screen stint. Frames arrive many times a second while a mode is on
#: the panel; between mode blocks the gap is the length of every other
#: mode's block -- a minute or more. Anything past a few seconds can only
#: be a block boundary, or the very first frame after startup.
_DWELL_REENTRY_GAP_SECONDS: ClassVar[float] = 5.0
#: Turns a favourite's card gets per turn of any other card in switch
#: mode. The plugins set it per instance from config; this default keeps
#: a host that does not on the plain rotation.
favorite_rotation_boost: int = 1
def _favorite_key(self, game: Dict, side: str) -> Optional[str]:
"""Override point: which view-model field identifies a team when
matching against ``favorite_teams``.
``side`` is ``"home"`` or ``"away"``. The default is the team
abbreviation -- what eight of the nine scoreboards match on, and what
users type into their favorites list.
NRL needs the team **id** instead, because NRL abbreviations are not
unique: "NEW" is both Newcastle Knights and New Zealand Warriors,
"CAN" both Canberra Raiders and Canterbury Bulldogs. It is a seam
rather than a branch so core never has to learn the string "nrl"::
def _favorite_key(self, game, side):
return str(game.get(f"{side}_id"))
An override that stringifies should note that a missing id becomes the
literal ``"None"``, which would spuriously match a favorites list
containing that string. The default returns ``None`` for a missing
abbreviation, which never matches.
Carried from ``src/base_classes/sports/core.py`` for later phases;
nothing in this module calls it yet.
"""
return game.get(f"{side}_abbr")
def _mode_customization(self) -> dict:
"""``customization`` with this mode's overrides merged over it.
SportsUpcoming / SportsRecent / SportsLive are separate instances
with their own SKIN_MODE, so merging once here makes every
per-element lookup mode-aware without changing one of them.
``None`` in a mode block means "inherit", which is what lets a user
restyle one element on live cards and leave everything else
following the settings above. It has to stay distinct from 0: a mode
y_offset of 0 means "sit at the base position", not "no preference".
"""
customization = self.config.get('customization', {})
if not isinstance(customization, dict):
return {}
mode = getattr(self, 'SKIN_MODE', None)
if not mode:
return customization
modes = customization.get('modes')
block = modes.get(mode) if isinstance(modes, dict) else None
if not isinstance(block, dict):
return customization
merged = dict(customization)
for element, override in block.items():
if element == 'layout' or not isinstance(override, dict):
continue
base = merged.get(element)
base = dict(base) if isinstance(base, dict) else {}
base.update({k: v for k, v in override.items() if v is not None})
merged[element] = base
mode_layout = block.get('layout')
if isinstance(mode_layout, dict):
base_layout = merged.get('layout')
new_layout = dict(base_layout) if isinstance(base_layout, dict) else {}
for element, axes in mode_layout.items():
if not isinstance(axes, dict):
continue
current = new_layout.get(element)
current = dict(current) if isinstance(current, dict) else {}
current.update({k: v for k, v in axes.items() if v is not None})
new_layout[element] = current
merged['layout'] = new_layout
return merged
def _setting_int(self, key: str, default: int, low: int, high: int) -> int:
"""A count from config, clamped to the range its schema declares.
The schema constrains these, but config.json can be hand-edited or
written by an older tool, and a string or a negative here does not
raise where anyone would see it -- it raises inside update()'s own
try/except, which shows up as a mode that silently renders nothing.
Same shape as the favorite_live_boost clamp in the plugins.
"""
try:
return max(low, min(high, int(self.mode_config.get(key, default))))
except (TypeError, ValueError, OverflowError):
# OverflowError: json parses a bare Infinity, and int(inf) raises
# -- from __init__, outside any try/except, so the manager would
# fail to construct instead of falling back.
self.logger.warning(
"%s: ignoring unusable %s=%r, using %s",
getattr(self, "league", "?"), key,
self.mode_config.get(key), default,
)
return default
def _reset_dwell_on_reentry(self) -> bool:
"""Give the current card a full turn when this mode (re)takes the panel.
The dwell clock (last_game_switch) keeps running while the mode is off
screen, so on re-entry it was always long expired and the first
display() call advanced immediately: the card cut off by the end of
the previous block was skipped instead of shown -- measured at one in
five card transitions on a 30s block of 15s cards -- and after a
service restart the clock started at manager construction, seconds
before the first frame, shaving that much off the first card. Both are
the same defect: the dwell clock counting time the viewer never saw.
Returns True when the dwell was reset, so the caller forces a redraw.
The one-frame card at the end of a block (the advance that races the
controller's mode switch) still renders -- this reset is what turns it
into the card that opens the next block with a full turn, instead of
one the rotation skipped.
"""
# getattr, and zero treated as "never displayed": the managers are
# constructed in several places -- the plugin tests among them -- not
# all of which set every attribute, and a freshly booted Pi can reach
# the first frame while time.monotonic() itself is still under the
# gap threshold, which would make `now - 0.0` look like one stint.
last = getattr(self, "_last_display_call_monotonic", 0.0)
now = time.monotonic()
self._last_display_call_monotonic = now
if last > 0.0 and now - last < self._DWELL_REENTRY_GAP_SECONDS:
return False
if getattr(self, "last_game_switch", 0) <= 0:
# Zero is the live screen's "no game shown yet" sentinel with its
# own handling; overwriting it here would hide the first game's
# arrival from that logic.
return False
self.last_game_switch = time.time()
return True
_spread_weighted_order = staticmethod(spread_weighted_order)
def _next_switch_index(self) -> int:
"""The games_list index switch mode shows next.
favorite_rotation_boost gives a favourite's card that many turns for
every one turn another card gets, spread through the rotation and kept
apart wherever the other cards leave room (a boost above the number of
other cards makes some repeats adjacent; the ratio is kept either way).
games_list itself stays one entry per game -- the
cycle-duration count, the scroll strip and the other-games re-cut all
read it -- so the weighting is an order walked over it instead.
The order is rebuilt whenever the list's games change, and the walk
resyncs from current_game_index whenever the two disagree: update()
and the other-games rotation both set the index directly when they
swap a list in, and the card on screen is where the walk resumes.
Called with _games_lock held and games_list non-empty.
"""
count = len(self.games_list)
boost = getattr(self, "favorite_rotation_boost", 1)
if boost <= 1 or count < 2:
return (self.current_game_index + 1) % count
key = (boost, tuple(g.get("id") for g in self.games_list))
if getattr(self, "_switch_order_key", None) != key:
self._switch_order = self._spread_weighted_order(
[boost if self._is_favorite_game(g) else 1 for g in self.games_list]
)
self._switch_order_key = key
self._switch_position = -1
order = self._switch_order
position = getattr(self, "_switch_position", -1)
if not 0 <= position < len(order) or order[position] != self.current_game_index:
position = (order.index(self.current_game_index)
if self.current_game_index in order else -1)
position = (position + 1) % len(order)
self._switch_position = position
return order[position]
def _odds_color(self) -> Tuple[int, int, int]:
"""Colour for the odds text; the green it always drew unless configured.
Guarded with getattr because not every class that reaches
_draw_dynamic_odds carries the element-colour helper -- the plugins'
own test harnesses build minimal manager objects, and a bare
AttributeError here is swallowed by the surrounding except, which
drops the odds off the card instead of failing loudly.
"""
getter = getattr(self, "_element_color", None)
if getter is None:
return (0, 255, 0)
try:
return getter("odds_text", (0, 255, 0)) # pylint: disable=not-callable
except Exception:
return (0, 255, 0)
def _upcoming_date_and_time_text(self, game_date: str, game_time: str,
game: Optional[Dict] = None) -> Tuple[str, str]:
"""The formatted (date, time) pair, blanked by switch_show_date/_time.
Deliberately not the shared show_date/show_time: those governed only
the scroll and Vegas cards before this display read the block, so a
config that had turned them off there would silently blank a scorebug
that has always drawn both lines. The switch keys default to True for
the same reason switch_upcoming_center defaults to "date_time" -- an
untouched panel keeps rendering exactly what it rendered before.
"""
date_text = (self._format_game_date(game_date, game)
if self._card_option("switch_show_date", True) else "")
time_text = (self._format_game_time(game_time)
if self._card_option("switch_show_time", True) else "")
return date_text, time_text