Files
LEDMatrix/src/common/sports_fetch.py
T
ChuckandClaude Opus 5.5 084697346b feat(fetch): one ESPN scoreboard cache key and a max-age response cache (fetch service stage 2) (#728)
Fetch service stage 2: one ESPN scoreboard cache key shared across the sports base classes (legacy keys still read), and a max-age response cache in the fetch service.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 18:15:28 -04:00

275 lines
12 KiB
Python

"""Which requests a scoreboard makes: season fetches, the lookback, live odds.
Four ``SportsCore`` methods are identical (executable AST, docstrings
stripped) in all nine scoreboards' ``sports.py`` -- afl, baseball,
basketball, football, hockey, lacrosse, nrl, soccer and ufc -- and were
copied here from ledmatrix-plugins ``30455671`` (origin/main, 2026-09-29)
under their existing names:
- ``_background_fetches_espn_ranges`` -- whether the core's background
service can fetch an ESPN date range, or the plugin must;
- ``_fetch_season_directly`` -- fetch and cache a season in chunks ESPN
accepts, on the calling thread;
- ``_needs_previous_day`` (with ``_LOOKBACK_CUTOFF_HOUR``) -- whether the
live fetch still has to ask for yesterday;
- ``_wants_live_odds`` (with ``_LIVE_ODDS_LOOKAHEAD``) -- whether a live
game is close enough to the screen to be worth an odds request.
Three other ``SportsCore`` methods are as identical and stay in the plugins,
for the reasons ``sports_shared`` gives: ``_get_timezone`` binds each
plugin's own ``resolve_timezone`` shim, and ``_extract_game_details`` /
``_fetch_data`` are the abstract sport-specific contract. So does
``SportsUpcoming.__init__``: the mixins in ``src/common`` hold no
constructor, so the plugins' constructor signature stays theirs.
A new module rather than more methods on ``sports_shared``, for the reason
``sports_helpers`` gives: a missing module fails at load, where the version
checks see it; a missing method fails mid-update.
WHAT A HOST MUST PROVIDE
------------------------
Derived by walking every ``self.<attr>`` the mixin reads; the host-contract
test in ``test/test_sports_fetch.py`` fails if a read is added without being
listed here.
- ``session``, ``headers``, ``cache_manager`` and ``logger`` --
``_fetch_season_directly``.
- ``_games_lock`` -- ``_wants_live_odds``, which also reads ``live_games``,
``current_game_index`` and ``_rotation_schedule`` with ``getattr``
(only ``SportsLive`` has them).
- ``live_games``, read with ``getattr`` -- ``_needs_previous_day``.
- ``background_service``, read with ``getattr`` --
``_background_fetches_espn_ranges``.
- ``sport`` and ``league`` (ESPN's path segments, e.g. ``football`` /
``nfl``) -- ``_schedule_cache_key``, and ``_fetch_season_directly`` when
it is given no key and cannot read one from its URL.
THE SCHEDULE CACHE KEY (fetch service stage 2)
----------------------------------------------
``_schedule_cache_key`` names a schedule window with the canonical
``espn_scoreboard_cache_key`` instead of a plugin-built
``{sport_key}_schedule_{window}``, and ``_cached_schedule`` reads it with the
old key as a fallback for one release, so an upgrade serves the copy already
on disk instead of refetching every league at once. The canonical key
carries the window's dates, so it moves on a day as the window slides; a
miss on it also deletes the copy for the day before, so a league keeps one
window file instead of a week of them.
Add it as a base of the plugin's ``SportsCore``, e.g.
``class SportsCore(SportsFetchMixin, SportsCoreSharedMixin,
SportsHelpersMixin, ABC)``. It defines nothing those define; a method or
constant on the plugin's own class still wins over the mixin's.
"""
import logging
import threading
from datetime import datetime, timedelta
from typing import Any, ClassVar, Dict, Iterable, Optional
from src.common.espn_dates import (
ESPN_MAX_LIMIT,
espn_scoreboard_cache_key,
espn_scoreboard_cache_key_for_url,
fetch_espn_scoreboard,
parse_espn_date_range,
)
from src.common.fetch_service import get_fetch_service
_ESPN_SITE = "https://site.api.espn.com/"
def _previous_window_key(cache_key: str) -> Optional[str]:
"""The canonical key of the same window one day earlier, or None when
``cache_key`` is not a canonical day-range key."""
head, sep, dates = cache_key.rpartition("_")
if not sep or not head.startswith("espn_scoreboard_"):
return None
span = parse_espn_date_range(dates)
if span is None:
return None
start, end = (day - timedelta(days=1) for day in span)
return f"{head}_{start.strftime('%Y%m%d')}-{end.strftime('%Y%m%d')}"
class SportsFetchMixin:
"""Season fetch, lookback and live-odds decisions. See module docstring."""
# The host contract, declared for type checking only: these create no
# attributes, so the host's own values are what the methods read.
session: Any
headers: Dict[str, str]
cache_manager: Any
logger: logging.Logger
_games_lock: threading.RLock
sport: str
league: str
#: How many games past the one on screen keep their odds warm. One is
#: enough for the line to be ready when the rotation advances; more just
#: re-creates the whole-slate fetch this replaced.
_LIVE_ODDS_LOOKAHEAD: ClassVar[int] = 1
def _wants_live_odds(self, game: Dict) -> bool:
"""Whether a live game is near enough the front of the rotation to be
worth an odds request.
Odds used to be fetched for *every* live game in the league on every
update. The renderer only ever draws ``current_game``, and a full
rotation of a big slate takes minutes while ``live_odds_update_interval``
is 60s -- so all but one of those requests expired before the game they
belonged to came round.
Measured 2026-09-19 over a full college-football slate: 11,978 odds
requests in 13h on one rig, 54% of all its ESPN traffic, across only
~140 distinct games. The eager loop also cost up to 2s of ``update()``
per live game, because ``_fetch_odds`` waits on its worker thread.
Mirrors the narrowing already applied to the upcoming path and to
``_attach_odds_to_rotated_games``: only games about to be on screen are
asked about. ``get_odds`` still caches per game, so a game re-entering
the window inside its TTL costs a cache lookup, not a request.
The rotation state read here is the previous cycle's -- the new list is
still being built -- which is exactly the question being asked: is this
game at or near the position currently on the panel?
"""
# Read defensively: this predicate lives on SportsCore so it sits
# beside _fetch_odds, but live_games/_rotation_schedule belong to
# SportsLive, which is the only caller.
with self._games_lock:
games = list(getattr(self, "live_games", ()) or ())
index = getattr(self, "current_game_index", 0)
schedule = list(getattr(self, "_rotation_schedule", ()) or ())
if not games:
# Cold start: nothing is on screen yet, so let the games seen on
# this first pass through rather than render a blank line for a
# whole cycle. Bounded -- the next pass has a rotation to narrow by.
return True
order = schedule or [g.get("id") for g in games]
if not order:
return True
start = index if 0 <= index < len(order) else 0
wanted = {
order[(start + offset) % len(order)]
for offset in range(self._LIVE_ODDS_LOOKAHEAD + 1)
}
return game.get("id") in wanted
#: Hour of the Eastern day past which last night's games are assumed over.
#:
#: The live fetch asks ESPN for a two-day window so a game that started
#: yesterday and is still running is not lost. ESPN rejects date *ranges*,
#: so that window is split into one request per day -- doubling every live
#: poll. Measured 2026-09-19: 1,858 requests per rig spent on yesterday's
#: date, which after breakfast holds nothing but final games.
#:
#: No sport on these boards runs six hours past midnight, and one that
#: somehow did is still covered: a game already being tracked keeps its own
#: day in the window regardless of the hour.
_LOOKBACK_CUTOFF_HOUR: ClassVar[int] = 6
def _needs_previous_day(self, now: datetime) -> bool:
"""Whether the previous Eastern day can still hold a live game."""
if now.hour < self._LOOKBACK_CUTOFF_HOUR:
return True
previous = (now - timedelta(days=1)).strftime("%Y%m%d")
for game in (getattr(self, "live_games", None) or []):
start: Any = game.get("start_time_utc") if hasattr(game, "get") else None
try:
if start.astimezone(now.tzinfo).strftime("%Y%m%d") == previous:
return True
except (AttributeError, ValueError, OSError, OverflowError):
continue
return False
def _background_fetches_espn_ranges(self) -> bool:
"""Can the core's background service fetch an ESPN date range?
Cores from before the 2026-09-15 fix send a season range to ESPN as-is,
which now answers 400 for every sport. On those cores the managers fetch
the season themselves with _fetch_season_directly instead.
"""
service = getattr(self, "background_service", None)
return bool(getattr(service, "handles_espn_date_ranges", False))
def _schedule_cache_key(self, datestring: str) -> str:
"""The canonical cache key for this league's schedule over
``datestring`` (``espn_scoreboard_cache_key``)."""
return espn_scoreboard_cache_key(self.sport, self.league, datestring)
def _cached_schedule(self, cache_key: str, legacy_keys: Iterable[str] = ()) -> Any:
"""What ``self.cache_manager.get(cache_key)`` returns, falling back
to each of ``legacy_keys`` (the plugin's pre-canonical keys) in turn.
The same read the managers made before -- same default max age, a
stored ttl still wins -- so moving to the canonical key changes
where a schedule is cached, not for how long. A read from an old key
is counted (``legacy_cache_hits``) so it is visible when the
fallback can go. A miss on the canonical key also deletes the same
window's copy from the day before (see the module docstring).
"""
cached = self.cache_manager.get(cache_key)
if cached:
return cached
self._retire_previous_window(cache_key)
for legacy in legacy_keys:
if not legacy or legacy == cache_key:
continue
cached = self.cache_manager.get(legacy)
if cached:
try:
get_fetch_service().note_cache_hit(
_ESPN_SITE, legacy=True, avoided_request=False)
except Exception: # noqa: BLE001 - counting never breaks a read
pass
return cached
return None
def _retire_previous_window(self, cache_key: str) -> None:
previous = _previous_window_key(cache_key)
delete = getattr(self.cache_manager, "delete", None)
if previous is None or not callable(delete):
return
try:
delete(previous)
except Exception as e: # noqa: BLE001 - housekeeping only
self.logger.debug(f"Could not delete old schedule copy {previous}: {e}")
def _fetch_season_directly(
self,
url: str,
datestring: str,
cache_key: Optional[str],
label: str,
ttl: Optional[int] = None,
) -> Optional[Dict]:
"""Fetch a season schedule on this thread, in chunks ESPN accepts, and cache it.
``label`` names the schedule in log lines, e.g. ``"2026 season"``.
``cache_key=None`` caches it under the canonical key
(``espn_scoreboard_cache_key`` for ``url``'s sport and league).
"""
if cache_key is None:
cache_key = (espn_scoreboard_cache_key_for_url(url, datestring)
or self._schedule_cache_key(datestring))
try:
data = fetch_espn_scoreboard(
self.session,
url,
params={"dates": datestring, "limit": ESPN_MAX_LIMIT},
headers=self.headers,
timeout=30,
logger=self.logger,
)
except Exception as e:
self.logger.error(f"Failed to fetch {label} schedule: {e}")
return None
if ttl is None:
self.cache_manager.set(cache_key, data)
else:
self.cache_manager.set(cache_key, data, ttl=ttl)
self.logger.info(
f"Fetched {label} schedule: {len(data.get('events', []))} events"
)
return data