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