From e92f44ea14cafeefdb70e514f8919cfd6d9d40a7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 13:23:44 -0400 Subject: [PATCH 1/2] feat(sports): back off the live poll when a league has nothing on Rebases #287 onto main and fixes the three things that stopped it working. #287 stacked on #286, whose content reached main through #293 in a fixed form, so only its own commit is carried here; the code applied cleanly and the only conflicts were manifest version numbers and the generated registry. The two settings could not reach the code. Each plugin's _adapt_config_for_manager builds its output key by key, so a key it does not name is dropped -- the same defect that made the schedule window inert, and #293 fixed that by naming those two keys in a loop. Rather than adding two more names to nine copies of a hard-coded tuple, the loop now walks a module-level _ROOT_CONFIG_KEYS listing every plugin-root setting SportsCore reads, so the next one only has to be declared once. Verified by running all nine adapters: with all four keys set, all four now arrive at the config root. The ceiling did not bound the base interval. _idle_live_interval ended with a bare `return base`, and the two settings are independent integers with no cross-validation, so base > ceiling is a reachable config -- base=3600 with the default 900 ceiling waited 3600s at streak 0 and 900s at streak 24. The interval shrank as the streak grew, which is the opposite of what a setting named "maximum" promises. Now min(base, ceiling), and checked across three base/ceiling combinations that the sequence never decreases. The test for that escalation could not fail: it compared long_wait to a second call of the same method with the same state. Compared against the short-streak value now. Mutation-checked -- making the long branch behave like the short one is caught, where before it was not. The plumbing test from #293 covers all four keys rather than two: removing the loop from the nine adapters fails 36 of 36 checks. All 104 test files across the nine plugins pass. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01STMbQE4YctTacQXfbYqKuW --- plugins.json | 20 +-- plugins/afl-scoreboard/config_schema.json | 18 +++ plugins/afl-scoreboard/manager.py | 25 ++- plugins/afl-scoreboard/manifest.json | 9 +- plugins/afl-scoreboard/sports.py | 86 ++++++++-- .../test_idle_league_backoff.py | 150 ++++++++++++++++++ .../baseball-scoreboard/config_schema.json | 18 +++ plugins/baseball-scoreboard/manager.py | 25 ++- plugins/baseball-scoreboard/manifest.json | 9 +- plugins/baseball-scoreboard/sports.py | 86 ++++++++-- .../test_idle_league_backoff.py | 150 ++++++++++++++++++ .../basketball-scoreboard/config_schema.json | 18 +++ plugins/basketball-scoreboard/manager.py | 25 ++- plugins/basketball-scoreboard/manifest.json | 9 +- plugins/basketball-scoreboard/sports.py | 86 ++++++++-- .../test_idle_league_backoff.py | 150 ++++++++++++++++++ .../football-scoreboard/config_schema.json | 18 +++ plugins/football-scoreboard/manager.py | 25 ++- plugins/football-scoreboard/manifest.json | 9 +- plugins/football-scoreboard/sports.py | 86 ++++++++-- .../test_idle_league_backoff.py | 150 ++++++++++++++++++ plugins/hockey-scoreboard/config_schema.json | 18 +++ plugins/hockey-scoreboard/manager.py | 25 ++- plugins/hockey-scoreboard/manifest.json | 9 +- plugins/hockey-scoreboard/sports.py | 91 +++++++++-- .../test_idle_league_backoff.py | 150 ++++++++++++++++++ .../lacrosse-scoreboard/config_schema.json | 18 +++ plugins/lacrosse-scoreboard/manager.py | 25 ++- plugins/lacrosse-scoreboard/manifest.json | 9 +- plugins/lacrosse-scoreboard/sports.py | 91 +++++++++-- .../test_idle_league_backoff.py | 150 ++++++++++++++++++ plugins/nrl-scoreboard/config_schema.json | 18 +++ plugins/nrl-scoreboard/manager.py | 25 ++- plugins/nrl-scoreboard/manifest.json | 9 +- plugins/nrl-scoreboard/sports.py | 86 ++++++++-- .../test_idle_league_backoff.py | 150 ++++++++++++++++++ plugins/soccer-scoreboard/config_schema.json | 18 +++ plugins/soccer-scoreboard/manager.py | 25 ++- plugins/soccer-scoreboard/manifest.json | 9 +- plugins/soccer-scoreboard/sports.py | 86 ++++++++-- .../test_idle_league_backoff.py | 150 ++++++++++++++++++ plugins/ufc-scoreboard/config_schema.json | 18 +++ plugins/ufc-scoreboard/manager.py | 25 ++- plugins/ufc-scoreboard/manifest.json | 9 +- plugins/ufc-scoreboard/sports.py | 86 ++++++++-- .../test_idle_league_backoff.py | 150 ++++++++++++++++++ scripts/test_schedule_window_plumbing.py | 12 +- 47 files changed, 2456 insertions(+), 178 deletions(-) create mode 100644 plugins/afl-scoreboard/test_idle_league_backoff.py create mode 100644 plugins/baseball-scoreboard/test_idle_league_backoff.py create mode 100644 plugins/basketball-scoreboard/test_idle_league_backoff.py create mode 100644 plugins/football-scoreboard/test_idle_league_backoff.py create mode 100644 plugins/hockey-scoreboard/test_idle_league_backoff.py create mode 100644 plugins/lacrosse-scoreboard/test_idle_league_backoff.py create mode 100644 plugins/nrl-scoreboard/test_idle_league_backoff.py create mode 100644 plugins/soccer-scoreboard/test_idle_league_backoff.py create mode 100644 plugins/ufc-scoreboard/test_idle_league_backoff.py diff --git a/plugins.json b/plugins.json index 65d4a96c..f2a50b1c 100644 --- a/plugins.json +++ b/plugins.json @@ -1,6 +1,6 @@ { "version": "1.0.0", - "last_updated": "2026-08-18", + "last_updated": "2026-08-19", "plugins": [ { "id": "cricket-scoreboard", @@ -76,7 +76,7 @@ "last_updated": "2026-08-13", "verified": true, "screenshot": "", - "latest_version": "1.26.1" + "latest_version": "1.27.0" }, { "id": "basketball-scoreboard", @@ -101,7 +101,7 @@ "last_updated": "2026-08-13", "verified": true, "screenshot": "", - "latest_version": "1.14.1" + "latest_version": "1.15.0" }, { "id": "calendar", @@ -240,7 +240,7 @@ "last_updated": "2026-08-13", "verified": true, "screenshot": "", - "latest_version": "2.16.1" + "latest_version": "2.17.0" }, { "id": "geochron", @@ -335,7 +335,7 @@ "last_updated": "2026-08-13", "verified": true, "screenshot": "", - "latest_version": "1.11.1", + "latest_version": "1.12.0", "icon": "fas fa-hockey-puck" }, { @@ -359,7 +359,7 @@ "last_updated": "2026-08-13", "verified": true, "screenshot": "", - "latest_version": "1.11.1", + "latest_version": "1.12.0", "icon": "fas fa-baseball-ball" }, { @@ -760,7 +760,7 @@ "last_updated": "2026-08-13", "verified": true, "screenshot": "", - "latest_version": "2.11.1" + "latest_version": "2.12.0" }, { "id": "static-image", @@ -905,7 +905,7 @@ "last_updated": "2026-08-05", "verified": true, "screenshot": "", - "latest_version": "1.5.1", + "latest_version": "1.6.0", "icon": "fas fa-fist-raised" }, { @@ -1048,7 +1048,7 @@ "downloads": 0, "verified": true, "screenshot": "", - "latest_version": "1.8.1", + "latest_version": "1.9.0", "last_updated": "2026-08-13" }, { @@ -1095,7 +1095,7 @@ "last_updated": "2026-08-13", "verified": true, "screenshot": "", - "latest_version": "1.8.1" + "latest_version": "1.9.0" }, { "id": "jellyfin-now-playing", diff --git a/plugins/afl-scoreboard/config_schema.json b/plugins/afl-scoreboard/config_schema.json index 513a3b57..a6beea3e 100644 --- a/plugins/afl-scoreboard/config_schema.json +++ b/plugins/afl-scoreboard/config_schema.json @@ -1271,6 +1271,24 @@ "title": "Schedule lookahead (days)", "description": "How far ahead to fetch games for the Upcoming screen. Raise it for a league that plays weekly: a fixture just beyond this horizon is not fetched at all, so it never reaches the board even though the date is known.", "x-advanced": true + }, + "no_data_interval_seconds": { + "type": "integer", + "default": 300, + "minimum": 5, + "maximum": 86400, + "title": "Idle live-check interval (seconds)", + "description": "How long to wait between checks for live games when there are none. Backs off further the longer nothing is found, up to the cap below.", + "x-advanced": true + }, + "live_idle_max_interval_seconds": { + "type": "integer", + "default": 900, + "minimum": 5, + "maximum": 86400, + "title": "Maximum idle live-check interval (seconds)", + "description": "Ceiling for the back-off above. Raise it to poll an out-of-season league even less; lower it to notice the first game after a quiet spell sooner.", + "x-advanced": true } }, "additionalProperties": false, diff --git a/plugins/afl-scoreboard/manager.py b/plugins/afl-scoreboard/manager.py index 020cbb1f..a1aff837 100644 --- a/plugins/afl-scoreboard/manager.py +++ b/plugins/afl-scoreboard/manager.py @@ -55,6 +55,15 @@ from afl_timezone import resolve_timezone_name from afl_favorite_check import FavoriteTeamCheck + +_ROOT_CONFIG_KEYS = ( + "schedule_lookback_days", + "schedule_lookahead_days", + "no_data_interval_seconds", + "live_idle_max_interval_seconds", +) + + # Which ESPN endpoint backs the league, for the favorite-team diagnostic. FAVORITE_CHECK_KEY = 'afl' FAVORITE_CHECK_LEAGUES = {FAVORITE_CHECK_KEY: ('AFL', 'australian-football/afl')} @@ -308,13 +317,15 @@ def _adapt_config_for_manager(self) -> Dict[str, Any]: "customization": customization_config, }) - # The schedule-window settings live at the plugin config root, and - # SportsCore reads them from the root of the config it is handed. This - # adapter builds its output key by key, so anything not named here is - # dropped -- which silently pinned every user to the defaults. - for _window_key in ("schedule_lookback_days", "schedule_lookahead_days"): - if _window_key in self.config: - manager_config[_window_key] = self.config[_window_key] + # Plugin-root settings that SportsCore reads from the root of the config + # it is handed. This adapter builds its output key by key, so anything + # not named here is dropped -- which is how the schedule window silently + # pinned every user to the defaults, and would have done the same to the + # idle-poll settings. Generalised to a list so the next one added to + # SportsCore only has to be named once. + for _root_key in _ROOT_CONFIG_KEYS: + if _root_key in self.config: + manager_config[_root_key] = self.config[_root_key] return manager_config def _parse_display_mode_settings(self) -> Dict[str, str]: diff --git a/plugins/afl-scoreboard/manifest.json b/plugins/afl-scoreboard/manifest.json index 45937aca..13d1d7d4 100644 --- a/plugins/afl-scoreboard/manifest.json +++ b/plugins/afl-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "afl-scoreboard", "name": "AFL Scoreboard", - "version": "1.8.1", + "version": "1.9.0", "author": "ChuckBuilds", "description": "Live, recent, and upcoming AFL (Australian Football League) games with real-time scores and game status.", "category": "sports", @@ -18,6 +18,13 @@ "afl_upcoming" ], "versions": [ + { + "version": "1.9.0", + "released": "2026-08-19", + "ledmatrix_min_version": "2.0.0", + "notes": "Make the schedule fetch window configurable. How far back and forward games are fetched was hard-coded at 14 days back and 7 forward. Days is the wrong unit for a league that plays weekly -- a whole matchweek can fall just past a short horizon and never reach the board -- so both are now advanced settings, bounded 1-60 days, with a stray or non-numeric value falling back to the default rather than raising. Defaults keep today's behaviour.", + "changes": "Back off the live-game poll while a league has nothing on, and let both the idle interval and its ceiling be configured. Saves API calls out of season and between games without delaying an in-progress game, which still resets the interval on the first fetch that finds one." + }, { "version": "1.8.1", "released": "2026-08-18", diff --git a/plugins/afl-scoreboard/sports.py b/plugins/afl-scoreboard/sports.py index 09051491..59aa7709 100644 --- a/plugins/afl-scoreboard/sports.py +++ b/plugins/afl-scoreboard/sports.py @@ -101,6 +101,26 @@ def _clamp_window(value: Any, fallback: int) -> int: return max(_MIN_WINDOW_DAYS, min(_MAX_WINDOW_DAYS, days)) +# Backing off the live poll while a league has nothing on. Gentle at first -- +# a gap between games in a live season should cost little -- then firmer, so a +# league months out of season stops polling on a live cadence altogether. +_IDLE_SHORT_STREAK = 6 +_IDLE_SHORT_FACTOR = 2 +_IDLE_LONG_STREAK = 24 +_IDLE_LONG_FACTOR = 6 +_DEFAULT_LIVE_IDLE_MAX_SECONDS = 900 + + +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): + return fallback + return max(low, min(high, seconds)) + + class SportsCore(ABC): def __init__( self, @@ -2493,7 +2513,12 @@ def __init__( ): super().__init__(config, display_manager, cache_manager, logger, sport_key) self.update_interval = self.mode_config.get("live_update_interval", 15) - self.no_data_interval = 300 + self.no_data_interval = _clamp_seconds( + self.mode_config.get("no_data_interval_seconds"), 300) + self.live_idle_max_interval = _clamp_seconds( + self.mode_config.get("live_idle_max_interval_seconds"), + _DEFAULT_LIVE_IDLE_MAX_SECONDS) + self._empty_live_streak = 0 # Log the configured interval for debugging self.logger.info( f"SportsLive initialized: live_update_interval={self.update_interval}s, " @@ -3032,6 +3057,43 @@ def _detect_stale_games(self, games: List[Dict]) -> None: if game_id in self.game_update_timestamps: del self.game_update_timestamps[game_id] + def _idle_live_interval(self) -> int: + """How long to wait before looking for live games again, when there are none. + + Escalates the longer nothing turns up, and any live game resets it, so + an in-season gap between games costs at most one escalated wait while + an out-of-season league stops polling on a live cadence entirely. + + Capped rather than unbounded: the cost of backing off is how late the + first game after a quiet spell is noticed, and past the cap the saving + stops being worth that. + """ + streak = getattr(self, "_empty_live_streak", 0) + base = self.no_data_interval + ceiling = getattr(self, "live_idle_max_interval", + _DEFAULT_LIVE_IDLE_MAX_SECONDS) + # The ceiling bounds the un-escalated interval too. The two settings are + # independent integers with no cross-validation, so base > ceiling is a + # reachable config -- and returning base unclamped there made the wait + # *shrink* as the streak grew (3600s at streak 0, 900s at streak 24), + # the opposite of what the setting named "maximum" promises. + if streak >= _IDLE_LONG_STREAK: + return min(int(base * _IDLE_LONG_FACTOR), ceiling) + if streak >= _IDLE_SHORT_STREAK: + return min(int(base * _IDLE_SHORT_FACTOR), ceiling) + return min(base, ceiling) + + def _note_live_fetch(self, found_live: bool) -> None: + """Record whether a look for live games found any.""" + if found_live: + if getattr(self, "_empty_live_streak", 0): + self.logger.info( + "Live games found after %d empty check(s); back to the " + "live update interval", self._empty_live_streak) + self._empty_live_streak = 0 + else: + self._empty_live_streak = getattr(self, "_empty_live_streak", 0) + 1 + def update(self): """Update live game data and handle game switching.""" if not self.is_enabled: @@ -3056,21 +3118,25 @@ def update(self): # Only use no_data_interval if we've recently checked and confirmed there are no live games. # This ensures we check for live games frequently even if the list is temporarily empty. # Only use no_data_interval if we have no live games AND we've checked recently (within last 5 minutes) - time_since_last_update = current_time - self.last_update - has_recently_checked = self.last_update > 0 and time_since_last_update < 300 - + # Whether the last look found anything, tracked explicitly rather than + # inferred from how long ago it was. The old form asked "did we check + # within the last 300s?" and only then used no_data_interval -- but + # once 300s had elapsed the answer became no, the interval dropped + # back to live_update_interval, and it fetched. no_data_interval could + # therefore never delay anything past 300s whatever it was set to. + # Measured on a live rig: an out-of-season NHL polled every ~5.5 + # minutes around the clock, returning nothing every time. if _live_games_attr: - # We have live games, use the configured update interval interval = _update_interval_attr - elif has_recently_checked: - # We've checked recently and found no live games, use longer interval - interval = _no_data_interval_attr else: - # First check or haven't checked in a while, use update interval to check for live games - interval = _update_interval_attr + interval = self._idle_live_interval() # Original line from traceback (line 455), now with variables defined: if current_time - self.last_update >= interval: + # What the previous look found, recorded before this one + # replaces it. The streak is what drives the back-off, and + # any live game resets it. + self._note_live_fetch(bool(_live_games_attr)) self.last_update = current_time # Fetch rankings if enabled diff --git a/plugins/afl-scoreboard/test_idle_league_backoff.py b/plugins/afl-scoreboard/test_idle_league_backoff.py new file mode 100644 index 00000000..21d196ca --- /dev/null +++ b/plugins/afl-scoreboard/test_idle_league_backoff.py @@ -0,0 +1,150 @@ +#!/usr/bin/env python3 +""" +Tests that a league with nothing on stops polling on a live cadence. + +Regression under test: the live gate decided its interval like this -- + + has_recently_checked = self.last_update > 0 and time_since_last_update < 300 + if live_games: interval = live_update_interval + elif has_recently_checked: interval = no_data_interval + else: interval = live_update_interval + +Once 300s had elapsed, has_recently_checked became False, the interval fell +back to live_update_interval, and it fetched. no_data_interval could therefore +never delay anything past 300s whatever it was set to -- the setting was +inert. Measured on a live rig in mid-August: NHLLiveManager fetched 0 games 22 +times in 2 hours, every ~5.5 minutes, around the clock, for a league whose +season had not started. Roughly 264 wasted requests a day, per league. + +The interval now comes from an explicit streak of empty looks, which escalates +and is capped, and which any live game resets. + +The methods are exercised against a stand-in ``self``, so the test needs no +display hardware, no network and no cache. + +Run: /bin/python plugins/afl-scoreboard/test_idle_league_backoff.py +""" + +import ast +import sys +from pathlib import Path + +plugin_dir = Path(__file__).resolve().parent +sys.path.insert(0, str(plugin_dir)) +for candidate in (Path("/home/rackpi/projects/LEDMatrix"), + plugin_dir.parents[2] / "LEDMatrix"): + if (candidate / "src" / "plugin_system" / "base_plugin.py").exists(): + sys.path.insert(0, str(candidate)) + break + +import sports # noqa: E402 + +failures = [] + + +def check(label, ok): + print((" PASS " if ok else " FAIL ") + label) + if not ok: + failures.append(label) + + +class _Logger: + def info(self, *a, **k): + pass + + +class _Live: + _idle_live_interval = sports.SportsLive._idle_live_interval + _note_live_fetch = sports.SportsLive._note_live_fetch + + def __init__(self, base=300, ceiling=900): + self.no_data_interval = base + self.live_idle_max_interval = ceiling + self._empty_live_streak = 0 + self.logger = _Logger() + + +def main(): + print("the wait grows the longer nothing is found") + live = _Live() + check("first look uses the base interval", + live._idle_live_interval() == 300) + + for _ in range(sports._IDLE_SHORT_STREAK): + live._note_live_fetch(False) + check("after a short streak it is longer (%ds)" % live._idle_live_interval(), + live._idle_live_interval() > 300) + + short_wait = live._idle_live_interval() + for _ in range(sports._IDLE_LONG_STREAK): + live._note_live_fetch(False) + long_wait = live._idle_live_interval() + # Against short_wait, not against itself: comparing the value to a second + # call of the same method with the same state can never fail, so the + # monotonicity it is meant to protect went untested. + check("after a long streak it is longer still (%ds vs %ds)" + % (long_wait, short_wait), long_wait > short_wait) + check("and never exceeds the ceiling", long_wait <= 900) + + for _ in range(500): + live._note_live_fetch(False) + check("a very long streak stays at the ceiling, not beyond", + live._idle_live_interval() == 900) + + print("\na live game resets it immediately") + live._note_live_fetch(True) + check("the streak is cleared", live._empty_live_streak == 0) + check("and the base interval is back", + live._idle_live_interval() == 300) + + print("\nthe saving is real, and bounded") + idle = _Live() + for _ in range(500): + idle._note_live_fetch(False) + per_day = 86400 / idle._idle_live_interval() + check("an out-of-season league polls far less (%d/day vs 288)" % per_day, + per_day < 288 / 2) + check("...but still often enough to notice a season starting (<= 1h)", + idle._idle_live_interval() <= 3600) + + print("\nthe ceiling is configurable") + tight = _Live(ceiling=300) + for _ in range(500): + tight._note_live_fetch(False) + check("a lower ceiling is honoured", tight._idle_live_interval() == 300) + loose = _Live(ceiling=3600) + for _ in range(500): + loose._note_live_fetch(False) + check("a higher ceiling is honoured", loose._idle_live_interval() == 1800) + + print("\nintervals from config are clamped, never trusted raw") + check("a sane value is used", sports._clamp_seconds(120, 300) == 120) + check("absent falls back", sports._clamp_seconds(None, 300) == 300) + check("nonsense falls back", sports._clamp_seconds("soon", 300) == 300) + check("zero is clamped up", sports._clamp_seconds(0, 300) >= 5) + check("a week is clamped down", sports._clamp_seconds(604800, 300) <= 86400) + + print("\nthe old recency-based logic is gone") + src = (plugin_dir / "sports.py").read_text(encoding="utf-8") + check("has_recently_checked no longer decides the interval", + "has_recently_checked" not in src) + + tree = ast.parse(src) + live_cls = next(c for c in ast.walk(tree) + if isinstance(c, ast.ClassDef) and c.name == "SportsLive") + upd = next(m for m in live_cls.body + if isinstance(m, ast.FunctionDef) and m.name == "update") + idle_calls = [n for n in ast.walk(upd) if isinstance(n, ast.Call) + and getattr(n.func, "attr", None) == "_idle_live_interval"] + note_calls = [n for n in ast.walk(upd) if isinstance(n, ast.Call) + and getattr(n.func, "attr", None) == "_note_live_fetch"] + check("update() takes its idle interval from the back-off", len(idle_calls) == 1) + check("update() records each look's outcome", len(note_calls) == 1) + + print("\n%s" % ("FAILED: %d" % len(failures) if failures + else "All checks passed")) + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/plugins/baseball-scoreboard/config_schema.json b/plugins/baseball-scoreboard/config_schema.json index 603a7e86..cf2bea98 100644 --- a/plugins/baseball-scoreboard/config_schema.json +++ b/plugins/baseball-scoreboard/config_schema.json @@ -2539,6 +2539,24 @@ "title": "Schedule lookahead (days)", "description": "How far ahead to fetch games for the Upcoming screen. Raise it for a league that plays weekly: a fixture just beyond this horizon is not fetched at all, so it never reaches the board even though the date is known.", "x-advanced": true + }, + "no_data_interval_seconds": { + "type": "integer", + "default": 300, + "minimum": 5, + "maximum": 86400, + "title": "Idle live-check interval (seconds)", + "description": "How long to wait between checks for live games when there are none. Backs off further the longer nothing is found, up to the cap below.", + "x-advanced": true + }, + "live_idle_max_interval_seconds": { + "type": "integer", + "default": 900, + "minimum": 5, + "maximum": 86400, + "title": "Maximum idle live-check interval (seconds)", + "description": "Ceiling for the back-off above. Raise it to poll an out-of-season league even less; lower it to notice the first game after a quiet spell sooner.", + "x-advanced": true } }, "additionalProperties": false, diff --git a/plugins/baseball-scoreboard/manager.py b/plugins/baseball-scoreboard/manager.py index 61b03d09..53658809 100644 --- a/plugins/baseball-scoreboard/manager.py +++ b/plugins/baseball-scoreboard/manager.py @@ -53,6 +53,15 @@ from baseball_timezone import resolve_timezone_name from baseball_favorite_check import FavoriteTeamCheck + +_ROOT_CONFIG_KEYS = ( + "schedule_lookback_days", + "schedule_lookahead_days", + "no_data_interval_seconds", + "live_idle_max_interval_seconds", +) + + # Which ESPN endpoint backs each league, for the favorite-team diagnostic. FAVORITE_CHECK_LEAGUES = { 'mlb': ('MLB', 'baseball/mlb'), @@ -765,13 +774,15 @@ def _adapt_config_for_manager(self, league: str) -> Dict[str, Any]: self.logger.debug(f"Using timezone: {timezone_str} for {league} managers") - # The schedule-window settings live at the plugin config root, and - # SportsCore reads them from the root of the config it is handed. This - # adapter builds its output key by key, so anything not named here is - # dropped -- which silently pinned every user to the defaults. - for _window_key in ("schedule_lookback_days", "schedule_lookahead_days"): - if _window_key in self.config: - manager_config[_window_key] = self.config[_window_key] + # Plugin-root settings that SportsCore reads from the root of the config + # it is handed. This adapter builds its output key by key, so anything + # not named here is dropped -- which is how the schedule window silently + # pinned every user to the defaults, and would have done the same to the + # idle-poll settings. Generalised to a list so the next one added to + # SportsCore only has to be named once. + for _root_key in _ROOT_CONFIG_KEYS: + if _root_key in self.config: + manager_config[_root_key] = self.config[_root_key] return manager_config def _sub_component_config(self) -> Dict[str, Any]: diff --git a/plugins/baseball-scoreboard/manifest.json b/plugins/baseball-scoreboard/manifest.json index cd73625c..55dfac4d 100644 --- a/plugins/baseball-scoreboard/manifest.json +++ b/plugins/baseball-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "baseball-scoreboard", "name": "Baseball Scoreboard", - "version": "1.26.1", + "version": "1.27.0", "author": "ChuckBuilds", "description": "Live, recent, and upcoming baseball games across MLB, MiLB, and NCAA Baseball with real-time scores and schedules", "category": "sports", @@ -30,6 +30,13 @@ "branch": "main", "plugin_path": "plugins/baseball-scoreboard", "versions": [ + { + "version": "1.27.0", + "released": "2026-08-19", + "ledmatrix_min_version": "2.0.0", + "notes": "Make the schedule fetch window configurable. How far back and forward games are fetched was hard-coded at 14 days back and 7 forward. Days is the wrong unit for a league that plays weekly -- a whole matchweek can fall just past a short horizon and never reach the board -- so both are now advanced settings, bounded 1-60 days, with a stray or non-numeric value falling back to the default rather than raising. Defaults keep today's behaviour.", + "changes": "Back off the live-game poll while a league has nothing on, and let both the idle interval and its ceiling be configured. Saves API calls out of season and between games without delaying an in-progress game, which still resets the interval on the first fetch that finds one." + }, { "version": "1.26.1", "released": "2026-08-18", diff --git a/plugins/baseball-scoreboard/sports.py b/plugins/baseball-scoreboard/sports.py index d638aa77..d9df0f03 100644 --- a/plugins/baseball-scoreboard/sports.py +++ b/plugins/baseball-scoreboard/sports.py @@ -111,6 +111,26 @@ def _clamp_window(value: Any, fallback: int) -> int: return max(_MIN_WINDOW_DAYS, min(_MAX_WINDOW_DAYS, days)) +# Backing off the live poll while a league has nothing on. Gentle at first -- +# a gap between games in a live season should cost little -- then firmer, so a +# league months out of season stops polling on a live cadence altogether. +_IDLE_SHORT_STREAK = 6 +_IDLE_SHORT_FACTOR = 2 +_IDLE_LONG_STREAK = 24 +_IDLE_LONG_FACTOR = 6 +_DEFAULT_LIVE_IDLE_MAX_SECONDS = 900 + + +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): + return fallback + return max(low, min(high, seconds)) + + class SportsCore(ABC): def __init__( self, @@ -2350,7 +2370,12 @@ def __init__( ): super().__init__(config, display_manager, cache_manager, logger, sport_key) self.update_interval = self.mode_config.get("live_update_interval", 15) - self.no_data_interval = 300 + self.no_data_interval = _clamp_seconds( + self.mode_config.get("no_data_interval_seconds"), 300) + self.live_idle_max_interval = _clamp_seconds( + self.mode_config.get("live_idle_max_interval_seconds"), + _DEFAULT_LIVE_IDLE_MAX_SECONDS) + self._empty_live_streak = 0 # Log the configured interval for debugging self.logger.info( f"SportsLive initialized: live_update_interval={self.update_interval}s, " @@ -2517,6 +2542,43 @@ def _detect_stale_games(self, games: List[Dict]) -> None: if game_id in self.game_update_timestamps: del self.game_update_timestamps[game_id] + def _idle_live_interval(self) -> int: + """How long to wait before looking for live games again, when there are none. + + Escalates the longer nothing turns up, and any live game resets it, so + an in-season gap between games costs at most one escalated wait while + an out-of-season league stops polling on a live cadence entirely. + + Capped rather than unbounded: the cost of backing off is how late the + first game after a quiet spell is noticed, and past the cap the saving + stops being worth that. + """ + streak = getattr(self, "_empty_live_streak", 0) + base = self.no_data_interval + ceiling = getattr(self, "live_idle_max_interval", + _DEFAULT_LIVE_IDLE_MAX_SECONDS) + # The ceiling bounds the un-escalated interval too. The two settings are + # independent integers with no cross-validation, so base > ceiling is a + # reachable config -- and returning base unclamped there made the wait + # *shrink* as the streak grew (3600s at streak 0, 900s at streak 24), + # the opposite of what the setting named "maximum" promises. + if streak >= _IDLE_LONG_STREAK: + return min(int(base * _IDLE_LONG_FACTOR), ceiling) + if streak >= _IDLE_SHORT_STREAK: + return min(int(base * _IDLE_SHORT_FACTOR), ceiling) + return min(base, ceiling) + + def _note_live_fetch(self, found_live: bool) -> None: + """Record whether a look for live games found any.""" + if found_live: + if getattr(self, "_empty_live_streak", 0): + self.logger.info( + "Live games found after %d empty check(s); back to the " + "live update interval", self._empty_live_streak) + self._empty_live_streak = 0 + else: + self._empty_live_streak = getattr(self, "_empty_live_streak", 0) + 1 + def update(self): """Update live game data and handle game switching.""" if not self.is_enabled: @@ -2544,21 +2606,25 @@ def update(self): # Only use no_data_interval if we've recently checked and confirmed there are no live games. # This ensures we check for live games frequently even if the list is temporarily empty. # Only use no_data_interval if we have no live games AND we've checked recently (within last 5 minutes) - time_since_last_update = current_time - self.last_update - has_recently_checked = self.last_update > 0 and time_since_last_update < 300 - + # Whether the last look found anything, tracked explicitly rather than + # inferred from how long ago it was. The old form asked "did we check + # within the last 300s?" and only then used no_data_interval -- but + # once 300s had elapsed the answer became no, the interval dropped + # back to live_update_interval, and it fetched. no_data_interval could + # therefore never delay anything past 300s whatever it was set to. + # Measured on a live rig: an out-of-season NHL polled every ~5.5 + # minutes around the clock, returning nothing every time. if _live_games_attr or _test_mode_attr: - # We have live games or are in test mode, use the configured update interval interval = _update_interval_attr - elif has_recently_checked: - # We've checked recently and found no live games, use longer interval - interval = _no_data_interval_attr else: - # First check or haven't checked in a while, use update interval to check for live games - interval = _update_interval_attr + interval = self._idle_live_interval() # Original line from traceback (line 455), now with variables defined: if current_time - self.last_update >= interval: + # What the previous look found, recorded before this one + # replaces it. The streak is what drives the back-off, and + # any live game resets it. + self._note_live_fetch(bool(_live_games_attr)) self.last_update = current_time # Test mode: advance the simulated game instead of fetching real API diff --git a/plugins/baseball-scoreboard/test_idle_league_backoff.py b/plugins/baseball-scoreboard/test_idle_league_backoff.py new file mode 100644 index 00000000..8e9db47c --- /dev/null +++ b/plugins/baseball-scoreboard/test_idle_league_backoff.py @@ -0,0 +1,150 @@ +#!/usr/bin/env python3 +""" +Tests that a league with nothing on stops polling on a live cadence. + +Regression under test: the live gate decided its interval like this -- + + has_recently_checked = self.last_update > 0 and time_since_last_update < 300 + if live_games: interval = live_update_interval + elif has_recently_checked: interval = no_data_interval + else: interval = live_update_interval + +Once 300s had elapsed, has_recently_checked became False, the interval fell +back to live_update_interval, and it fetched. no_data_interval could therefore +never delay anything past 300s whatever it was set to -- the setting was +inert. Measured on a live rig in mid-August: NHLLiveManager fetched 0 games 22 +times in 2 hours, every ~5.5 minutes, around the clock, for a league whose +season had not started. Roughly 264 wasted requests a day, per league. + +The interval now comes from an explicit streak of empty looks, which escalates +and is capped, and which any live game resets. + +The methods are exercised against a stand-in ``self``, so the test needs no +display hardware, no network and no cache. + +Run: /bin/python plugins/baseball-scoreboard/test_idle_league_backoff.py +""" + +import ast +import sys +from pathlib import Path + +plugin_dir = Path(__file__).resolve().parent +sys.path.insert(0, str(plugin_dir)) +for candidate in (Path("/home/rackpi/projects/LEDMatrix"), + plugin_dir.parents[2] / "LEDMatrix"): + if (candidate / "src" / "plugin_system" / "base_plugin.py").exists(): + sys.path.insert(0, str(candidate)) + break + +import sports # noqa: E402 + +failures = [] + + +def check(label, ok): + print((" PASS " if ok else " FAIL ") + label) + if not ok: + failures.append(label) + + +class _Logger: + def info(self, *a, **k): + pass + + +class _Live: + _idle_live_interval = sports.SportsLive._idle_live_interval + _note_live_fetch = sports.SportsLive._note_live_fetch + + def __init__(self, base=300, ceiling=900): + self.no_data_interval = base + self.live_idle_max_interval = ceiling + self._empty_live_streak = 0 + self.logger = _Logger() + + +def main(): + print("the wait grows the longer nothing is found") + live = _Live() + check("first look uses the base interval", + live._idle_live_interval() == 300) + + for _ in range(sports._IDLE_SHORT_STREAK): + live._note_live_fetch(False) + check("after a short streak it is longer (%ds)" % live._idle_live_interval(), + live._idle_live_interval() > 300) + + short_wait = live._idle_live_interval() + for _ in range(sports._IDLE_LONG_STREAK): + live._note_live_fetch(False) + long_wait = live._idle_live_interval() + # Against short_wait, not against itself: comparing the value to a second + # call of the same method with the same state can never fail, so the + # monotonicity it is meant to protect went untested. + check("after a long streak it is longer still (%ds vs %ds)" + % (long_wait, short_wait), long_wait > short_wait) + check("and never exceeds the ceiling", long_wait <= 900) + + for _ in range(500): + live._note_live_fetch(False) + check("a very long streak stays at the ceiling, not beyond", + live._idle_live_interval() == 900) + + print("\na live game resets it immediately") + live._note_live_fetch(True) + check("the streak is cleared", live._empty_live_streak == 0) + check("and the base interval is back", + live._idle_live_interval() == 300) + + print("\nthe saving is real, and bounded") + idle = _Live() + for _ in range(500): + idle._note_live_fetch(False) + per_day = 86400 / idle._idle_live_interval() + check("an out-of-season league polls far less (%d/day vs 288)" % per_day, + per_day < 288 / 2) + check("...but still often enough to notice a season starting (<= 1h)", + idle._idle_live_interval() <= 3600) + + print("\nthe ceiling is configurable") + tight = _Live(ceiling=300) + for _ in range(500): + tight._note_live_fetch(False) + check("a lower ceiling is honoured", tight._idle_live_interval() == 300) + loose = _Live(ceiling=3600) + for _ in range(500): + loose._note_live_fetch(False) + check("a higher ceiling is honoured", loose._idle_live_interval() == 1800) + + print("\nintervals from config are clamped, never trusted raw") + check("a sane value is used", sports._clamp_seconds(120, 300) == 120) + check("absent falls back", sports._clamp_seconds(None, 300) == 300) + check("nonsense falls back", sports._clamp_seconds("soon", 300) == 300) + check("zero is clamped up", sports._clamp_seconds(0, 300) >= 5) + check("a week is clamped down", sports._clamp_seconds(604800, 300) <= 86400) + + print("\nthe old recency-based logic is gone") + src = (plugin_dir / "sports.py").read_text(encoding="utf-8") + check("has_recently_checked no longer decides the interval", + "has_recently_checked" not in src) + + tree = ast.parse(src) + live_cls = next(c for c in ast.walk(tree) + if isinstance(c, ast.ClassDef) and c.name == "SportsLive") + upd = next(m for m in live_cls.body + if isinstance(m, ast.FunctionDef) and m.name == "update") + idle_calls = [n for n in ast.walk(upd) if isinstance(n, ast.Call) + and getattr(n.func, "attr", None) == "_idle_live_interval"] + note_calls = [n for n in ast.walk(upd) if isinstance(n, ast.Call) + and getattr(n.func, "attr", None) == "_note_live_fetch"] + check("update() takes its idle interval from the back-off", len(idle_calls) == 1) + check("update() records each look's outcome", len(note_calls) == 1) + + print("\n%s" % ("FAILED: %d" % len(failures) if failures + else "All checks passed")) + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/plugins/basketball-scoreboard/config_schema.json b/plugins/basketball-scoreboard/config_schema.json index bf4461b6..993b9595 100644 --- a/plugins/basketball-scoreboard/config_schema.json +++ b/plugins/basketball-scoreboard/config_schema.json @@ -2414,6 +2414,24 @@ "title": "Schedule lookahead (days)", "description": "How far ahead to fetch games for the Upcoming screen. Raise it for a league that plays weekly: a fixture just beyond this horizon is not fetched at all, so it never reaches the board even though the date is known.", "x-advanced": true + }, + "no_data_interval_seconds": { + "type": "integer", + "default": 300, + "minimum": 5, + "maximum": 86400, + "title": "Idle live-check interval (seconds)", + "description": "How long to wait between checks for live games when there are none. Backs off further the longer nothing is found, up to the cap below.", + "x-advanced": true + }, + "live_idle_max_interval_seconds": { + "type": "integer", + "default": 900, + "minimum": 5, + "maximum": 86400, + "title": "Maximum idle live-check interval (seconds)", + "description": "Ceiling for the back-off above. Raise it to poll an out-of-season league even less; lower it to notice the first game after a quiet spell sooner.", + "x-advanced": true } }, "additionalProperties": false, diff --git a/plugins/basketball-scoreboard/manager.py b/plugins/basketball-scoreboard/manager.py index fb18bf1f..7895d14f 100644 --- a/plugins/basketball-scoreboard/manager.py +++ b/plugins/basketball-scoreboard/manager.py @@ -58,6 +58,15 @@ from basketball_timezone import resolve_timezone_name from basketball_favorite_check import FavoriteTeamCheck + +_ROOT_CONFIG_KEYS = ( + "schedule_lookback_days", + "schedule_lookahead_days", + "no_data_interval_seconds", + "live_idle_max_interval_seconds", +) + + # Which ESPN endpoint backs each league, for the favorite-team diagnostic. FAVORITE_CHECK_LEAGUES = { 'nba': ('NBA', 'basketball/nba'), @@ -845,13 +854,15 @@ def _adapt_config_for_manager(self, league: str) -> Dict[str, Any]: self.logger.debug(f"Using timezone: {timezone_str} for {league} managers") - # The schedule-window settings live at the plugin config root, and - # SportsCore reads them from the root of the config it is handed. This - # adapter builds its output key by key, so anything not named here is - # dropped -- which silently pinned every user to the defaults. - for _window_key in ("schedule_lookback_days", "schedule_lookahead_days"): - if _window_key in self.config: - manager_config[_window_key] = self.config[_window_key] + # Plugin-root settings that SportsCore reads from the root of the config + # it is handed. This adapter builds its output key by key, so anything + # not named here is dropped -- which is how the schedule window silently + # pinned every user to the defaults, and would have done the same to the + # idle-poll settings. Generalised to a list so the next one added to + # SportsCore only has to be named once. + for _root_key in _ROOT_CONFIG_KEYS: + if _root_key in self.config: + manager_config[_root_key] = self.config[_root_key] return manager_config def _get_available_modes(self) -> list: diff --git a/plugins/basketball-scoreboard/manifest.json b/plugins/basketball-scoreboard/manifest.json index ce1a17a2..68f775b4 100644 --- a/plugins/basketball-scoreboard/manifest.json +++ b/plugins/basketball-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "basketball-scoreboard", "name": "Basketball Scoreboard", - "version": "1.14.1", + "version": "1.15.0", "description": "Live, recent, and upcoming basketball games across NBA, NCAA Men's, NCAA Women's, and WNBA with real-time scores, schedules, and March Madness tournament support", "author": "ChuckBuilds", "category": "sports", @@ -18,6 +18,13 @@ "branch": "main", "plugin_path": "plugins/basketball-scoreboard", "versions": [ + { + "version": "1.15.0", + "released": "2026-08-19", + "ledmatrix_min_version": "2.0.0", + "notes": "Make the schedule fetch window configurable. How far back and forward games are fetched was hard-coded at 14 days back and 7 forward. Days is the wrong unit for a league that plays weekly -- a whole matchweek can fall just past a short horizon and never reach the board -- so both are now advanced settings, bounded 1-60 days, with a stray or non-numeric value falling back to the default rather than raising. Defaults keep today's behaviour.", + "changes": "Back off the live-game poll while a league has nothing on, and let both the idle interval and its ceiling be configured. Saves API calls out of season and between games without delaying an in-progress game, which still resets the interval on the first fetch that finds one." + }, { "version": "1.14.1", "released": "2026-08-18", diff --git a/plugins/basketball-scoreboard/sports.py b/plugins/basketball-scoreboard/sports.py index 71545a6f..f32e3d96 100644 --- a/plugins/basketball-scoreboard/sports.py +++ b/plugins/basketball-scoreboard/sports.py @@ -102,6 +102,26 @@ def _clamp_window(value: Any, fallback: int) -> int: return max(_MIN_WINDOW_DAYS, min(_MAX_WINDOW_DAYS, days)) +# Backing off the live poll while a league has nothing on. Gentle at first -- +# a gap between games in a live season should cost little -- then firmer, so a +# league months out of season stops polling on a live cadence altogether. +_IDLE_SHORT_STREAK = 6 +_IDLE_SHORT_FACTOR = 2 +_IDLE_LONG_STREAK = 24 +_IDLE_LONG_FACTOR = 6 +_DEFAULT_LIVE_IDLE_MAX_SECONDS = 900 + + +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): + return fallback + return max(low, min(high, seconds)) + + class SportsCore(ABC): TOURNAMENT_ROUND_ORDER: ClassVar[Dict[str, int]] = {"NCG": 0, "F4": 1, "E8": 2, "S16": 3, "R32": 4, "R64": 5, "": 6} @@ -2584,7 +2604,12 @@ def __init__( ): super().__init__(config, display_manager, cache_manager, logger, sport_key) self.update_interval = self.mode_config.get("live_update_interval", 15) - self.no_data_interval = 300 + self.no_data_interval = _clamp_seconds( + self.mode_config.get("no_data_interval_seconds"), 300) + self.live_idle_max_interval = _clamp_seconds( + self.mode_config.get("live_idle_max_interval_seconds"), + _DEFAULT_LIVE_IDLE_MAX_SECONDS) + self._empty_live_streak = 0 # Log the configured interval for debugging self.logger.info( f"SportsLive initialized: live_update_interval={self.update_interval}s, " @@ -2771,6 +2796,43 @@ def _detect_stale_games(self, games: List[Dict]) -> None: if game_id in self.game_update_timestamps: del self.game_update_timestamps[game_id] + def _idle_live_interval(self) -> int: + """How long to wait before looking for live games again, when there are none. + + Escalates the longer nothing turns up, and any live game resets it, so + an in-season gap between games costs at most one escalated wait while + an out-of-season league stops polling on a live cadence entirely. + + Capped rather than unbounded: the cost of backing off is how late the + first game after a quiet spell is noticed, and past the cap the saving + stops being worth that. + """ + streak = getattr(self, "_empty_live_streak", 0) + base = self.no_data_interval + ceiling = getattr(self, "live_idle_max_interval", + _DEFAULT_LIVE_IDLE_MAX_SECONDS) + # The ceiling bounds the un-escalated interval too. The two settings are + # independent integers with no cross-validation, so base > ceiling is a + # reachable config -- and returning base unclamped there made the wait + # *shrink* as the streak grew (3600s at streak 0, 900s at streak 24), + # the opposite of what the setting named "maximum" promises. + if streak >= _IDLE_LONG_STREAK: + return min(int(base * _IDLE_LONG_FACTOR), ceiling) + if streak >= _IDLE_SHORT_STREAK: + return min(int(base * _IDLE_SHORT_FACTOR), ceiling) + return min(base, ceiling) + + def _note_live_fetch(self, found_live: bool) -> None: + """Record whether a look for live games found any.""" + if found_live: + if getattr(self, "_empty_live_streak", 0): + self.logger.info( + "Live games found after %d empty check(s); back to the " + "live update interval", self._empty_live_streak) + self._empty_live_streak = 0 + else: + self._empty_live_streak = getattr(self, "_empty_live_streak", 0) + 1 + def update(self): """Update live game data and handle game switching.""" if not self.is_enabled: @@ -2795,21 +2857,25 @@ def update(self): # Only use no_data_interval if we've recently checked and confirmed there are no live games. # This ensures we check for live games frequently even if the list is temporarily empty. # Only use no_data_interval if we have no live games AND we've checked recently (within last 5 minutes) - time_since_last_update = current_time - self.last_update - has_recently_checked = self.last_update > 0 and time_since_last_update < 300 - + # Whether the last look found anything, tracked explicitly rather than + # inferred from how long ago it was. The old form asked "did we check + # within the last 300s?" and only then used no_data_interval -- but + # once 300s had elapsed the answer became no, the interval dropped + # back to live_update_interval, and it fetched. no_data_interval could + # therefore never delay anything past 300s whatever it was set to. + # Measured on a live rig: an out-of-season NHL polled every ~5.5 + # minutes around the clock, returning nothing every time. if _live_games_attr: - # We have live games, use the configured update interval interval = _update_interval_attr - elif has_recently_checked: - # We've checked recently and found no live games, use longer interval - interval = _no_data_interval_attr else: - # First check or haven't checked in a while, use update interval to check for live games - interval = _update_interval_attr + interval = self._idle_live_interval() # Original line from traceback (line 455), now with variables defined: if current_time - self.last_update >= interval: + # What the previous look found, recorded before this one + # replaces it. The streak is what drives the back-off, and + # any live game resets it. + self._note_live_fetch(bool(_live_games_attr)) self.last_update = current_time # Fetch rankings if enabled diff --git a/plugins/basketball-scoreboard/test_idle_league_backoff.py b/plugins/basketball-scoreboard/test_idle_league_backoff.py new file mode 100644 index 00000000..3bfad8a8 --- /dev/null +++ b/plugins/basketball-scoreboard/test_idle_league_backoff.py @@ -0,0 +1,150 @@ +#!/usr/bin/env python3 +""" +Tests that a league with nothing on stops polling on a live cadence. + +Regression under test: the live gate decided its interval like this -- + + has_recently_checked = self.last_update > 0 and time_since_last_update < 300 + if live_games: interval = live_update_interval + elif has_recently_checked: interval = no_data_interval + else: interval = live_update_interval + +Once 300s had elapsed, has_recently_checked became False, the interval fell +back to live_update_interval, and it fetched. no_data_interval could therefore +never delay anything past 300s whatever it was set to -- the setting was +inert. Measured on a live rig in mid-August: NHLLiveManager fetched 0 games 22 +times in 2 hours, every ~5.5 minutes, around the clock, for a league whose +season had not started. Roughly 264 wasted requests a day, per league. + +The interval now comes from an explicit streak of empty looks, which escalates +and is capped, and which any live game resets. + +The methods are exercised against a stand-in ``self``, so the test needs no +display hardware, no network and no cache. + +Run: /bin/python plugins/basketball-scoreboard/test_idle_league_backoff.py +""" + +import ast +import sys +from pathlib import Path + +plugin_dir = Path(__file__).resolve().parent +sys.path.insert(0, str(plugin_dir)) +for candidate in (Path("/home/rackpi/projects/LEDMatrix"), + plugin_dir.parents[2] / "LEDMatrix"): + if (candidate / "src" / "plugin_system" / "base_plugin.py").exists(): + sys.path.insert(0, str(candidate)) + break + +import sports # noqa: E402 + +failures = [] + + +def check(label, ok): + print((" PASS " if ok else " FAIL ") + label) + if not ok: + failures.append(label) + + +class _Logger: + def info(self, *a, **k): + pass + + +class _Live: + _idle_live_interval = sports.SportsLive._idle_live_interval + _note_live_fetch = sports.SportsLive._note_live_fetch + + def __init__(self, base=300, ceiling=900): + self.no_data_interval = base + self.live_idle_max_interval = ceiling + self._empty_live_streak = 0 + self.logger = _Logger() + + +def main(): + print("the wait grows the longer nothing is found") + live = _Live() + check("first look uses the base interval", + live._idle_live_interval() == 300) + + for _ in range(sports._IDLE_SHORT_STREAK): + live._note_live_fetch(False) + check("after a short streak it is longer (%ds)" % live._idle_live_interval(), + live._idle_live_interval() > 300) + + short_wait = live._idle_live_interval() + for _ in range(sports._IDLE_LONG_STREAK): + live._note_live_fetch(False) + long_wait = live._idle_live_interval() + # Against short_wait, not against itself: comparing the value to a second + # call of the same method with the same state can never fail, so the + # monotonicity it is meant to protect went untested. + check("after a long streak it is longer still (%ds vs %ds)" + % (long_wait, short_wait), long_wait > short_wait) + check("and never exceeds the ceiling", long_wait <= 900) + + for _ in range(500): + live._note_live_fetch(False) + check("a very long streak stays at the ceiling, not beyond", + live._idle_live_interval() == 900) + + print("\na live game resets it immediately") + live._note_live_fetch(True) + check("the streak is cleared", live._empty_live_streak == 0) + check("and the base interval is back", + live._idle_live_interval() == 300) + + print("\nthe saving is real, and bounded") + idle = _Live() + for _ in range(500): + idle._note_live_fetch(False) + per_day = 86400 / idle._idle_live_interval() + check("an out-of-season league polls far less (%d/day vs 288)" % per_day, + per_day < 288 / 2) + check("...but still often enough to notice a season starting (<= 1h)", + idle._idle_live_interval() <= 3600) + + print("\nthe ceiling is configurable") + tight = _Live(ceiling=300) + for _ in range(500): + tight._note_live_fetch(False) + check("a lower ceiling is honoured", tight._idle_live_interval() == 300) + loose = _Live(ceiling=3600) + for _ in range(500): + loose._note_live_fetch(False) + check("a higher ceiling is honoured", loose._idle_live_interval() == 1800) + + print("\nintervals from config are clamped, never trusted raw") + check("a sane value is used", sports._clamp_seconds(120, 300) == 120) + check("absent falls back", sports._clamp_seconds(None, 300) == 300) + check("nonsense falls back", sports._clamp_seconds("soon", 300) == 300) + check("zero is clamped up", sports._clamp_seconds(0, 300) >= 5) + check("a week is clamped down", sports._clamp_seconds(604800, 300) <= 86400) + + print("\nthe old recency-based logic is gone") + src = (plugin_dir / "sports.py").read_text(encoding="utf-8") + check("has_recently_checked no longer decides the interval", + "has_recently_checked" not in src) + + tree = ast.parse(src) + live_cls = next(c for c in ast.walk(tree) + if isinstance(c, ast.ClassDef) and c.name == "SportsLive") + upd = next(m for m in live_cls.body + if isinstance(m, ast.FunctionDef) and m.name == "update") + idle_calls = [n for n in ast.walk(upd) if isinstance(n, ast.Call) + and getattr(n.func, "attr", None) == "_idle_live_interval"] + note_calls = [n for n in ast.walk(upd) if isinstance(n, ast.Call) + and getattr(n.func, "attr", None) == "_note_live_fetch"] + check("update() takes its idle interval from the back-off", len(idle_calls) == 1) + check("update() records each look's outcome", len(note_calls) == 1) + + print("\n%s" % ("FAILED: %d" % len(failures) if failures + else "All checks passed")) + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/plugins/football-scoreboard/config_schema.json b/plugins/football-scoreboard/config_schema.json index 7c90270d..de6c7b37 100644 --- a/plugins/football-scoreboard/config_schema.json +++ b/plugins/football-scoreboard/config_schema.json @@ -1609,6 +1609,24 @@ "title": "Schedule lookahead (days)", "description": "How far ahead to fetch games for the Upcoming screen. Raise it for a league that plays weekly: a fixture just beyond this horizon is not fetched at all, so it never reaches the board even though the date is known.", "x-advanced": true + }, + "no_data_interval_seconds": { + "type": "integer", + "default": 300, + "minimum": 5, + "maximum": 86400, + "title": "Idle live-check interval (seconds)", + "description": "How long to wait between checks for live games when there are none. Backs off further the longer nothing is found, up to the cap below.", + "x-advanced": true + }, + "live_idle_max_interval_seconds": { + "type": "integer", + "default": 900, + "minimum": 5, + "maximum": 86400, + "title": "Maximum idle live-check interval (seconds)", + "description": "Ceiling for the back-off above. Raise it to poll an out-of-season league even less; lower it to notice the first game after a quiet spell sooner.", + "x-advanced": true } }, "additionalProperties": false, diff --git a/plugins/football-scoreboard/manager.py b/plugins/football-scoreboard/manager.py index 48dd5972..8aaefe42 100644 --- a/plugins/football-scoreboard/manager.py +++ b/plugins/football-scoreboard/manager.py @@ -61,6 +61,15 @@ from football_timezone import resolve_timezone_name from football_favorite_check import FavoriteTeamCheck + +_ROOT_CONFIG_KEYS = ( + "schedule_lookback_days", + "schedule_lookahead_days", + "no_data_interval_seconds", + "live_idle_max_interval_seconds", +) + + # Which ESPN endpoint backs each league, for the favorite-team diagnostic. FAVORITE_CHECK_LEAGUES = { 'nfl': ('NFL', 'football/nfl'), @@ -722,13 +731,15 @@ def _adapt_config_for_manager(self, league: str) -> Dict[str, Any]: self.logger.debug(f"Using timezone: {timezone_str} for {league} managers") - # The schedule-window settings live at the plugin config root, and - # SportsCore reads them from the root of the config it is handed. This - # adapter builds its output key by key, so anything not named here is - # dropped -- which silently pinned every user to the defaults. - for _window_key in ("schedule_lookback_days", "schedule_lookahead_days"): - if _window_key in self.config: - manager_config[_window_key] = self.config[_window_key] + # Plugin-root settings that SportsCore reads from the root of the config + # it is handed. This adapter builds its output key by key, so anything + # not named here is dropped -- which is how the schedule window silently + # pinned every user to the defaults, and would have done the same to the + # idle-poll settings. Generalised to a list so the next one added to + # SportsCore only has to be named once. + for _root_key in _ROOT_CONFIG_KEYS: + if _root_key in self.config: + manager_config[_root_key] = self.config[_root_key] return manager_config def _parse_display_mode_settings(self) -> Dict[str, Dict[str, str]]: diff --git a/plugins/football-scoreboard/manifest.json b/plugins/football-scoreboard/manifest.json index 7d43e7ce..b4c681cd 100644 --- a/plugins/football-scoreboard/manifest.json +++ b/plugins/football-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "football-scoreboard", "name": "Football Scoreboard", - "version": "2.16.1", + "version": "2.17.0", "author": "ChuckBuilds", "class_name": "FootballScoreboardPlugin", "description": "Standalone plugin for live, recent, and upcoming football games across NFL and NCAA Football with real-time scores, down/distance, possession, and game status. Now with organized nested config!", @@ -24,6 +24,13 @@ "ncaa_fb_live" ], "versions": [ + { + "version": "2.17.0", + "released": "2026-08-19", + "ledmatrix_min_version": "2.0.0", + "notes": "Make the schedule fetch window configurable. How far back and forward games are fetched was hard-coded at 14 days back and 7 forward. Days is the wrong unit for a league that plays weekly -- a whole matchweek can fall just past a short horizon and never reach the board -- so both are now advanced settings, bounded 1-60 days, with a stray or non-numeric value falling back to the default rather than raising. Defaults keep today's behaviour.", + "changes": "Back off the live-game poll while a league has nothing on, and let both the idle interval and its ceiling be configured. Saves API calls out of season and between games without delaying an in-progress game, which still resets the interval on the first fetch that finds one." + }, { "version": "2.16.1", "released": "2026-08-18", diff --git a/plugins/football-scoreboard/sports.py b/plugins/football-scoreboard/sports.py index e9f0d645..15e82dc7 100644 --- a/plugins/football-scoreboard/sports.py +++ b/plugins/football-scoreboard/sports.py @@ -104,6 +104,26 @@ def _clamp_window(value: Any, fallback: int) -> int: return max(_MIN_WINDOW_DAYS, min(_MAX_WINDOW_DAYS, days)) +# Backing off the live poll while a league has nothing on. Gentle at first -- +# a gap between games in a live season should cost little -- then firmer, so a +# league months out of season stops polling on a live cadence altogether. +_IDLE_SHORT_STREAK = 6 +_IDLE_SHORT_FACTOR = 2 +_IDLE_LONG_STREAK = 24 +_IDLE_LONG_FACTOR = 6 +_DEFAULT_LIVE_IDLE_MAX_SECONDS = 900 + + +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): + return fallback + return max(low, min(high, seconds)) + + class SportsCore(ABC): def __init__( self, @@ -2322,7 +2342,12 @@ def __init__( ): super().__init__(config, display_manager, cache_manager, logger, sport_key) self.update_interval = self.mode_config.get("live_update_interval", 15) - self.no_data_interval = 300 + self.no_data_interval = _clamp_seconds( + self.mode_config.get("no_data_interval_seconds"), 300) + self.live_idle_max_interval = _clamp_seconds( + self.mode_config.get("live_idle_max_interval_seconds"), + _DEFAULT_LIVE_IDLE_MAX_SECONDS) + self._empty_live_streak = 0 # Log the configured interval for debugging self.logger.info( f"SportsLive initialized: live_update_interval={self.update_interval}s, " @@ -2866,6 +2891,43 @@ def _detect_stale_games(self, games: List[Dict]) -> None: if game_id in self.game_update_timestamps: del self.game_update_timestamps[game_id] + def _idle_live_interval(self) -> int: + """How long to wait before looking for live games again, when there are none. + + Escalates the longer nothing turns up, and any live game resets it, so + an in-season gap between games costs at most one escalated wait while + an out-of-season league stops polling on a live cadence entirely. + + Capped rather than unbounded: the cost of backing off is how late the + first game after a quiet spell is noticed, and past the cap the saving + stops being worth that. + """ + streak = getattr(self, "_empty_live_streak", 0) + base = self.no_data_interval + ceiling = getattr(self, "live_idle_max_interval", + _DEFAULT_LIVE_IDLE_MAX_SECONDS) + # The ceiling bounds the un-escalated interval too. The two settings are + # independent integers with no cross-validation, so base > ceiling is a + # reachable config -- and returning base unclamped there made the wait + # *shrink* as the streak grew (3600s at streak 0, 900s at streak 24), + # the opposite of what the setting named "maximum" promises. + if streak >= _IDLE_LONG_STREAK: + return min(int(base * _IDLE_LONG_FACTOR), ceiling) + if streak >= _IDLE_SHORT_STREAK: + return min(int(base * _IDLE_SHORT_FACTOR), ceiling) + return min(base, ceiling) + + def _note_live_fetch(self, found_live: bool) -> None: + """Record whether a look for live games found any.""" + if found_live: + if getattr(self, "_empty_live_streak", 0): + self.logger.info( + "Live games found after %d empty check(s); back to the " + "live update interval", self._empty_live_streak) + self._empty_live_streak = 0 + else: + self._empty_live_streak = getattr(self, "_empty_live_streak", 0) + 1 + def update(self): """Update live game data and handle game switching.""" if not self.is_enabled: @@ -2893,21 +2955,25 @@ def update(self): # Only use no_data_interval if we've recently checked and confirmed there are no live games. # This ensures we check for live games frequently even if the list is temporarily empty. # Only use no_data_interval if we have no live games AND we've checked recently (within last 5 minutes) - time_since_last_update = current_time - self.last_update - has_recently_checked = self.last_update > 0 and time_since_last_update < 300 - + # Whether the last look found anything, tracked explicitly rather than + # inferred from how long ago it was. The old form asked "did we check + # within the last 300s?" and only then used no_data_interval -- but + # once 300s had elapsed the answer became no, the interval dropped + # back to live_update_interval, and it fetched. no_data_interval could + # therefore never delay anything past 300s whatever it was set to. + # Measured on a live rig: an out-of-season NHL polled every ~5.5 + # minutes around the clock, returning nothing every time. if _live_games_attr or _test_mode_attr: - # We have live games or are in test mode, use the configured update interval interval = _update_interval_attr - elif has_recently_checked: - # We've checked recently and found no live games, use longer interval - interval = _no_data_interval_attr else: - # First check or haven't checked in a while, use update interval to check for live games - interval = _update_interval_attr + interval = self._idle_live_interval() # Original line from traceback (line 455), now with variables defined: if current_time - self.last_update >= interval: + # What the previous look found, recorded before this one + # replaces it. The streak is what drives the back-off, and + # any live game resets it. + self._note_live_fetch(bool(_live_games_attr)) self.last_update = current_time # Test mode: advance the simulated game instead of fetching real API diff --git a/plugins/football-scoreboard/test_idle_league_backoff.py b/plugins/football-scoreboard/test_idle_league_backoff.py new file mode 100644 index 00000000..541baa76 --- /dev/null +++ b/plugins/football-scoreboard/test_idle_league_backoff.py @@ -0,0 +1,150 @@ +#!/usr/bin/env python3 +""" +Tests that a league with nothing on stops polling on a live cadence. + +Regression under test: the live gate decided its interval like this -- + + has_recently_checked = self.last_update > 0 and time_since_last_update < 300 + if live_games: interval = live_update_interval + elif has_recently_checked: interval = no_data_interval + else: interval = live_update_interval + +Once 300s had elapsed, has_recently_checked became False, the interval fell +back to live_update_interval, and it fetched. no_data_interval could therefore +never delay anything past 300s whatever it was set to -- the setting was +inert. Measured on a live rig in mid-August: NHLLiveManager fetched 0 games 22 +times in 2 hours, every ~5.5 minutes, around the clock, for a league whose +season had not started. Roughly 264 wasted requests a day, per league. + +The interval now comes from an explicit streak of empty looks, which escalates +and is capped, and which any live game resets. + +The methods are exercised against a stand-in ``self``, so the test needs no +display hardware, no network and no cache. + +Run: /bin/python plugins/football-scoreboard/test_idle_league_backoff.py +""" + +import ast +import sys +from pathlib import Path + +plugin_dir = Path(__file__).resolve().parent +sys.path.insert(0, str(plugin_dir)) +for candidate in (Path("/home/rackpi/projects/LEDMatrix"), + plugin_dir.parents[2] / "LEDMatrix"): + if (candidate / "src" / "plugin_system" / "base_plugin.py").exists(): + sys.path.insert(0, str(candidate)) + break + +import sports # noqa: E402 + +failures = [] + + +def check(label, ok): + print((" PASS " if ok else " FAIL ") + label) + if not ok: + failures.append(label) + + +class _Logger: + def info(self, *a, **k): + pass + + +class _Live: + _idle_live_interval = sports.SportsLive._idle_live_interval + _note_live_fetch = sports.SportsLive._note_live_fetch + + def __init__(self, base=300, ceiling=900): + self.no_data_interval = base + self.live_idle_max_interval = ceiling + self._empty_live_streak = 0 + self.logger = _Logger() + + +def main(): + print("the wait grows the longer nothing is found") + live = _Live() + check("first look uses the base interval", + live._idle_live_interval() == 300) + + for _ in range(sports._IDLE_SHORT_STREAK): + live._note_live_fetch(False) + check("after a short streak it is longer (%ds)" % live._idle_live_interval(), + live._idle_live_interval() > 300) + + short_wait = live._idle_live_interval() + for _ in range(sports._IDLE_LONG_STREAK): + live._note_live_fetch(False) + long_wait = live._idle_live_interval() + # Against short_wait, not against itself: comparing the value to a second + # call of the same method with the same state can never fail, so the + # monotonicity it is meant to protect went untested. + check("after a long streak it is longer still (%ds vs %ds)" + % (long_wait, short_wait), long_wait > short_wait) + check("and never exceeds the ceiling", long_wait <= 900) + + for _ in range(500): + live._note_live_fetch(False) + check("a very long streak stays at the ceiling, not beyond", + live._idle_live_interval() == 900) + + print("\na live game resets it immediately") + live._note_live_fetch(True) + check("the streak is cleared", live._empty_live_streak == 0) + check("and the base interval is back", + live._idle_live_interval() == 300) + + print("\nthe saving is real, and bounded") + idle = _Live() + for _ in range(500): + idle._note_live_fetch(False) + per_day = 86400 / idle._idle_live_interval() + check("an out-of-season league polls far less (%d/day vs 288)" % per_day, + per_day < 288 / 2) + check("...but still often enough to notice a season starting (<= 1h)", + idle._idle_live_interval() <= 3600) + + print("\nthe ceiling is configurable") + tight = _Live(ceiling=300) + for _ in range(500): + tight._note_live_fetch(False) + check("a lower ceiling is honoured", tight._idle_live_interval() == 300) + loose = _Live(ceiling=3600) + for _ in range(500): + loose._note_live_fetch(False) + check("a higher ceiling is honoured", loose._idle_live_interval() == 1800) + + print("\nintervals from config are clamped, never trusted raw") + check("a sane value is used", sports._clamp_seconds(120, 300) == 120) + check("absent falls back", sports._clamp_seconds(None, 300) == 300) + check("nonsense falls back", sports._clamp_seconds("soon", 300) == 300) + check("zero is clamped up", sports._clamp_seconds(0, 300) >= 5) + check("a week is clamped down", sports._clamp_seconds(604800, 300) <= 86400) + + print("\nthe old recency-based logic is gone") + src = (plugin_dir / "sports.py").read_text(encoding="utf-8") + check("has_recently_checked no longer decides the interval", + "has_recently_checked" not in src) + + tree = ast.parse(src) + live_cls = next(c for c in ast.walk(tree) + if isinstance(c, ast.ClassDef) and c.name == "SportsLive") + upd = next(m for m in live_cls.body + if isinstance(m, ast.FunctionDef) and m.name == "update") + idle_calls = [n for n in ast.walk(upd) if isinstance(n, ast.Call) + and getattr(n.func, "attr", None) == "_idle_live_interval"] + note_calls = [n for n in ast.walk(upd) if isinstance(n, ast.Call) + and getattr(n.func, "attr", None) == "_note_live_fetch"] + check("update() takes its idle interval from the back-off", len(idle_calls) == 1) + check("update() records each look's outcome", len(note_calls) == 1) + + print("\n%s" % ("FAILED: %d" % len(failures) if failures + else "All checks passed")) + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/plugins/hockey-scoreboard/config_schema.json b/plugins/hockey-scoreboard/config_schema.json index fbf96cad..6706fc8e 100644 --- a/plugins/hockey-scoreboard/config_schema.json +++ b/plugins/hockey-scoreboard/config_schema.json @@ -2067,6 +2067,24 @@ "title": "Schedule lookahead (days)", "description": "How far ahead to fetch games for the Upcoming screen. Raise it for a league that plays weekly: a fixture just beyond this horizon is not fetched at all, so it never reaches the board even though the date is known.", "x-advanced": true + }, + "no_data_interval_seconds": { + "type": "integer", + "default": 300, + "minimum": 5, + "maximum": 86400, + "title": "Idle live-check interval (seconds)", + "description": "How long to wait between checks for live games when there are none. Backs off further the longer nothing is found, up to the cap below.", + "x-advanced": true + }, + "live_idle_max_interval_seconds": { + "type": "integer", + "default": 900, + "minimum": 5, + "maximum": 86400, + "title": "Maximum idle live-check interval (seconds)", + "description": "Ceiling for the back-off above. Raise it to poll an out-of-season league even less; lower it to notice the first game after a quiet spell sooner.", + "x-advanced": true } }, "required": [ diff --git a/plugins/hockey-scoreboard/manager.py b/plugins/hockey-scoreboard/manager.py index 3c19564d..81ba0c10 100644 --- a/plugins/hockey-scoreboard/manager.py +++ b/plugins/hockey-scoreboard/manager.py @@ -45,6 +45,15 @@ from hockey_timezone import resolve_timezone_name from hockey_favorite_check import FavoriteTeamCheck + +_ROOT_CONFIG_KEYS = ( + "schedule_lookback_days", + "schedule_lookahead_days", + "no_data_interval_seconds", + "live_idle_max_interval_seconds", +) + + # Which ESPN endpoint backs each league, for the favorite-team diagnostic. FAVORITE_CHECK_LEAGUES = { 'nhl': ('NHL', 'hockey/nhl'), @@ -862,13 +871,15 @@ def resolve_non_favorite_live_duration() -> int: } ) - # The schedule-window settings live at the plugin config root, and - # SportsCore reads them from the root of the config it is handed. This - # adapter builds its output key by key, so anything not named here is - # dropped -- which silently pinned every user to the defaults. - for _window_key in ("schedule_lookback_days", "schedule_lookahead_days"): - if _window_key in self.config: - manager_config[_window_key] = self.config[_window_key] + # Plugin-root settings that SportsCore reads from the root of the config + # it is handed. This adapter builds its output key by key, so anything + # not named here is dropped -- which is how the schedule window silently + # pinned every user to the defaults, and would have done the same to the + # idle-poll settings. Generalised to a list so the next one added to + # SportsCore only has to be named once. + for _root_key in _ROOT_CONFIG_KEYS: + if _root_key in self.config: + manager_config[_root_key] = self.config[_root_key] return manager_config def _get_available_modes(self) -> list: diff --git a/plugins/hockey-scoreboard/manifest.json b/plugins/hockey-scoreboard/manifest.json index 108792d1..0497a5c3 100644 --- a/plugins/hockey-scoreboard/manifest.json +++ b/plugins/hockey-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "hockey-scoreboard", "name": "Hockey Scoreboard", - "version": "1.11.1", + "version": "1.12.0", "author": "ChuckBuilds", "description": "Live, recent, and upcoming hockey games across NHL, NCAA Men's, and NCAA Women's hockey with real-time scores and schedules", "homepage": "https://github.com/ChuckBuilds/ledmatrix-plugins/tree/main/plugins/hockey-scoreboard", @@ -54,6 +54,13 @@ } ], "versions": [ + { + "version": "1.12.0", + "released": "2026-08-19", + "ledmatrix_min_version": "2.0.0", + "notes": "Make the schedule fetch window configurable. How far back and forward games are fetched was hard-coded at 14 days back and 7 forward. Days is the wrong unit for a league that plays weekly -- a whole matchweek can fall just past a short horizon and never reach the board -- so both are now advanced settings, bounded 1-60 days, with a stray or non-numeric value falling back to the default rather than raising. Defaults keep today's behaviour.", + "changes": "Back off the live-game poll while a league has nothing on, and let both the idle interval and its ceiling be configured. Saves API calls out of season and between games without delaying an in-progress game, which still resets the interval on the first fetch that finds one." + }, { "version": "1.11.1", "released": "2026-08-18", diff --git a/plugins/hockey-scoreboard/sports.py b/plugins/hockey-scoreboard/sports.py index fcec90d0..be2380b8 100644 --- a/plugins/hockey-scoreboard/sports.py +++ b/plugins/hockey-scoreboard/sports.py @@ -98,6 +98,26 @@ def _clamp_window(value: Any, fallback: int) -> int: return max(_MIN_WINDOW_DAYS, min(_MAX_WINDOW_DAYS, days)) +# Backing off the live poll while a league has nothing on. Gentle at first -- +# a gap between games in a live season should cost little -- then firmer, so a +# league months out of season stops polling on a live cadence altogether. +_IDLE_SHORT_STREAK = 6 +_IDLE_SHORT_FACTOR = 2 +_IDLE_LONG_STREAK = 24 +_IDLE_LONG_FACTOR = 6 +_DEFAULT_LIVE_IDLE_MAX_SECONDS = 900 + + +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): + return fallback + return max(low, min(high, seconds)) + + class SportsCore(ABC): def __init__( self, @@ -2159,7 +2179,12 @@ def __init__( ): super().__init__(config, display_manager, cache_manager, logger, sport_key) self.update_interval = self.mode_config.get("live_update_interval", 15) - self.no_data_interval = 300 + self.no_data_interval = _clamp_seconds( + self.mode_config.get("no_data_interval_seconds"), 300) + self.live_idle_max_interval = _clamp_seconds( + self.mode_config.get("live_idle_max_interval_seconds"), + _DEFAULT_LIVE_IDLE_MAX_SECONDS) + self._empty_live_streak = 0 # Log the configured interval for debugging try: mode_config_keys = list(self.mode_config.keys()) if isinstance(self.mode_config, dict) else "N/A" @@ -2345,6 +2370,43 @@ def _detect_stale_games(self, games: List[Dict]) -> None: if game_id in self.game_update_timestamps: del self.game_update_timestamps[game_id] + def _idle_live_interval(self) -> int: + """How long to wait before looking for live games again, when there are none. + + Escalates the longer nothing turns up, and any live game resets it, so + an in-season gap between games costs at most one escalated wait while + an out-of-season league stops polling on a live cadence entirely. + + Capped rather than unbounded: the cost of backing off is how late the + first game after a quiet spell is noticed, and past the cap the saving + stops being worth that. + """ + streak = getattr(self, "_empty_live_streak", 0) + base = self.no_data_interval + ceiling = getattr(self, "live_idle_max_interval", + _DEFAULT_LIVE_IDLE_MAX_SECONDS) + # The ceiling bounds the un-escalated interval too. The two settings are + # independent integers with no cross-validation, so base > ceiling is a + # reachable config -- and returning base unclamped there made the wait + # *shrink* as the streak grew (3600s at streak 0, 900s at streak 24), + # the opposite of what the setting named "maximum" promises. + if streak >= _IDLE_LONG_STREAK: + return min(int(base * _IDLE_LONG_FACTOR), ceiling) + if streak >= _IDLE_SHORT_STREAK: + return min(int(base * _IDLE_SHORT_FACTOR), ceiling) + return min(base, ceiling) + + def _note_live_fetch(self, found_live: bool) -> None: + """Record whether a look for live games found any.""" + if found_live: + if getattr(self, "_empty_live_streak", 0): + self.logger.info( + "Live games found after %d empty check(s); back to the " + "live update interval", self._empty_live_streak) + self._empty_live_streak = 0 + else: + self._empty_live_streak = getattr(self, "_empty_live_streak", 0) + 1 + def update(self): """Update live game data and handle game switching.""" if not self.is_enabled: @@ -2369,31 +2431,36 @@ def update(self): # Only use no_data_interval if we've recently checked and confirmed there are no live games. # This ensures we check for live games frequently even if the list is temporarily empty. # Only use no_data_interval if we have no live games AND we've checked recently (within last 5 minutes) - time_since_last_update = current_time - self.last_update - has_recently_checked = self.last_update > 0 and time_since_last_update < 300 - + # Whether the last look found anything, tracked explicitly rather than + # inferred from how long ago it was. The old form asked "did we check + # within the last 300s?" and only then used no_data_interval -- but + # once 300s had elapsed the answer became no, the interval dropped + # back to live_update_interval, and it fetched. no_data_interval could + # therefore never delay anything past 300s whatever it was set to. + # Measured on a live rig: an out-of-season NHL polled every ~5.5 + # minutes around the clock, returning nothing every time. if _live_games_attr: - # We have live games, use the configured update interval interval = _update_interval_attr - elif has_recently_checked: - # We've checked recently and found no live games, use longer interval - interval = _no_data_interval_attr else: - # First check or haven't checked in a while, use update interval to check for live games - interval = _update_interval_attr + interval = self._idle_live_interval() # Debug logging for interval selection (log every 5 minutes or when interval changes) if current_time - self.last_log_time >= 300: # Log every 5 minutes self.logger.info( f"Update check: live_games={len(_live_games_attr) if _live_games_attr else 0}, " f"update_interval={_update_interval_attr}, no_data_interval={_no_data_interval_attr}, " - f"selected_interval={interval}, time_since_last_update={time_since_last_update:.1f}s, " - f"has_recently_checked={has_recently_checked}" + f"selected_interval={interval}, " + f"time_since_last_update={current_time - self.last_update:.1f}s, " + f"empty_live_streak={getattr(self, '_empty_live_streak', 0)}" ) self.last_log_time = current_time # Original line from traceback (line 455), now with variables defined: if current_time - self.last_update >= interval: + # What the previous look found, recorded before this one + # replaces it. The streak is what drives the back-off, and + # any live game resets it. + self._note_live_fetch(bool(_live_games_attr)) self.last_update = current_time # Fetch rankings if enabled diff --git a/plugins/hockey-scoreboard/test_idle_league_backoff.py b/plugins/hockey-scoreboard/test_idle_league_backoff.py new file mode 100644 index 00000000..ff299bb0 --- /dev/null +++ b/plugins/hockey-scoreboard/test_idle_league_backoff.py @@ -0,0 +1,150 @@ +#!/usr/bin/env python3 +""" +Tests that a league with nothing on stops polling on a live cadence. + +Regression under test: the live gate decided its interval like this -- + + has_recently_checked = self.last_update > 0 and time_since_last_update < 300 + if live_games: interval = live_update_interval + elif has_recently_checked: interval = no_data_interval + else: interval = live_update_interval + +Once 300s had elapsed, has_recently_checked became False, the interval fell +back to live_update_interval, and it fetched. no_data_interval could therefore +never delay anything past 300s whatever it was set to -- the setting was +inert. Measured on a live rig in mid-August: NHLLiveManager fetched 0 games 22 +times in 2 hours, every ~5.5 minutes, around the clock, for a league whose +season had not started. Roughly 264 wasted requests a day, per league. + +The interval now comes from an explicit streak of empty looks, which escalates +and is capped, and which any live game resets. + +The methods are exercised against a stand-in ``self``, so the test needs no +display hardware, no network and no cache. + +Run: /bin/python plugins/hockey-scoreboard/test_idle_league_backoff.py +""" + +import ast +import sys +from pathlib import Path + +plugin_dir = Path(__file__).resolve().parent +sys.path.insert(0, str(plugin_dir)) +for candidate in (Path("/home/rackpi/projects/LEDMatrix"), + plugin_dir.parents[2] / "LEDMatrix"): + if (candidate / "src" / "plugin_system" / "base_plugin.py").exists(): + sys.path.insert(0, str(candidate)) + break + +import sports # noqa: E402 + +failures = [] + + +def check(label, ok): + print((" PASS " if ok else " FAIL ") + label) + if not ok: + failures.append(label) + + +class _Logger: + def info(self, *a, **k): + pass + + +class _Live: + _idle_live_interval = sports.SportsLive._idle_live_interval + _note_live_fetch = sports.SportsLive._note_live_fetch + + def __init__(self, base=300, ceiling=900): + self.no_data_interval = base + self.live_idle_max_interval = ceiling + self._empty_live_streak = 0 + self.logger = _Logger() + + +def main(): + print("the wait grows the longer nothing is found") + live = _Live() + check("first look uses the base interval", + live._idle_live_interval() == 300) + + for _ in range(sports._IDLE_SHORT_STREAK): + live._note_live_fetch(False) + check("after a short streak it is longer (%ds)" % live._idle_live_interval(), + live._idle_live_interval() > 300) + + short_wait = live._idle_live_interval() + for _ in range(sports._IDLE_LONG_STREAK): + live._note_live_fetch(False) + long_wait = live._idle_live_interval() + # Against short_wait, not against itself: comparing the value to a second + # call of the same method with the same state can never fail, so the + # monotonicity it is meant to protect went untested. + check("after a long streak it is longer still (%ds vs %ds)" + % (long_wait, short_wait), long_wait > short_wait) + check("and never exceeds the ceiling", long_wait <= 900) + + for _ in range(500): + live._note_live_fetch(False) + check("a very long streak stays at the ceiling, not beyond", + live._idle_live_interval() == 900) + + print("\na live game resets it immediately") + live._note_live_fetch(True) + check("the streak is cleared", live._empty_live_streak == 0) + check("and the base interval is back", + live._idle_live_interval() == 300) + + print("\nthe saving is real, and bounded") + idle = _Live() + for _ in range(500): + idle._note_live_fetch(False) + per_day = 86400 / idle._idle_live_interval() + check("an out-of-season league polls far less (%d/day vs 288)" % per_day, + per_day < 288 / 2) + check("...but still often enough to notice a season starting (<= 1h)", + idle._idle_live_interval() <= 3600) + + print("\nthe ceiling is configurable") + tight = _Live(ceiling=300) + for _ in range(500): + tight._note_live_fetch(False) + check("a lower ceiling is honoured", tight._idle_live_interval() == 300) + loose = _Live(ceiling=3600) + for _ in range(500): + loose._note_live_fetch(False) + check("a higher ceiling is honoured", loose._idle_live_interval() == 1800) + + print("\nintervals from config are clamped, never trusted raw") + check("a sane value is used", sports._clamp_seconds(120, 300) == 120) + check("absent falls back", sports._clamp_seconds(None, 300) == 300) + check("nonsense falls back", sports._clamp_seconds("soon", 300) == 300) + check("zero is clamped up", sports._clamp_seconds(0, 300) >= 5) + check("a week is clamped down", sports._clamp_seconds(604800, 300) <= 86400) + + print("\nthe old recency-based logic is gone") + src = (plugin_dir / "sports.py").read_text(encoding="utf-8") + check("has_recently_checked no longer decides the interval", + "has_recently_checked" not in src) + + tree = ast.parse(src) + live_cls = next(c for c in ast.walk(tree) + if isinstance(c, ast.ClassDef) and c.name == "SportsLive") + upd = next(m for m in live_cls.body + if isinstance(m, ast.FunctionDef) and m.name == "update") + idle_calls = [n for n in ast.walk(upd) if isinstance(n, ast.Call) + and getattr(n.func, "attr", None) == "_idle_live_interval"] + note_calls = [n for n in ast.walk(upd) if isinstance(n, ast.Call) + and getattr(n.func, "attr", None) == "_note_live_fetch"] + check("update() takes its idle interval from the back-off", len(idle_calls) == 1) + check("update() records each look's outcome", len(note_calls) == 1) + + print("\n%s" % ("FAILED: %d" % len(failures) if failures + else "All checks passed")) + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/plugins/lacrosse-scoreboard/config_schema.json b/plugins/lacrosse-scoreboard/config_schema.json index 8818e91a..8884fa38 100644 --- a/plugins/lacrosse-scoreboard/config_schema.json +++ b/plugins/lacrosse-scoreboard/config_schema.json @@ -1610,6 +1610,24 @@ "title": "Schedule lookahead (days)", "description": "How far ahead to fetch games for the Upcoming screen. Raise it for a league that plays weekly: a fixture just beyond this horizon is not fetched at all, so it never reaches the board even though the date is known.", "x-advanced": true + }, + "no_data_interval_seconds": { + "type": "integer", + "default": 300, + "minimum": 5, + "maximum": 86400, + "title": "Idle live-check interval (seconds)", + "description": "How long to wait between checks for live games when there are none. Backs off further the longer nothing is found, up to the cap below.", + "x-advanced": true + }, + "live_idle_max_interval_seconds": { + "type": "integer", + "default": 900, + "minimum": 5, + "maximum": 86400, + "title": "Maximum idle live-check interval (seconds)", + "description": "Ceiling for the back-off above. Raise it to poll an out-of-season league even less; lower it to notice the first game after a quiet spell sooner.", + "x-advanced": true } }, "required": [ diff --git a/plugins/lacrosse-scoreboard/manager.py b/plugins/lacrosse-scoreboard/manager.py index d6996eff..471b286a 100644 --- a/plugins/lacrosse-scoreboard/manager.py +++ b/plugins/lacrosse-scoreboard/manager.py @@ -56,6 +56,15 @@ from lacrosse_timezone import resolve_timezone_name from lacrosse_favorite_check import FavoriteTeamCheck + +_ROOT_CONFIG_KEYS = ( + "schedule_lookback_days", + "schedule_lookahead_days", + "no_data_interval_seconds", + "live_idle_max_interval_seconds", +) + + # Which ESPN endpoint backs each league, for the favorite-team diagnostic. FAVORITE_CHECK_LEAGUES = { 'ncaa_mens': ("NCAA Men's Lacrosse", 'lacrosse/mens-college-lacrosse'), @@ -834,13 +843,15 @@ def resolve_non_favorite_live_duration() -> int: } ) - # The schedule-window settings live at the plugin config root, and - # SportsCore reads them from the root of the config it is handed. This - # adapter builds its output key by key, so anything not named here is - # dropped -- which silently pinned every user to the defaults. - for _window_key in ("schedule_lookback_days", "schedule_lookahead_days"): - if _window_key in self.config: - manager_config[_window_key] = self.config[_window_key] + # Plugin-root settings that SportsCore reads from the root of the config + # it is handed. This adapter builds its output key by key, so anything + # not named here is dropped -- which is how the schedule window silently + # pinned every user to the defaults, and would have done the same to the + # idle-poll settings. Generalised to a list so the next one added to + # SportsCore only has to be named once. + for _root_key in _ROOT_CONFIG_KEYS: + if _root_key in self.config: + manager_config[_root_key] = self.config[_root_key] return manager_config def _get_available_modes(self) -> list: diff --git a/plugins/lacrosse-scoreboard/manifest.json b/plugins/lacrosse-scoreboard/manifest.json index f95aa141..1e9a5d8b 100644 --- a/plugins/lacrosse-scoreboard/manifest.json +++ b/plugins/lacrosse-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "lacrosse-scoreboard", "name": "Lacrosse Scoreboard", - "version": "1.11.1", + "version": "1.12.0", "author": "ChuckBuilds", "description": "Live, recent, and upcoming NCAA men's and women's lacrosse games with real-time scores and schedules", "homepage": "https://github.com/ChuckBuilds/ledmatrix-plugins/tree/main/plugins/lacrosse-scoreboard", @@ -50,6 +50,13 @@ } ], "versions": [ + { + "version": "1.12.0", + "released": "2026-08-19", + "ledmatrix_min_version": "2.0.0", + "notes": "Make the schedule fetch window configurable. How far back and forward games are fetched was hard-coded at 14 days back and 7 forward. Days is the wrong unit for a league that plays weekly -- a whole matchweek can fall just past a short horizon and never reach the board -- so both are now advanced settings, bounded 1-60 days, with a stray or non-numeric value falling back to the default rather than raising. Defaults keep today's behaviour.", + "changes": "Back off the live-game poll while a league has nothing on, and let both the idle interval and its ceiling be configured. Saves API calls out of season and between games without delaying an in-progress game, which still resets the interval on the first fetch that finds one." + }, { "version": "1.11.1", "released": "2026-08-18", diff --git a/plugins/lacrosse-scoreboard/sports.py b/plugins/lacrosse-scoreboard/sports.py index b424b2b9..8fdec87d 100644 --- a/plugins/lacrosse-scoreboard/sports.py +++ b/plugins/lacrosse-scoreboard/sports.py @@ -98,6 +98,26 @@ def _clamp_window(value: Any, fallback: int) -> int: return max(_MIN_WINDOW_DAYS, min(_MAX_WINDOW_DAYS, days)) +# Backing off the live poll while a league has nothing on. Gentle at first -- +# a gap between games in a live season should cost little -- then firmer, so a +# league months out of season stops polling on a live cadence altogether. +_IDLE_SHORT_STREAK = 6 +_IDLE_SHORT_FACTOR = 2 +_IDLE_LONG_STREAK = 24 +_IDLE_LONG_FACTOR = 6 +_DEFAULT_LIVE_IDLE_MAX_SECONDS = 900 + + +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): + return fallback + return max(low, min(high, seconds)) + + class SportsCore(ABC): def __init__( self, @@ -2182,7 +2202,12 @@ def __init__( super().__init__(config, display_manager, cache_manager, logger, sport_key) self.update_interval = self.mode_config.get("live_update_interval", 15) self._rotation_schedule: List[str] = [] - self.no_data_interval = 300 + self.no_data_interval = _clamp_seconds( + self.mode_config.get("no_data_interval_seconds"), 300) + self.live_idle_max_interval = _clamp_seconds( + self.mode_config.get("live_idle_max_interval_seconds"), + _DEFAULT_LIVE_IDLE_MAX_SECONDS) + self._empty_live_streak = 0 # Log the configured interval for debugging try: mode_config_keys = list(self.mode_config.keys()) if isinstance(self.mode_config, dict) else "N/A" @@ -2312,6 +2337,43 @@ def _detect_stale_games(self, games: List[Dict]) -> None: if game_id in self.game_update_timestamps: del self.game_update_timestamps[game_id] + def _idle_live_interval(self) -> int: + """How long to wait before looking for live games again, when there are none. + + Escalates the longer nothing turns up, and any live game resets it, so + an in-season gap between games costs at most one escalated wait while + an out-of-season league stops polling on a live cadence entirely. + + Capped rather than unbounded: the cost of backing off is how late the + first game after a quiet spell is noticed, and past the cap the saving + stops being worth that. + """ + streak = getattr(self, "_empty_live_streak", 0) + base = self.no_data_interval + ceiling = getattr(self, "live_idle_max_interval", + _DEFAULT_LIVE_IDLE_MAX_SECONDS) + # The ceiling bounds the un-escalated interval too. The two settings are + # independent integers with no cross-validation, so base > ceiling is a + # reachable config -- and returning base unclamped there made the wait + # *shrink* as the streak grew (3600s at streak 0, 900s at streak 24), + # the opposite of what the setting named "maximum" promises. + if streak >= _IDLE_LONG_STREAK: + return min(int(base * _IDLE_LONG_FACTOR), ceiling) + if streak >= _IDLE_SHORT_STREAK: + return min(int(base * _IDLE_SHORT_FACTOR), ceiling) + return min(base, ceiling) + + def _note_live_fetch(self, found_live: bool) -> None: + """Record whether a look for live games found any.""" + if found_live: + if getattr(self, "_empty_live_streak", 0): + self.logger.info( + "Live games found after %d empty check(s); back to the " + "live update interval", self._empty_live_streak) + self._empty_live_streak = 0 + else: + self._empty_live_streak = getattr(self, "_empty_live_streak", 0) + 1 + def update(self): """Update live game data and handle game switching.""" if not self.is_enabled: @@ -2336,31 +2398,36 @@ def update(self): # Only use no_data_interval if we've recently checked and confirmed there are no live games. # This ensures we check for live games frequently even if the list is temporarily empty. # Only use no_data_interval if we have no live games AND we've checked recently (within last 5 minutes) - time_since_last_update = current_time - self.last_update - has_recently_checked = self.last_update > 0 and time_since_last_update < 300 - + # Whether the last look found anything, tracked explicitly rather than + # inferred from how long ago it was. The old form asked "did we check + # within the last 300s?" and only then used no_data_interval -- but + # once 300s had elapsed the answer became no, the interval dropped + # back to live_update_interval, and it fetched. no_data_interval could + # therefore never delay anything past 300s whatever it was set to. + # Measured on a live rig: an out-of-season NHL polled every ~5.5 + # minutes around the clock, returning nothing every time. if _live_games_attr: - # We have live games, use the configured update interval interval = _update_interval_attr - elif has_recently_checked: - # We've checked recently and found no live games, use longer interval - interval = _no_data_interval_attr else: - # First check or haven't checked in a while, use update interval to check for live games - interval = _update_interval_attr + interval = self._idle_live_interval() # Debug logging for interval selection (log every 5 minutes or when interval changes) if current_time - self.last_log_time >= 300: # Log every 5 minutes self.logger.info( f"Update check: live_games={len(_live_games_attr) if _live_games_attr else 0}, " f"update_interval={_update_interval_attr}, no_data_interval={_no_data_interval_attr}, " - f"selected_interval={interval}, time_since_last_update={time_since_last_update:.1f}s, " - f"has_recently_checked={has_recently_checked}" + f"selected_interval={interval}, " + f"time_since_last_update={current_time - self.last_update:.1f}s, " + f"empty_live_streak={getattr(self, '_empty_live_streak', 0)}" ) self.last_log_time = current_time # Original line from traceback (line 455), now with variables defined: if current_time - self.last_update >= interval: + # What the previous look found, recorded before this one + # replaces it. The streak is what drives the back-off, and + # any live game resets it. + self._note_live_fetch(bool(_live_games_attr)) self.last_update = current_time # Fetch rankings if enabled diff --git a/plugins/lacrosse-scoreboard/test_idle_league_backoff.py b/plugins/lacrosse-scoreboard/test_idle_league_backoff.py new file mode 100644 index 00000000..ff5ccba9 --- /dev/null +++ b/plugins/lacrosse-scoreboard/test_idle_league_backoff.py @@ -0,0 +1,150 @@ +#!/usr/bin/env python3 +""" +Tests that a league with nothing on stops polling on a live cadence. + +Regression under test: the live gate decided its interval like this -- + + has_recently_checked = self.last_update > 0 and time_since_last_update < 300 + if live_games: interval = live_update_interval + elif has_recently_checked: interval = no_data_interval + else: interval = live_update_interval + +Once 300s had elapsed, has_recently_checked became False, the interval fell +back to live_update_interval, and it fetched. no_data_interval could therefore +never delay anything past 300s whatever it was set to -- the setting was +inert. Measured on a live rig in mid-August: NHLLiveManager fetched 0 games 22 +times in 2 hours, every ~5.5 minutes, around the clock, for a league whose +season had not started. Roughly 264 wasted requests a day, per league. + +The interval now comes from an explicit streak of empty looks, which escalates +and is capped, and which any live game resets. + +The methods are exercised against a stand-in ``self``, so the test needs no +display hardware, no network and no cache. + +Run: /bin/python plugins/lacrosse-scoreboard/test_idle_league_backoff.py +""" + +import ast +import sys +from pathlib import Path + +plugin_dir = Path(__file__).resolve().parent +sys.path.insert(0, str(plugin_dir)) +for candidate in (Path("/home/rackpi/projects/LEDMatrix"), + plugin_dir.parents[2] / "LEDMatrix"): + if (candidate / "src" / "plugin_system" / "base_plugin.py").exists(): + sys.path.insert(0, str(candidate)) + break + +import sports # noqa: E402 + +failures = [] + + +def check(label, ok): + print((" PASS " if ok else " FAIL ") + label) + if not ok: + failures.append(label) + + +class _Logger: + def info(self, *a, **k): + pass + + +class _Live: + _idle_live_interval = sports.SportsLive._idle_live_interval + _note_live_fetch = sports.SportsLive._note_live_fetch + + def __init__(self, base=300, ceiling=900): + self.no_data_interval = base + self.live_idle_max_interval = ceiling + self._empty_live_streak = 0 + self.logger = _Logger() + + +def main(): + print("the wait grows the longer nothing is found") + live = _Live() + check("first look uses the base interval", + live._idle_live_interval() == 300) + + for _ in range(sports._IDLE_SHORT_STREAK): + live._note_live_fetch(False) + check("after a short streak it is longer (%ds)" % live._idle_live_interval(), + live._idle_live_interval() > 300) + + short_wait = live._idle_live_interval() + for _ in range(sports._IDLE_LONG_STREAK): + live._note_live_fetch(False) + long_wait = live._idle_live_interval() + # Against short_wait, not against itself: comparing the value to a second + # call of the same method with the same state can never fail, so the + # monotonicity it is meant to protect went untested. + check("after a long streak it is longer still (%ds vs %ds)" + % (long_wait, short_wait), long_wait > short_wait) + check("and never exceeds the ceiling", long_wait <= 900) + + for _ in range(500): + live._note_live_fetch(False) + check("a very long streak stays at the ceiling, not beyond", + live._idle_live_interval() == 900) + + print("\na live game resets it immediately") + live._note_live_fetch(True) + check("the streak is cleared", live._empty_live_streak == 0) + check("and the base interval is back", + live._idle_live_interval() == 300) + + print("\nthe saving is real, and bounded") + idle = _Live() + for _ in range(500): + idle._note_live_fetch(False) + per_day = 86400 / idle._idle_live_interval() + check("an out-of-season league polls far less (%d/day vs 288)" % per_day, + per_day < 288 / 2) + check("...but still often enough to notice a season starting (<= 1h)", + idle._idle_live_interval() <= 3600) + + print("\nthe ceiling is configurable") + tight = _Live(ceiling=300) + for _ in range(500): + tight._note_live_fetch(False) + check("a lower ceiling is honoured", tight._idle_live_interval() == 300) + loose = _Live(ceiling=3600) + for _ in range(500): + loose._note_live_fetch(False) + check("a higher ceiling is honoured", loose._idle_live_interval() == 1800) + + print("\nintervals from config are clamped, never trusted raw") + check("a sane value is used", sports._clamp_seconds(120, 300) == 120) + check("absent falls back", sports._clamp_seconds(None, 300) == 300) + check("nonsense falls back", sports._clamp_seconds("soon", 300) == 300) + check("zero is clamped up", sports._clamp_seconds(0, 300) >= 5) + check("a week is clamped down", sports._clamp_seconds(604800, 300) <= 86400) + + print("\nthe old recency-based logic is gone") + src = (plugin_dir / "sports.py").read_text(encoding="utf-8") + check("has_recently_checked no longer decides the interval", + "has_recently_checked" not in src) + + tree = ast.parse(src) + live_cls = next(c for c in ast.walk(tree) + if isinstance(c, ast.ClassDef) and c.name == "SportsLive") + upd = next(m for m in live_cls.body + if isinstance(m, ast.FunctionDef) and m.name == "update") + idle_calls = [n for n in ast.walk(upd) if isinstance(n, ast.Call) + and getattr(n.func, "attr", None) == "_idle_live_interval"] + note_calls = [n for n in ast.walk(upd) if isinstance(n, ast.Call) + and getattr(n.func, "attr", None) == "_note_live_fetch"] + check("update() takes its idle interval from the back-off", len(idle_calls) == 1) + check("update() records each look's outcome", len(note_calls) == 1) + + print("\n%s" % ("FAILED: %d" % len(failures) if failures + else "All checks passed")) + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/plugins/nrl-scoreboard/config_schema.json b/plugins/nrl-scoreboard/config_schema.json index 286a5fa8..f67bcd81 100644 --- a/plugins/nrl-scoreboard/config_schema.json +++ b/plugins/nrl-scoreboard/config_schema.json @@ -1240,6 +1240,24 @@ "title": "Schedule lookahead (days)", "description": "How far ahead to fetch games for the Upcoming screen. Raise it for a league that plays weekly: a fixture just beyond this horizon is not fetched at all, so it never reaches the board even though the date is known.", "x-advanced": true + }, + "no_data_interval_seconds": { + "type": "integer", + "default": 300, + "minimum": 5, + "maximum": 86400, + "title": "Idle live-check interval (seconds)", + "description": "How long to wait between checks for live games when there are none. Backs off further the longer nothing is found, up to the cap below.", + "x-advanced": true + }, + "live_idle_max_interval_seconds": { + "type": "integer", + "default": 900, + "minimum": 5, + "maximum": 86400, + "title": "Maximum idle live-check interval (seconds)", + "description": "Ceiling for the back-off above. Raise it to poll an out-of-season league even less; lower it to notice the first game after a quiet spell sooner.", + "x-advanced": true } }, "additionalProperties": false, diff --git a/plugins/nrl-scoreboard/manager.py b/plugins/nrl-scoreboard/manager.py index b4b47b32..4564fcd0 100644 --- a/plugins/nrl-scoreboard/manager.py +++ b/plugins/nrl-scoreboard/manager.py @@ -56,6 +56,15 @@ from nrl_timezone import resolve_timezone_name from nrl_favorite_check import FavoriteTeamCheck + +_ROOT_CONFIG_KEYS = ( + "schedule_lookback_days", + "schedule_lookahead_days", + "no_data_interval_seconds", + "live_idle_max_interval_seconds", +) + + # Which ESPN endpoint backs the league, for the favorite-team diagnostic. FAVORITE_CHECK_KEY = 'nrl' FAVORITE_CHECK_LEAGUES = {FAVORITE_CHECK_KEY: ('NRL', 'rugby-league/3')} @@ -301,13 +310,15 @@ def _adapt_config_for_manager(self) -> Dict[str, Any]: "customization": cfg.get("customization", {}), }) - # The schedule-window settings live at the plugin config root, and - # SportsCore reads them from the root of the config it is handed. This - # adapter builds its output key by key, so anything not named here is - # dropped -- which silently pinned every user to the defaults. - for _window_key in ("schedule_lookback_days", "schedule_lookahead_days"): - if _window_key in self.config: - manager_config[_window_key] = self.config[_window_key] + # Plugin-root settings that SportsCore reads from the root of the config + # it is handed. This adapter builds its output key by key, so anything + # not named here is dropped -- which is how the schedule window silently + # pinned every user to the defaults, and would have done the same to the + # idle-poll settings. Generalised to a list so the next one added to + # SportsCore only has to be named once. + for _root_key in _ROOT_CONFIG_KEYS: + if _root_key in self.config: + manager_config[_root_key] = self.config[_root_key] return manager_config def _parse_display_mode_settings(self) -> Dict[str, str]: diff --git a/plugins/nrl-scoreboard/manifest.json b/plugins/nrl-scoreboard/manifest.json index 9041b556..566d751d 100644 --- a/plugins/nrl-scoreboard/manifest.json +++ b/plugins/nrl-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "nrl-scoreboard", "name": "NRL Scoreboard", - "version": "1.8.1", + "version": "1.9.0", "author": "ChuckBuilds", "description": "Live, recent, and upcoming NRL (National Rugby League) games with real-time scores and game status.", "category": "sports", @@ -18,6 +18,13 @@ "nrl_upcoming" ], "versions": [ + { + "version": "1.9.0", + "released": "2026-08-19", + "ledmatrix_min_version": "2.0.0", + "notes": "Make the schedule fetch window configurable. How far back and forward games are fetched was hard-coded at 14 days back and 7 forward. Days is the wrong unit for a league that plays weekly -- a whole matchweek can fall just past a short horizon and never reach the board -- so both are now advanced settings, bounded 1-60 days, with a stray or non-numeric value falling back to the default rather than raising. Defaults keep today's behaviour.", + "changes": "Back off the live-game poll while a league has nothing on, and let both the idle interval and its ceiling be configured. Saves API calls out of season and between games without delaying an in-progress game, which still resets the interval on the first fetch that finds one." + }, { "version": "1.8.1", "released": "2026-08-18", diff --git a/plugins/nrl-scoreboard/sports.py b/plugins/nrl-scoreboard/sports.py index b717f027..e7352931 100644 --- a/plugins/nrl-scoreboard/sports.py +++ b/plugins/nrl-scoreboard/sports.py @@ -101,6 +101,26 @@ def _clamp_window(value: Any, fallback: int) -> int: return max(_MIN_WINDOW_DAYS, min(_MAX_WINDOW_DAYS, days)) +# Backing off the live poll while a league has nothing on. Gentle at first -- +# a gap between games in a live season should cost little -- then firmer, so a +# league months out of season stops polling on a live cadence altogether. +_IDLE_SHORT_STREAK = 6 +_IDLE_SHORT_FACTOR = 2 +_IDLE_LONG_STREAK = 24 +_IDLE_LONG_FACTOR = 6 +_DEFAULT_LIVE_IDLE_MAX_SECONDS = 900 + + +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): + return fallback + return max(low, min(high, seconds)) + + class SportsCore(ABC): def __init__( self, @@ -2506,7 +2526,12 @@ def __init__( ): super().__init__(config, display_manager, cache_manager, logger, sport_key) self.update_interval = self.mode_config.get("live_update_interval", 15) - self.no_data_interval = 300 + self.no_data_interval = _clamp_seconds( + self.mode_config.get("no_data_interval_seconds"), 300) + self.live_idle_max_interval = _clamp_seconds( + self.mode_config.get("live_idle_max_interval_seconds"), + _DEFAULT_LIVE_IDLE_MAX_SECONDS) + self._empty_live_streak = 0 # Log the configured interval for debugging self.logger.info( f"SportsLive initialized: live_update_interval={self.update_interval}s, " @@ -3044,6 +3069,43 @@ def _detect_stale_games(self, games: List[Dict]) -> None: if game_id in self.game_update_timestamps: del self.game_update_timestamps[game_id] + def _idle_live_interval(self) -> int: + """How long to wait before looking for live games again, when there are none. + + Escalates the longer nothing turns up, and any live game resets it, so + an in-season gap between games costs at most one escalated wait while + an out-of-season league stops polling on a live cadence entirely. + + Capped rather than unbounded: the cost of backing off is how late the + first game after a quiet spell is noticed, and past the cap the saving + stops being worth that. + """ + streak = getattr(self, "_empty_live_streak", 0) + base = self.no_data_interval + ceiling = getattr(self, "live_idle_max_interval", + _DEFAULT_LIVE_IDLE_MAX_SECONDS) + # The ceiling bounds the un-escalated interval too. The two settings are + # independent integers with no cross-validation, so base > ceiling is a + # reachable config -- and returning base unclamped there made the wait + # *shrink* as the streak grew (3600s at streak 0, 900s at streak 24), + # the opposite of what the setting named "maximum" promises. + if streak >= _IDLE_LONG_STREAK: + return min(int(base * _IDLE_LONG_FACTOR), ceiling) + if streak >= _IDLE_SHORT_STREAK: + return min(int(base * _IDLE_SHORT_FACTOR), ceiling) + return min(base, ceiling) + + def _note_live_fetch(self, found_live: bool) -> None: + """Record whether a look for live games found any.""" + if found_live: + if getattr(self, "_empty_live_streak", 0): + self.logger.info( + "Live games found after %d empty check(s); back to the " + "live update interval", self._empty_live_streak) + self._empty_live_streak = 0 + else: + self._empty_live_streak = getattr(self, "_empty_live_streak", 0) + 1 + def update(self): """Update live game data and handle game switching.""" if not self.is_enabled: @@ -3068,21 +3130,25 @@ def update(self): # Only use no_data_interval if we've recently checked and confirmed there are no live games. # This ensures we check for live games frequently even if the list is temporarily empty. # Only use no_data_interval if we have no live games AND we've checked recently (within last 5 minutes) - time_since_last_update = current_time - self.last_update - has_recently_checked = self.last_update > 0 and time_since_last_update < 300 - + # Whether the last look found anything, tracked explicitly rather than + # inferred from how long ago it was. The old form asked "did we check + # within the last 300s?" and only then used no_data_interval -- but + # once 300s had elapsed the answer became no, the interval dropped + # back to live_update_interval, and it fetched. no_data_interval could + # therefore never delay anything past 300s whatever it was set to. + # Measured on a live rig: an out-of-season NHL polled every ~5.5 + # minutes around the clock, returning nothing every time. if _live_games_attr: - # We have live games, use the configured update interval interval = _update_interval_attr - elif has_recently_checked: - # We've checked recently and found no live games, use longer interval - interval = _no_data_interval_attr else: - # First check or haven't checked in a while, use update interval to check for live games - interval = _update_interval_attr + interval = self._idle_live_interval() # Original line from traceback (line 455), now with variables defined: if current_time - self.last_update >= interval: + # What the previous look found, recorded before this one + # replaces it. The streak is what drives the back-off, and + # any live game resets it. + self._note_live_fetch(bool(_live_games_attr)) self.last_update = current_time # Fetch rankings if enabled diff --git a/plugins/nrl-scoreboard/test_idle_league_backoff.py b/plugins/nrl-scoreboard/test_idle_league_backoff.py new file mode 100644 index 00000000..655e8731 --- /dev/null +++ b/plugins/nrl-scoreboard/test_idle_league_backoff.py @@ -0,0 +1,150 @@ +#!/usr/bin/env python3 +""" +Tests that a league with nothing on stops polling on a live cadence. + +Regression under test: the live gate decided its interval like this -- + + has_recently_checked = self.last_update > 0 and time_since_last_update < 300 + if live_games: interval = live_update_interval + elif has_recently_checked: interval = no_data_interval + else: interval = live_update_interval + +Once 300s had elapsed, has_recently_checked became False, the interval fell +back to live_update_interval, and it fetched. no_data_interval could therefore +never delay anything past 300s whatever it was set to -- the setting was +inert. Measured on a live rig in mid-August: NHLLiveManager fetched 0 games 22 +times in 2 hours, every ~5.5 minutes, around the clock, for a league whose +season had not started. Roughly 264 wasted requests a day, per league. + +The interval now comes from an explicit streak of empty looks, which escalates +and is capped, and which any live game resets. + +The methods are exercised against a stand-in ``self``, so the test needs no +display hardware, no network and no cache. + +Run: /bin/python plugins/nrl-scoreboard/test_idle_league_backoff.py +""" + +import ast +import sys +from pathlib import Path + +plugin_dir = Path(__file__).resolve().parent +sys.path.insert(0, str(plugin_dir)) +for candidate in (Path("/home/rackpi/projects/LEDMatrix"), + plugin_dir.parents[2] / "LEDMatrix"): + if (candidate / "src" / "plugin_system" / "base_plugin.py").exists(): + sys.path.insert(0, str(candidate)) + break + +import sports # noqa: E402 + +failures = [] + + +def check(label, ok): + print((" PASS " if ok else " FAIL ") + label) + if not ok: + failures.append(label) + + +class _Logger: + def info(self, *a, **k): + pass + + +class _Live: + _idle_live_interval = sports.SportsLive._idle_live_interval + _note_live_fetch = sports.SportsLive._note_live_fetch + + def __init__(self, base=300, ceiling=900): + self.no_data_interval = base + self.live_idle_max_interval = ceiling + self._empty_live_streak = 0 + self.logger = _Logger() + + +def main(): + print("the wait grows the longer nothing is found") + live = _Live() + check("first look uses the base interval", + live._idle_live_interval() == 300) + + for _ in range(sports._IDLE_SHORT_STREAK): + live._note_live_fetch(False) + check("after a short streak it is longer (%ds)" % live._idle_live_interval(), + live._idle_live_interval() > 300) + + short_wait = live._idle_live_interval() + for _ in range(sports._IDLE_LONG_STREAK): + live._note_live_fetch(False) + long_wait = live._idle_live_interval() + # Against short_wait, not against itself: comparing the value to a second + # call of the same method with the same state can never fail, so the + # monotonicity it is meant to protect went untested. + check("after a long streak it is longer still (%ds vs %ds)" + % (long_wait, short_wait), long_wait > short_wait) + check("and never exceeds the ceiling", long_wait <= 900) + + for _ in range(500): + live._note_live_fetch(False) + check("a very long streak stays at the ceiling, not beyond", + live._idle_live_interval() == 900) + + print("\na live game resets it immediately") + live._note_live_fetch(True) + check("the streak is cleared", live._empty_live_streak == 0) + check("and the base interval is back", + live._idle_live_interval() == 300) + + print("\nthe saving is real, and bounded") + idle = _Live() + for _ in range(500): + idle._note_live_fetch(False) + per_day = 86400 / idle._idle_live_interval() + check("an out-of-season league polls far less (%d/day vs 288)" % per_day, + per_day < 288 / 2) + check("...but still often enough to notice a season starting (<= 1h)", + idle._idle_live_interval() <= 3600) + + print("\nthe ceiling is configurable") + tight = _Live(ceiling=300) + for _ in range(500): + tight._note_live_fetch(False) + check("a lower ceiling is honoured", tight._idle_live_interval() == 300) + loose = _Live(ceiling=3600) + for _ in range(500): + loose._note_live_fetch(False) + check("a higher ceiling is honoured", loose._idle_live_interval() == 1800) + + print("\nintervals from config are clamped, never trusted raw") + check("a sane value is used", sports._clamp_seconds(120, 300) == 120) + check("absent falls back", sports._clamp_seconds(None, 300) == 300) + check("nonsense falls back", sports._clamp_seconds("soon", 300) == 300) + check("zero is clamped up", sports._clamp_seconds(0, 300) >= 5) + check("a week is clamped down", sports._clamp_seconds(604800, 300) <= 86400) + + print("\nthe old recency-based logic is gone") + src = (plugin_dir / "sports.py").read_text(encoding="utf-8") + check("has_recently_checked no longer decides the interval", + "has_recently_checked" not in src) + + tree = ast.parse(src) + live_cls = next(c for c in ast.walk(tree) + if isinstance(c, ast.ClassDef) and c.name == "SportsLive") + upd = next(m for m in live_cls.body + if isinstance(m, ast.FunctionDef) and m.name == "update") + idle_calls = [n for n in ast.walk(upd) if isinstance(n, ast.Call) + and getattr(n.func, "attr", None) == "_idle_live_interval"] + note_calls = [n for n in ast.walk(upd) if isinstance(n, ast.Call) + and getattr(n.func, "attr", None) == "_note_live_fetch"] + check("update() takes its idle interval from the back-off", len(idle_calls) == 1) + check("update() records each look's outcome", len(note_calls) == 1) + + print("\n%s" % ("FAILED: %d" % len(failures) if failures + else "All checks passed")) + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/plugins/soccer-scoreboard/config_schema.json b/plugins/soccer-scoreboard/config_schema.json index 73a1b76a..907194ea 100644 --- a/plugins/soccer-scoreboard/config_schema.json +++ b/plugins/soccer-scoreboard/config_schema.json @@ -5359,6 +5359,24 @@ "title": "Schedule lookahead (days)", "description": "How far ahead to fetch games for the Upcoming screen. Raise it for a league that plays weekly: a fixture just beyond this horizon is not fetched at all, so it never reaches the board even though the date is known.", "x-advanced": true + }, + "no_data_interval_seconds": { + "type": "integer", + "default": 300, + "minimum": 5, + "maximum": 86400, + "title": "Idle live-check interval (seconds)", + "description": "How long to wait between checks for live games when there are none. Backs off further the longer nothing is found, up to the cap below.", + "x-advanced": true + }, + "live_idle_max_interval_seconds": { + "type": "integer", + "default": 900, + "minimum": 5, + "maximum": 86400, + "title": "Maximum idle live-check interval (seconds)", + "description": "Ceiling for the back-off above. Raise it to poll an out-of-season league even less; lower it to notice the first game after a quiet spell sooner.", + "x-advanced": true } }, "additionalProperties": false, diff --git a/plugins/soccer-scoreboard/manager.py b/plugins/soccer-scoreboard/manager.py index 1e1d58a4..3541fb09 100644 --- a/plugins/soccer-scoreboard/manager.py +++ b/plugins/soccer-scoreboard/manager.py @@ -78,6 +78,15 @@ from soccer_timezone import resolve_timezone_name + +_ROOT_CONFIG_KEYS = ( + "schedule_lookback_days", + "schedule_lookahead_days", + "no_data_interval_seconds", + "live_idle_max_interval_seconds", +) + + logger = logging.getLogger(__name__) # Predefined league keys and display names (priority 1-8) @@ -465,13 +474,15 @@ def _adapt_config_for_manager(self, league_key: str) -> Dict[str, Any]: self.logger.debug(f"Using timezone: {timezone_str} for {league_key} managers") - # The schedule-window settings live at the plugin config root, and - # SportsCore reads them from the root of the config it is handed. This - # adapter builds its output key by key, so anything not named here is - # dropped -- which silently pinned every user to the defaults. - for _window_key in ("schedule_lookback_days", "schedule_lookahead_days"): - if _window_key in self.config: - manager_config[_window_key] = self.config[_window_key] + # Plugin-root settings that SportsCore reads from the root of the config + # it is handed. This adapter builds its output key by key, so anything + # not named here is dropped -- which is how the schedule window silently + # pinned every user to the defaults, and would have done the same to the + # idle-poll settings. Generalised to a list so the next one added to + # SportsCore only has to be named once. + for _root_key in _ROOT_CONFIG_KEYS: + if _root_key in self.config: + manager_config[_root_key] = self.config[_root_key] return manager_config def _build_custom_league_map(self) -> None: diff --git a/plugins/soccer-scoreboard/manifest.json b/plugins/soccer-scoreboard/manifest.json index 826ad334..6e512f25 100644 --- a/plugins/soccer-scoreboard/manifest.json +++ b/plugins/soccer-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "soccer-scoreboard", "name": "Soccer Scoreboard", - "version": "2.11.1", + "version": "2.12.0", "author": "ChuckBuilds", "description": "Live, recent, and upcoming soccer games across multiple leagues including Premier League, La Liga, Bundesliga, Serie A, Ligue 1, MLS, Liga Portugal, Champions League, Europa League, and FIFA World Cup", "category": "sports", @@ -26,6 +26,13 @@ "soccer_upcoming" ], "versions": [ + { + "version": "2.12.0", + "released": "2026-08-19", + "ledmatrix_min_version": "2.0.0", + "notes": "Make the schedule fetch window configurable. How far back and forward games are fetched was hard-coded at 14 days back and 7 forward. Days is the wrong unit for a league that plays weekly -- a whole matchweek can fall just past a short horizon and never reach the board -- so both are now advanced settings, bounded 1-60 days, with a stray or non-numeric value falling back to the default rather than raising. Defaults keep today's behaviour, except this plugin's lookahead which moves to 14 so a fixture eight days out is fetched at all.", + "changes": "Back off the live-game poll while a league has nothing on, and let both the idle interval and its ceiling be configured. Saves API calls out of season and between games without delaying an in-progress game, which still resets the interval on the first fetch that finds one." + }, { "version": "2.11.1", "released": "2026-08-18", diff --git a/plugins/soccer-scoreboard/sports.py b/plugins/soccer-scoreboard/sports.py index cb509f4a..96bbe2e4 100644 --- a/plugins/soccer-scoreboard/sports.py +++ b/plugins/soccer-scoreboard/sports.py @@ -108,6 +108,26 @@ def _clamp_window(value: Any, fallback: int) -> int: return max(_MIN_WINDOW_DAYS, min(_MAX_WINDOW_DAYS, days)) +# Backing off the live poll while a league has nothing on. Gentle at first -- +# a gap between games in a live season should cost little -- then firmer, so a +# league months out of season stops polling on a live cadence altogether. +_IDLE_SHORT_STREAK = 6 +_IDLE_SHORT_FACTOR = 2 +_IDLE_LONG_STREAK = 24 +_IDLE_LONG_FACTOR = 6 +_DEFAULT_LIVE_IDLE_MAX_SECONDS = 900 + + +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): + return fallback + return max(low, min(high, seconds)) + + class SportsCore(ABC): def __init__( self, @@ -2515,7 +2535,12 @@ def __init__( ): super().__init__(config, display_manager, cache_manager, logger, sport_key) self.update_interval = self.mode_config.get("live_update_interval", 15) - self.no_data_interval = 300 + self.no_data_interval = _clamp_seconds( + self.mode_config.get("no_data_interval_seconds"), 300) + self.live_idle_max_interval = _clamp_seconds( + self.mode_config.get("live_idle_max_interval_seconds"), + _DEFAULT_LIVE_IDLE_MAX_SECONDS) + self._empty_live_streak = 0 # Log the configured interval for debugging self.logger.info( f"SportsLive initialized: live_update_interval={self.update_interval}s, " @@ -3053,6 +3078,43 @@ def _detect_stale_games(self, games: List[Dict]) -> None: if game_id in self.game_update_timestamps: del self.game_update_timestamps[game_id] + def _idle_live_interval(self) -> int: + """How long to wait before looking for live games again, when there are none. + + Escalates the longer nothing turns up, and any live game resets it, so + an in-season gap between games costs at most one escalated wait while + an out-of-season league stops polling on a live cadence entirely. + + Capped rather than unbounded: the cost of backing off is how late the + first game after a quiet spell is noticed, and past the cap the saving + stops being worth that. + """ + streak = getattr(self, "_empty_live_streak", 0) + base = self.no_data_interval + ceiling = getattr(self, "live_idle_max_interval", + _DEFAULT_LIVE_IDLE_MAX_SECONDS) + # The ceiling bounds the un-escalated interval too. The two settings are + # independent integers with no cross-validation, so base > ceiling is a + # reachable config -- and returning base unclamped there made the wait + # *shrink* as the streak grew (3600s at streak 0, 900s at streak 24), + # the opposite of what the setting named "maximum" promises. + if streak >= _IDLE_LONG_STREAK: + return min(int(base * _IDLE_LONG_FACTOR), ceiling) + if streak >= _IDLE_SHORT_STREAK: + return min(int(base * _IDLE_SHORT_FACTOR), ceiling) + return min(base, ceiling) + + def _note_live_fetch(self, found_live: bool) -> None: + """Record whether a look for live games found any.""" + if found_live: + if getattr(self, "_empty_live_streak", 0): + self.logger.info( + "Live games found after %d empty check(s); back to the " + "live update interval", self._empty_live_streak) + self._empty_live_streak = 0 + else: + self._empty_live_streak = getattr(self, "_empty_live_streak", 0) + 1 + def update(self): """Update live game data and handle game switching.""" if not self.is_enabled: @@ -3077,21 +3139,25 @@ def update(self): # Only use no_data_interval if we've recently checked and confirmed there are no live games. # This ensures we check for live games frequently even if the list is temporarily empty. # Only use no_data_interval if we have no live games AND we've checked recently (within last 5 minutes) - time_since_last_update = current_time - self.last_update - has_recently_checked = self.last_update > 0 and time_since_last_update < 300 - + # Whether the last look found anything, tracked explicitly rather than + # inferred from how long ago it was. The old form asked "did we check + # within the last 300s?" and only then used no_data_interval -- but + # once 300s had elapsed the answer became no, the interval dropped + # back to live_update_interval, and it fetched. no_data_interval could + # therefore never delay anything past 300s whatever it was set to. + # Measured on a live rig: an out-of-season NHL polled every ~5.5 + # minutes around the clock, returning nothing every time. if _live_games_attr: - # We have live games, use the configured update interval interval = _update_interval_attr - elif has_recently_checked: - # We've checked recently and found no live games, use longer interval - interval = _no_data_interval_attr else: - # First check or haven't checked in a while, use update interval to check for live games - interval = _update_interval_attr + interval = self._idle_live_interval() # Original line from traceback (line 455), now with variables defined: if current_time - self.last_update >= interval: + # What the previous look found, recorded before this one + # replaces it. The streak is what drives the back-off, and + # any live game resets it. + self._note_live_fetch(bool(_live_games_attr)) self.last_update = current_time # Fetch rankings if enabled diff --git a/plugins/soccer-scoreboard/test_idle_league_backoff.py b/plugins/soccer-scoreboard/test_idle_league_backoff.py new file mode 100644 index 00000000..2cc17fff --- /dev/null +++ b/plugins/soccer-scoreboard/test_idle_league_backoff.py @@ -0,0 +1,150 @@ +#!/usr/bin/env python3 +""" +Tests that a league with nothing on stops polling on a live cadence. + +Regression under test: the live gate decided its interval like this -- + + has_recently_checked = self.last_update > 0 and time_since_last_update < 300 + if live_games: interval = live_update_interval + elif has_recently_checked: interval = no_data_interval + else: interval = live_update_interval + +Once 300s had elapsed, has_recently_checked became False, the interval fell +back to live_update_interval, and it fetched. no_data_interval could therefore +never delay anything past 300s whatever it was set to -- the setting was +inert. Measured on a live rig in mid-August: NHLLiveManager fetched 0 games 22 +times in 2 hours, every ~5.5 minutes, around the clock, for a league whose +season had not started. Roughly 264 wasted requests a day, per league. + +The interval now comes from an explicit streak of empty looks, which escalates +and is capped, and which any live game resets. + +The methods are exercised against a stand-in ``self``, so the test needs no +display hardware, no network and no cache. + +Run: /bin/python plugins/soccer-scoreboard/test_idle_league_backoff.py +""" + +import ast +import sys +from pathlib import Path + +plugin_dir = Path(__file__).resolve().parent +sys.path.insert(0, str(plugin_dir)) +for candidate in (Path("/home/rackpi/projects/LEDMatrix"), + plugin_dir.parents[2] / "LEDMatrix"): + if (candidate / "src" / "plugin_system" / "base_plugin.py").exists(): + sys.path.insert(0, str(candidate)) + break + +import sports # noqa: E402 + +failures = [] + + +def check(label, ok): + print((" PASS " if ok else " FAIL ") + label) + if not ok: + failures.append(label) + + +class _Logger: + def info(self, *a, **k): + pass + + +class _Live: + _idle_live_interval = sports.SportsLive._idle_live_interval + _note_live_fetch = sports.SportsLive._note_live_fetch + + def __init__(self, base=300, ceiling=900): + self.no_data_interval = base + self.live_idle_max_interval = ceiling + self._empty_live_streak = 0 + self.logger = _Logger() + + +def main(): + print("the wait grows the longer nothing is found") + live = _Live() + check("first look uses the base interval", + live._idle_live_interval() == 300) + + for _ in range(sports._IDLE_SHORT_STREAK): + live._note_live_fetch(False) + check("after a short streak it is longer (%ds)" % live._idle_live_interval(), + live._idle_live_interval() > 300) + + short_wait = live._idle_live_interval() + for _ in range(sports._IDLE_LONG_STREAK): + live._note_live_fetch(False) + long_wait = live._idle_live_interval() + # Against short_wait, not against itself: comparing the value to a second + # call of the same method with the same state can never fail, so the + # monotonicity it is meant to protect went untested. + check("after a long streak it is longer still (%ds vs %ds)" + % (long_wait, short_wait), long_wait > short_wait) + check("and never exceeds the ceiling", long_wait <= 900) + + for _ in range(500): + live._note_live_fetch(False) + check("a very long streak stays at the ceiling, not beyond", + live._idle_live_interval() == 900) + + print("\na live game resets it immediately") + live._note_live_fetch(True) + check("the streak is cleared", live._empty_live_streak == 0) + check("and the base interval is back", + live._idle_live_interval() == 300) + + print("\nthe saving is real, and bounded") + idle = _Live() + for _ in range(500): + idle._note_live_fetch(False) + per_day = 86400 / idle._idle_live_interval() + check("an out-of-season league polls far less (%d/day vs 288)" % per_day, + per_day < 288 / 2) + check("...but still often enough to notice a season starting (<= 1h)", + idle._idle_live_interval() <= 3600) + + print("\nthe ceiling is configurable") + tight = _Live(ceiling=300) + for _ in range(500): + tight._note_live_fetch(False) + check("a lower ceiling is honoured", tight._idle_live_interval() == 300) + loose = _Live(ceiling=3600) + for _ in range(500): + loose._note_live_fetch(False) + check("a higher ceiling is honoured", loose._idle_live_interval() == 1800) + + print("\nintervals from config are clamped, never trusted raw") + check("a sane value is used", sports._clamp_seconds(120, 300) == 120) + check("absent falls back", sports._clamp_seconds(None, 300) == 300) + check("nonsense falls back", sports._clamp_seconds("soon", 300) == 300) + check("zero is clamped up", sports._clamp_seconds(0, 300) >= 5) + check("a week is clamped down", sports._clamp_seconds(604800, 300) <= 86400) + + print("\nthe old recency-based logic is gone") + src = (plugin_dir / "sports.py").read_text(encoding="utf-8") + check("has_recently_checked no longer decides the interval", + "has_recently_checked" not in src) + + tree = ast.parse(src) + live_cls = next(c for c in ast.walk(tree) + if isinstance(c, ast.ClassDef) and c.name == "SportsLive") + upd = next(m for m in live_cls.body + if isinstance(m, ast.FunctionDef) and m.name == "update") + idle_calls = [n for n in ast.walk(upd) if isinstance(n, ast.Call) + and getattr(n.func, "attr", None) == "_idle_live_interval"] + note_calls = [n for n in ast.walk(upd) if isinstance(n, ast.Call) + and getattr(n.func, "attr", None) == "_note_live_fetch"] + check("update() takes its idle interval from the back-off", len(idle_calls) == 1) + check("update() records each look's outcome", len(note_calls) == 1) + + print("\n%s" % ("FAILED: %d" % len(failures) if failures + else "All checks passed")) + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/plugins/ufc-scoreboard/config_schema.json b/plugins/ufc-scoreboard/config_schema.json index 622d489e..30c15ded 100644 --- a/plugins/ufc-scoreboard/config_schema.json +++ b/plugins/ufc-scoreboard/config_schema.json @@ -671,6 +671,24 @@ "title": "Schedule lookahead (days)", "description": "How far ahead to fetch games for the Upcoming screen. Raise it for a league that plays weekly: a fixture just beyond this horizon is not fetched at all, so it never reaches the board even though the date is known.", "x-advanced": true + }, + "no_data_interval_seconds": { + "type": "integer", + "default": 300, + "minimum": 5, + "maximum": 86400, + "title": "Idle live-check interval (seconds)", + "description": "How long to wait between checks for live games when there are none. Backs off further the longer nothing is found, up to the cap below.", + "x-advanced": true + }, + "live_idle_max_interval_seconds": { + "type": "integer", + "default": 900, + "minimum": 5, + "maximum": 86400, + "title": "Maximum idle live-check interval (seconds)", + "description": "Ceiling for the back-off above. Raise it to poll an out-of-season league even less; lower it to notice the first game after a quiet spell sooner.", + "x-advanced": true } }, "additionalProperties": false, diff --git a/plugins/ufc-scoreboard/manager.py b/plugins/ufc-scoreboard/manager.py index 36f94c99..1a871ba6 100644 --- a/plugins/ufc-scoreboard/manager.py +++ b/plugins/ufc-scoreboard/manager.py @@ -38,6 +38,15 @@ from ufc_timezone import resolve_timezone_name + +_ROOT_CONFIG_KEYS = ( + "schedule_lookback_days", + "schedule_lookahead_days", + "no_data_interval_seconds", + "live_idle_max_interval_seconds", +) + + logger = logging.getLogger(__name__) @@ -343,13 +352,15 @@ def _adapt_config_for_manager(self, league: str) -> Dict[str, Any]: ) self.logger.debug(f"Using timezone: {timezone_str} for {league} managers") - # The schedule-window settings live at the plugin config root, and - # SportsCore reads them from the root of the config it is handed. This - # adapter builds its output key by key, so anything not named here is - # dropped -- which silently pinned every user to the defaults. - for _window_key in ("schedule_lookback_days", "schedule_lookahead_days"): - if _window_key in self.config: - manager_config[_window_key] = self.config[_window_key] + # Plugin-root settings that SportsCore reads from the root of the config + # it is handed. This adapter builds its output key by key, so anything + # not named here is dropped -- which is how the schedule window silently + # pinned every user to the defaults, and would have done the same to the + # idle-poll settings. Generalised to a list so the next one added to + # SportsCore only has to be named once. + for _root_key in _ROOT_CONFIG_KEYS: + if _root_key in self.config: + manager_config[_root_key] = self.config[_root_key] return manager_config def _parse_display_mode_settings(self) -> Dict[str, Dict[str, str]]: diff --git a/plugins/ufc-scoreboard/manifest.json b/plugins/ufc-scoreboard/manifest.json index 394e3282..7db841f3 100644 --- a/plugins/ufc-scoreboard/manifest.json +++ b/plugins/ufc-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "ufc-scoreboard", "name": "UFC Scoreboard", - "version": "1.5.1", + "version": "1.6.0", "author": "LegoGuy1000", "contributors": [ { @@ -32,6 +32,13 @@ "default_duration": 15, "config_schema": "config_schema.json", "versions": [ + { + "version": "1.6.0", + "released": "2026-08-19", + "ledmatrix_min_version": "2.0.0", + "notes": "Make the schedule fetch window configurable. How far back and forward games are fetched was hard-coded at 14 days back and 7 forward. Days is the wrong unit for a league that plays weekly -- a whole matchweek can fall just past a short horizon and never reach the board -- so both are now advanced settings, bounded 1-60 days, with a stray or non-numeric value falling back to the default rather than raising. Defaults keep today's behaviour.", + "changes": "Back off the live-game poll while a league has nothing on, and let both the idle interval and its ceiling be configured. Saves API calls out of season and between games without delaying an in-progress game, which still resets the interval on the first fetch that finds one." + }, { "version": "1.5.1", "released": "2026-08-18", diff --git a/plugins/ufc-scoreboard/sports.py b/plugins/ufc-scoreboard/sports.py index 7ab634f4..ce15646c 100644 --- a/plugins/ufc-scoreboard/sports.py +++ b/plugins/ufc-scoreboard/sports.py @@ -97,6 +97,26 @@ def _clamp_window(value: Any, fallback: int) -> int: return max(_MIN_WINDOW_DAYS, min(_MAX_WINDOW_DAYS, days)) +# Backing off the live poll while a league has nothing on. Gentle at first -- +# a gap between games in a live season should cost little -- then firmer, so a +# league months out of season stops polling on a live cadence altogether. +_IDLE_SHORT_STREAK = 6 +_IDLE_SHORT_FACTOR = 2 +_IDLE_LONG_STREAK = 24 +_IDLE_LONG_FACTOR = 6 +_DEFAULT_LIVE_IDLE_MAX_SECONDS = 900 + + +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): + return fallback + return max(low, min(high, seconds)) + + class SportsCore(ABC): def __init__( self, @@ -2137,7 +2157,12 @@ def __init__( ): super().__init__(config, display_manager, cache_manager, logger, sport_key) self.update_interval = self.mode_config.get("live_update_interval", 15) - self.no_data_interval = 300 + self.no_data_interval = _clamp_seconds( + self.mode_config.get("no_data_interval_seconds"), 300) + self.live_idle_max_interval = _clamp_seconds( + self.mode_config.get("live_idle_max_interval_seconds"), + _DEFAULT_LIVE_IDLE_MAX_SECONDS) + self._empty_live_streak = 0 # Log the configured interval for debugging self.logger.info( f"SportsLive initialized: live_update_interval={self.update_interval}s, " @@ -2238,6 +2263,43 @@ def _detect_stale_games(self, games: List[Dict]) -> None: if game_id in self.game_update_timestamps: del self.game_update_timestamps[game_id] + def _idle_live_interval(self) -> int: + """How long to wait before looking for live games again, when there are none. + + Escalates the longer nothing turns up, and any live game resets it, so + an in-season gap between games costs at most one escalated wait while + an out-of-season league stops polling on a live cadence entirely. + + Capped rather than unbounded: the cost of backing off is how late the + first game after a quiet spell is noticed, and past the cap the saving + stops being worth that. + """ + streak = getattr(self, "_empty_live_streak", 0) + base = self.no_data_interval + ceiling = getattr(self, "live_idle_max_interval", + _DEFAULT_LIVE_IDLE_MAX_SECONDS) + # The ceiling bounds the un-escalated interval too. The two settings are + # independent integers with no cross-validation, so base > ceiling is a + # reachable config -- and returning base unclamped there made the wait + # *shrink* as the streak grew (3600s at streak 0, 900s at streak 24), + # the opposite of what the setting named "maximum" promises. + if streak >= _IDLE_LONG_STREAK: + return min(int(base * _IDLE_LONG_FACTOR), ceiling) + if streak >= _IDLE_SHORT_STREAK: + return min(int(base * _IDLE_SHORT_FACTOR), ceiling) + return min(base, ceiling) + + def _note_live_fetch(self, found_live: bool) -> None: + """Record whether a look for live games found any.""" + if found_live: + if getattr(self, "_empty_live_streak", 0): + self.logger.info( + "Live games found after %d empty check(s); back to the " + "live update interval", self._empty_live_streak) + self._empty_live_streak = 0 + else: + self._empty_live_streak = getattr(self, "_empty_live_streak", 0) + 1 + def update(self): """Update live game data and handle game switching.""" if not self.is_enabled: @@ -2265,21 +2327,25 @@ def update(self): # Only use no_data_interval if we've recently checked and confirmed there are no live games. # This ensures we check for live games frequently even if the list is temporarily empty. # Only use no_data_interval if we have no live games AND we've checked recently (within last 5 minutes) - time_since_last_update = current_time - self.last_update - has_recently_checked = self.last_update > 0 and time_since_last_update < 300 - + # Whether the last look found anything, tracked explicitly rather than + # inferred from how long ago it was. The old form asked "did we check + # within the last 300s?" and only then used no_data_interval -- but + # once 300s had elapsed the answer became no, the interval dropped + # back to live_update_interval, and it fetched. no_data_interval could + # therefore never delay anything past 300s whatever it was set to. + # Measured on a live rig: an out-of-season NHL polled every ~5.5 + # minutes around the clock, returning nothing every time. if _live_games_attr or _test_mode_attr: - # We have live games or are in test mode, use the configured update interval interval = _update_interval_attr - elif has_recently_checked: - # We've checked recently and found no live games, use longer interval - interval = _no_data_interval_attr else: - # First check or haven't checked in a while, use update interval to check for live games - interval = _update_interval_attr + interval = self._idle_live_interval() # Original line from traceback (line 455), now with variables defined: if current_time - self.last_update >= interval: + # What the previous look found, recorded before this one + # replaces it. The streak is what drives the back-off, and + # any live game resets it. + self._note_live_fetch(bool(_live_games_attr)) self.last_update = current_time # Fetch rankings if enabled diff --git a/plugins/ufc-scoreboard/test_idle_league_backoff.py b/plugins/ufc-scoreboard/test_idle_league_backoff.py new file mode 100644 index 00000000..7129697a --- /dev/null +++ b/plugins/ufc-scoreboard/test_idle_league_backoff.py @@ -0,0 +1,150 @@ +#!/usr/bin/env python3 +""" +Tests that a league with nothing on stops polling on a live cadence. + +Regression under test: the live gate decided its interval like this -- + + has_recently_checked = self.last_update > 0 and time_since_last_update < 300 + if live_games: interval = live_update_interval + elif has_recently_checked: interval = no_data_interval + else: interval = live_update_interval + +Once 300s had elapsed, has_recently_checked became False, the interval fell +back to live_update_interval, and it fetched. no_data_interval could therefore +never delay anything past 300s whatever it was set to -- the setting was +inert. Measured on a live rig in mid-August: NHLLiveManager fetched 0 games 22 +times in 2 hours, every ~5.5 minutes, around the clock, for a league whose +season had not started. Roughly 264 wasted requests a day, per league. + +The interval now comes from an explicit streak of empty looks, which escalates +and is capped, and which any live game resets. + +The methods are exercised against a stand-in ``self``, so the test needs no +display hardware, no network and no cache. + +Run: /bin/python plugins/ufc-scoreboard/test_idle_league_backoff.py +""" + +import ast +import sys +from pathlib import Path + +plugin_dir = Path(__file__).resolve().parent +sys.path.insert(0, str(plugin_dir)) +for candidate in (Path("/home/rackpi/projects/LEDMatrix"), + plugin_dir.parents[2] / "LEDMatrix"): + if (candidate / "src" / "plugin_system" / "base_plugin.py").exists(): + sys.path.insert(0, str(candidate)) + break + +import sports # noqa: E402 + +failures = [] + + +def check(label, ok): + print((" PASS " if ok else " FAIL ") + label) + if not ok: + failures.append(label) + + +class _Logger: + def info(self, *a, **k): + pass + + +class _Live: + _idle_live_interval = sports.SportsLive._idle_live_interval + _note_live_fetch = sports.SportsLive._note_live_fetch + + def __init__(self, base=300, ceiling=900): + self.no_data_interval = base + self.live_idle_max_interval = ceiling + self._empty_live_streak = 0 + self.logger = _Logger() + + +def main(): + print("the wait grows the longer nothing is found") + live = _Live() + check("first look uses the base interval", + live._idle_live_interval() == 300) + + for _ in range(sports._IDLE_SHORT_STREAK): + live._note_live_fetch(False) + check("after a short streak it is longer (%ds)" % live._idle_live_interval(), + live._idle_live_interval() > 300) + + short_wait = live._idle_live_interval() + for _ in range(sports._IDLE_LONG_STREAK): + live._note_live_fetch(False) + long_wait = live._idle_live_interval() + # Against short_wait, not against itself: comparing the value to a second + # call of the same method with the same state can never fail, so the + # monotonicity it is meant to protect went untested. + check("after a long streak it is longer still (%ds vs %ds)" + % (long_wait, short_wait), long_wait > short_wait) + check("and never exceeds the ceiling", long_wait <= 900) + + for _ in range(500): + live._note_live_fetch(False) + check("a very long streak stays at the ceiling, not beyond", + live._idle_live_interval() == 900) + + print("\na live game resets it immediately") + live._note_live_fetch(True) + check("the streak is cleared", live._empty_live_streak == 0) + check("and the base interval is back", + live._idle_live_interval() == 300) + + print("\nthe saving is real, and bounded") + idle = _Live() + for _ in range(500): + idle._note_live_fetch(False) + per_day = 86400 / idle._idle_live_interval() + check("an out-of-season league polls far less (%d/day vs 288)" % per_day, + per_day < 288 / 2) + check("...but still often enough to notice a season starting (<= 1h)", + idle._idle_live_interval() <= 3600) + + print("\nthe ceiling is configurable") + tight = _Live(ceiling=300) + for _ in range(500): + tight._note_live_fetch(False) + check("a lower ceiling is honoured", tight._idle_live_interval() == 300) + loose = _Live(ceiling=3600) + for _ in range(500): + loose._note_live_fetch(False) + check("a higher ceiling is honoured", loose._idle_live_interval() == 1800) + + print("\nintervals from config are clamped, never trusted raw") + check("a sane value is used", sports._clamp_seconds(120, 300) == 120) + check("absent falls back", sports._clamp_seconds(None, 300) == 300) + check("nonsense falls back", sports._clamp_seconds("soon", 300) == 300) + check("zero is clamped up", sports._clamp_seconds(0, 300) >= 5) + check("a week is clamped down", sports._clamp_seconds(604800, 300) <= 86400) + + print("\nthe old recency-based logic is gone") + src = (plugin_dir / "sports.py").read_text(encoding="utf-8") + check("has_recently_checked no longer decides the interval", + "has_recently_checked" not in src) + + tree = ast.parse(src) + live_cls = next(c for c in ast.walk(tree) + if isinstance(c, ast.ClassDef) and c.name == "SportsLive") + upd = next(m for m in live_cls.body + if isinstance(m, ast.FunctionDef) and m.name == "update") + idle_calls = [n for n in ast.walk(upd) if isinstance(n, ast.Call) + and getattr(n.func, "attr", None) == "_idle_live_interval"] + note_calls = [n for n in ast.walk(upd) if isinstance(n, ast.Call) + and getattr(n.func, "attr", None) == "_note_live_fetch"] + check("update() takes its idle interval from the back-off", len(idle_calls) == 1) + check("update() records each look's outcome", len(note_calls) == 1) + + print("\n%s" % ("FAILED: %d" % len(failures) if failures + else "All checks passed")) + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/test_schedule_window_plumbing.py b/scripts/test_schedule_window_plumbing.py index f4543bd0..954b35d3 100644 --- a/scripts/test_schedule_window_plumbing.py +++ b/scripts/test_schedule_window_plumbing.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 """ -A schedule-window setting the user saves has to survive the config adapter. +A plugin-root setting the user saves has to survive the config adapter. Every scoreboard plugin hands its managers a config built by _adapt_config_for_manager, and each of those adapters constructs its output key @@ -38,7 +38,13 @@ if not _CORE: print("SKIP: no LEDMatrix core checkout found (set LEDMATRIX_CORE)") sys.exit(2) -WINDOW_KEYS = ("schedule_lookback_days", "schedule_lookahead_days") +# Every plugin-root setting SportsCore reads. They share one failure mode: the +# adapter builds its output key by key, so a key it does not name is dropped and +# the user silently keeps the default. +WINDOW_KEYS = ( + "schedule_lookback_days", "schedule_lookahead_days", + "no_data_interval_seconds", "live_idle_max_interval_seconds", +) # (plugin directory, a league key that plugin's adapter understands) PLUGINS = [ @@ -106,6 +112,8 @@ def __getattr__(self, name): "enabled": True, "schedule_lookback_days": 30, "schedule_lookahead_days": 21, + "no_data_interval_seconds": 1800, + "live_idle_max_interval_seconds": 7200, league: {"enabled": True, "display_modes": {"live": True}}, } adapter = cls._adapt_config_for_manager From b239dcb7ec92c4c19cbf94e1157100b498b2d247 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 15:17:14 -0400 Subject: [PATCH 2/2] fix(sports): read the idle-poll settings from where they are written Review follow-up on the back-off change. Five findings, four real. The settings were read from self.mode_config -- the per-league {sport_key}_scoreboard block -- while the schema declares them, and the web UI writes them, at the config root. Nothing ever wrote them where the code looked, so a user who changed either value silently kept the default and the whole setting was inert. The plumbing added alongside it carried the keys to the root, and the test asserted they arrived there, but neither established that the root was where they were read from: the wrong invariant, verified thoroughly. Now read from the root with mode_config kept as a fallback, so a hand-placed per-league value still works. Also: - _clamp_seconds now catches OverflowError, the gap _clamp_window in the same file already covered. json accepts bare Infinity by default and int(inf) raises, so a hand-edited config could take down the update loop. - soccer's custom leagues go through a second whitelist adapter that was left out of the forwarding, so these settings applied to every built-in league but silently not to custom ones. Same defect class, second instance. - the back-off test built a stand-in object and set the intervals on it by hand, so it could not see the config lookup at all -- which is exactly how the defect above survived it. It now constructs a real SportsLive from a real config dict and asserts on what __init__ resolved. Only SportsCore.__init__ is stubbed (it pulls in logo downloading, fonts and an ESPN data source); the lookup lines themselves run for real, and the stub's two assumptions are asserted against the real SportsCore AST so they cannot drift unnoticed. That guard earned itself immediately: ufc-scoreboard keys its league block on the bare sport_key rather than {sport_key}_scoreboard, so a hardcoded key made the test assert fiction there. The key is now read out of the source instead. Verified by mutation, per plugin: reverting the read to mode_config fails 2 checks, letting a stale per-league value shadow the root fails 1, dropping OverflowError raises, removing the custom-league forwarding fails 4, and either stub assumption breaking fails its own check. The two pre-existing baseball failures (test_config_reload, test_odds_placement) are byte-identical on main and untouched here. --- plugins/afl-scoreboard/sports.py | 16 +- .../test_idle_league_backoff.py | 180 ++++++++++++++++-- plugins/baseball-scoreboard/sports.py | 16 +- .../test_idle_league_backoff.py | 180 ++++++++++++++++-- plugins/basketball-scoreboard/sports.py | 16 +- .../test_idle_league_backoff.py | 180 ++++++++++++++++-- plugins/football-scoreboard/sports.py | 16 +- .../test_idle_league_backoff.py | 180 ++++++++++++++++-- plugins/hockey-scoreboard/sports.py | 16 +- .../test_idle_league_backoff.py | 180 ++++++++++++++++-- plugins/lacrosse-scoreboard/sports.py | 16 +- .../test_idle_league_backoff.py | 180 ++++++++++++++++-- plugins/nrl-scoreboard/sports.py | 16 +- .../test_idle_league_backoff.py | 180 ++++++++++++++++-- plugins/soccer-scoreboard/manager.py | 8 + plugins/soccer-scoreboard/sports.py | 16 +- .../test_idle_league_backoff.py | 180 ++++++++++++++++-- plugins/ufc-scoreboard/sports.py | 16 +- .../test_idle_league_backoff.py | 180 ++++++++++++++++-- scripts/test_schedule_window_plumbing.py | 28 +++ 20 files changed, 1674 insertions(+), 126 deletions(-) diff --git a/plugins/afl-scoreboard/sports.py b/plugins/afl-scoreboard/sports.py index 59aa7709..02b11344 100644 --- a/plugins/afl-scoreboard/sports.py +++ b/plugins/afl-scoreboard/sports.py @@ -116,7 +116,9 @@ def _clamp_seconds(value: Any, fallback: int, low: int = 5, """An interval in seconds, or the fallback when the value is unusable.""" try: seconds = int(value) - except (TypeError, ValueError): + 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)) @@ -2513,10 +2515,18 @@ def __init__( ): super().__init__(config, display_manager, cache_manager, logger, sport_key) self.update_interval = self.mode_config.get("live_update_interval", 15) + # Read from the config root, where the schema declares them and the web + # UI writes them -- not from mode_config, which is the per-league block + # ({sport}_scoreboard) and never carries these keys. Looking them up + # there meant the saved value was invisible and every user silently kept + # the default. mode_config is still consulted as a fallback so a + # hand-placed per-league value keeps working. self.no_data_interval = _clamp_seconds( - self.mode_config.get("no_data_interval_seconds"), 300) + self.config.get("no_data_interval_seconds", + self.mode_config.get("no_data_interval_seconds")), 300) self.live_idle_max_interval = _clamp_seconds( - self.mode_config.get("live_idle_max_interval_seconds"), + self.config.get("live_idle_max_interval_seconds", + self.mode_config.get("live_idle_max_interval_seconds")), _DEFAULT_LIVE_IDLE_MAX_SECONDS) self._empty_live_streak = 0 # Log the configured interval for debugging diff --git a/plugins/afl-scoreboard/test_idle_league_backoff.py b/plugins/afl-scoreboard/test_idle_league_backoff.py index 21d196ca..5bfa154c 100644 --- a/plugins/afl-scoreboard/test_idle_league_backoff.py +++ b/plugins/afl-scoreboard/test_idle_league_backoff.py @@ -19,12 +19,26 @@ The interval now comes from an explicit streak of empty looks, which escalates and is capped, and which any live game resets. -The methods are exercised against a stand-in ``self``, so the test needs no -display hardware, no network and no cache. +Second regression under test: the two interval settings were read from +``self.mode_config`` -- the per-league ``{sport_key}_scoreboard`` block -- while +the schema declares them (and the web UI writes them) at the config *root*. The +saved value was therefore never seen and every user silently kept the default. +An earlier version of this test could not catch that, because it set +``no_data_interval`` on a stand-in object by hand and so never ran the lookup. +It now builds a real ``SportsLive`` and asserts on what its ``__init__`` +resolved. + +Only ``SportsCore.__init__`` is stubbed -- it pulls in logo downloading, fonts +and an ESPN data source, none of which this test needs -- so the lookup lines +themselves run for real. The stub's fidelity is asserted against the real +``SportsCore`` source below, so it cannot drift out of sync unnoticed. Run: /bin/python plugins/afl-scoreboard/test_idle_league_backoff.py """ +# A test harness: it reaches into protected members on purpose and builds a +# concrete subclass at runtime, neither of which pylint can see as intentional. +# pylint: disable=protected-access,abstract-class-instantiated,unused-argument import ast import sys from pathlib import Path @@ -53,19 +67,163 @@ def info(self, *a, **k): pass -class _Live: - _idle_live_interval = sports.SportsLive._idle_live_interval - _note_live_fetch = sports.SportsLive._note_live_fetch +SPORT_KEY = "test" + + +def _core_init(): + core_src = (plugin_dir / "sports.py").read_text(encoding="utf-8") + core = next(c for c in ast.walk(ast.parse(core_src)) + if isinstance(c, ast.ClassDef) and c.name == "SportsCore") + return core_src, next(m for m in core.body + if isinstance(m, ast.FunctionDef) and m.name == "__init__") + + +def _derive_mode_key(): + """Build the per-league key exactly as this plugin's SportsCore does. + + Not hardcoded: most plugins use f"{sport_key}_scoreboard" but ufc-scoreboard + uses the bare sport_key. Hardcoding one of them made the stub test fiction + on the other -- which the fidelity check below caught. Reading the real + expression out of the source keeps every plugin honest. + """ + _, init = _core_init() + for node in ast.walk(init): + if (isinstance(node, ast.Assign) + and any(getattr(t, "attr", None) == "mode_config" + for t in node.targets) + and isinstance(node.value, ast.Call) and node.value.args): + arg = node.value.args[0] + return _resolve_key(arg), ast.unparse(arg) + raise AssertionError("SportsCore no longer assigns self.mode_config") + + +def _resolve_key(node): + """Evaluate the key expression by hand -- deliberately not with eval(). + + Only the two shapes that actually occur are supported; anything else is a + hard error rather than a guess, so a future rewrite of that line surfaces + here instead of silently producing a key nothing is stored under. + """ + if isinstance(node, ast.Constant): + return node.value + if isinstance(node, ast.Name) and node.id == "sport_key": + return SPORT_KEY + if isinstance(node, ast.JoinedStr): + return "".join(_resolve_key(part) for part in node.values) + if isinstance(node, ast.FormattedValue): + return _resolve_key(node.value) + raise AssertionError( + "unsupported mode_config key expression: %s" % ast.unparse(node)) + + +MODE_KEY, _MODE_KEY_EXPR = _derive_mode_key() + + +def _assert_stub_is_faithful(): + """The stub below stands in for SportsCore.__init__; prove it still matches. + + It has to reproduce exactly the two assignments the lookup under test + depends on. If SportsCore ever changes where config or mode_config come + from, this fails loudly rather than letting the stub quietly test fiction. + """ + _, init = _core_init() + assigns = {} + for node in ast.walk(init): + if isinstance(node, ast.Assign): + for target in node.targets: + if getattr(target, "attr", None) in ("config", "mode_config"): + assigns[target.attr] = node.value + + # The stub is only faithful if SportsCore still (a) keeps the handed-in + # config verbatim as self.config -- if it ever stored a sub-dict, "root" + # would mean something different -- and (b) derives mode_config as a + # lookup *within* that same config. The key itself is read from source + # above, so it needs no assertion; these two shapes do. + raw = assigns.get("config") + check("SportsCore still keeps the handed-in config verbatim as self.config", + isinstance(raw, ast.Name) and raw.id == "config") + + mode = assigns.get("mode_config") + check("mode_config is still a lookup inside that same config", + isinstance(mode, ast.Call) + and isinstance(mode.func, ast.Attribute) + and mode.func.attr == "get" + and isinstance(mode.func.value, ast.Name) + and mode.func.value.id == "config") - def __init__(self, base=300, ceiling=900): - self.no_data_interval = base - self.live_idle_max_interval = ceiling - self._empty_live_streak = 0 - self.logger = _Logger() + +class _StubCore: + """Exactly the two SportsCore assignments the lookup under test reads.""" + # pylint: disable=too-few-public-methods + + def __init__(self, config, display_manager, cache_manager, logger, sport_key): + self.logger = logger + self.config = config + self.mode_config = config.get(MODE_KEY, {}) or {} + + +# SportsLive may carry abstract methods from SportsCore; fill them in so it can +# be constructed. They are never called here. +_Concrete = type("_ConcreteLive", (sports.SportsLive,), + {name: (lambda self, *a, **k: None) + for name in getattr(sports.SportsLive, + "__abstractmethods__", ())}) + + +def _live(root=None, league=None): + """A real SportsLive built from a real config dict. + + root/league place the settings at the config root and in the per-league + block respectively, which is the whole point: the test can now tell those + two locations apart. + """ + config = dict(root or {}) + config[MODE_KEY] = dict(league or {}) + real_init = sports.SportsCore.__init__ + sports.SportsCore.__init__ = _StubCore.__init__ + try: + return _Concrete(config, object(), object(), _Logger(), SPORT_KEY) + finally: + sports.SportsCore.__init__ = real_init + + +def _Live(base=300, ceiling=900): + """The old signature, now backed by a real config round-trip.""" + return _live(root={"no_data_interval_seconds": base, + "live_idle_max_interval_seconds": ceiling}) def main(): - print("the wait grows the longer nothing is found") + print("the settings are read from where they are actually written") + _assert_stub_is_faithful() + + at_root = _live(root={"no_data_interval_seconds": 600, + "live_idle_max_interval_seconds": 1200}) + check("a root value is honoured (the schema declares them at the root)", + at_root.no_data_interval == 600) + check("...including the ceiling", at_root.live_idle_max_interval == 1200) + + defaults = _live() + check("absent anywhere falls back to the default", + defaults.no_data_interval == 300) + + legacy = _live(league={"no_data_interval_seconds": 600}) + check("a hand-placed per-league value still works", + legacy.no_data_interval == 600) + + both = _live(root={"no_data_interval_seconds": 600}, + league={"no_data_interval_seconds": 45}) + check("the root wins over a stale per-league value", + both.no_data_interval == 600) + + junk = _live(root={"no_data_interval_seconds": "soon", + "live_idle_max_interval_seconds": float("inf")}) + check("garbage at the root falls back rather than raising", + junk.no_data_interval == 300) + check("an infinite ceiling falls back too", + junk.live_idle_max_interval == sports._DEFAULT_LIVE_IDLE_MAX_SECONDS) + + print("\nthe wait grows the longer nothing is found") live = _Live() check("first look uses the base interval", live._idle_live_interval() == 300) diff --git a/plugins/baseball-scoreboard/sports.py b/plugins/baseball-scoreboard/sports.py index d9df0f03..3ab8b86f 100644 --- a/plugins/baseball-scoreboard/sports.py +++ b/plugins/baseball-scoreboard/sports.py @@ -126,7 +126,9 @@ def _clamp_seconds(value: Any, fallback: int, low: int = 5, """An interval in seconds, or the fallback when the value is unusable.""" try: seconds = int(value) - except (TypeError, ValueError): + 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)) @@ -2370,10 +2372,18 @@ def __init__( ): super().__init__(config, display_manager, cache_manager, logger, sport_key) self.update_interval = self.mode_config.get("live_update_interval", 15) + # Read from the config root, where the schema declares them and the web + # UI writes them -- not from mode_config, which is the per-league block + # ({sport}_scoreboard) and never carries these keys. Looking them up + # there meant the saved value was invisible and every user silently kept + # the default. mode_config is still consulted as a fallback so a + # hand-placed per-league value keeps working. self.no_data_interval = _clamp_seconds( - self.mode_config.get("no_data_interval_seconds"), 300) + self.config.get("no_data_interval_seconds", + self.mode_config.get("no_data_interval_seconds")), 300) self.live_idle_max_interval = _clamp_seconds( - self.mode_config.get("live_idle_max_interval_seconds"), + self.config.get("live_idle_max_interval_seconds", + self.mode_config.get("live_idle_max_interval_seconds")), _DEFAULT_LIVE_IDLE_MAX_SECONDS) self._empty_live_streak = 0 # Log the configured interval for debugging diff --git a/plugins/baseball-scoreboard/test_idle_league_backoff.py b/plugins/baseball-scoreboard/test_idle_league_backoff.py index 8e9db47c..fe2e8ee9 100644 --- a/plugins/baseball-scoreboard/test_idle_league_backoff.py +++ b/plugins/baseball-scoreboard/test_idle_league_backoff.py @@ -19,12 +19,26 @@ The interval now comes from an explicit streak of empty looks, which escalates and is capped, and which any live game resets. -The methods are exercised against a stand-in ``self``, so the test needs no -display hardware, no network and no cache. +Second regression under test: the two interval settings were read from +``self.mode_config`` -- the per-league ``{sport_key}_scoreboard`` block -- while +the schema declares them (and the web UI writes them) at the config *root*. The +saved value was therefore never seen and every user silently kept the default. +An earlier version of this test could not catch that, because it set +``no_data_interval`` on a stand-in object by hand and so never ran the lookup. +It now builds a real ``SportsLive`` and asserts on what its ``__init__`` +resolved. + +Only ``SportsCore.__init__`` is stubbed -- it pulls in logo downloading, fonts +and an ESPN data source, none of which this test needs -- so the lookup lines +themselves run for real. The stub's fidelity is asserted against the real +``SportsCore`` source below, so it cannot drift out of sync unnoticed. Run: /bin/python plugins/baseball-scoreboard/test_idle_league_backoff.py """ +# A test harness: it reaches into protected members on purpose and builds a +# concrete subclass at runtime, neither of which pylint can see as intentional. +# pylint: disable=protected-access,abstract-class-instantiated,unused-argument import ast import sys from pathlib import Path @@ -53,19 +67,163 @@ def info(self, *a, **k): pass -class _Live: - _idle_live_interval = sports.SportsLive._idle_live_interval - _note_live_fetch = sports.SportsLive._note_live_fetch +SPORT_KEY = "test" + + +def _core_init(): + core_src = (plugin_dir / "sports.py").read_text(encoding="utf-8") + core = next(c for c in ast.walk(ast.parse(core_src)) + if isinstance(c, ast.ClassDef) and c.name == "SportsCore") + return core_src, next(m for m in core.body + if isinstance(m, ast.FunctionDef) and m.name == "__init__") + + +def _derive_mode_key(): + """Build the per-league key exactly as this plugin's SportsCore does. + + Not hardcoded: most plugins use f"{sport_key}_scoreboard" but ufc-scoreboard + uses the bare sport_key. Hardcoding one of them made the stub test fiction + on the other -- which the fidelity check below caught. Reading the real + expression out of the source keeps every plugin honest. + """ + _, init = _core_init() + for node in ast.walk(init): + if (isinstance(node, ast.Assign) + and any(getattr(t, "attr", None) == "mode_config" + for t in node.targets) + and isinstance(node.value, ast.Call) and node.value.args): + arg = node.value.args[0] + return _resolve_key(arg), ast.unparse(arg) + raise AssertionError("SportsCore no longer assigns self.mode_config") + + +def _resolve_key(node): + """Evaluate the key expression by hand -- deliberately not with eval(). + + Only the two shapes that actually occur are supported; anything else is a + hard error rather than a guess, so a future rewrite of that line surfaces + here instead of silently producing a key nothing is stored under. + """ + if isinstance(node, ast.Constant): + return node.value + if isinstance(node, ast.Name) and node.id == "sport_key": + return SPORT_KEY + if isinstance(node, ast.JoinedStr): + return "".join(_resolve_key(part) for part in node.values) + if isinstance(node, ast.FormattedValue): + return _resolve_key(node.value) + raise AssertionError( + "unsupported mode_config key expression: %s" % ast.unparse(node)) + + +MODE_KEY, _MODE_KEY_EXPR = _derive_mode_key() + + +def _assert_stub_is_faithful(): + """The stub below stands in for SportsCore.__init__; prove it still matches. + + It has to reproduce exactly the two assignments the lookup under test + depends on. If SportsCore ever changes where config or mode_config come + from, this fails loudly rather than letting the stub quietly test fiction. + """ + _, init = _core_init() + assigns = {} + for node in ast.walk(init): + if isinstance(node, ast.Assign): + for target in node.targets: + if getattr(target, "attr", None) in ("config", "mode_config"): + assigns[target.attr] = node.value + + # The stub is only faithful if SportsCore still (a) keeps the handed-in + # config verbatim as self.config -- if it ever stored a sub-dict, "root" + # would mean something different -- and (b) derives mode_config as a + # lookup *within* that same config. The key itself is read from source + # above, so it needs no assertion; these two shapes do. + raw = assigns.get("config") + check("SportsCore still keeps the handed-in config verbatim as self.config", + isinstance(raw, ast.Name) and raw.id == "config") + + mode = assigns.get("mode_config") + check("mode_config is still a lookup inside that same config", + isinstance(mode, ast.Call) + and isinstance(mode.func, ast.Attribute) + and mode.func.attr == "get" + and isinstance(mode.func.value, ast.Name) + and mode.func.value.id == "config") - def __init__(self, base=300, ceiling=900): - self.no_data_interval = base - self.live_idle_max_interval = ceiling - self._empty_live_streak = 0 - self.logger = _Logger() + +class _StubCore: + """Exactly the two SportsCore assignments the lookup under test reads.""" + # pylint: disable=too-few-public-methods + + def __init__(self, config, display_manager, cache_manager, logger, sport_key): + self.logger = logger + self.config = config + self.mode_config = config.get(MODE_KEY, {}) or {} + + +# SportsLive may carry abstract methods from SportsCore; fill them in so it can +# be constructed. They are never called here. +_Concrete = type("_ConcreteLive", (sports.SportsLive,), + {name: (lambda self, *a, **k: None) + for name in getattr(sports.SportsLive, + "__abstractmethods__", ())}) + + +def _live(root=None, league=None): + """A real SportsLive built from a real config dict. + + root/league place the settings at the config root and in the per-league + block respectively, which is the whole point: the test can now tell those + two locations apart. + """ + config = dict(root or {}) + config[MODE_KEY] = dict(league or {}) + real_init = sports.SportsCore.__init__ + sports.SportsCore.__init__ = _StubCore.__init__ + try: + return _Concrete(config, object(), object(), _Logger(), SPORT_KEY) + finally: + sports.SportsCore.__init__ = real_init + + +def _Live(base=300, ceiling=900): + """The old signature, now backed by a real config round-trip.""" + return _live(root={"no_data_interval_seconds": base, + "live_idle_max_interval_seconds": ceiling}) def main(): - print("the wait grows the longer nothing is found") + print("the settings are read from where they are actually written") + _assert_stub_is_faithful() + + at_root = _live(root={"no_data_interval_seconds": 600, + "live_idle_max_interval_seconds": 1200}) + check("a root value is honoured (the schema declares them at the root)", + at_root.no_data_interval == 600) + check("...including the ceiling", at_root.live_idle_max_interval == 1200) + + defaults = _live() + check("absent anywhere falls back to the default", + defaults.no_data_interval == 300) + + legacy = _live(league={"no_data_interval_seconds": 600}) + check("a hand-placed per-league value still works", + legacy.no_data_interval == 600) + + both = _live(root={"no_data_interval_seconds": 600}, + league={"no_data_interval_seconds": 45}) + check("the root wins over a stale per-league value", + both.no_data_interval == 600) + + junk = _live(root={"no_data_interval_seconds": "soon", + "live_idle_max_interval_seconds": float("inf")}) + check("garbage at the root falls back rather than raising", + junk.no_data_interval == 300) + check("an infinite ceiling falls back too", + junk.live_idle_max_interval == sports._DEFAULT_LIVE_IDLE_MAX_SECONDS) + + print("\nthe wait grows the longer nothing is found") live = _Live() check("first look uses the base interval", live._idle_live_interval() == 300) diff --git a/plugins/basketball-scoreboard/sports.py b/plugins/basketball-scoreboard/sports.py index f32e3d96..d96059d0 100644 --- a/plugins/basketball-scoreboard/sports.py +++ b/plugins/basketball-scoreboard/sports.py @@ -117,7 +117,9 @@ def _clamp_seconds(value: Any, fallback: int, low: int = 5, """An interval in seconds, or the fallback when the value is unusable.""" try: seconds = int(value) - except (TypeError, ValueError): + 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)) @@ -2604,10 +2606,18 @@ def __init__( ): super().__init__(config, display_manager, cache_manager, logger, sport_key) self.update_interval = self.mode_config.get("live_update_interval", 15) + # Read from the config root, where the schema declares them and the web + # UI writes them -- not from mode_config, which is the per-league block + # ({sport}_scoreboard) and never carries these keys. Looking them up + # there meant the saved value was invisible and every user silently kept + # the default. mode_config is still consulted as a fallback so a + # hand-placed per-league value keeps working. self.no_data_interval = _clamp_seconds( - self.mode_config.get("no_data_interval_seconds"), 300) + self.config.get("no_data_interval_seconds", + self.mode_config.get("no_data_interval_seconds")), 300) self.live_idle_max_interval = _clamp_seconds( - self.mode_config.get("live_idle_max_interval_seconds"), + self.config.get("live_idle_max_interval_seconds", + self.mode_config.get("live_idle_max_interval_seconds")), _DEFAULT_LIVE_IDLE_MAX_SECONDS) self._empty_live_streak = 0 # Log the configured interval for debugging diff --git a/plugins/basketball-scoreboard/test_idle_league_backoff.py b/plugins/basketball-scoreboard/test_idle_league_backoff.py index 3bfad8a8..57e9a0ce 100644 --- a/plugins/basketball-scoreboard/test_idle_league_backoff.py +++ b/plugins/basketball-scoreboard/test_idle_league_backoff.py @@ -19,12 +19,26 @@ The interval now comes from an explicit streak of empty looks, which escalates and is capped, and which any live game resets. -The methods are exercised against a stand-in ``self``, so the test needs no -display hardware, no network and no cache. +Second regression under test: the two interval settings were read from +``self.mode_config`` -- the per-league ``{sport_key}_scoreboard`` block -- while +the schema declares them (and the web UI writes them) at the config *root*. The +saved value was therefore never seen and every user silently kept the default. +An earlier version of this test could not catch that, because it set +``no_data_interval`` on a stand-in object by hand and so never ran the lookup. +It now builds a real ``SportsLive`` and asserts on what its ``__init__`` +resolved. + +Only ``SportsCore.__init__`` is stubbed -- it pulls in logo downloading, fonts +and an ESPN data source, none of which this test needs -- so the lookup lines +themselves run for real. The stub's fidelity is asserted against the real +``SportsCore`` source below, so it cannot drift out of sync unnoticed. Run: /bin/python plugins/basketball-scoreboard/test_idle_league_backoff.py """ +# A test harness: it reaches into protected members on purpose and builds a +# concrete subclass at runtime, neither of which pylint can see as intentional. +# pylint: disable=protected-access,abstract-class-instantiated,unused-argument import ast import sys from pathlib import Path @@ -53,19 +67,163 @@ def info(self, *a, **k): pass -class _Live: - _idle_live_interval = sports.SportsLive._idle_live_interval - _note_live_fetch = sports.SportsLive._note_live_fetch +SPORT_KEY = "test" + + +def _core_init(): + core_src = (plugin_dir / "sports.py").read_text(encoding="utf-8") + core = next(c for c in ast.walk(ast.parse(core_src)) + if isinstance(c, ast.ClassDef) and c.name == "SportsCore") + return core_src, next(m for m in core.body + if isinstance(m, ast.FunctionDef) and m.name == "__init__") + + +def _derive_mode_key(): + """Build the per-league key exactly as this plugin's SportsCore does. + + Not hardcoded: most plugins use f"{sport_key}_scoreboard" but ufc-scoreboard + uses the bare sport_key. Hardcoding one of them made the stub test fiction + on the other -- which the fidelity check below caught. Reading the real + expression out of the source keeps every plugin honest. + """ + _, init = _core_init() + for node in ast.walk(init): + if (isinstance(node, ast.Assign) + and any(getattr(t, "attr", None) == "mode_config" + for t in node.targets) + and isinstance(node.value, ast.Call) and node.value.args): + arg = node.value.args[0] + return _resolve_key(arg), ast.unparse(arg) + raise AssertionError("SportsCore no longer assigns self.mode_config") + + +def _resolve_key(node): + """Evaluate the key expression by hand -- deliberately not with eval(). + + Only the two shapes that actually occur are supported; anything else is a + hard error rather than a guess, so a future rewrite of that line surfaces + here instead of silently producing a key nothing is stored under. + """ + if isinstance(node, ast.Constant): + return node.value + if isinstance(node, ast.Name) and node.id == "sport_key": + return SPORT_KEY + if isinstance(node, ast.JoinedStr): + return "".join(_resolve_key(part) for part in node.values) + if isinstance(node, ast.FormattedValue): + return _resolve_key(node.value) + raise AssertionError( + "unsupported mode_config key expression: %s" % ast.unparse(node)) + + +MODE_KEY, _MODE_KEY_EXPR = _derive_mode_key() + + +def _assert_stub_is_faithful(): + """The stub below stands in for SportsCore.__init__; prove it still matches. + + It has to reproduce exactly the two assignments the lookup under test + depends on. If SportsCore ever changes where config or mode_config come + from, this fails loudly rather than letting the stub quietly test fiction. + """ + _, init = _core_init() + assigns = {} + for node in ast.walk(init): + if isinstance(node, ast.Assign): + for target in node.targets: + if getattr(target, "attr", None) in ("config", "mode_config"): + assigns[target.attr] = node.value + + # The stub is only faithful if SportsCore still (a) keeps the handed-in + # config verbatim as self.config -- if it ever stored a sub-dict, "root" + # would mean something different -- and (b) derives mode_config as a + # lookup *within* that same config. The key itself is read from source + # above, so it needs no assertion; these two shapes do. + raw = assigns.get("config") + check("SportsCore still keeps the handed-in config verbatim as self.config", + isinstance(raw, ast.Name) and raw.id == "config") + + mode = assigns.get("mode_config") + check("mode_config is still a lookup inside that same config", + isinstance(mode, ast.Call) + and isinstance(mode.func, ast.Attribute) + and mode.func.attr == "get" + and isinstance(mode.func.value, ast.Name) + and mode.func.value.id == "config") - def __init__(self, base=300, ceiling=900): - self.no_data_interval = base - self.live_idle_max_interval = ceiling - self._empty_live_streak = 0 - self.logger = _Logger() + +class _StubCore: + """Exactly the two SportsCore assignments the lookup under test reads.""" + # pylint: disable=too-few-public-methods + + def __init__(self, config, display_manager, cache_manager, logger, sport_key): + self.logger = logger + self.config = config + self.mode_config = config.get(MODE_KEY, {}) or {} + + +# SportsLive may carry abstract methods from SportsCore; fill them in so it can +# be constructed. They are never called here. +_Concrete = type("_ConcreteLive", (sports.SportsLive,), + {name: (lambda self, *a, **k: None) + for name in getattr(sports.SportsLive, + "__abstractmethods__", ())}) + + +def _live(root=None, league=None): + """A real SportsLive built from a real config dict. + + root/league place the settings at the config root and in the per-league + block respectively, which is the whole point: the test can now tell those + two locations apart. + """ + config = dict(root or {}) + config[MODE_KEY] = dict(league or {}) + real_init = sports.SportsCore.__init__ + sports.SportsCore.__init__ = _StubCore.__init__ + try: + return _Concrete(config, object(), object(), _Logger(), SPORT_KEY) + finally: + sports.SportsCore.__init__ = real_init + + +def _Live(base=300, ceiling=900): + """The old signature, now backed by a real config round-trip.""" + return _live(root={"no_data_interval_seconds": base, + "live_idle_max_interval_seconds": ceiling}) def main(): - print("the wait grows the longer nothing is found") + print("the settings are read from where they are actually written") + _assert_stub_is_faithful() + + at_root = _live(root={"no_data_interval_seconds": 600, + "live_idle_max_interval_seconds": 1200}) + check("a root value is honoured (the schema declares them at the root)", + at_root.no_data_interval == 600) + check("...including the ceiling", at_root.live_idle_max_interval == 1200) + + defaults = _live() + check("absent anywhere falls back to the default", + defaults.no_data_interval == 300) + + legacy = _live(league={"no_data_interval_seconds": 600}) + check("a hand-placed per-league value still works", + legacy.no_data_interval == 600) + + both = _live(root={"no_data_interval_seconds": 600}, + league={"no_data_interval_seconds": 45}) + check("the root wins over a stale per-league value", + both.no_data_interval == 600) + + junk = _live(root={"no_data_interval_seconds": "soon", + "live_idle_max_interval_seconds": float("inf")}) + check("garbage at the root falls back rather than raising", + junk.no_data_interval == 300) + check("an infinite ceiling falls back too", + junk.live_idle_max_interval == sports._DEFAULT_LIVE_IDLE_MAX_SECONDS) + + print("\nthe wait grows the longer nothing is found") live = _Live() check("first look uses the base interval", live._idle_live_interval() == 300) diff --git a/plugins/football-scoreboard/sports.py b/plugins/football-scoreboard/sports.py index 15e82dc7..34135c78 100644 --- a/plugins/football-scoreboard/sports.py +++ b/plugins/football-scoreboard/sports.py @@ -119,7 +119,9 @@ def _clamp_seconds(value: Any, fallback: int, low: int = 5, """An interval in seconds, or the fallback when the value is unusable.""" try: seconds = int(value) - except (TypeError, ValueError): + 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)) @@ -2342,10 +2344,18 @@ def __init__( ): super().__init__(config, display_manager, cache_manager, logger, sport_key) self.update_interval = self.mode_config.get("live_update_interval", 15) + # Read from the config root, where the schema declares them and the web + # UI writes them -- not from mode_config, which is the per-league block + # ({sport}_scoreboard) and never carries these keys. Looking them up + # there meant the saved value was invisible and every user silently kept + # the default. mode_config is still consulted as a fallback so a + # hand-placed per-league value keeps working. self.no_data_interval = _clamp_seconds( - self.mode_config.get("no_data_interval_seconds"), 300) + self.config.get("no_data_interval_seconds", + self.mode_config.get("no_data_interval_seconds")), 300) self.live_idle_max_interval = _clamp_seconds( - self.mode_config.get("live_idle_max_interval_seconds"), + self.config.get("live_idle_max_interval_seconds", + self.mode_config.get("live_idle_max_interval_seconds")), _DEFAULT_LIVE_IDLE_MAX_SECONDS) self._empty_live_streak = 0 # Log the configured interval for debugging diff --git a/plugins/football-scoreboard/test_idle_league_backoff.py b/plugins/football-scoreboard/test_idle_league_backoff.py index 541baa76..e50a512a 100644 --- a/plugins/football-scoreboard/test_idle_league_backoff.py +++ b/plugins/football-scoreboard/test_idle_league_backoff.py @@ -19,12 +19,26 @@ The interval now comes from an explicit streak of empty looks, which escalates and is capped, and which any live game resets. -The methods are exercised against a stand-in ``self``, so the test needs no -display hardware, no network and no cache. +Second regression under test: the two interval settings were read from +``self.mode_config`` -- the per-league ``{sport_key}_scoreboard`` block -- while +the schema declares them (and the web UI writes them) at the config *root*. The +saved value was therefore never seen and every user silently kept the default. +An earlier version of this test could not catch that, because it set +``no_data_interval`` on a stand-in object by hand and so never ran the lookup. +It now builds a real ``SportsLive`` and asserts on what its ``__init__`` +resolved. + +Only ``SportsCore.__init__`` is stubbed -- it pulls in logo downloading, fonts +and an ESPN data source, none of which this test needs -- so the lookup lines +themselves run for real. The stub's fidelity is asserted against the real +``SportsCore`` source below, so it cannot drift out of sync unnoticed. Run: /bin/python plugins/football-scoreboard/test_idle_league_backoff.py """ +# A test harness: it reaches into protected members on purpose and builds a +# concrete subclass at runtime, neither of which pylint can see as intentional. +# pylint: disable=protected-access,abstract-class-instantiated,unused-argument import ast import sys from pathlib import Path @@ -53,19 +67,163 @@ def info(self, *a, **k): pass -class _Live: - _idle_live_interval = sports.SportsLive._idle_live_interval - _note_live_fetch = sports.SportsLive._note_live_fetch +SPORT_KEY = "test" + + +def _core_init(): + core_src = (plugin_dir / "sports.py").read_text(encoding="utf-8") + core = next(c for c in ast.walk(ast.parse(core_src)) + if isinstance(c, ast.ClassDef) and c.name == "SportsCore") + return core_src, next(m for m in core.body + if isinstance(m, ast.FunctionDef) and m.name == "__init__") + + +def _derive_mode_key(): + """Build the per-league key exactly as this plugin's SportsCore does. + + Not hardcoded: most plugins use f"{sport_key}_scoreboard" but ufc-scoreboard + uses the bare sport_key. Hardcoding one of them made the stub test fiction + on the other -- which the fidelity check below caught. Reading the real + expression out of the source keeps every plugin honest. + """ + _, init = _core_init() + for node in ast.walk(init): + if (isinstance(node, ast.Assign) + and any(getattr(t, "attr", None) == "mode_config" + for t in node.targets) + and isinstance(node.value, ast.Call) and node.value.args): + arg = node.value.args[0] + return _resolve_key(arg), ast.unparse(arg) + raise AssertionError("SportsCore no longer assigns self.mode_config") + + +def _resolve_key(node): + """Evaluate the key expression by hand -- deliberately not with eval(). + + Only the two shapes that actually occur are supported; anything else is a + hard error rather than a guess, so a future rewrite of that line surfaces + here instead of silently producing a key nothing is stored under. + """ + if isinstance(node, ast.Constant): + return node.value + if isinstance(node, ast.Name) and node.id == "sport_key": + return SPORT_KEY + if isinstance(node, ast.JoinedStr): + return "".join(_resolve_key(part) for part in node.values) + if isinstance(node, ast.FormattedValue): + return _resolve_key(node.value) + raise AssertionError( + "unsupported mode_config key expression: %s" % ast.unparse(node)) + + +MODE_KEY, _MODE_KEY_EXPR = _derive_mode_key() + + +def _assert_stub_is_faithful(): + """The stub below stands in for SportsCore.__init__; prove it still matches. + + It has to reproduce exactly the two assignments the lookup under test + depends on. If SportsCore ever changes where config or mode_config come + from, this fails loudly rather than letting the stub quietly test fiction. + """ + _, init = _core_init() + assigns = {} + for node in ast.walk(init): + if isinstance(node, ast.Assign): + for target in node.targets: + if getattr(target, "attr", None) in ("config", "mode_config"): + assigns[target.attr] = node.value + + # The stub is only faithful if SportsCore still (a) keeps the handed-in + # config verbatim as self.config -- if it ever stored a sub-dict, "root" + # would mean something different -- and (b) derives mode_config as a + # lookup *within* that same config. The key itself is read from source + # above, so it needs no assertion; these two shapes do. + raw = assigns.get("config") + check("SportsCore still keeps the handed-in config verbatim as self.config", + isinstance(raw, ast.Name) and raw.id == "config") + + mode = assigns.get("mode_config") + check("mode_config is still a lookup inside that same config", + isinstance(mode, ast.Call) + and isinstance(mode.func, ast.Attribute) + and mode.func.attr == "get" + and isinstance(mode.func.value, ast.Name) + and mode.func.value.id == "config") - def __init__(self, base=300, ceiling=900): - self.no_data_interval = base - self.live_idle_max_interval = ceiling - self._empty_live_streak = 0 - self.logger = _Logger() + +class _StubCore: + """Exactly the two SportsCore assignments the lookup under test reads.""" + # pylint: disable=too-few-public-methods + + def __init__(self, config, display_manager, cache_manager, logger, sport_key): + self.logger = logger + self.config = config + self.mode_config = config.get(MODE_KEY, {}) or {} + + +# SportsLive may carry abstract methods from SportsCore; fill them in so it can +# be constructed. They are never called here. +_Concrete = type("_ConcreteLive", (sports.SportsLive,), + {name: (lambda self, *a, **k: None) + for name in getattr(sports.SportsLive, + "__abstractmethods__", ())}) + + +def _live(root=None, league=None): + """A real SportsLive built from a real config dict. + + root/league place the settings at the config root and in the per-league + block respectively, which is the whole point: the test can now tell those + two locations apart. + """ + config = dict(root or {}) + config[MODE_KEY] = dict(league or {}) + real_init = sports.SportsCore.__init__ + sports.SportsCore.__init__ = _StubCore.__init__ + try: + return _Concrete(config, object(), object(), _Logger(), SPORT_KEY) + finally: + sports.SportsCore.__init__ = real_init + + +def _Live(base=300, ceiling=900): + """The old signature, now backed by a real config round-trip.""" + return _live(root={"no_data_interval_seconds": base, + "live_idle_max_interval_seconds": ceiling}) def main(): - print("the wait grows the longer nothing is found") + print("the settings are read from where they are actually written") + _assert_stub_is_faithful() + + at_root = _live(root={"no_data_interval_seconds": 600, + "live_idle_max_interval_seconds": 1200}) + check("a root value is honoured (the schema declares them at the root)", + at_root.no_data_interval == 600) + check("...including the ceiling", at_root.live_idle_max_interval == 1200) + + defaults = _live() + check("absent anywhere falls back to the default", + defaults.no_data_interval == 300) + + legacy = _live(league={"no_data_interval_seconds": 600}) + check("a hand-placed per-league value still works", + legacy.no_data_interval == 600) + + both = _live(root={"no_data_interval_seconds": 600}, + league={"no_data_interval_seconds": 45}) + check("the root wins over a stale per-league value", + both.no_data_interval == 600) + + junk = _live(root={"no_data_interval_seconds": "soon", + "live_idle_max_interval_seconds": float("inf")}) + check("garbage at the root falls back rather than raising", + junk.no_data_interval == 300) + check("an infinite ceiling falls back too", + junk.live_idle_max_interval == sports._DEFAULT_LIVE_IDLE_MAX_SECONDS) + + print("\nthe wait grows the longer nothing is found") live = _Live() check("first look uses the base interval", live._idle_live_interval() == 300) diff --git a/plugins/hockey-scoreboard/sports.py b/plugins/hockey-scoreboard/sports.py index be2380b8..dd4c8457 100644 --- a/plugins/hockey-scoreboard/sports.py +++ b/plugins/hockey-scoreboard/sports.py @@ -113,7 +113,9 @@ def _clamp_seconds(value: Any, fallback: int, low: int = 5, """An interval in seconds, or the fallback when the value is unusable.""" try: seconds = int(value) - except (TypeError, ValueError): + 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)) @@ -2179,10 +2181,18 @@ def __init__( ): super().__init__(config, display_manager, cache_manager, logger, sport_key) self.update_interval = self.mode_config.get("live_update_interval", 15) + # Read from the config root, where the schema declares them and the web + # UI writes them -- not from mode_config, which is the per-league block + # ({sport}_scoreboard) and never carries these keys. Looking them up + # there meant the saved value was invisible and every user silently kept + # the default. mode_config is still consulted as a fallback so a + # hand-placed per-league value keeps working. self.no_data_interval = _clamp_seconds( - self.mode_config.get("no_data_interval_seconds"), 300) + self.config.get("no_data_interval_seconds", + self.mode_config.get("no_data_interval_seconds")), 300) self.live_idle_max_interval = _clamp_seconds( - self.mode_config.get("live_idle_max_interval_seconds"), + self.config.get("live_idle_max_interval_seconds", + self.mode_config.get("live_idle_max_interval_seconds")), _DEFAULT_LIVE_IDLE_MAX_SECONDS) self._empty_live_streak = 0 # Log the configured interval for debugging diff --git a/plugins/hockey-scoreboard/test_idle_league_backoff.py b/plugins/hockey-scoreboard/test_idle_league_backoff.py index ff299bb0..ffe3bc2f 100644 --- a/plugins/hockey-scoreboard/test_idle_league_backoff.py +++ b/plugins/hockey-scoreboard/test_idle_league_backoff.py @@ -19,12 +19,26 @@ The interval now comes from an explicit streak of empty looks, which escalates and is capped, and which any live game resets. -The methods are exercised against a stand-in ``self``, so the test needs no -display hardware, no network and no cache. +Second regression under test: the two interval settings were read from +``self.mode_config`` -- the per-league ``{sport_key}_scoreboard`` block -- while +the schema declares them (and the web UI writes them) at the config *root*. The +saved value was therefore never seen and every user silently kept the default. +An earlier version of this test could not catch that, because it set +``no_data_interval`` on a stand-in object by hand and so never ran the lookup. +It now builds a real ``SportsLive`` and asserts on what its ``__init__`` +resolved. + +Only ``SportsCore.__init__`` is stubbed -- it pulls in logo downloading, fonts +and an ESPN data source, none of which this test needs -- so the lookup lines +themselves run for real. The stub's fidelity is asserted against the real +``SportsCore`` source below, so it cannot drift out of sync unnoticed. Run: /bin/python plugins/hockey-scoreboard/test_idle_league_backoff.py """ +# A test harness: it reaches into protected members on purpose and builds a +# concrete subclass at runtime, neither of which pylint can see as intentional. +# pylint: disable=protected-access,abstract-class-instantiated,unused-argument import ast import sys from pathlib import Path @@ -53,19 +67,163 @@ def info(self, *a, **k): pass -class _Live: - _idle_live_interval = sports.SportsLive._idle_live_interval - _note_live_fetch = sports.SportsLive._note_live_fetch +SPORT_KEY = "test" + + +def _core_init(): + core_src = (plugin_dir / "sports.py").read_text(encoding="utf-8") + core = next(c for c in ast.walk(ast.parse(core_src)) + if isinstance(c, ast.ClassDef) and c.name == "SportsCore") + return core_src, next(m for m in core.body + if isinstance(m, ast.FunctionDef) and m.name == "__init__") + + +def _derive_mode_key(): + """Build the per-league key exactly as this plugin's SportsCore does. + + Not hardcoded: most plugins use f"{sport_key}_scoreboard" but ufc-scoreboard + uses the bare sport_key. Hardcoding one of them made the stub test fiction + on the other -- which the fidelity check below caught. Reading the real + expression out of the source keeps every plugin honest. + """ + _, init = _core_init() + for node in ast.walk(init): + if (isinstance(node, ast.Assign) + and any(getattr(t, "attr", None) == "mode_config" + for t in node.targets) + and isinstance(node.value, ast.Call) and node.value.args): + arg = node.value.args[0] + return _resolve_key(arg), ast.unparse(arg) + raise AssertionError("SportsCore no longer assigns self.mode_config") + + +def _resolve_key(node): + """Evaluate the key expression by hand -- deliberately not with eval(). + + Only the two shapes that actually occur are supported; anything else is a + hard error rather than a guess, so a future rewrite of that line surfaces + here instead of silently producing a key nothing is stored under. + """ + if isinstance(node, ast.Constant): + return node.value + if isinstance(node, ast.Name) and node.id == "sport_key": + return SPORT_KEY + if isinstance(node, ast.JoinedStr): + return "".join(_resolve_key(part) for part in node.values) + if isinstance(node, ast.FormattedValue): + return _resolve_key(node.value) + raise AssertionError( + "unsupported mode_config key expression: %s" % ast.unparse(node)) + + +MODE_KEY, _MODE_KEY_EXPR = _derive_mode_key() + + +def _assert_stub_is_faithful(): + """The stub below stands in for SportsCore.__init__; prove it still matches. + + It has to reproduce exactly the two assignments the lookup under test + depends on. If SportsCore ever changes where config or mode_config come + from, this fails loudly rather than letting the stub quietly test fiction. + """ + _, init = _core_init() + assigns = {} + for node in ast.walk(init): + if isinstance(node, ast.Assign): + for target in node.targets: + if getattr(target, "attr", None) in ("config", "mode_config"): + assigns[target.attr] = node.value + + # The stub is only faithful if SportsCore still (a) keeps the handed-in + # config verbatim as self.config -- if it ever stored a sub-dict, "root" + # would mean something different -- and (b) derives mode_config as a + # lookup *within* that same config. The key itself is read from source + # above, so it needs no assertion; these two shapes do. + raw = assigns.get("config") + check("SportsCore still keeps the handed-in config verbatim as self.config", + isinstance(raw, ast.Name) and raw.id == "config") + + mode = assigns.get("mode_config") + check("mode_config is still a lookup inside that same config", + isinstance(mode, ast.Call) + and isinstance(mode.func, ast.Attribute) + and mode.func.attr == "get" + and isinstance(mode.func.value, ast.Name) + and mode.func.value.id == "config") - def __init__(self, base=300, ceiling=900): - self.no_data_interval = base - self.live_idle_max_interval = ceiling - self._empty_live_streak = 0 - self.logger = _Logger() + +class _StubCore: + """Exactly the two SportsCore assignments the lookup under test reads.""" + # pylint: disable=too-few-public-methods + + def __init__(self, config, display_manager, cache_manager, logger, sport_key): + self.logger = logger + self.config = config + self.mode_config = config.get(MODE_KEY, {}) or {} + + +# SportsLive may carry abstract methods from SportsCore; fill them in so it can +# be constructed. They are never called here. +_Concrete = type("_ConcreteLive", (sports.SportsLive,), + {name: (lambda self, *a, **k: None) + for name in getattr(sports.SportsLive, + "__abstractmethods__", ())}) + + +def _live(root=None, league=None): + """A real SportsLive built from a real config dict. + + root/league place the settings at the config root and in the per-league + block respectively, which is the whole point: the test can now tell those + two locations apart. + """ + config = dict(root or {}) + config[MODE_KEY] = dict(league or {}) + real_init = sports.SportsCore.__init__ + sports.SportsCore.__init__ = _StubCore.__init__ + try: + return _Concrete(config, object(), object(), _Logger(), SPORT_KEY) + finally: + sports.SportsCore.__init__ = real_init + + +def _Live(base=300, ceiling=900): + """The old signature, now backed by a real config round-trip.""" + return _live(root={"no_data_interval_seconds": base, + "live_idle_max_interval_seconds": ceiling}) def main(): - print("the wait grows the longer nothing is found") + print("the settings are read from where they are actually written") + _assert_stub_is_faithful() + + at_root = _live(root={"no_data_interval_seconds": 600, + "live_idle_max_interval_seconds": 1200}) + check("a root value is honoured (the schema declares them at the root)", + at_root.no_data_interval == 600) + check("...including the ceiling", at_root.live_idle_max_interval == 1200) + + defaults = _live() + check("absent anywhere falls back to the default", + defaults.no_data_interval == 300) + + legacy = _live(league={"no_data_interval_seconds": 600}) + check("a hand-placed per-league value still works", + legacy.no_data_interval == 600) + + both = _live(root={"no_data_interval_seconds": 600}, + league={"no_data_interval_seconds": 45}) + check("the root wins over a stale per-league value", + both.no_data_interval == 600) + + junk = _live(root={"no_data_interval_seconds": "soon", + "live_idle_max_interval_seconds": float("inf")}) + check("garbage at the root falls back rather than raising", + junk.no_data_interval == 300) + check("an infinite ceiling falls back too", + junk.live_idle_max_interval == sports._DEFAULT_LIVE_IDLE_MAX_SECONDS) + + print("\nthe wait grows the longer nothing is found") live = _Live() check("first look uses the base interval", live._idle_live_interval() == 300) diff --git a/plugins/lacrosse-scoreboard/sports.py b/plugins/lacrosse-scoreboard/sports.py index 8fdec87d..a1a6d422 100644 --- a/plugins/lacrosse-scoreboard/sports.py +++ b/plugins/lacrosse-scoreboard/sports.py @@ -113,7 +113,9 @@ def _clamp_seconds(value: Any, fallback: int, low: int = 5, """An interval in seconds, or the fallback when the value is unusable.""" try: seconds = int(value) - except (TypeError, ValueError): + 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)) @@ -2202,10 +2204,18 @@ def __init__( super().__init__(config, display_manager, cache_manager, logger, sport_key) self.update_interval = self.mode_config.get("live_update_interval", 15) self._rotation_schedule: List[str] = [] + # Read from the config root, where the schema declares them and the web + # UI writes them -- not from mode_config, which is the per-league block + # ({sport}_scoreboard) and never carries these keys. Looking them up + # there meant the saved value was invisible and every user silently kept + # the default. mode_config is still consulted as a fallback so a + # hand-placed per-league value keeps working. self.no_data_interval = _clamp_seconds( - self.mode_config.get("no_data_interval_seconds"), 300) + self.config.get("no_data_interval_seconds", + self.mode_config.get("no_data_interval_seconds")), 300) self.live_idle_max_interval = _clamp_seconds( - self.mode_config.get("live_idle_max_interval_seconds"), + self.config.get("live_idle_max_interval_seconds", + self.mode_config.get("live_idle_max_interval_seconds")), _DEFAULT_LIVE_IDLE_MAX_SECONDS) self._empty_live_streak = 0 # Log the configured interval for debugging diff --git a/plugins/lacrosse-scoreboard/test_idle_league_backoff.py b/plugins/lacrosse-scoreboard/test_idle_league_backoff.py index ff5ccba9..2a1be762 100644 --- a/plugins/lacrosse-scoreboard/test_idle_league_backoff.py +++ b/plugins/lacrosse-scoreboard/test_idle_league_backoff.py @@ -19,12 +19,26 @@ The interval now comes from an explicit streak of empty looks, which escalates and is capped, and which any live game resets. -The methods are exercised against a stand-in ``self``, so the test needs no -display hardware, no network and no cache. +Second regression under test: the two interval settings were read from +``self.mode_config`` -- the per-league ``{sport_key}_scoreboard`` block -- while +the schema declares them (and the web UI writes them) at the config *root*. The +saved value was therefore never seen and every user silently kept the default. +An earlier version of this test could not catch that, because it set +``no_data_interval`` on a stand-in object by hand and so never ran the lookup. +It now builds a real ``SportsLive`` and asserts on what its ``__init__`` +resolved. + +Only ``SportsCore.__init__`` is stubbed -- it pulls in logo downloading, fonts +and an ESPN data source, none of which this test needs -- so the lookup lines +themselves run for real. The stub's fidelity is asserted against the real +``SportsCore`` source below, so it cannot drift out of sync unnoticed. Run: /bin/python plugins/lacrosse-scoreboard/test_idle_league_backoff.py """ +# A test harness: it reaches into protected members on purpose and builds a +# concrete subclass at runtime, neither of which pylint can see as intentional. +# pylint: disable=protected-access,abstract-class-instantiated,unused-argument import ast import sys from pathlib import Path @@ -53,19 +67,163 @@ def info(self, *a, **k): pass -class _Live: - _idle_live_interval = sports.SportsLive._idle_live_interval - _note_live_fetch = sports.SportsLive._note_live_fetch +SPORT_KEY = "test" + + +def _core_init(): + core_src = (plugin_dir / "sports.py").read_text(encoding="utf-8") + core = next(c for c in ast.walk(ast.parse(core_src)) + if isinstance(c, ast.ClassDef) and c.name == "SportsCore") + return core_src, next(m for m in core.body + if isinstance(m, ast.FunctionDef) and m.name == "__init__") + + +def _derive_mode_key(): + """Build the per-league key exactly as this plugin's SportsCore does. + + Not hardcoded: most plugins use f"{sport_key}_scoreboard" but ufc-scoreboard + uses the bare sport_key. Hardcoding one of them made the stub test fiction + on the other -- which the fidelity check below caught. Reading the real + expression out of the source keeps every plugin honest. + """ + _, init = _core_init() + for node in ast.walk(init): + if (isinstance(node, ast.Assign) + and any(getattr(t, "attr", None) == "mode_config" + for t in node.targets) + and isinstance(node.value, ast.Call) and node.value.args): + arg = node.value.args[0] + return _resolve_key(arg), ast.unparse(arg) + raise AssertionError("SportsCore no longer assigns self.mode_config") + + +def _resolve_key(node): + """Evaluate the key expression by hand -- deliberately not with eval(). + + Only the two shapes that actually occur are supported; anything else is a + hard error rather than a guess, so a future rewrite of that line surfaces + here instead of silently producing a key nothing is stored under. + """ + if isinstance(node, ast.Constant): + return node.value + if isinstance(node, ast.Name) and node.id == "sport_key": + return SPORT_KEY + if isinstance(node, ast.JoinedStr): + return "".join(_resolve_key(part) for part in node.values) + if isinstance(node, ast.FormattedValue): + return _resolve_key(node.value) + raise AssertionError( + "unsupported mode_config key expression: %s" % ast.unparse(node)) + + +MODE_KEY, _MODE_KEY_EXPR = _derive_mode_key() + + +def _assert_stub_is_faithful(): + """The stub below stands in for SportsCore.__init__; prove it still matches. + + It has to reproduce exactly the two assignments the lookup under test + depends on. If SportsCore ever changes where config or mode_config come + from, this fails loudly rather than letting the stub quietly test fiction. + """ + _, init = _core_init() + assigns = {} + for node in ast.walk(init): + if isinstance(node, ast.Assign): + for target in node.targets: + if getattr(target, "attr", None) in ("config", "mode_config"): + assigns[target.attr] = node.value + + # The stub is only faithful if SportsCore still (a) keeps the handed-in + # config verbatim as self.config -- if it ever stored a sub-dict, "root" + # would mean something different -- and (b) derives mode_config as a + # lookup *within* that same config. The key itself is read from source + # above, so it needs no assertion; these two shapes do. + raw = assigns.get("config") + check("SportsCore still keeps the handed-in config verbatim as self.config", + isinstance(raw, ast.Name) and raw.id == "config") + + mode = assigns.get("mode_config") + check("mode_config is still a lookup inside that same config", + isinstance(mode, ast.Call) + and isinstance(mode.func, ast.Attribute) + and mode.func.attr == "get" + and isinstance(mode.func.value, ast.Name) + and mode.func.value.id == "config") - def __init__(self, base=300, ceiling=900): - self.no_data_interval = base - self.live_idle_max_interval = ceiling - self._empty_live_streak = 0 - self.logger = _Logger() + +class _StubCore: + """Exactly the two SportsCore assignments the lookup under test reads.""" + # pylint: disable=too-few-public-methods + + def __init__(self, config, display_manager, cache_manager, logger, sport_key): + self.logger = logger + self.config = config + self.mode_config = config.get(MODE_KEY, {}) or {} + + +# SportsLive may carry abstract methods from SportsCore; fill them in so it can +# be constructed. They are never called here. +_Concrete = type("_ConcreteLive", (sports.SportsLive,), + {name: (lambda self, *a, **k: None) + for name in getattr(sports.SportsLive, + "__abstractmethods__", ())}) + + +def _live(root=None, league=None): + """A real SportsLive built from a real config dict. + + root/league place the settings at the config root and in the per-league + block respectively, which is the whole point: the test can now tell those + two locations apart. + """ + config = dict(root or {}) + config[MODE_KEY] = dict(league or {}) + real_init = sports.SportsCore.__init__ + sports.SportsCore.__init__ = _StubCore.__init__ + try: + return _Concrete(config, object(), object(), _Logger(), SPORT_KEY) + finally: + sports.SportsCore.__init__ = real_init + + +def _Live(base=300, ceiling=900): + """The old signature, now backed by a real config round-trip.""" + return _live(root={"no_data_interval_seconds": base, + "live_idle_max_interval_seconds": ceiling}) def main(): - print("the wait grows the longer nothing is found") + print("the settings are read from where they are actually written") + _assert_stub_is_faithful() + + at_root = _live(root={"no_data_interval_seconds": 600, + "live_idle_max_interval_seconds": 1200}) + check("a root value is honoured (the schema declares them at the root)", + at_root.no_data_interval == 600) + check("...including the ceiling", at_root.live_idle_max_interval == 1200) + + defaults = _live() + check("absent anywhere falls back to the default", + defaults.no_data_interval == 300) + + legacy = _live(league={"no_data_interval_seconds": 600}) + check("a hand-placed per-league value still works", + legacy.no_data_interval == 600) + + both = _live(root={"no_data_interval_seconds": 600}, + league={"no_data_interval_seconds": 45}) + check("the root wins over a stale per-league value", + both.no_data_interval == 600) + + junk = _live(root={"no_data_interval_seconds": "soon", + "live_idle_max_interval_seconds": float("inf")}) + check("garbage at the root falls back rather than raising", + junk.no_data_interval == 300) + check("an infinite ceiling falls back too", + junk.live_idle_max_interval == sports._DEFAULT_LIVE_IDLE_MAX_SECONDS) + + print("\nthe wait grows the longer nothing is found") live = _Live() check("first look uses the base interval", live._idle_live_interval() == 300) diff --git a/plugins/nrl-scoreboard/sports.py b/plugins/nrl-scoreboard/sports.py index e7352931..b62d7c39 100644 --- a/plugins/nrl-scoreboard/sports.py +++ b/plugins/nrl-scoreboard/sports.py @@ -116,7 +116,9 @@ def _clamp_seconds(value: Any, fallback: int, low: int = 5, """An interval in seconds, or the fallback when the value is unusable.""" try: seconds = int(value) - except (TypeError, ValueError): + 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)) @@ -2526,10 +2528,18 @@ def __init__( ): super().__init__(config, display_manager, cache_manager, logger, sport_key) self.update_interval = self.mode_config.get("live_update_interval", 15) + # Read from the config root, where the schema declares them and the web + # UI writes them -- not from mode_config, which is the per-league block + # ({sport}_scoreboard) and never carries these keys. Looking them up + # there meant the saved value was invisible and every user silently kept + # the default. mode_config is still consulted as a fallback so a + # hand-placed per-league value keeps working. self.no_data_interval = _clamp_seconds( - self.mode_config.get("no_data_interval_seconds"), 300) + self.config.get("no_data_interval_seconds", + self.mode_config.get("no_data_interval_seconds")), 300) self.live_idle_max_interval = _clamp_seconds( - self.mode_config.get("live_idle_max_interval_seconds"), + self.config.get("live_idle_max_interval_seconds", + self.mode_config.get("live_idle_max_interval_seconds")), _DEFAULT_LIVE_IDLE_MAX_SECONDS) self._empty_live_streak = 0 # Log the configured interval for debugging diff --git a/plugins/nrl-scoreboard/test_idle_league_backoff.py b/plugins/nrl-scoreboard/test_idle_league_backoff.py index 655e8731..d881bb92 100644 --- a/plugins/nrl-scoreboard/test_idle_league_backoff.py +++ b/plugins/nrl-scoreboard/test_idle_league_backoff.py @@ -19,12 +19,26 @@ The interval now comes from an explicit streak of empty looks, which escalates and is capped, and which any live game resets. -The methods are exercised against a stand-in ``self``, so the test needs no -display hardware, no network and no cache. +Second regression under test: the two interval settings were read from +``self.mode_config`` -- the per-league ``{sport_key}_scoreboard`` block -- while +the schema declares them (and the web UI writes them) at the config *root*. The +saved value was therefore never seen and every user silently kept the default. +An earlier version of this test could not catch that, because it set +``no_data_interval`` on a stand-in object by hand and so never ran the lookup. +It now builds a real ``SportsLive`` and asserts on what its ``__init__`` +resolved. + +Only ``SportsCore.__init__`` is stubbed -- it pulls in logo downloading, fonts +and an ESPN data source, none of which this test needs -- so the lookup lines +themselves run for real. The stub's fidelity is asserted against the real +``SportsCore`` source below, so it cannot drift out of sync unnoticed. Run: /bin/python plugins/nrl-scoreboard/test_idle_league_backoff.py """ +# A test harness: it reaches into protected members on purpose and builds a +# concrete subclass at runtime, neither of which pylint can see as intentional. +# pylint: disable=protected-access,abstract-class-instantiated,unused-argument import ast import sys from pathlib import Path @@ -53,19 +67,163 @@ def info(self, *a, **k): pass -class _Live: - _idle_live_interval = sports.SportsLive._idle_live_interval - _note_live_fetch = sports.SportsLive._note_live_fetch +SPORT_KEY = "test" + + +def _core_init(): + core_src = (plugin_dir / "sports.py").read_text(encoding="utf-8") + core = next(c for c in ast.walk(ast.parse(core_src)) + if isinstance(c, ast.ClassDef) and c.name == "SportsCore") + return core_src, next(m for m in core.body + if isinstance(m, ast.FunctionDef) and m.name == "__init__") + + +def _derive_mode_key(): + """Build the per-league key exactly as this plugin's SportsCore does. + + Not hardcoded: most plugins use f"{sport_key}_scoreboard" but ufc-scoreboard + uses the bare sport_key. Hardcoding one of them made the stub test fiction + on the other -- which the fidelity check below caught. Reading the real + expression out of the source keeps every plugin honest. + """ + _, init = _core_init() + for node in ast.walk(init): + if (isinstance(node, ast.Assign) + and any(getattr(t, "attr", None) == "mode_config" + for t in node.targets) + and isinstance(node.value, ast.Call) and node.value.args): + arg = node.value.args[0] + return _resolve_key(arg), ast.unparse(arg) + raise AssertionError("SportsCore no longer assigns self.mode_config") + + +def _resolve_key(node): + """Evaluate the key expression by hand -- deliberately not with eval(). + + Only the two shapes that actually occur are supported; anything else is a + hard error rather than a guess, so a future rewrite of that line surfaces + here instead of silently producing a key nothing is stored under. + """ + if isinstance(node, ast.Constant): + return node.value + if isinstance(node, ast.Name) and node.id == "sport_key": + return SPORT_KEY + if isinstance(node, ast.JoinedStr): + return "".join(_resolve_key(part) for part in node.values) + if isinstance(node, ast.FormattedValue): + return _resolve_key(node.value) + raise AssertionError( + "unsupported mode_config key expression: %s" % ast.unparse(node)) + + +MODE_KEY, _MODE_KEY_EXPR = _derive_mode_key() + + +def _assert_stub_is_faithful(): + """The stub below stands in for SportsCore.__init__; prove it still matches. + + It has to reproduce exactly the two assignments the lookup under test + depends on. If SportsCore ever changes where config or mode_config come + from, this fails loudly rather than letting the stub quietly test fiction. + """ + _, init = _core_init() + assigns = {} + for node in ast.walk(init): + if isinstance(node, ast.Assign): + for target in node.targets: + if getattr(target, "attr", None) in ("config", "mode_config"): + assigns[target.attr] = node.value + + # The stub is only faithful if SportsCore still (a) keeps the handed-in + # config verbatim as self.config -- if it ever stored a sub-dict, "root" + # would mean something different -- and (b) derives mode_config as a + # lookup *within* that same config. The key itself is read from source + # above, so it needs no assertion; these two shapes do. + raw = assigns.get("config") + check("SportsCore still keeps the handed-in config verbatim as self.config", + isinstance(raw, ast.Name) and raw.id == "config") + + mode = assigns.get("mode_config") + check("mode_config is still a lookup inside that same config", + isinstance(mode, ast.Call) + and isinstance(mode.func, ast.Attribute) + and mode.func.attr == "get" + and isinstance(mode.func.value, ast.Name) + and mode.func.value.id == "config") - def __init__(self, base=300, ceiling=900): - self.no_data_interval = base - self.live_idle_max_interval = ceiling - self._empty_live_streak = 0 - self.logger = _Logger() + +class _StubCore: + """Exactly the two SportsCore assignments the lookup under test reads.""" + # pylint: disable=too-few-public-methods + + def __init__(self, config, display_manager, cache_manager, logger, sport_key): + self.logger = logger + self.config = config + self.mode_config = config.get(MODE_KEY, {}) or {} + + +# SportsLive may carry abstract methods from SportsCore; fill them in so it can +# be constructed. They are never called here. +_Concrete = type("_ConcreteLive", (sports.SportsLive,), + {name: (lambda self, *a, **k: None) + for name in getattr(sports.SportsLive, + "__abstractmethods__", ())}) + + +def _live(root=None, league=None): + """A real SportsLive built from a real config dict. + + root/league place the settings at the config root and in the per-league + block respectively, which is the whole point: the test can now tell those + two locations apart. + """ + config = dict(root or {}) + config[MODE_KEY] = dict(league or {}) + real_init = sports.SportsCore.__init__ + sports.SportsCore.__init__ = _StubCore.__init__ + try: + return _Concrete(config, object(), object(), _Logger(), SPORT_KEY) + finally: + sports.SportsCore.__init__ = real_init + + +def _Live(base=300, ceiling=900): + """The old signature, now backed by a real config round-trip.""" + return _live(root={"no_data_interval_seconds": base, + "live_idle_max_interval_seconds": ceiling}) def main(): - print("the wait grows the longer nothing is found") + print("the settings are read from where they are actually written") + _assert_stub_is_faithful() + + at_root = _live(root={"no_data_interval_seconds": 600, + "live_idle_max_interval_seconds": 1200}) + check("a root value is honoured (the schema declares them at the root)", + at_root.no_data_interval == 600) + check("...including the ceiling", at_root.live_idle_max_interval == 1200) + + defaults = _live() + check("absent anywhere falls back to the default", + defaults.no_data_interval == 300) + + legacy = _live(league={"no_data_interval_seconds": 600}) + check("a hand-placed per-league value still works", + legacy.no_data_interval == 600) + + both = _live(root={"no_data_interval_seconds": 600}, + league={"no_data_interval_seconds": 45}) + check("the root wins over a stale per-league value", + both.no_data_interval == 600) + + junk = _live(root={"no_data_interval_seconds": "soon", + "live_idle_max_interval_seconds": float("inf")}) + check("garbage at the root falls back rather than raising", + junk.no_data_interval == 300) + check("an infinite ceiling falls back too", + junk.live_idle_max_interval == sports._DEFAULT_LIVE_IDLE_MAX_SECONDS) + + print("\nthe wait grows the longer nothing is found") live = _Live() check("first look uses the base interval", live._idle_live_interval() == 300) diff --git a/plugins/soccer-scoreboard/manager.py b/plugins/soccer-scoreboard/manager.py index 3541fb09..9aaed1d8 100644 --- a/plugins/soccer-scoreboard/manager.py +++ b/plugins/soccer-scoreboard/manager.py @@ -780,6 +780,14 @@ def _adapt_config_for_custom_league(self, custom_league: Dict[str, Any]) -> Dict "customization": customization_config, }) + # Custom leagues go through their own whitelist adapter, so they need + # the same root-key forwarding the predefined leagues get above -- + # otherwise a user's schedule-window and idle-poll settings apply to + # every built-in league but silently not to their custom ones. + for _root_key in _ROOT_CONFIG_KEYS: + if _root_key in self.config: + manager_config[_root_key] = self.config[_root_key] + return manager_config def _initialize_league_registry(self) -> None: diff --git a/plugins/soccer-scoreboard/sports.py b/plugins/soccer-scoreboard/sports.py index 96bbe2e4..73e4c4b6 100644 --- a/plugins/soccer-scoreboard/sports.py +++ b/plugins/soccer-scoreboard/sports.py @@ -123,7 +123,9 @@ def _clamp_seconds(value: Any, fallback: int, low: int = 5, """An interval in seconds, or the fallback when the value is unusable.""" try: seconds = int(value) - except (TypeError, ValueError): + 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)) @@ -2535,10 +2537,18 @@ def __init__( ): super().__init__(config, display_manager, cache_manager, logger, sport_key) self.update_interval = self.mode_config.get("live_update_interval", 15) + # Read from the config root, where the schema declares them and the web + # UI writes them -- not from mode_config, which is the per-league block + # ({sport}_scoreboard) and never carries these keys. Looking them up + # there meant the saved value was invisible and every user silently kept + # the default. mode_config is still consulted as a fallback so a + # hand-placed per-league value keeps working. self.no_data_interval = _clamp_seconds( - self.mode_config.get("no_data_interval_seconds"), 300) + self.config.get("no_data_interval_seconds", + self.mode_config.get("no_data_interval_seconds")), 300) self.live_idle_max_interval = _clamp_seconds( - self.mode_config.get("live_idle_max_interval_seconds"), + self.config.get("live_idle_max_interval_seconds", + self.mode_config.get("live_idle_max_interval_seconds")), _DEFAULT_LIVE_IDLE_MAX_SECONDS) self._empty_live_streak = 0 # Log the configured interval for debugging diff --git a/plugins/soccer-scoreboard/test_idle_league_backoff.py b/plugins/soccer-scoreboard/test_idle_league_backoff.py index 2cc17fff..8efa0724 100644 --- a/plugins/soccer-scoreboard/test_idle_league_backoff.py +++ b/plugins/soccer-scoreboard/test_idle_league_backoff.py @@ -19,12 +19,26 @@ The interval now comes from an explicit streak of empty looks, which escalates and is capped, and which any live game resets. -The methods are exercised against a stand-in ``self``, so the test needs no -display hardware, no network and no cache. +Second regression under test: the two interval settings were read from +``self.mode_config`` -- the per-league ``{sport_key}_scoreboard`` block -- while +the schema declares them (and the web UI writes them) at the config *root*. The +saved value was therefore never seen and every user silently kept the default. +An earlier version of this test could not catch that, because it set +``no_data_interval`` on a stand-in object by hand and so never ran the lookup. +It now builds a real ``SportsLive`` and asserts on what its ``__init__`` +resolved. + +Only ``SportsCore.__init__`` is stubbed -- it pulls in logo downloading, fonts +and an ESPN data source, none of which this test needs -- so the lookup lines +themselves run for real. The stub's fidelity is asserted against the real +``SportsCore`` source below, so it cannot drift out of sync unnoticed. Run: /bin/python plugins/soccer-scoreboard/test_idle_league_backoff.py """ +# A test harness: it reaches into protected members on purpose and builds a +# concrete subclass at runtime, neither of which pylint can see as intentional. +# pylint: disable=protected-access,abstract-class-instantiated,unused-argument import ast import sys from pathlib import Path @@ -53,19 +67,163 @@ def info(self, *a, **k): pass -class _Live: - _idle_live_interval = sports.SportsLive._idle_live_interval - _note_live_fetch = sports.SportsLive._note_live_fetch +SPORT_KEY = "test" + + +def _core_init(): + core_src = (plugin_dir / "sports.py").read_text(encoding="utf-8") + core = next(c for c in ast.walk(ast.parse(core_src)) + if isinstance(c, ast.ClassDef) and c.name == "SportsCore") + return core_src, next(m for m in core.body + if isinstance(m, ast.FunctionDef) and m.name == "__init__") + + +def _derive_mode_key(): + """Build the per-league key exactly as this plugin's SportsCore does. + + Not hardcoded: most plugins use f"{sport_key}_scoreboard" but ufc-scoreboard + uses the bare sport_key. Hardcoding one of them made the stub test fiction + on the other -- which the fidelity check below caught. Reading the real + expression out of the source keeps every plugin honest. + """ + _, init = _core_init() + for node in ast.walk(init): + if (isinstance(node, ast.Assign) + and any(getattr(t, "attr", None) == "mode_config" + for t in node.targets) + and isinstance(node.value, ast.Call) and node.value.args): + arg = node.value.args[0] + return _resolve_key(arg), ast.unparse(arg) + raise AssertionError("SportsCore no longer assigns self.mode_config") + + +def _resolve_key(node): + """Evaluate the key expression by hand -- deliberately not with eval(). + + Only the two shapes that actually occur are supported; anything else is a + hard error rather than a guess, so a future rewrite of that line surfaces + here instead of silently producing a key nothing is stored under. + """ + if isinstance(node, ast.Constant): + return node.value + if isinstance(node, ast.Name) and node.id == "sport_key": + return SPORT_KEY + if isinstance(node, ast.JoinedStr): + return "".join(_resolve_key(part) for part in node.values) + if isinstance(node, ast.FormattedValue): + return _resolve_key(node.value) + raise AssertionError( + "unsupported mode_config key expression: %s" % ast.unparse(node)) + + +MODE_KEY, _MODE_KEY_EXPR = _derive_mode_key() + + +def _assert_stub_is_faithful(): + """The stub below stands in for SportsCore.__init__; prove it still matches. + + It has to reproduce exactly the two assignments the lookup under test + depends on. If SportsCore ever changes where config or mode_config come + from, this fails loudly rather than letting the stub quietly test fiction. + """ + _, init = _core_init() + assigns = {} + for node in ast.walk(init): + if isinstance(node, ast.Assign): + for target in node.targets: + if getattr(target, "attr", None) in ("config", "mode_config"): + assigns[target.attr] = node.value + + # The stub is only faithful if SportsCore still (a) keeps the handed-in + # config verbatim as self.config -- if it ever stored a sub-dict, "root" + # would mean something different -- and (b) derives mode_config as a + # lookup *within* that same config. The key itself is read from source + # above, so it needs no assertion; these two shapes do. + raw = assigns.get("config") + check("SportsCore still keeps the handed-in config verbatim as self.config", + isinstance(raw, ast.Name) and raw.id == "config") + + mode = assigns.get("mode_config") + check("mode_config is still a lookup inside that same config", + isinstance(mode, ast.Call) + and isinstance(mode.func, ast.Attribute) + and mode.func.attr == "get" + and isinstance(mode.func.value, ast.Name) + and mode.func.value.id == "config") - def __init__(self, base=300, ceiling=900): - self.no_data_interval = base - self.live_idle_max_interval = ceiling - self._empty_live_streak = 0 - self.logger = _Logger() + +class _StubCore: + """Exactly the two SportsCore assignments the lookup under test reads.""" + # pylint: disable=too-few-public-methods + + def __init__(self, config, display_manager, cache_manager, logger, sport_key): + self.logger = logger + self.config = config + self.mode_config = config.get(MODE_KEY, {}) or {} + + +# SportsLive may carry abstract methods from SportsCore; fill them in so it can +# be constructed. They are never called here. +_Concrete = type("_ConcreteLive", (sports.SportsLive,), + {name: (lambda self, *a, **k: None) + for name in getattr(sports.SportsLive, + "__abstractmethods__", ())}) + + +def _live(root=None, league=None): + """A real SportsLive built from a real config dict. + + root/league place the settings at the config root and in the per-league + block respectively, which is the whole point: the test can now tell those + two locations apart. + """ + config = dict(root or {}) + config[MODE_KEY] = dict(league or {}) + real_init = sports.SportsCore.__init__ + sports.SportsCore.__init__ = _StubCore.__init__ + try: + return _Concrete(config, object(), object(), _Logger(), SPORT_KEY) + finally: + sports.SportsCore.__init__ = real_init + + +def _Live(base=300, ceiling=900): + """The old signature, now backed by a real config round-trip.""" + return _live(root={"no_data_interval_seconds": base, + "live_idle_max_interval_seconds": ceiling}) def main(): - print("the wait grows the longer nothing is found") + print("the settings are read from where they are actually written") + _assert_stub_is_faithful() + + at_root = _live(root={"no_data_interval_seconds": 600, + "live_idle_max_interval_seconds": 1200}) + check("a root value is honoured (the schema declares them at the root)", + at_root.no_data_interval == 600) + check("...including the ceiling", at_root.live_idle_max_interval == 1200) + + defaults = _live() + check("absent anywhere falls back to the default", + defaults.no_data_interval == 300) + + legacy = _live(league={"no_data_interval_seconds": 600}) + check("a hand-placed per-league value still works", + legacy.no_data_interval == 600) + + both = _live(root={"no_data_interval_seconds": 600}, + league={"no_data_interval_seconds": 45}) + check("the root wins over a stale per-league value", + both.no_data_interval == 600) + + junk = _live(root={"no_data_interval_seconds": "soon", + "live_idle_max_interval_seconds": float("inf")}) + check("garbage at the root falls back rather than raising", + junk.no_data_interval == 300) + check("an infinite ceiling falls back too", + junk.live_idle_max_interval == sports._DEFAULT_LIVE_IDLE_MAX_SECONDS) + + print("\nthe wait grows the longer nothing is found") live = _Live() check("first look uses the base interval", live._idle_live_interval() == 300) diff --git a/plugins/ufc-scoreboard/sports.py b/plugins/ufc-scoreboard/sports.py index ce15646c..af55fee3 100644 --- a/plugins/ufc-scoreboard/sports.py +++ b/plugins/ufc-scoreboard/sports.py @@ -112,7 +112,9 @@ def _clamp_seconds(value: Any, fallback: int, low: int = 5, """An interval in seconds, or the fallback when the value is unusable.""" try: seconds = int(value) - except (TypeError, ValueError): + 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)) @@ -2157,10 +2159,18 @@ def __init__( ): super().__init__(config, display_manager, cache_manager, logger, sport_key) self.update_interval = self.mode_config.get("live_update_interval", 15) + # Read from the config root, where the schema declares them and the web + # UI writes them -- not from mode_config, which is the per-league block + # ({sport}_scoreboard) and never carries these keys. Looking them up + # there meant the saved value was invisible and every user silently kept + # the default. mode_config is still consulted as a fallback so a + # hand-placed per-league value keeps working. self.no_data_interval = _clamp_seconds( - self.mode_config.get("no_data_interval_seconds"), 300) + self.config.get("no_data_interval_seconds", + self.mode_config.get("no_data_interval_seconds")), 300) self.live_idle_max_interval = _clamp_seconds( - self.mode_config.get("live_idle_max_interval_seconds"), + self.config.get("live_idle_max_interval_seconds", + self.mode_config.get("live_idle_max_interval_seconds")), _DEFAULT_LIVE_IDLE_MAX_SECONDS) self._empty_live_streak = 0 # Log the configured interval for debugging diff --git a/plugins/ufc-scoreboard/test_idle_league_backoff.py b/plugins/ufc-scoreboard/test_idle_league_backoff.py index 7129697a..64780b56 100644 --- a/plugins/ufc-scoreboard/test_idle_league_backoff.py +++ b/plugins/ufc-scoreboard/test_idle_league_backoff.py @@ -19,12 +19,26 @@ The interval now comes from an explicit streak of empty looks, which escalates and is capped, and which any live game resets. -The methods are exercised against a stand-in ``self``, so the test needs no -display hardware, no network and no cache. +Second regression under test: the two interval settings were read from +``self.mode_config`` -- the per-league ``{sport_key}_scoreboard`` block -- while +the schema declares them (and the web UI writes them) at the config *root*. The +saved value was therefore never seen and every user silently kept the default. +An earlier version of this test could not catch that, because it set +``no_data_interval`` on a stand-in object by hand and so never ran the lookup. +It now builds a real ``SportsLive`` and asserts on what its ``__init__`` +resolved. + +Only ``SportsCore.__init__`` is stubbed -- it pulls in logo downloading, fonts +and an ESPN data source, none of which this test needs -- so the lookup lines +themselves run for real. The stub's fidelity is asserted against the real +``SportsCore`` source below, so it cannot drift out of sync unnoticed. Run: /bin/python plugins/ufc-scoreboard/test_idle_league_backoff.py """ +# A test harness: it reaches into protected members on purpose and builds a +# concrete subclass at runtime, neither of which pylint can see as intentional. +# pylint: disable=protected-access,abstract-class-instantiated,unused-argument import ast import sys from pathlib import Path @@ -53,19 +67,163 @@ def info(self, *a, **k): pass -class _Live: - _idle_live_interval = sports.SportsLive._idle_live_interval - _note_live_fetch = sports.SportsLive._note_live_fetch +SPORT_KEY = "test" + + +def _core_init(): + core_src = (plugin_dir / "sports.py").read_text(encoding="utf-8") + core = next(c for c in ast.walk(ast.parse(core_src)) + if isinstance(c, ast.ClassDef) and c.name == "SportsCore") + return core_src, next(m for m in core.body + if isinstance(m, ast.FunctionDef) and m.name == "__init__") + + +def _derive_mode_key(): + """Build the per-league key exactly as this plugin's SportsCore does. + + Not hardcoded: most plugins use f"{sport_key}_scoreboard" but ufc-scoreboard + uses the bare sport_key. Hardcoding one of them made the stub test fiction + on the other -- which the fidelity check below caught. Reading the real + expression out of the source keeps every plugin honest. + """ + _, init = _core_init() + for node in ast.walk(init): + if (isinstance(node, ast.Assign) + and any(getattr(t, "attr", None) == "mode_config" + for t in node.targets) + and isinstance(node.value, ast.Call) and node.value.args): + arg = node.value.args[0] + return _resolve_key(arg), ast.unparse(arg) + raise AssertionError("SportsCore no longer assigns self.mode_config") + + +def _resolve_key(node): + """Evaluate the key expression by hand -- deliberately not with eval(). + + Only the two shapes that actually occur are supported; anything else is a + hard error rather than a guess, so a future rewrite of that line surfaces + here instead of silently producing a key nothing is stored under. + """ + if isinstance(node, ast.Constant): + return node.value + if isinstance(node, ast.Name) and node.id == "sport_key": + return SPORT_KEY + if isinstance(node, ast.JoinedStr): + return "".join(_resolve_key(part) for part in node.values) + if isinstance(node, ast.FormattedValue): + return _resolve_key(node.value) + raise AssertionError( + "unsupported mode_config key expression: %s" % ast.unparse(node)) + + +MODE_KEY, _MODE_KEY_EXPR = _derive_mode_key() + + +def _assert_stub_is_faithful(): + """The stub below stands in for SportsCore.__init__; prove it still matches. + + It has to reproduce exactly the two assignments the lookup under test + depends on. If SportsCore ever changes where config or mode_config come + from, this fails loudly rather than letting the stub quietly test fiction. + """ + _, init = _core_init() + assigns = {} + for node in ast.walk(init): + if isinstance(node, ast.Assign): + for target in node.targets: + if getattr(target, "attr", None) in ("config", "mode_config"): + assigns[target.attr] = node.value + + # The stub is only faithful if SportsCore still (a) keeps the handed-in + # config verbatim as self.config -- if it ever stored a sub-dict, "root" + # would mean something different -- and (b) derives mode_config as a + # lookup *within* that same config. The key itself is read from source + # above, so it needs no assertion; these two shapes do. + raw = assigns.get("config") + check("SportsCore still keeps the handed-in config verbatim as self.config", + isinstance(raw, ast.Name) and raw.id == "config") + + mode = assigns.get("mode_config") + check("mode_config is still a lookup inside that same config", + isinstance(mode, ast.Call) + and isinstance(mode.func, ast.Attribute) + and mode.func.attr == "get" + and isinstance(mode.func.value, ast.Name) + and mode.func.value.id == "config") - def __init__(self, base=300, ceiling=900): - self.no_data_interval = base - self.live_idle_max_interval = ceiling - self._empty_live_streak = 0 - self.logger = _Logger() + +class _StubCore: + """Exactly the two SportsCore assignments the lookup under test reads.""" + # pylint: disable=too-few-public-methods + + def __init__(self, config, display_manager, cache_manager, logger, sport_key): + self.logger = logger + self.config = config + self.mode_config = config.get(MODE_KEY, {}) or {} + + +# SportsLive may carry abstract methods from SportsCore; fill them in so it can +# be constructed. They are never called here. +_Concrete = type("_ConcreteLive", (sports.SportsLive,), + {name: (lambda self, *a, **k: None) + for name in getattr(sports.SportsLive, + "__abstractmethods__", ())}) + + +def _live(root=None, league=None): + """A real SportsLive built from a real config dict. + + root/league place the settings at the config root and in the per-league + block respectively, which is the whole point: the test can now tell those + two locations apart. + """ + config = dict(root or {}) + config[MODE_KEY] = dict(league or {}) + real_init = sports.SportsCore.__init__ + sports.SportsCore.__init__ = _StubCore.__init__ + try: + return _Concrete(config, object(), object(), _Logger(), SPORT_KEY) + finally: + sports.SportsCore.__init__ = real_init + + +def _Live(base=300, ceiling=900): + """The old signature, now backed by a real config round-trip.""" + return _live(root={"no_data_interval_seconds": base, + "live_idle_max_interval_seconds": ceiling}) def main(): - print("the wait grows the longer nothing is found") + print("the settings are read from where they are actually written") + _assert_stub_is_faithful() + + at_root = _live(root={"no_data_interval_seconds": 600, + "live_idle_max_interval_seconds": 1200}) + check("a root value is honoured (the schema declares them at the root)", + at_root.no_data_interval == 600) + check("...including the ceiling", at_root.live_idle_max_interval == 1200) + + defaults = _live() + check("absent anywhere falls back to the default", + defaults.no_data_interval == 300) + + legacy = _live(league={"no_data_interval_seconds": 600}) + check("a hand-placed per-league value still works", + legacy.no_data_interval == 600) + + both = _live(root={"no_data_interval_seconds": 600}, + league={"no_data_interval_seconds": 45}) + check("the root wins over a stale per-league value", + both.no_data_interval == 600) + + junk = _live(root={"no_data_interval_seconds": "soon", + "live_idle_max_interval_seconds": float("inf")}) + check("garbage at the root falls back rather than raising", + junk.no_data_interval == 300) + check("an infinite ceiling falls back too", + junk.live_idle_max_interval == sports._DEFAULT_LIVE_IDLE_MAX_SECONDS) + + print("\nthe wait grows the longer nothing is found") live = _Live() check("first look uses the base interval", live._idle_live_interval() == 300) diff --git a/scripts/test_schedule_window_plumbing.py b/scripts/test_schedule_window_plumbing.py index 954b35d3..92cd93e2 100644 --- a/scripts/test_schedule_window_plumbing.py +++ b/scripts/test_schedule_window_plumbing.py @@ -147,9 +147,37 @@ def main(): continue for key in WINDOW_KEYS: # SportsCore reads these off the root of the config it is handed. + # That "root is where they are read" half is asserted directly, per + # plugin, by test_idle_league_backoff.py -- this file only proves + # the adapters get them there. check(f"{name}: {key} reaches the config root", where(config, key) == "root") + print("\nsoccer's custom leagues go through a second adapter:") + soccer = REPO / "plugins" / "soccer-scoreboard" + if soccer.is_dir(): + try: + cls = load_plugin_class(soccer) + probe = object.__new__(type("Probe", (cls,), + {"__getattr__": lambda self, n: 0})) + probe.logger = logging.getLogger("plumbing-test") + probe.config = { + "schedule_lookback_days": 30, + "schedule_lookahead_days": 21, + "no_data_interval_seconds": 1800, + "live_idle_max_interval_seconds": 7200, + } + custom = cls._adapt_config_for_custom_league( + probe, {"league_code": "epl2", "enabled": True, + "display_modes": {"live": True}}) + except (ImportError, AttributeError, TypeError, KeyError, ValueError) as exc: + check("soccer: custom-league adapter ran", False) + print(f" {type(exc).__name__}: {exc}") + else: + for key in WINDOW_KEYS: + check(f"soccer custom league: {key} reaches the config root", + where(custom, key) == "root") + print(f"\n{'FAILED: ' + str(len(FAILURES)) + ' check(s)' if FAILURES else 'All checks passed.'}") return 1 if FAILURES else 0