mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-05 23:05:10 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7f96075076 | ||
|
|
439013b18c | ||
|
|
e5bbfa2ae3 | ||
|
|
db49275075 | ||
|
|
a11412dabb | ||
|
|
fe5bed2886 |
@@ -19,6 +19,59 @@ accepts both, but the store flags the old spelling as deprecated
|
|||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
|
## 3.6.2
|
||||||
|
|
||||||
|
A fix to `src.common.favorite_team_check` (#670).
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- The favourite-team check no longer says the Europa League season has
|
||||||
|
finished between matchdays. Its scoreboard keeps showing the last matchday,
|
||||||
|
and its calendar is a "list" of rounds rather than match days, so neither
|
||||||
|
3.6.1 rule applied. When every event is past, a round in a list calendar
|
||||||
|
that has not started yet (outside an offseason phase) now draws no
|
||||||
|
conclusion. PLL, the World Cup and AFL, whose seasons are over, are still
|
||||||
|
reported as finished: no round of theirs is still to start. (#670)
|
||||||
|
|
||||||
|
## 3.6.1
|
||||||
|
|
||||||
|
A fix to `src.common.favorite_team_check` (#667). Plugins that drop their
|
||||||
|
bundled copy of it should floor on 3.6.1, not 3.6.0.
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- The favourite-team check no longer logs "the season has finished" for a
|
||||||
|
league that is still playing. ESPN's default scoreboard keeps showing the
|
||||||
|
last slate after it: MLB's regular-season games two days into the
|
||||||
|
postseason, a soccer league's previous matchday between rounds. When every
|
||||||
|
event is in the past, the check now looks first at the league's phase (a
|
||||||
|
regular season or postseason that has moved past the events shown draws no
|
||||||
|
conclusion) and at a match-day calendar (`calendarType` "day" with
|
||||||
|
`calendarIsWhitelist`, as soccer, the NHL and the NBA use), whose next date
|
||||||
|
becomes "nothing on until <date>". An offseason, or a payload without these
|
||||||
|
fields, is reported as before.
|
||||||
|
|
||||||
|
## 3.6.0
|
||||||
|
|
||||||
|
New modules a plugin may import via `src.*` (floor on 3.6.0). Both are
|
||||||
|
promoted from files the scoreboard plugins carry as copies; the plugins keep
|
||||||
|
their copies as a fallback until they floor on 3.6.0. No other change since
|
||||||
|
3.5.0.
|
||||||
|
|
||||||
|
- `src/common/favorite_team_check.py` — `FavoriteTeamCheck(logger, leagues)`:
|
||||||
|
checks configured favourite team codes against ESPN once per league, on a
|
||||||
|
daemon thread, and logs why a league shows nothing (a wrong code, with the
|
||||||
|
nearest real one, or a season that has not started). The seven copies
|
||||||
|
(`<sport>_favorite_check.py`) were byte-identical; this is the same code,
|
||||||
|
with type annotations added.
|
||||||
|
- `src/common/sports_timezone.py` — `resolve_timezone_name()` /
|
||||||
|
`resolve_timezone()` (plus `system_timezone_name()`): the timezone a
|
||||||
|
scoreboard draws start times in. The ten copies (`<sport>_timezone.py`)
|
||||||
|
differed only in two values, which are keyword-only arguments here:
|
||||||
|
`plugin_label` (named in the warning logged when nothing resolves) and
|
||||||
|
`writeback_fixed_in` (for a plugin that once wrote `"UTC"` back into the
|
||||||
|
saved config; `None` otherwise). Same resolution order and log messages.
|
||||||
|
|
||||||
## 3.5.0
|
## 3.5.0
|
||||||
|
|
||||||
New modules a plugin may import via `src.*` (floor on 3.5.0):
|
New modules a plugin may import via `src.*` (floor on 3.5.0):
|
||||||
|
|||||||
@@ -81,6 +81,8 @@ more. Shared sports code lives in `src/common`:
|
|||||||
| `sports_shared.py` | 3.3.0 | `SportsCoreSharedMixin`, `SportsLiveSharedMixin`, `SportsRecentSharedMixin` — the sport-independent `sports.py` methods |
|
| `sports_shared.py` | 3.3.0 | `SportsCoreSharedMixin`, `SportsLiveSharedMixin`, `SportsRecentSharedMixin` — the sport-independent `sports.py` methods |
|
||||||
| `sports_helpers.py` | 3.5.0 | clamp/logo/rotation free functions and `SportsHelpersMixin`, plus the `_favorite_key` seam |
|
| `sports_helpers.py` | 3.5.0 | clamp/logo/rotation free functions and `SportsHelpersMixin`, plus the `_favorite_key` seam |
|
||||||
| `espn_dates.py` | 3.5.0 | ESPN date-range and `limit` workarounds |
|
| `espn_dates.py` | 3.5.0 | ESPN date-range and `limit` workarounds |
|
||||||
|
| `favorite_team_check.py` | 3.6.0 | `FavoriteTeamCheck` — logs why a favourite team code shows nothing |
|
||||||
|
| `sports_timezone.py` | 3.6.0 | Which timezone start times are drawn in (`resolve_timezone_name`) |
|
||||||
|
|
||||||
Each is described in [src/common/README.md](../src/common/README.md).
|
Each is described in [src/common/README.md](../src/common/README.md).
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ src/common/__init__.py
|
|||||||
src/common/api_helper.py
|
src/common/api_helper.py
|
||||||
src/common/bdf_font.py
|
src/common/bdf_font.py
|
||||||
src/common/espn_dates.py
|
src/common/espn_dates.py
|
||||||
|
src/common/favorite_team_check.py
|
||||||
src/common/font_layout.py
|
src/common/font_layout.py
|
||||||
src/common/frame_timing.py
|
src/common/frame_timing.py
|
||||||
src/common/json_body.py
|
src/common/json_body.py
|
||||||
@@ -32,6 +33,7 @@ src/common/scroll_config.py
|
|||||||
src/common/snapshot_policy.py
|
src/common/snapshot_policy.py
|
||||||
src/common/sports_card.py
|
src/common/sports_card.py
|
||||||
src/common/sports_scroll.py
|
src/common/sports_scroll.py
|
||||||
|
src/common/sports_timezone.py
|
||||||
src/config_service.py
|
src/config_service.py
|
||||||
src/core_config_keys.py
|
src/core_config_keys.py
|
||||||
src/deprecation.py
|
src/deprecation.py
|
||||||
|
|||||||
+1
-1
@@ -4,5 +4,5 @@ LEDMatrix Display System
|
|||||||
Core source package for the LED Matrix Display project.
|
Core source package for the LED Matrix Display project.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__version__ = "3.5.0"
|
__version__ = "3.6.2"
|
||||||
|
|
||||||
|
|||||||
+27
-2
@@ -24,11 +24,12 @@ Rules for the package:
|
|||||||
| Module | For | Plugins import it? | Since |
|
| Module | For | Plugins import it? | Since |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| [`api_helper`](#api_helper) | HTTP GET/POST with caching and rate limiting | Yes | — |
|
| [`api_helper`](#api_helper) | HTTP GET/POST with caching and rate limiting | Yes | — |
|
||||||
| [`bdf_font`](#bdf_font) | Load and draw BDF bitmap fonts | Yes, if drawing BDF text directly | Unreleased |
|
| [`bdf_font`](#bdf_font) | Load and draw BDF bitmap fonts | Yes, if drawing BDF text directly | 3.5.0 |
|
||||||
| [`espn_dates`](#espn_dates) | Fetch ESPN scoreboards across a date range | Yes (scoreboards) | 3.5.0 |
|
| [`espn_dates`](#espn_dates) | Fetch ESPN scoreboards across a date range | Yes (scoreboards) | 3.5.0 |
|
||||||
|
| [`favorite_team_check`](#favorite_team_check) | Log why a favourite team code shows nothing | Yes (scoreboards) | 3.6.0 |
|
||||||
| [`font_layout`](#font_layout) | Reproducible TrueType loading, crisp sizes | Yes | 3.4.0 |
|
| [`font_layout`](#font_layout) | Reproducible TrueType loading, crisp sizes | Yes | 3.4.0 |
|
||||||
| [`frame_timing`](#frame_timing) | Timing of every presented frame, stall watchdog | No, core-internal | n/a |
|
| [`frame_timing`](#frame_timing) | Timing of every presented frame, stall watchdog | No, core-internal | n/a |
|
||||||
| [`json_body`](#json_body) | Parse a response body as JSON, with orjson if installed | Optional (large payloads) | Unreleased |
|
| [`json_body`](#json_body) | Parse a response body as JSON, with orjson if installed | Optional (large payloads) | 3.5.0 |
|
||||||
| [`logo_helper`](#logo_helper) | Load, resize and cache team logos | Yes | — |
|
| [`logo_helper`](#logo_helper) | Load, resize and cache team logos | Yes | — |
|
||||||
| [`path_safety`](#path_safety) | Turn request-supplied names into safe paths | No, core-internal | n/a |
|
| [`path_safety`](#path_safety) | Turn request-supplied names into safe paths | No, core-internal | n/a |
|
||||||
| [`permission_utils`](#permission_utils) | File modes and shared-group ownership | Rarely | — |
|
| [`permission_utils`](#permission_utils) | File modes and shared-group ownership | Rarely | — |
|
||||||
@@ -41,6 +42,7 @@ Rules for the package:
|
|||||||
| [`sports_helpers`](#sports_helpers) | Small helpers every scoreboard `sports.py` copies | Yes (scoreboards) | 3.5.0 |
|
| [`sports_helpers`](#sports_helpers) | Small helpers every scoreboard `sports.py` copies | Yes (scoreboards) | 3.5.0 |
|
||||||
| [`sports_scroll`](#sports_scroll) | Scoreboard scroll-display orchestration | Yes (scoreboards) | 3.2.0 |
|
| [`sports_scroll`](#sports_scroll) | Scoreboard scroll-display orchestration | Yes (scoreboards) | 3.2.0 |
|
||||||
| [`sports_shared`](#sports_shared) | Sport-independent `sports.py` methods | Yes (scoreboards) | 3.3.0 |
|
| [`sports_shared`](#sports_shared) | Sport-independent `sports.py` methods | Yes (scoreboards) | 3.3.0 |
|
||||||
|
| [`sports_timezone`](#sports_timezone) | Which timezone a scoreboard draws start times in | Yes (scoreboards) | 3.6.0 |
|
||||||
| [`sync_manager`](#sync_manager) | Leader/follower sync between two displays | No, core-internal | n/a |
|
| [`sync_manager`](#sync_manager) | Leader/follower sync between two displays | No, core-internal | n/a |
|
||||||
| [`text_helper`](#text_helper) | Outlined text, wrapping, measurement | Yes | — |
|
| [`text_helper`](#text_helper) | Outlined text, wrapping, measurement | Yes | — |
|
||||||
|
|
||||||
@@ -100,6 +102,17 @@ results; `espn_date_chunks()`, `fetch_espn_date_chunks()`,
|
|||||||
`clamp_espn_limit()` and `merge_scoreboard_payloads()` are the pieces.
|
`clamp_espn_limit()` and `merge_scoreboard_payloads()` are the pieces.
|
||||||
Scoreboard plugins also bundle a copy for older cores.
|
Scoreboard plugins also bundle a copy for older cores.
|
||||||
|
|
||||||
|
### favorite_team_check
|
||||||
|
|
||||||
|
[`favorite_team_check.py`](favorite_team_check.py).
|
||||||
|
`FavoriteTeamCheck(logger, leagues)`, where `leagues` maps a league key to
|
||||||
|
`(display name, ESPN sport/league path)`. `schedule(league_key, favorites)`
|
||||||
|
checks the configured favourite team codes against ESPN's team list once per
|
||||||
|
league, on a daemon thread, and logs a bad code with the nearest real one, or
|
||||||
|
says the league has nothing on yet; `reset()` re-arms it after a config edit.
|
||||||
|
Diagnostics only: every failure is swallowed. Scoreboard plugins also bundle
|
||||||
|
a copy for older cores.
|
||||||
|
|
||||||
### font_layout
|
### font_layout
|
||||||
|
|
||||||
[`font_layout.py`](font_layout.py). `load_truetype(path, size)` is
|
[`font_layout.py`](font_layout.py). `load_truetype(path, size)` is
|
||||||
@@ -237,6 +250,18 @@ fonts, colours, dates, the switch-mode upcoming card). The docstring lists
|
|||||||
the attributes the host class must have and the three methods deliberately
|
the attributes the host class must have and the three methods deliberately
|
||||||
left out.
|
left out.
|
||||||
|
|
||||||
|
### sports_timezone
|
||||||
|
|
||||||
|
[`sports_timezone.py`](sports_timezone.py).
|
||||||
|
`resolve_timezone_name(config, plugin_manager, cache_manager, log, *,
|
||||||
|
plugin_label, writeback_fixed_in=None)` and `resolve_timezone(...)` (the same
|
||||||
|
as a pytz zone): the plugin's own `timezone`, then the global one via either
|
||||||
|
manager's `config_manager`, then the host's zone (`system_timezone_name()`),
|
||||||
|
then UTC. `plugin_label` names the plugin in the warning logged when nothing
|
||||||
|
resolves; `writeback_fixed_in` is for a plugin that once wrote `"UTC"` into
|
||||||
|
the saved config (a bare plugin-level `"UTC"` is then ignored when another
|
||||||
|
source disagrees). Scoreboard plugins also bundle a copy for older cores.
|
||||||
|
|
||||||
### sync_manager
|
### sync_manager
|
||||||
|
|
||||||
[`sync_manager.py`](sync_manager.py). Core-internal. `DisplaySyncManager`
|
[`sync_manager.py`](sync_manager.py). Core-internal. `DisplaySyncManager`
|
||||||
|
|||||||
@@ -0,0 +1,409 @@
|
|||||||
|
"""
|
||||||
|
Explain an empty screen: a wrong team code, or a season that has not started.
|
||||||
|
|
||||||
|
Favourite teams are matched by exact ESPN abbreviation, so a plausible-looking
|
||||||
|
code silently matches nothing and the plugin shows an empty screen with no hint
|
||||||
|
that the code is at fault. The codes are not always guessable — ESPN calls
|
||||||
|
Alabama ``ALA`` rather than ``BAMA``, and Golden State ``GS`` rather than
|
||||||
|
``GSW``. Between seasons a perfectly correct code produces the same empty
|
||||||
|
screen for a completely different reason, and the two were indistinguishable
|
||||||
|
from the logs.
|
||||||
|
|
||||||
|
This module is diagnostics only. It runs on a daemon thread, once per league per
|
||||||
|
process, and every failure is swallowed: it must never delay a frame or change
|
||||||
|
what is displayed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import difflib
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import threading
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Dict, Iterable, List, Optional, Set, Tuple
|
||||||
|
|
||||||
|
TEAMS_URL = "https://site.api.espn.com/apis/site/v2/sports/{path}/teams?limit=1000"
|
||||||
|
SCOREBOARD_URL = "https://site.api.espn.com/apis/site/v2/sports/{path}/scoreboard"
|
||||||
|
REQUEST_TIMEOUT = 15
|
||||||
|
|
||||||
|
|
||||||
|
class FavoriteTeamCheck:
|
||||||
|
"""
|
||||||
|
Validates configured favourite team codes against ESPN, and says so in the log.
|
||||||
|
|
||||||
|
``leagues`` maps the plugin's own league key to a
|
||||||
|
``(human readable name, ESPN sport/league path)`` pair, e.g.
|
||||||
|
``{'nhl': ('NHL', 'hockey/nhl')}``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# How far out the next fixture has to be before it is worth mentioning.
|
||||||
|
# An off day or two is normal mid-season and saying so would just be noise.
|
||||||
|
GAP_DAYS = 3
|
||||||
|
|
||||||
|
def __init__(self, logger: Optional[logging.Logger],
|
||||||
|
leagues: Dict[str, Tuple[str, str]]) -> None:
|
||||||
|
self.logger = logger or logging.getLogger(__name__)
|
||||||
|
self.leagues = leagues
|
||||||
|
self._checked: Set[str] = set()
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
|
def reset(self) -> None:
|
||||||
|
"""Re-check on the next call, e.g. after the user edits the config."""
|
||||||
|
with self._lock:
|
||||||
|
self._checked.clear()
|
||||||
|
|
||||||
|
def schedule(self, league_key: str, favorites: Iterable[str]) -> None:
|
||||||
|
"""Check one league in the background, at most once per process."""
|
||||||
|
try:
|
||||||
|
favorites = [str(f) for f in (favorites or []) if str(f).strip()]
|
||||||
|
if not favorites or league_key not in self.leagues:
|
||||||
|
return
|
||||||
|
with self._lock:
|
||||||
|
if league_key in self._checked:
|
||||||
|
return
|
||||||
|
self._checked.add(league_key)
|
||||||
|
threading.Thread(
|
||||||
|
target=self._run, args=(league_key, favorites),
|
||||||
|
name="favorite-team-check", daemon=True,
|
||||||
|
).start()
|
||||||
|
except Exception:
|
||||||
|
pass # nosec B110 - a diagnostic must never be the reason an update fails # nosemgrep
|
||||||
|
|
||||||
|
def _run(self, league_key: str, favorites) -> None:
|
||||||
|
try:
|
||||||
|
self._check(league_key, favorites)
|
||||||
|
except Exception as exc:
|
||||||
|
self.logger.debug("Favorite team check failed for %s: %s",
|
||||||
|
league_key, exc)
|
||||||
|
|
||||||
|
def _check(self, league_key: str, favorites) -> None:
|
||||||
|
name, path = self.leagues[league_key]
|
||||||
|
|
||||||
|
try:
|
||||||
|
teams = self._fetch_teams(path)
|
||||||
|
except Exception as exc:
|
||||||
|
self.logger.debug("Could not verify %s favorite teams: %s", name, exc)
|
||||||
|
return
|
||||||
|
if not teams:
|
||||||
|
# Some ESPN endpoints (college lacrosse) return no teams at all.
|
||||||
|
# Nothing can be concluded, so say nothing.
|
||||||
|
return
|
||||||
|
|
||||||
|
# Dynamic groups like AP_TOP_25 are expanded elsewhere; they are not
|
||||||
|
# team codes and must not be reported as bad ones.
|
||||||
|
codes = [f for f in favorites if not self._is_dynamic(f)]
|
||||||
|
recognised = [f for f in codes if f in teams]
|
||||||
|
unknown = [f for f in codes if f not in teams]
|
||||||
|
|
||||||
|
for code in unknown:
|
||||||
|
self.logger.warning(
|
||||||
|
"%s favorite team %r is not a %s team code.%s "
|
||||||
|
"Every code this league accepts is listed at %s.",
|
||||||
|
name, code, name, self._suggest(code, teams),
|
||||||
|
TEAMS_URL.format(path=path),
|
||||||
|
)
|
||||||
|
|
||||||
|
if codes and not recognised:
|
||||||
|
self.logger.warning(
|
||||||
|
"%s has no recognised favorite teams, so nothing will be shown "
|
||||||
|
"for it. Codes must be ESPN abbreviations, e.g. %s.",
|
||||||
|
name, ", ".join("{} ({})".format(a, n)
|
||||||
|
for a, n in list(sorted(teams.items()))[:3]),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if not recognised:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Codes are fine, so check the other cause of an empty screen.
|
||||||
|
try:
|
||||||
|
note = self._schedule_note(path)
|
||||||
|
except Exception as exc:
|
||||||
|
self.logger.debug("Could not check the %s schedule: %s", name, exc)
|
||||||
|
return
|
||||||
|
|
||||||
|
if note:
|
||||||
|
self.logger.info(
|
||||||
|
"%s favorite teams %s look correct, but %s. An empty display "
|
||||||
|
"until then is expected, not a configuration problem.",
|
||||||
|
name, ", ".join(recognised), note,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.logger.info("%s favorite teams recognised: %s",
|
||||||
|
name, ", ".join(recognised))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _is_dynamic(code: str) -> bool:
|
||||||
|
upper = (code or "").strip().upper()
|
||||||
|
return upper.startswith("AP_") or upper.startswith("TOP_") or "TOP_" in upper
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _fetch_teams(path: str) -> Dict[str, str]:
|
||||||
|
"""ESPN's {abbreviation: display name} for a league.
|
||||||
|
|
||||||
|
``limit=1000`` is required: the default page size truncates the NCAA
|
||||||
|
responses to roughly half their teams, which makes valid codes look wrong.
|
||||||
|
"""
|
||||||
|
import requests
|
||||||
|
|
||||||
|
payload = requests.get(TEAMS_URL.format(path=path),
|
||||||
|
timeout=REQUEST_TIMEOUT).json()
|
||||||
|
entries = payload['sports'][0]['leagues'][0]['teams']
|
||||||
|
return {
|
||||||
|
t['team']['abbreviation']: t['team']['displayName']
|
||||||
|
for t in entries if t.get('team', {}).get('abbreviation')
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _schedule_note(cls, path: str) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Why the league has nothing to show, as a clause, or ``None`` if it does.
|
||||||
|
|
||||||
|
Two things make this harder than reading ``events``:
|
||||||
|
|
||||||
|
* An out-of-season league does not come back empty. ESPN rolls the
|
||||||
|
scoreboard forward to the next day that has fixtures, so in July the
|
||||||
|
NHL endpoint returns seven September games. Emptiness cannot be the
|
||||||
|
signal; the date of those games is, and it is more useful anyway.
|
||||||
|
* A *finished* season rolls nowhere and returns its last game instead,
|
||||||
|
months in the past — so dates have to be filtered to the future
|
||||||
|
before the soonest one means anything.
|
||||||
|
"""
|
||||||
|
import requests
|
||||||
|
|
||||||
|
payload = requests.get(SCOREBOARD_URL.format(path=path),
|
||||||
|
timeout=REQUEST_TIMEOUT).json()
|
||||||
|
|
||||||
|
event_dates = [cls._parse_date(e.get('date'))
|
||||||
|
for e in payload.get('events') or []]
|
||||||
|
calendar_dates = []
|
||||||
|
for entry in (payload.get('leagues') or [{}])[0].get('calendar') or []:
|
||||||
|
calendar_dates.append(cls._parse_date(
|
||||||
|
entry if isinstance(entry, str) else entry.get('startDate')))
|
||||||
|
|
||||||
|
# Count the last day as current, rather than filtering on "later than
|
||||||
|
# right now": a game that began a few hours ago still means the league
|
||||||
|
# has something on, and dropping it would report a live slate as a
|
||||||
|
# finished season. A day's grace also keeps this correct whatever the
|
||||||
|
# user's timezone, since these timestamps are UTC.
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
def future(candidates):
|
||||||
|
return sorted(d for d in candidates if d and (now - d).days < 1)
|
||||||
|
|
||||||
|
# Events are fixtures; the calendar is week and phase boundaries,
|
||||||
|
# which routinely open days before their first game (an NFL week 1
|
||||||
|
# calendar entry starts the weekend before the Thursday opener).
|
||||||
|
# Reading the two together reported the earliest boundary as a game
|
||||||
|
# date -- "nothing on until 06 September" for a league whose first
|
||||||
|
# snap is the 10th. The calendar only gets a say when the scoreboard
|
||||||
|
# has no events at all to roll forward to: events that exist but are
|
||||||
|
# all in the past mean the season is over, and an offseason calendar
|
||||||
|
# phase must not be dressed up as its next game.
|
||||||
|
#
|
||||||
|
# The exception is a calendar of match days. With calendarType "day"
|
||||||
|
# and calendarIsWhitelist true, every entry is a day that has games,
|
||||||
|
# so a future entry is a real next fixture. Soccer needs it: between
|
||||||
|
# matchdays the scoreboard keeps showing the last one, so on
|
||||||
|
# 2026-09-29 every Premier League event was from 20 September and the
|
||||||
|
# next games (10 October) were only in the calendar. A day calendar
|
||||||
|
# that is not a whitelist (MLB's) lists days *without* games.
|
||||||
|
if any(event_dates):
|
||||||
|
upcoming = future(event_dates)
|
||||||
|
if not upcoming and cls._calendar_is_match_days(payload):
|
||||||
|
upcoming = future(calendar_dates)
|
||||||
|
else:
|
||||||
|
upcoming = future(calendar_dates)
|
||||||
|
if not upcoming:
|
||||||
|
if not any(event_dates) and not any(calendar_dates):
|
||||||
|
return None # Nothing published either way; draw no conclusion.
|
||||||
|
if cls._moved_to_later_phase(payload):
|
||||||
|
return None # e.g. postseason under way; see the method.
|
||||||
|
if cls._later_round_scheduled(payload, now):
|
||||||
|
return None # e.g. Europa League between matchdays.
|
||||||
|
return ("the season has finished and the next one's fixtures are "
|
||||||
|
"not published yet")
|
||||||
|
|
||||||
|
# A day or two out is just an off day, and saying so would be noise.
|
||||||
|
if (upcoming[0] - now).days < cls.GAP_DAYS:
|
||||||
|
return None
|
||||||
|
return "the league has nothing on until {}".format(
|
||||||
|
upcoming[0].strftime('%d %B %Y'))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _calendar_is_match_days(payload) -> bool:
|
||||||
|
"""Whether the league calendar lists the days that have games."""
|
||||||
|
league = (payload.get('leagues') or [{}])[0] or {}
|
||||||
|
return (league.get('calendarType') == 'day'
|
||||||
|
and league.get('calendarIsWhitelist') is True)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _moved_to_later_phase(payload) -> bool:
|
||||||
|
"""
|
||||||
|
Whether the league is in a later in-season phase than its events.
|
||||||
|
|
||||||
|
ESPN does not roll the scoreboard forward into a postseason. The day
|
||||||
|
after MLB's regular season ended, the default scoreboard still returned
|
||||||
|
that last regular-season day, while ``leagues[0].season`` already said
|
||||||
|
Postseason and the wild-card games were two days out. Past events alone
|
||||||
|
then read as a finished season while the same process's upcoming
|
||||||
|
manager was showing the favourite's playoff games.
|
||||||
|
|
||||||
|
Only regular season (2) and postseason (3) count as "later". The
|
||||||
|
offseason (4) follows the postseason too, and there past events really
|
||||||
|
do mean the season is over.
|
||||||
|
"""
|
||||||
|
season = ((payload.get('leagues') or [{}])[0] or {}).get('season') or {}
|
||||||
|
league_type = (season.get('type') or {}).get('type')
|
||||||
|
if league_type not in (2, 3):
|
||||||
|
return False
|
||||||
|
event_types = [(e.get('season') or {}).get('type')
|
||||||
|
for e in payload.get('events') or []]
|
||||||
|
known = [t for t in event_types if isinstance(t, int)]
|
||||||
|
return bool(known) and all(t < league_type for t in known)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _later_round_scheduled(cls, payload, now: datetime) -> bool:
|
||||||
|
"""
|
||||||
|
Whether a "list" calendar has a round that has not started yet.
|
||||||
|
|
||||||
|
Competitions with a list calendar (the UEFA club competitions, the
|
||||||
|
World Cup, AFL, NFL) give each phase its rounds as ``entries`` with
|
||||||
|
start and end dates. Between matchdays the Europa League scoreboard
|
||||||
|
keeps showing the last one: on 2026-09-29 every event was from 17
|
||||||
|
September, the next matchday was only days away, and the rounds from
|
||||||
|
the knockout play-offs to the final were all still to come. A round
|
||||||
|
that starts later means the season is not over, even though the
|
||||||
|
date of the next fixture is not known.
|
||||||
|
|
||||||
|
Only a round's *start* counts. End dates are padded well past the
|
||||||
|
last game -- the World Cup's final round ran to 1 August for a 19 July
|
||||||
|
final -- so a future end date is also true of a finished season.
|
||||||
|
Rounds in an offseason phase (the college football All-Star week)
|
||||||
|
are not games for the favourites and do not count either.
|
||||||
|
"""
|
||||||
|
league = (payload.get('leagues') or [{}])[0] or {}
|
||||||
|
for phase in league.get('calendar') or []:
|
||||||
|
if not isinstance(phase, dict) or cls._is_offseason(phase.get('label')):
|
||||||
|
continue
|
||||||
|
for entry in phase.get('entries') or []:
|
||||||
|
if not isinstance(entry, dict) or cls._is_offseason(entry.get('label')):
|
||||||
|
continue
|
||||||
|
start = cls._parse_date(entry.get('startDate'))
|
||||||
|
if start and start > now:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _is_offseason(label) -> bool:
|
||||||
|
"""'Off Season', 'Offseason', 'Off-season' ..."""
|
||||||
|
return isinstance(label, str) and 'offseason' in re.sub(
|
||||||
|
r'[^a-z]', '', label.lower())
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_date(raw) -> Optional[datetime]:
|
||||||
|
if not raw or not isinstance(raw, str):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return datetime.fromisoformat(raw.replace('Z', '+00:00'))
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _suggest(cls, code: str, teams: Dict[str, str]) -> str:
|
||||||
|
"""Nearest matching code for a typo, as a ready-to-log clause."""
|
||||||
|
upper = (code or "").strip().upper()
|
||||||
|
if not upper or code in teams:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
# Right code, wrong case — matching is case-sensitive. Guard on the case
|
||||||
|
# actually differing, so a valid code never draws this message.
|
||||||
|
for abbr in teams:
|
||||||
|
if abbr.upper() == upper:
|
||||||
|
return " Codes are case-sensitive; use {!r} ({}).".format(
|
||||||
|
abbr, teams[abbr])
|
||||||
|
|
||||||
|
ranked = cls._rank(upper, (a for a, n in teams.items()
|
||||||
|
if cls._abbreviates(upper, n)), teams)
|
||||||
|
if not ranked:
|
||||||
|
# Nicknames are often a fragment of a word rather than its initials:
|
||||||
|
# 'BAMA' sits inside 'Alabama' but abbreviates nothing in it. Require
|
||||||
|
# three characters, since shorter fragments match far too much.
|
||||||
|
if len(upper) >= 3:
|
||||||
|
ranked = cls._rank(
|
||||||
|
upper,
|
||||||
|
(a for a, n in teams.items()
|
||||||
|
if any(upper in w for w in cls._words(n))),
|
||||||
|
teams)
|
||||||
|
|
||||||
|
if len(ranked) == 1:
|
||||||
|
return " Closest match is {!r} ({}).".format(
|
||||||
|
ranked[0], teams[ranked[0]])
|
||||||
|
if ranked:
|
||||||
|
return " Did you mean {}?".format(", ".join(
|
||||||
|
"{!r} ({})".format(a, teams[a]) for a in ranked[:3]))
|
||||||
|
|
||||||
|
# Otherwise fall back to similarity, against names before codes: a name
|
||||||
|
# gives more characters to compare and so produces fewer ties.
|
||||||
|
names = {n.upper(): a for a, n in teams.items()}
|
||||||
|
hits = difflib.get_close_matches(upper, list(names), n=1, cutoff=0.6)
|
||||||
|
if hits:
|
||||||
|
abbr = names[hits[0]]
|
||||||
|
return " Closest match is {!r} ({}).".format(abbr, teams[abbr])
|
||||||
|
|
||||||
|
code_hits = difflib.get_close_matches(upper, list(teams), n=1, cutoff=0.6)
|
||||||
|
if code_hits:
|
||||||
|
return " Closest match is {!r} ({}).".format(
|
||||||
|
code_hits[0], teams[code_hits[0]])
|
||||||
|
return ""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _words(name: str):
|
||||||
|
return [w for w in re.split(r'[^A-Za-z0-9]+', (name or '').upper()) if w]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _rank(cls, code: str, candidates, teams: Dict[str, str]):
|
||||||
|
"""
|
||||||
|
Order candidate codes best-first.
|
||||||
|
|
||||||
|
A code that picks up the *first* word of the name wins, because that is
|
||||||
|
how people shorten team names: 'SCAR' for South Carolina starts at
|
||||||
|
'South', whereas for Rutgers Scarlet Knights it starts mid-name. Without
|
||||||
|
this the tie is broken alphabetically and the obvious answer can land
|
||||||
|
third in the list.
|
||||||
|
"""
|
||||||
|
def key(abbr):
|
||||||
|
words = cls._words(teams.get(abbr, ''))
|
||||||
|
first_word_hit = bool(words) and words[0].startswith(code[:1])
|
||||||
|
return (not first_word_hit, len(abbr), abbr)
|
||||||
|
|
||||||
|
return sorted(set(candidates), key=key)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _abbreviates(code: str, name: str) -> bool:
|
||||||
|
"""
|
||||||
|
Whether ``code`` reads as an abbreviation of ``name``.
|
||||||
|
|
||||||
|
Each part of the code must be a prefix of one of the name's words, taken
|
||||||
|
in order — which is how people actually shorten team names. Plain string
|
||||||
|
similarity is no use for three-letter codes: 'MUN' scores identically
|
||||||
|
against 'MAN' and 'SUN', so Manchester United and Sunderland tie and the
|
||||||
|
suggestion is a coin flip. This rule separates them, because 'MUN'
|
||||||
|
splits as M-anchester UN-ited while Sunderland has no word starting M.
|
||||||
|
"""
|
||||||
|
words = [w for w in re.split(r'[^A-Za-z0-9]+', (name or '').upper()) if w]
|
||||||
|
|
||||||
|
def consume(rest: str, remaining: List[str]) -> bool:
|
||||||
|
if not rest:
|
||||||
|
return True
|
||||||
|
if not remaining:
|
||||||
|
return False
|
||||||
|
head, tail = remaining[0], remaining[1:]
|
||||||
|
# Skip this word entirely, as in "Manchester United" -> "UTD".
|
||||||
|
if consume(rest, tail):
|
||||||
|
return True
|
||||||
|
for size in range(1, min(len(rest), len(head)) + 1):
|
||||||
|
if head.startswith(rest[:size]) and consume(rest[size:], tail):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
return consume((code or "").strip().upper(), words)
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
"""Timezone resolution for the scoreboard plugins.
|
||||||
|
|
||||||
|
Game start times arrive from ESPN in UTC and have to be converted to the
|
||||||
|
user's local zone before they are drawn. This module owns the "which zone?"
|
||||||
|
decision so every part of a scoreboard (its scorebug, its scroll-mode game
|
||||||
|
card and its plugin manager) agrees.
|
||||||
|
|
||||||
|
The scoreboards each carried a copy of this module as ``<sport>_timezone.py``.
|
||||||
|
The copies were identical apart from two per-plugin values, which are
|
||||||
|
keyword-only arguments here: ``plugin_label``, the name the final warning
|
||||||
|
tells the user to open, and ``writeback_fixed_in`` (see below).
|
||||||
|
|
||||||
|
Resolution order, first valid wins:
|
||||||
|
|
||||||
|
1. ``timezone`` in the plugin's own config (explicit per-plugin override)
|
||||||
|
2. The LEDMatrix global timezone via ``plugin_manager.config_manager``
|
||||||
|
3. The LEDMatrix global timezone via ``cache_manager.config_manager``
|
||||||
|
4. The host system's zone (``TZ``, ``/etc/timezone``, ``/etc/localtime``)
|
||||||
|
5. UTC
|
||||||
|
|
||||||
|
Steps 2 and 3 matter because the core does not consistently hang
|
||||||
|
``config_manager`` off both objects -- reading only one of them is what made
|
||||||
|
a scoreboard fall through to UTC while the clock plugin (which checks
|
||||||
|
``plugin_manager`` first) showed the right time on the same device. Step 4 is
|
||||||
|
the backstop for cores that expose no ``config_manager`` at all: a Pi with its
|
||||||
|
system clock set correctly should never end up rendering UTC.
|
||||||
|
|
||||||
|
Two traps this module exists to avoid, both of which render every start time
|
||||||
|
in UTC on a correctly-configured device:
|
||||||
|
|
||||||
|
* **The stale ``"UTC"`` artifact.** Some plugins once wrote
|
||||||
|
``"timezone": "UTC"`` into the *saved* config whenever resolution failed, and
|
||||||
|
that write-back stuck -- thereafter shadowing the real global timezone. Such
|
||||||
|
a plugin passes ``writeback_fixed_in`` (the release that fixed it), and step 1
|
||||||
|
then treats a bare ``"UTC"`` as suspect: it is honored only when nothing
|
||||||
|
downstream disagrees. ``Etc/UTC`` is the unambiguous spelling for "I really
|
||||||
|
do want UTC"; the bug never produced it, so it is always honored. A plugin
|
||||||
|
that never had the bug leaves ``writeback_fixed_in`` as ``None``, and its
|
||||||
|
plugin-level ``"UTC"`` is honored verbatim.
|
||||||
|
* **``get_timezone()``'s own default.** The core's
|
||||||
|
``ConfigManager.get_timezone()`` is ``self.config.get('timezone', 'UTC')``, so
|
||||||
|
it hands back ``"UTC"`` for a config that simply has no ``timezone`` key.
|
||||||
|
Steps 2 and 3 read the raw config dict instead, so an absent key falls
|
||||||
|
through to the system zone rather than latching onto that default.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from typing import Any, Dict, Iterable, Optional, Tuple
|
||||||
|
|
||||||
|
import pytz
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _from_config_manager(config_manager: Any, log: logging.Logger) -> Optional[str]:
|
||||||
|
"""Pull the global timezone out of a core ConfigManager, if it has one.
|
||||||
|
|
||||||
|
Reads the raw config dict in preference to ``get_timezone()``. The core's
|
||||||
|
``ConfigManager.get_timezone()`` is ``self.config.get('timezone', 'UTC')`` --
|
||||||
|
it substitutes its own ``"UTC"`` when the key is absent, which is
|
||||||
|
indistinguishable from the user deliberately choosing UTC. Taking that at
|
||||||
|
face value would mask a missing global setting and stop resolution ever
|
||||||
|
reaching the host system zone. So: if the raw config is readable and has no
|
||||||
|
``timezone`` key, report "nothing here" and let the caller fall through.
|
||||||
|
``get_timezone()`` is only consulted for cores that expose no raw config.
|
||||||
|
"""
|
||||||
|
if config_manager is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
raw_readable = False
|
||||||
|
for loader_name in ("get_config", "load_config"):
|
||||||
|
loader = getattr(config_manager, loader_name, None)
|
||||||
|
if not callable(loader):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
main_config = loader()
|
||||||
|
except Exception:
|
||||||
|
log.debug("config_manager.%s() failed", loader_name, exc_info=True)
|
||||||
|
continue
|
||||||
|
if isinstance(main_config, dict):
|
||||||
|
raw_readable = True
|
||||||
|
name: Optional[str] = main_config.get("timezone")
|
||||||
|
if name:
|
||||||
|
return name
|
||||||
|
|
||||||
|
if raw_readable:
|
||||||
|
return None
|
||||||
|
|
||||||
|
getter = getattr(config_manager, "get_timezone", None)
|
||||||
|
if callable(getter):
|
||||||
|
try:
|
||||||
|
name = getter()
|
||||||
|
if name:
|
||||||
|
return name
|
||||||
|
except Exception:
|
||||||
|
log.debug("config_manager.get_timezone() failed", exc_info=True)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def system_timezone_name() -> Optional[str]:
|
||||||
|
"""Best-effort IANA name for the host's configured timezone."""
|
||||||
|
name = os.environ.get("TZ")
|
||||||
|
if name:
|
||||||
|
return name
|
||||||
|
|
||||||
|
# Debian / Raspberry Pi OS record the zone name here.
|
||||||
|
try:
|
||||||
|
with open("/etc/timezone", "r", encoding="utf-8") as handle:
|
||||||
|
name = handle.read().strip()
|
||||||
|
if name:
|
||||||
|
return name
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Otherwise /etc/localtime is a symlink into the zoneinfo tree.
|
||||||
|
try:
|
||||||
|
path = os.path.realpath("/etc/localtime")
|
||||||
|
marker = "zoneinfo" + os.sep
|
||||||
|
if marker in path:
|
||||||
|
return path.split(marker, 1)[1]
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _validated(name: object, source: str, log: logging.Logger) -> Optional[str]:
|
||||||
|
"""Return a usable IANA name, or None if blank/absent/not a real zone."""
|
||||||
|
if not isinstance(name, str):
|
||||||
|
return None
|
||||||
|
name = name.strip()
|
||||||
|
if not name:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
pytz.timezone(name)
|
||||||
|
except pytz.UnknownTimeZoneError:
|
||||||
|
log.warning("Ignoring invalid timezone %r from %s", name, source)
|
||||||
|
return None
|
||||||
|
except Exception:
|
||||||
|
# Not an unknown-zone error, so something else went wrong inside pytz.
|
||||||
|
# Log it loudly rather than silently reclassifying it as "invalid" --
|
||||||
|
# but still don't propagate: this runs in the render path, and a
|
||||||
|
# mislabelled zone beats taking the whole display down.
|
||||||
|
log.warning(
|
||||||
|
"Unexpected error validating timezone %r from %s; ignoring it",
|
||||||
|
name, source, exc_info=True,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
return name
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_timezone_name(
|
||||||
|
config: Optional[Dict[str, Any]] = None,
|
||||||
|
plugin_manager: Any = None,
|
||||||
|
cache_manager: Any = None,
|
||||||
|
log: Optional[logging.Logger] = None,
|
||||||
|
*,
|
||||||
|
plugin_label: str,
|
||||||
|
writeback_fixed_in: Optional[str] = None,
|
||||||
|
) -> str:
|
||||||
|
"""Return the IANA timezone name to render game times in.
|
||||||
|
|
||||||
|
Never raises and never returns an empty string; falls back to ``"UTC"``
|
||||||
|
only when every source is missing or invalid.
|
||||||
|
|
||||||
|
``plugin_label`` names the plugin in the warning logged when nothing
|
||||||
|
resolves (e.g. ``"hockey scoreboard"``). ``writeback_fixed_in`` is the
|
||||||
|
plugin release that stopped writing ``"UTC"`` back into the saved config,
|
||||||
|
for a plugin that ever did; ``None`` (the default) otherwise.
|
||||||
|
"""
|
||||||
|
log = log or logger
|
||||||
|
|
||||||
|
def downstream():
|
||||||
|
"""Yield (source, name) for everything except the plugin's own config.
|
||||||
|
|
||||||
|
Lazy: ``SportsCore._get_timezone()`` runs this once per game and the
|
||||||
|
answer is almost always already in the plugin config, so evaluating on
|
||||||
|
demand keeps the common case from calling into both config managers and
|
||||||
|
stat-ing the host timezone files every time.
|
||||||
|
"""
|
||||||
|
yield (
|
||||||
|
"plugin_manager.config_manager",
|
||||||
|
_from_config_manager(getattr(plugin_manager, "config_manager", None), log),
|
||||||
|
)
|
||||||
|
yield (
|
||||||
|
"cache_manager.config_manager",
|
||||||
|
_from_config_manager(getattr(cache_manager, "config_manager", None), log),
|
||||||
|
)
|
||||||
|
yield "system timezone", system_timezone_name()
|
||||||
|
|
||||||
|
def first_valid(
|
||||||
|
sources: Iterable[Tuple[str, Any]],
|
||||||
|
) -> Tuple[Optional[str], Optional[str]]:
|
||||||
|
for source, name in sources:
|
||||||
|
name = _validated(name, source, log)
|
||||||
|
if name:
|
||||||
|
return source, name
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
plugin_value = _validated((config or {}).get("timezone"), "plugin config", log)
|
||||||
|
|
||||||
|
if writeback_fixed_in is not None and plugin_value and plugin_value.lower() == "utc":
|
||||||
|
# Before writeback_fixed_in this plugin wrote "timezone": "UTC" into
|
||||||
|
# the saved config whenever it failed to resolve a global timezone, and
|
||||||
|
# that write-back persisted. A bare "UTC" is therefore far more likely
|
||||||
|
# to be that artifact than a deliberate choice -- it only ever appeared
|
||||||
|
# on failure. Honor it only when nothing downstream disagrees; a user
|
||||||
|
# who genuinely wants UTC writes the unambiguous "Etc/UTC", which the
|
||||||
|
# bug never produced and which falls through to the normal path below.
|
||||||
|
source, downstream_name = first_valid(downstream())
|
||||||
|
if downstream_name and downstream_name.lower() not in ("utc", "etc/utc"):
|
||||||
|
log.warning(
|
||||||
|
"Ignoring the plugin-level timezone 'UTC': it is almost "
|
||||||
|
"certainly left over from the write-back bug fixed in %s, and "
|
||||||
|
"%s says %s. Using %s. If you really do want UTC here, set "
|
||||||
|
"this plugin's timezone to 'Etc/UTC' instead.",
|
||||||
|
writeback_fixed_in, source, downstream_name, downstream_name,
|
||||||
|
)
|
||||||
|
return downstream_name
|
||||||
|
log.debug("Plugin-level timezone 'UTC' agrees with %s; using UTC", source or "no other source")
|
||||||
|
return "UTC"
|
||||||
|
|
||||||
|
if plugin_value:
|
||||||
|
log.debug("Resolved timezone %s from plugin config", plugin_value)
|
||||||
|
return plugin_value
|
||||||
|
|
||||||
|
source, name = first_valid(downstream())
|
||||||
|
if name:
|
||||||
|
log.debug("Resolved timezone %s from %s", name, source)
|
||||||
|
return name
|
||||||
|
|
||||||
|
log.warning(
|
||||||
|
"Could not determine a timezone from the plugin config, the LEDMatrix "
|
||||||
|
"config or the system; game times will be shown in UTC. Set a timezone "
|
||||||
|
"in the %s's Advanced Settings to override.",
|
||||||
|
plugin_label,
|
||||||
|
)
|
||||||
|
return "UTC"
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_timezone(
|
||||||
|
config: Optional[Dict[str, Any]] = None,
|
||||||
|
plugin_manager: Any = None,
|
||||||
|
cache_manager: Any = None,
|
||||||
|
log: Optional[logging.Logger] = None,
|
||||||
|
*,
|
||||||
|
plugin_label: str,
|
||||||
|
writeback_fixed_in: Optional[str] = None,
|
||||||
|
):
|
||||||
|
"""``resolve_timezone_name`` as a ready-to-use tzinfo object."""
|
||||||
|
return pytz.timezone(
|
||||||
|
resolve_timezone_name(
|
||||||
|
config=config,
|
||||||
|
plugin_manager=plugin_manager,
|
||||||
|
cache_manager=cache_manager,
|
||||||
|
log=log,
|
||||||
|
plugin_label=plugin_label,
|
||||||
|
writeback_fixed_in=writeback_fixed_in,
|
||||||
|
)
|
||||||
|
)
|
||||||
@@ -0,0 +1,539 @@
|
|||||||
|
"""src.common.favorite_team_check: the messages a user sees for a bad team code.
|
||||||
|
|
||||||
|
Ported from the scoreboard plugins' own tests (hockey-scoreboard's
|
||||||
|
test_favorite_check.py and football-scoreboard's
|
||||||
|
test_schedule_note_uses_game_dates.py), which test their bundled
|
||||||
|
``<sport>_favorite_check.py`` copies. Core now owns a copy that can drift on
|
||||||
|
its own, and a plugin that deletes its copy loses those tests with it.
|
||||||
|
|
||||||
|
Everything here is offline: ESPN is replaced with a fixed roster, so the tests
|
||||||
|
pin the *messages* the user actually sees. They are the point of the feature --
|
||||||
|
the whole thing exists to turn a blank screen into a sentence that says why.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from src.common.favorite_team_check import FavoriteTeamCheck
|
||||||
|
|
||||||
|
NHL = {
|
||||||
|
"BOS": "Boston Bruins",
|
||||||
|
"TB": "Tampa Bay Lightning",
|
||||||
|
"UTAH": "Utah Mammoth",
|
||||||
|
"SEA": "Seattle Kraken",
|
||||||
|
"VGK": "Vegas Golden Knights",
|
||||||
|
}
|
||||||
|
|
||||||
|
NCAA = {
|
||||||
|
"ALA": "Alabama Crimson Tide",
|
||||||
|
"UNA": "North Alabama Lions",
|
||||||
|
"CONN": "UConn Huskies",
|
||||||
|
"SC": "South Carolina Gamecocks",
|
||||||
|
"RUTG": "Rutgers Scarlet Knights",
|
||||||
|
"GS": "Golden State Warriors",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class RecordingLogger(logging.Logger):
|
||||||
|
"""Captures formatted records so tests can assert on the message text."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__("test")
|
||||||
|
self.records = []
|
||||||
|
|
||||||
|
def handle(self, record):
|
||||||
|
self.records.append((record.levelname, record.getMessage()))
|
||||||
|
|
||||||
|
def messages(self, level=None):
|
||||||
|
return [m for lvl, m in self.records if level is None or lvl == level]
|
||||||
|
|
||||||
|
|
||||||
|
class SuggestionTests(unittest.TestCase):
|
||||||
|
"""The suggestion has to name the right team, and name it first."""
|
||||||
|
|
||||||
|
def assert_first_suggestion(self, code, teams, expected):
|
||||||
|
message = FavoriteTeamCheck._suggest(code, teams)
|
||||||
|
self.assertIn(expected, message,
|
||||||
|
"{!r} did not suggest {!r}: {}".format(code, expected, message))
|
||||||
|
# Where several candidates are listed, the right one must lead, since
|
||||||
|
# users act on the first thing they read.
|
||||||
|
head = message.split("(")[0]
|
||||||
|
self.assertIn(expected, head,
|
||||||
|
"{!r} buried {!r} behind another suggestion: {}".format(
|
||||||
|
code, expected, message))
|
||||||
|
|
||||||
|
def test_retired_code_points_at_the_current_one(self):
|
||||||
|
self.assert_first_suggestion("UTA", NHL, "UTAH")
|
||||||
|
|
||||||
|
def test_common_wrong_guesses(self):
|
||||||
|
self.assert_first_suggestion("GSW", NCAA, "GS")
|
||||||
|
self.assert_first_suggestion("UCONN", NCAA, "CONN")
|
||||||
|
|
||||||
|
def test_fragment_of_a_word_is_matched(self):
|
||||||
|
# 'BAMA' abbreviates nothing in 'Alabama Crimson Tide' -- it is a chunk
|
||||||
|
# out of the middle of a word -- so the initials rule alone misses it.
|
||||||
|
self.assert_first_suggestion("BAMA", NCAA, "ALA")
|
||||||
|
|
||||||
|
def test_first_word_beats_a_mid_name_match(self):
|
||||||
|
# 'SCAR' fits Rutgers *Scar*let too, but South Carolina starts at the
|
||||||
|
# first word, which is how people actually shorten a name.
|
||||||
|
self.assert_first_suggestion("SCAR", NCAA, "SC")
|
||||||
|
|
||||||
|
def test_wrong_case_says_so_rather_than_guessing(self):
|
||||||
|
message = FavoriteTeamCheck._suggest("bos", NHL)
|
||||||
|
self.assertIn("case-sensitive", message)
|
||||||
|
self.assertIn("'BOS'", message)
|
||||||
|
|
||||||
|
def test_valid_code_draws_no_suggestion(self):
|
||||||
|
self.assertEqual(FavoriteTeamCheck._suggest("BOS", NHL), "")
|
||||||
|
|
||||||
|
def test_unmatchable_code_is_not_forced_into_a_suggestion(self):
|
||||||
|
# Nothing sensible to offer is better than something wrong.
|
||||||
|
self.assertEqual(FavoriteTeamCheck._suggest("ZZZZZZ", NHL), "")
|
||||||
|
|
||||||
|
def test_abbreviates_separates_codes_string_distance_ties(self):
|
||||||
|
# The case that motivated this: 'MUN' is equidistant from 'MAN' and
|
||||||
|
# 'SUN' by string similarity, so similarity cannot choose.
|
||||||
|
self.assertTrue(FavoriteTeamCheck._abbreviates("MUN", "Manchester United"))
|
||||||
|
self.assertFalse(FavoriteTeamCheck._abbreviates("MUN", "Sunderland"))
|
||||||
|
|
||||||
|
|
||||||
|
class CheckTests(unittest.TestCase):
|
||||||
|
"""The log output for each way a league can end up empty."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.logger = RecordingLogger()
|
||||||
|
self.checker = FavoriteTeamCheck(self.logger, {"nhl": ("NHL", "hockey/nhl")})
|
||||||
|
self.checker._fetch_teams = staticmethod(lambda path: dict(NHL))
|
||||||
|
self.checker._schedule_note = staticmethod(lambda path: None)
|
||||||
|
|
||||||
|
def test_bad_code_is_reported_with_a_suggestion(self):
|
||||||
|
self.checker._check("nhl", ["UTA", "BOS"])
|
||||||
|
warnings = self.logger.messages("WARNING")
|
||||||
|
self.assertEqual(len(warnings), 1)
|
||||||
|
self.assertIn("'UTA' is not a NHL team code", warnings[0])
|
||||||
|
self.assertIn("UTAH", warnings[0])
|
||||||
|
|
||||||
|
def test_all_codes_bad_says_nothing_will_show(self):
|
||||||
|
self.checker._check("nhl", ["UTA", "NOPE"])
|
||||||
|
joined = " ".join(self.logger.messages("WARNING"))
|
||||||
|
self.assertIn("no recognised favorite teams", joined)
|
||||||
|
self.assertIn("nothing will be shown", joined)
|
||||||
|
|
||||||
|
def test_good_codes_out_of_season_explain_the_empty_screen(self):
|
||||||
|
self.checker._schedule_note = staticmethod(
|
||||||
|
lambda path: "the league has nothing on until 07 October 2026")
|
||||||
|
self.checker._check("nhl", ["BOS"])
|
||||||
|
info = " ".join(self.logger.messages("INFO"))
|
||||||
|
self.assertIn("look correct", info)
|
||||||
|
self.assertIn("07 October 2026", info)
|
||||||
|
self.assertIn("not a configuration problem", info)
|
||||||
|
self.assertEqual(self.logger.messages("WARNING"), [])
|
||||||
|
|
||||||
|
def test_good_codes_in_season_stay_quiet(self):
|
||||||
|
self.checker._check("nhl", ["BOS", "TB"])
|
||||||
|
self.assertEqual(self.logger.messages("WARNING"), [])
|
||||||
|
self.assertIn("recognised: BOS, TB", " ".join(self.logger.messages("INFO")))
|
||||||
|
|
||||||
|
def test_dynamic_groups_are_not_treated_as_team_codes(self):
|
||||||
|
self.checker._check("nhl", ["AP_TOP_25", "NCAA_MENS_TOP_10"])
|
||||||
|
self.assertEqual(self.logger.messages("WARNING"), [])
|
||||||
|
|
||||||
|
def test_empty_roster_draws_no_conclusion(self):
|
||||||
|
# ESPN's college lacrosse endpoints return zero teams; a valid code
|
||||||
|
# must not be called wrong just because the roster is unavailable.
|
||||||
|
self.checker._fetch_teams = staticmethod(lambda path: {})
|
||||||
|
self.checker._check("nhl", ["ANYTHING"])
|
||||||
|
self.assertEqual(self.logger.records, [])
|
||||||
|
|
||||||
|
def test_fetch_failure_is_swallowed(self):
|
||||||
|
def boom(path):
|
||||||
|
raise RuntimeError("network down")
|
||||||
|
|
||||||
|
self.checker._fetch_teams = staticmethod(boom)
|
||||||
|
self.checker._check("nhl", ["BOS"]) # must not raise
|
||||||
|
self.assertEqual(self.logger.messages("WARNING"), [])
|
||||||
|
|
||||||
|
|
||||||
|
class ScheduleNoteTests(unittest.TestCase):
|
||||||
|
"""
|
||||||
|
Reading ESPN's scoreboard for "is there anything on?".
|
||||||
|
|
||||||
|
Both traps here are real API behaviour, confirmed against live endpoints:
|
||||||
|
an out-of-season league rolls forward to its next fixtures rather than
|
||||||
|
returning nothing, and a finished season returns its *last* game instead.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def note(self, payload):
|
||||||
|
"""Run _schedule_note against a fixed payload, with no network access.
|
||||||
|
|
||||||
|
The method imports ``requests`` in its own body, so the fake has to go
|
||||||
|
into ``sys.modules`` — patching the attribute on the module has
|
||||||
|
no effect on a function-local import.
|
||||||
|
"""
|
||||||
|
import types
|
||||||
|
|
||||||
|
class Response:
|
||||||
|
@staticmethod
|
||||||
|
def json():
|
||||||
|
return payload
|
||||||
|
|
||||||
|
fake = types.ModuleType("requests")
|
||||||
|
fake.get = lambda url, timeout=None: Response()
|
||||||
|
|
||||||
|
real = sys.modules.get("requests")
|
||||||
|
sys.modules["requests"] = fake
|
||||||
|
try:
|
||||||
|
return FavoriteTeamCheck._schedule_note("hockey/nhl")
|
||||||
|
finally:
|
||||||
|
if real is None:
|
||||||
|
sys.modules.pop("requests", None)
|
||||||
|
else:
|
||||||
|
sys.modules["requests"] = real
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def iso(days):
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
return (datetime.now(timezone.utc) + timedelta(days=days)).isoformat()
|
||||||
|
|
||||||
|
def test_games_today_says_nothing(self):
|
||||||
|
self.assertIsNone(self.note({"events": [{"date": self.iso(0)}]}))
|
||||||
|
|
||||||
|
def test_a_game_already_under_way_counts_as_something_on(self):
|
||||||
|
# Games that started earlier today are in the past by the clock. Reading
|
||||||
|
# them as "not upcoming" made a live slate report the season as over.
|
||||||
|
self.assertIsNone(self.note({"events": [{"date": self.iso(-0.3)}]}))
|
||||||
|
|
||||||
|
def test_an_off_day_or_two_is_not_worth_mentioning(self):
|
||||||
|
self.assertIsNone(self.note({"events": [{"date": self.iso(1)}]}))
|
||||||
|
|
||||||
|
def test_out_of_season_reports_the_next_fixture(self):
|
||||||
|
# ESPN rolls forward, so events exist but are months away.
|
||||||
|
note = self.note({"events": [{"date": self.iso(52)}, {"date": self.iso(53)}]})
|
||||||
|
self.assertIsNotNone(note)
|
||||||
|
self.assertIn("nothing on until", note)
|
||||||
|
|
||||||
|
def test_finished_season_is_reported_as_finished(self):
|
||||||
|
# A completed season returns its last game, in the past.
|
||||||
|
note = self.note({"events": [{"date": self.iso(-120)}],
|
||||||
|
"leagues": [{"calendar": [self.iso(-300)]}]})
|
||||||
|
self.assertIsNotNone(note)
|
||||||
|
self.assertIn("season has finished", note)
|
||||||
|
|
||||||
|
def test_past_dates_never_read_as_imminent(self):
|
||||||
|
# The bug this guards: taking the soonest of *all* dates makes a game
|
||||||
|
# from last March look like one happening right now, so a finished
|
||||||
|
# season silently reports itself as in progress.
|
||||||
|
note = self.note({"events": [{"date": self.iso(-120)},
|
||||||
|
{"date": self.iso(40)}]})
|
||||||
|
self.assertIn("nothing on until", note)
|
||||||
|
|
||||||
|
def test_calendar_is_used_when_there_are_no_events(self):
|
||||||
|
note = self.note({"events": [],
|
||||||
|
"leagues": [{"calendar": [{"startDate": self.iso(30)}]}]})
|
||||||
|
self.assertIn("nothing on until", note)
|
||||||
|
|
||||||
|
def test_nothing_published_draws_no_conclusion(self):
|
||||||
|
self.assertIsNone(self.note({"events": [], "leagues": [{"calendar": []}]}))
|
||||||
|
|
||||||
|
def test_unparseable_dates_are_ignored_rather_than_fatal(self):
|
||||||
|
self.assertIsNone(self.note(
|
||||||
|
{"events": [{"date": "not a date"}, {"date": None}, {}]}))
|
||||||
|
|
||||||
|
|
||||||
|
class SchedulingTests(unittest.TestCase):
|
||||||
|
"""The check must run once, off the render path, and never raise."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.logger = RecordingLogger()
|
||||||
|
self.checker = FavoriteTeamCheck(self.logger, {"nhl": ("NHL", "hockey/nhl")})
|
||||||
|
self.calls = []
|
||||||
|
self.checker._check = lambda key, favs: self.calls.append((key, list(favs)))
|
||||||
|
|
||||||
|
def drain(self):
|
||||||
|
import threading
|
||||||
|
for thread in threading.enumerate():
|
||||||
|
if thread.name == "favorite-team-check":
|
||||||
|
thread.join(timeout=5)
|
||||||
|
|
||||||
|
def test_runs_once_per_league(self):
|
||||||
|
for _ in range(5):
|
||||||
|
self.checker.schedule("nhl", ["BOS"])
|
||||||
|
self.drain()
|
||||||
|
self.assertEqual(len(self.calls), 1)
|
||||||
|
|
||||||
|
def test_reset_allows_a_recheck_after_a_config_edit(self):
|
||||||
|
self.checker.schedule("nhl", ["BOS"])
|
||||||
|
self.drain()
|
||||||
|
self.checker.reset()
|
||||||
|
self.checker.schedule("nhl", ["TB"])
|
||||||
|
self.drain()
|
||||||
|
self.assertEqual([favs for _, favs in self.calls], [["BOS"], ["TB"]])
|
||||||
|
|
||||||
|
def test_no_favorites_configured_does_nothing(self):
|
||||||
|
self.checker.schedule("nhl", [])
|
||||||
|
self.checker.schedule("nhl", None)
|
||||||
|
self.checker.schedule("nhl", ["", " "])
|
||||||
|
self.drain()
|
||||||
|
self.assertEqual(self.calls, [])
|
||||||
|
|
||||||
|
def test_unknown_league_key_is_ignored(self):
|
||||||
|
self.checker.schedule("not-a-league", ["BOS"])
|
||||||
|
self.drain()
|
||||||
|
self.assertEqual(self.calls, [])
|
||||||
|
|
||||||
|
def test_thread_is_a_daemon_so_it_cannot_hold_up_shutdown(self):
|
||||||
|
import threading
|
||||||
|
started = threading.Event()
|
||||||
|
seen = {}
|
||||||
|
|
||||||
|
def record(key, favs):
|
||||||
|
seen["daemon"] = threading.current_thread().daemon
|
||||||
|
started.set()
|
||||||
|
|
||||||
|
self.checker._check = record
|
||||||
|
self.checker.schedule("nhl", ["BOS"])
|
||||||
|
self.assertTrue(started.wait(timeout=5))
|
||||||
|
self.assertTrue(seen["daemon"])
|
||||||
|
|
||||||
|
|
||||||
|
class ScheduleNoteUsesGameDatesTests(unittest.TestCase):
|
||||||
|
"""The "nothing on until" note reports a game date, not a calendar boundary.
|
||||||
|
|
||||||
|
``_schedule_note`` used to pool ESPN's rolled-forward event dates with the
|
||||||
|
league calendar's week/phase startDates and take the earliest. Calendar
|
||||||
|
weeks routinely open days before their first game, so the note reported
|
||||||
|
"nothing on until 06 September" for a league whose first snap was the
|
||||||
|
10th. Events now win; the calendar only speaks when the scoreboard has no
|
||||||
|
events at all.
|
||||||
|
"""
|
||||||
|
|
||||||
|
note = ScheduleNoteTests.note
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def iso(base, days_out):
|
||||||
|
# Every date in a case derives from one captured *base*, so a UTC
|
||||||
|
# midnight crossing mid-test cannot make the payload and the expected
|
||||||
|
# strftime disagree about the day.
|
||||||
|
return (base + timedelta(days=days_out)).strftime("%Y-%m-%dT%H:%MZ")
|
||||||
|
|
||||||
|
def test_the_first_game_wins_over_an_earlier_calendar_boundary(self):
|
||||||
|
base = datetime.now(timezone.utc)
|
||||||
|
note = self.note({
|
||||||
|
"events": [{"date": self.iso(base, 10)}, {"date": self.iso(base, 14)}],
|
||||||
|
"leagues": [{"calendar": [{"startDate": self.iso(base, 6)}]}],
|
||||||
|
})
|
||||||
|
self.assertIn((base + timedelta(days=10)).strftime("%d %B %Y"), note)
|
||||||
|
self.assertNotIn((base + timedelta(days=6)).strftime("%d %B %Y"), note)
|
||||||
|
|
||||||
|
def test_with_no_events_the_calendar_still_gets_a_say(self):
|
||||||
|
base = datetime.now(timezone.utc)
|
||||||
|
note = self.note({"events": [], "leagues": [{"calendar": [self.iso(base, 20)]}]})
|
||||||
|
self.assertIn((base + timedelta(days=20)).strftime("%d %B %Y"), note)
|
||||||
|
|
||||||
|
def test_only_past_dates_reads_as_a_finished_season(self):
|
||||||
|
base = datetime.now(timezone.utc)
|
||||||
|
note = self.note({"events": [{"date": self.iso(base, -40)}],
|
||||||
|
"leagues": [{"calendar": []}]})
|
||||||
|
self.assertIn("finished", note)
|
||||||
|
|
||||||
|
def test_a_finished_season_is_not_dressed_up_by_an_offseason_calendar(self):
|
||||||
|
# Past events mean the season is over; a future calendar boundary
|
||||||
|
# (the draft, next season's week 1 shell) is not the next game.
|
||||||
|
base = datetime.now(timezone.utc)
|
||||||
|
note = self.note({"events": [{"date": self.iso(base, -40)}],
|
||||||
|
"leagues": [{"calendar": [{"startDate": self.iso(base, 45)}]}]})
|
||||||
|
self.assertIn("finished", note)
|
||||||
|
|
||||||
|
def test_an_imminent_slate_is_not_worth_a_note(self):
|
||||||
|
base = datetime.now(timezone.utc)
|
||||||
|
self.assertIsNone(self.note({
|
||||||
|
"events": [{"date": self.iso(base, 1)}],
|
||||||
|
"leagues": [{"calendar": [{"startDate": self.iso(base, 6)}]}],
|
||||||
|
}))
|
||||||
|
|
||||||
|
|
||||||
|
class ScheduleNotePostseasonTests(unittest.TestCase):
|
||||||
|
"""Past regular-season events are not a finished season once the postseason starts.
|
||||||
|
|
||||||
|
Captured from ESPN's MLB scoreboard on 2026-09-29, two days after the
|
||||||
|
regular season ended: the default scoreboard still returned the last
|
||||||
|
regular-season day (event ``season.type`` 2), while ``leagues[0].season``
|
||||||
|
had already moved to Postseason (type 3). The check told a Rays fan the
|
||||||
|
season was over while their upcoming manager listed TB's wild-card games.
|
||||||
|
"""
|
||||||
|
|
||||||
|
note = ScheduleNoteTests.note
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def payload(league_type, event_type, days=-2):
|
||||||
|
date = (datetime.now(timezone.utc) + timedelta(days=days)).isoformat()
|
||||||
|
return {
|
||||||
|
"events": [{"date": date, "season": {"year": 2026, "type": event_type}}],
|
||||||
|
"leagues": [{"season": {"year": 2026, "type": {"type": league_type}},
|
||||||
|
"calendar": [date]}],
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_postseason_after_regular_season_events_is_not_finished(self):
|
||||||
|
self.assertIsNone(self.note(self.payload(league_type=3, event_type=2)))
|
||||||
|
|
||||||
|
def test_a_finished_postseason_is_still_reported_as_finished(self):
|
||||||
|
# AFL the week after its grand final: league and events both postseason.
|
||||||
|
note = self.note(self.payload(league_type=3, event_type=3, days=-4))
|
||||||
|
self.assertIn("season has finished", note)
|
||||||
|
|
||||||
|
def test_offseason_after_the_postseason_is_still_finished(self):
|
||||||
|
note = self.note(self.payload(league_type=4, event_type=3, days=-40))
|
||||||
|
self.assertIn("season has finished", note)
|
||||||
|
|
||||||
|
|
||||||
|
class ScheduleNoteMatchdayTests(unittest.TestCase):
|
||||||
|
"""Between soccer matchdays the next fixture is only in the calendar.
|
||||||
|
|
||||||
|
Captured from ESPN's Premier League scoreboard on 2026-09-29: the
|
||||||
|
scoreboard still showed the 20 September matchday, the league was in the
|
||||||
|
same season phase as those events, and the next games (10 October) were
|
||||||
|
only in ``leagues[0].calendar`` -- a ``"day"`` calendar with
|
||||||
|
``calendarIsWhitelist`` true, i.e. the days that have games. The check
|
||||||
|
said the season had finished.
|
||||||
|
"""
|
||||||
|
|
||||||
|
note = ScheduleNoteTests.note
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def payload(event_days, calendar_days, whitelist=True):
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
def iso(days):
|
||||||
|
return (now + timedelta(days=days)).strftime("%Y-%m-%dT%H:%MZ")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"events": [{"date": iso(d), "season": {"type": 14308}}
|
||||||
|
for d in event_days],
|
||||||
|
"leagues": [{"season": {"type": {"type": 14308}},
|
||||||
|
"calendarType": "day",
|
||||||
|
"calendarIsWhitelist": whitelist,
|
||||||
|
"calendar": [iso(d) for d in calendar_days]}],
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_gap_between_matchdays_reports_the_next_matchday(self):
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
note = self.note(self.payload([-9], [-9, 11, 12]))
|
||||||
|
self.assertIn("nothing on until", note)
|
||||||
|
self.assertIn((datetime.now(timezone.utc) + timedelta(days=11))
|
||||||
|
.strftime("%d %B %Y"), note)
|
||||||
|
|
||||||
|
def test_next_matchday_within_a_couple_of_days_says_nothing(self):
|
||||||
|
self.assertIsNone(self.note(self.payload([-2], [-2, 1])))
|
||||||
|
|
||||||
|
def test_no_matchdays_left_is_still_finished(self):
|
||||||
|
# PLL on the same day: a match-day calendar whose last day is past.
|
||||||
|
note = self.note(self.payload([-9], [-30, -9]))
|
||||||
|
self.assertIn("season has finished", note)
|
||||||
|
|
||||||
|
def test_a_blacklist_day_calendar_is_not_read_as_fixtures(self):
|
||||||
|
# MLB's day calendar has calendarIsWhitelist false: it lists days
|
||||||
|
# without games, so a future entry there is not a next fixture.
|
||||||
|
note = self.note(self.payload([-9], [11], whitelist=False))
|
||||||
|
self.assertIn("season has finished", note)
|
||||||
|
|
||||||
|
|
||||||
|
class ScheduleNoteListCalendarTests(unittest.TestCase):
|
||||||
|
"""A round still to start in a "list" calendar is not a finished season.
|
||||||
|
|
||||||
|
Shapes captured from ESPN on 2026-09-29, with dates kept relative to that
|
||||||
|
day. The Europa League scoreboard still showed the 17 September matchday
|
||||||
|
and its calendar is a ``"list"`` of rounds, not match days, so the check
|
||||||
|
said the season had finished -- with the knockout rounds, and the next
|
||||||
|
league-phase matchday, still to come. PLL, the World Cup and AFL really had
|
||||||
|
finished and must still say so, although each has a season or round
|
||||||
|
``endDate`` in the future.
|
||||||
|
"""
|
||||||
|
|
||||||
|
note = ScheduleNoteTests.note
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def iso(days):
|
||||||
|
return (datetime.now(timezone.utc) + timedelta(days=days)).strftime(
|
||||||
|
"%Y-%m-%dT%H:%MZ")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def list_league(cls, event_days, rounds, league_type=14540,
|
||||||
|
event_type=14540, phase_label="UEFA Europa League",
|
||||||
|
extra_phases=()):
|
||||||
|
"""``rounds`` is ``[(label, start_day, end_day), ...]`` for one phase."""
|
||||||
|
return {
|
||||||
|
"events": [{"date": cls.iso(d), "season": {"type": event_type}}
|
||||||
|
for d in event_days],
|
||||||
|
"leagues": [{
|
||||||
|
"season": {"type": {"type": league_type}},
|
||||||
|
"calendarType": "list",
|
||||||
|
"calendarIsWhitelist": True,
|
||||||
|
"calendar": [{
|
||||||
|
"label": phase_label,
|
||||||
|
"startDate": cls.iso(-90), "endDate": cls.iso(275),
|
||||||
|
"entries": [{"label": label, "startDate": cls.iso(start),
|
||||||
|
"endDate": cls.iso(end)}
|
||||||
|
for label, start, end in rounds],
|
||||||
|
}] + list(extra_phases),
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_europa_between_matchdays_is_not_finished(self):
|
||||||
|
note = self.note(self.list_league([-12], [
|
||||||
|
("League Phase", -31, 123),
|
||||||
|
("Knockout Round Playoffs", 123, 151),
|
||||||
|
("Rd of 16", 151, 172),
|
||||||
|
("Quarterfinals", 172, 200),
|
||||||
|
("Semifinals", 200, 221),
|
||||||
|
("Final", 222, 275),
|
||||||
|
]))
|
||||||
|
self.assertIsNone(note)
|
||||||
|
|
||||||
|
def test_world_cup_after_the_final_is_still_finished(self):
|
||||||
|
# The competition runs to 31 December, and the last round ended 12
|
||||||
|
# days after the final; no round is still to start.
|
||||||
|
note = self.note(self.list_league([-72], [
|
||||||
|
("Group", -110, -93),
|
||||||
|
("Semifinals", -77, -72),
|
||||||
|
("Final", -72, -59),
|
||||||
|
], league_type=13803, event_type=13803, phase_label="FIFA World Cup"))
|
||||||
|
self.assertIn("season has finished", note)
|
||||||
|
|
||||||
|
def test_afl_after_the_grand_final_is_still_finished(self):
|
||||||
|
# The Grand Final round had started but had not ended yet.
|
||||||
|
note = self.note(self.list_league([-3], [
|
||||||
|
("Preliminary Finals", -13, -6),
|
||||||
|
("Grand Final", -6, 1),
|
||||||
|
], league_type=3, event_type=3, phase_label="Postseason"))
|
||||||
|
self.assertIn("season has finished", note)
|
||||||
|
|
||||||
|
def test_an_offseason_round_does_not_count(self):
|
||||||
|
# College football's "Off Season" phase holds the All-Star week.
|
||||||
|
offseason = {"label": "Off Season", "startDate": self.iso(2),
|
||||||
|
"endDate": self.iso(6),
|
||||||
|
"entries": [{"label": "All-Star", "startDate": self.iso(2),
|
||||||
|
"endDate": self.iso(6)}]}
|
||||||
|
note = self.note(self.list_league(
|
||||||
|
[-3], [("CFP", -40, 1)], league_type=3, event_type=3,
|
||||||
|
phase_label="Postseason", extra_phases=[offseason]))
|
||||||
|
self.assertIn("season has finished", note)
|
||||||
|
|
||||||
|
def test_pll_with_a_season_end_date_in_the_future_is_still_finished(self):
|
||||||
|
# A "day" whitelist whose last match day is past; the season's own
|
||||||
|
# endDate (1 January) is ignored.
|
||||||
|
note = self.note({
|
||||||
|
"events": [{"date": self.iso(-9), "season": {"type": 2}}],
|
||||||
|
"leagues": [{
|
||||||
|
"season": {"type": {"type": 2}, "startDate": self.iso(-271),
|
||||||
|
"endDate": self.iso(94)},
|
||||||
|
"calendarType": "day",
|
||||||
|
"calendarIsWhitelist": True,
|
||||||
|
"calendarEndDate": self.iso(94),
|
||||||
|
"calendar": [self.iso(-30), self.iso(-22), self.iso(-9)],
|
||||||
|
}],
|
||||||
|
})
|
||||||
|
self.assertIn("season has finished", note)
|
||||||
@@ -0,0 +1,313 @@
|
|||||||
|
"""src.common.sports_timezone: which zone a scoreboard renders start times in.
|
||||||
|
|
||||||
|
Ported from the scoreboard plugins' test_timezone_resolution.py, which test
|
||||||
|
their bundled ``<sport>_timezone.py`` copies. The copies differ only in the
|
||||||
|
two values this module takes as keyword arguments, so every test runs once per
|
||||||
|
plugin with that plugin's values (``PLUGINS``).
|
||||||
|
|
||||||
|
Regression the resolution order guards: a plugin used to read the global
|
||||||
|
timezone only from ``cache_manager.config_manager``. On cores that hang
|
||||||
|
``config_manager`` off the plugin manager instead, that lookup found nothing
|
||||||
|
and every start time was drawn in UTC.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import pytz
|
||||||
|
|
||||||
|
from src.common import sports_timezone
|
||||||
|
from src.common.sports_timezone import resolve_timezone, resolve_timezone_name
|
||||||
|
|
||||||
|
#: (plugin_label, writeback_fixed_in) as the plugins' copies carried them.
|
||||||
|
#: Only baseball and football ever wrote "UTC" back into the saved config.
|
||||||
|
PLUGINS = [
|
||||||
|
("AFL scoreboard", None),
|
||||||
|
("baseball scoreboard", "1.20.0"),
|
||||||
|
("basketball scoreboard", None),
|
||||||
|
("F1 scoreboard", None),
|
||||||
|
("football scoreboard", "2.9.0"),
|
||||||
|
("hockey scoreboard", None),
|
||||||
|
("lacrosse scoreboard", None),
|
||||||
|
("NRL scoreboard", None),
|
||||||
|
("soccer scoreboard", None),
|
||||||
|
("UFC scoreboard", None),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(params=PLUGINS, ids=[label for label, _ in PLUGINS])
|
||||||
|
def plugin(request):
|
||||||
|
label, fixed_in = request.param
|
||||||
|
return {"plugin_label": label, "writeback_fixed_in": fixed_in}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def system_zone(monkeypatch):
|
||||||
|
"""Stub system-zone detection so results don't depend on this machine."""
|
||||||
|
def stub(value):
|
||||||
|
monkeypatch.setattr(sports_timezone, "system_timezone_name", lambda: value)
|
||||||
|
stub(None)
|
||||||
|
return stub
|
||||||
|
|
||||||
|
|
||||||
|
class _ConfigManager:
|
||||||
|
"""Core ConfigManager stand-in exposing get_timezone()."""
|
||||||
|
|
||||||
|
def __init__(self, timezone=None):
|
||||||
|
self._timezone = timezone
|
||||||
|
|
||||||
|
def get_timezone(self):
|
||||||
|
return self._timezone
|
||||||
|
|
||||||
|
|
||||||
|
class _LegacyConfigManager:
|
||||||
|
"""Older core: no get_timezone(), only load_config()."""
|
||||||
|
|
||||||
|
def __init__(self, timezone=None):
|
||||||
|
self._timezone = timezone
|
||||||
|
|
||||||
|
def load_config(self):
|
||||||
|
return {"timezone": self._timezone}
|
||||||
|
|
||||||
|
|
||||||
|
class _CountingConfigManager(_ConfigManager):
|
||||||
|
"""Records how many times the core was asked for the timezone."""
|
||||||
|
|
||||||
|
calls = 0
|
||||||
|
|
||||||
|
def get_timezone(self):
|
||||||
|
self.calls += 1
|
||||||
|
return self._timezone
|
||||||
|
|
||||||
|
|
||||||
|
class _BrokenConfigManager:
|
||||||
|
"""Core whose get_timezone() blows up -- must not take the plugin down."""
|
||||||
|
|
||||||
|
def get_timezone(self):
|
||||||
|
raise RuntimeError("config not loaded")
|
||||||
|
|
||||||
|
|
||||||
|
class _RealCoreConfigManager:
|
||||||
|
"""Faithful stand-in for the shipping core ConfigManager.
|
||||||
|
|
||||||
|
The real get_timezone() is ``self.config.get('timezone', 'UTC')`` -- it
|
||||||
|
substitutes its own "UTC" when the global config has no timezone key.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, config):
|
||||||
|
self._config = config
|
||||||
|
|
||||||
|
def get_config(self):
|
||||||
|
return self._config
|
||||||
|
|
||||||
|
def load_config(self):
|
||||||
|
return self._config
|
||||||
|
|
||||||
|
def get_timezone(self):
|
||||||
|
return self._config.get("timezone", "UTC")
|
||||||
|
|
||||||
|
|
||||||
|
class _Holder:
|
||||||
|
"""Stands in for a plugin_manager / cache_manager."""
|
||||||
|
|
||||||
|
def __init__(self, config_manager=None):
|
||||||
|
if config_manager is not None:
|
||||||
|
self.config_manager = config_manager
|
||||||
|
|
||||||
|
|
||||||
|
def test_plugin_config_override_wins(plugin):
|
||||||
|
assert resolve_timezone_name(
|
||||||
|
config={"timezone": "America/Denver"},
|
||||||
|
plugin_manager=_Holder(_ConfigManager("America/New_York")),
|
||||||
|
cache_manager=_Holder(_ConfigManager("Europe/London")),
|
||||||
|
**plugin,
|
||||||
|
) == "America/Denver"
|
||||||
|
|
||||||
|
|
||||||
|
def test_lower_priority_sources_are_not_evaluated(plugin, monkeypatch):
|
||||||
|
"""SportsCore._get_timezone() runs per game; once a candidate resolves, the
|
||||||
|
remaining sources must not be touched."""
|
||||||
|
plugin_cm = _CountingConfigManager("America/New_York")
|
||||||
|
cache_cm = _CountingConfigManager("Europe/London")
|
||||||
|
system_calls = []
|
||||||
|
monkeypatch.setattr(sports_timezone, "system_timezone_name",
|
||||||
|
lambda: system_calls.append(1) or None)
|
||||||
|
name = resolve_timezone_name(
|
||||||
|
config={"timezone": "America/Chicago"},
|
||||||
|
plugin_manager=_Holder(plugin_cm),
|
||||||
|
cache_manager=_Holder(cache_cm),
|
||||||
|
**plugin,
|
||||||
|
)
|
||||||
|
assert name == "America/Chicago"
|
||||||
|
assert (plugin_cm.calls, cache_cm.calls, system_calls) == (0, 0, [])
|
||||||
|
|
||||||
|
|
||||||
|
def test_plugin_manager_config_manager_is_consulted(plugin, system_zone):
|
||||||
|
"""The regression: cache_manager has no config_manager at all."""
|
||||||
|
assert resolve_timezone_name(
|
||||||
|
config={},
|
||||||
|
plugin_manager=_Holder(_ConfigManager("America/Chicago")),
|
||||||
|
cache_manager=_Holder(),
|
||||||
|
**plugin,
|
||||||
|
) == "America/Chicago"
|
||||||
|
|
||||||
|
|
||||||
|
def test_cache_manager_config_manager_fallback(plugin, system_zone):
|
||||||
|
assert resolve_timezone_name(
|
||||||
|
config={},
|
||||||
|
plugin_manager=_Holder(),
|
||||||
|
cache_manager=_Holder(_ConfigManager("America/Chicago")),
|
||||||
|
**plugin,
|
||||||
|
) == "America/Chicago"
|
||||||
|
|
||||||
|
|
||||||
|
def test_legacy_load_config_fallback(plugin, system_zone):
|
||||||
|
assert resolve_timezone_name(
|
||||||
|
config={},
|
||||||
|
plugin_manager=_Holder(_LegacyConfigManager("America/Chicago")),
|
||||||
|
cache_manager=_Holder(),
|
||||||
|
**plugin,
|
||||||
|
) == "America/Chicago"
|
||||||
|
|
||||||
|
|
||||||
|
def test_raising_config_manager_falls_through(plugin, system_zone):
|
||||||
|
assert resolve_timezone_name(
|
||||||
|
config={},
|
||||||
|
plugin_manager=_Holder(_BrokenConfigManager()),
|
||||||
|
cache_manager=_Holder(_ConfigManager("America/Chicago")),
|
||||||
|
**plugin,
|
||||||
|
) == "America/Chicago"
|
||||||
|
|
||||||
|
|
||||||
|
def test_blank_and_invalid_values_are_skipped(plugin, system_zone, caplog):
|
||||||
|
with caplog.at_level(logging.WARNING, logger=sports_timezone.__name__):
|
||||||
|
name = resolve_timezone_name(
|
||||||
|
config={"timezone": " "},
|
||||||
|
plugin_manager=_Holder(_ConfigManager("Not/AZone")),
|
||||||
|
cache_manager=_Holder(_ConfigManager("America/Chicago")),
|
||||||
|
**plugin,
|
||||||
|
)
|
||||||
|
assert name == "America/Chicago"
|
||||||
|
assert caplog.messages == [
|
||||||
|
"Ignoring invalid timezone 'Not/AZone' from plugin_manager.config_manager"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_system_timezone_backstop(plugin, system_zone):
|
||||||
|
system_zone("America/Chicago")
|
||||||
|
assert resolve_timezone_name(
|
||||||
|
config={}, plugin_manager=_Holder(), cache_manager=_Holder(), **plugin,
|
||||||
|
) == "America/Chicago"
|
||||||
|
|
||||||
|
|
||||||
|
def test_utc_last_resort_names_the_plugin(plugin, system_zone, caplog):
|
||||||
|
with caplog.at_level(logging.WARNING, logger=sports_timezone.__name__):
|
||||||
|
name = resolve_timezone_name(config={}, **plugin)
|
||||||
|
assert name == "UTC"
|
||||||
|
# Word for word what the plugins' copies logged, with their own name in it.
|
||||||
|
assert caplog.messages == [
|
||||||
|
"Could not determine a timezone from the plugin config, the LEDMatrix "
|
||||||
|
"config or the system; game times will be shown in UTC. Set a timezone "
|
||||||
|
f"in the {plugin['plugin_label']}'s Advanced Settings to override."]
|
||||||
|
|
||||||
|
|
||||||
|
def test_log_defaults_to_this_modules_logger_and_honours_a_given_one(plugin, system_zone, caplog):
|
||||||
|
own = logging.getLogger("test.sports_timezone.own")
|
||||||
|
with caplog.at_level(logging.WARNING):
|
||||||
|
resolve_timezone_name(config={}, **plugin)
|
||||||
|
resolve_timezone_name(config={}, log=own, **plugin)
|
||||||
|
assert [r.name for r in caplog.records] == [sports_timezone.__name__, own.name]
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_timezone_returns_tzinfo_and_converts(plugin):
|
||||||
|
tz = resolve_timezone(config={"timezone": "America/Chicago"}, **plugin)
|
||||||
|
# 2026-07-28 23:45Z is a 6:45pm CDT first pitch -- the exact symptom that
|
||||||
|
# started this: a Chicago game rendering as 11:45PM.
|
||||||
|
local = datetime(2026, 7, 28, 23, 45, tzinfo=pytz.UTC).astimezone(tz)
|
||||||
|
assert local.strftime("%I:%M%p").lstrip("0") == "6:45PM"
|
||||||
|
|
||||||
|
|
||||||
|
def test_plugin_level_utc_when_the_global_config_disagrees(plugin, system_zone, caplog):
|
||||||
|
"""With the write-back bug, a bare "UTC" is its artifact and is ignored;
|
||||||
|
without it, "UTC" can only be the user's own choice and is honored."""
|
||||||
|
with caplog.at_level(logging.WARNING, logger=sports_timezone.__name__):
|
||||||
|
name = resolve_timezone_name(
|
||||||
|
config={"timezone": "UTC"},
|
||||||
|
plugin_manager=_Holder(_RealCoreConfigManager({"timezone": "America/Chicago"})),
|
||||||
|
cache_manager=_Holder(),
|
||||||
|
**plugin,
|
||||||
|
)
|
||||||
|
fixed_in = plugin["writeback_fixed_in"]
|
||||||
|
if fixed_in is None:
|
||||||
|
assert name == "UTC"
|
||||||
|
assert caplog.messages == []
|
||||||
|
else:
|
||||||
|
assert name == "America/Chicago"
|
||||||
|
assert caplog.messages == [
|
||||||
|
"Ignoring the plugin-level timezone 'UTC': it is almost certainly "
|
||||||
|
f"left over from the write-back bug fixed in {fixed_in}, and "
|
||||||
|
"plugin_manager.config_manager says America/Chicago. Using "
|
||||||
|
"America/Chicago. If you really do want UTC here, set this plugin's "
|
||||||
|
"timezone to 'Etc/UTC' instead."]
|
||||||
|
|
||||||
|
|
||||||
|
def test_plugin_level_utc_against_the_system_zone(plugin, system_zone):
|
||||||
|
system_zone("America/Chicago")
|
||||||
|
name = resolve_timezone_name(
|
||||||
|
config={"timezone": "UTC"}, plugin_manager=_Holder(), cache_manager=_Holder(),
|
||||||
|
**plugin,
|
||||||
|
)
|
||||||
|
assert name == ("UTC" if plugin["writeback_fixed_in"] is None else "America/Chicago")
|
||||||
|
|
||||||
|
|
||||||
|
def test_utc_is_kept_when_nothing_disagrees(plugin, system_zone):
|
||||||
|
"""A genuinely-UTC device must not be dragged off UTC."""
|
||||||
|
system_zone("UTC")
|
||||||
|
assert resolve_timezone_name(
|
||||||
|
config={"timezone": "UTC"},
|
||||||
|
plugin_manager=_Holder(_RealCoreConfigManager({"timezone": "UTC"})),
|
||||||
|
cache_manager=_Holder(),
|
||||||
|
**plugin,
|
||||||
|
) == "UTC"
|
||||||
|
|
||||||
|
|
||||||
|
def test_etc_utc_is_always_honored(plugin):
|
||||||
|
"""The unambiguous opt-in the write-back bug could never have produced."""
|
||||||
|
assert resolve_timezone_name(
|
||||||
|
config={"timezone": "Etc/UTC"},
|
||||||
|
plugin_manager=_Holder(_RealCoreConfigManager({"timezone": "America/Chicago"})),
|
||||||
|
cache_manager=_Holder(),
|
||||||
|
**plugin,
|
||||||
|
) == "Etc/UTC"
|
||||||
|
|
||||||
|
|
||||||
|
def test_absent_global_key_falls_through_to_system_zone(plugin, system_zone):
|
||||||
|
"""The core's get_timezone() returns its own 'UTC' default for a config
|
||||||
|
with no timezone key; that must not mask the system zone."""
|
||||||
|
system_zone("America/Chicago")
|
||||||
|
assert resolve_timezone_name(
|
||||||
|
config={},
|
||||||
|
plugin_manager=_Holder(_RealCoreConfigManager({"display": {}})),
|
||||||
|
cache_manager=_Holder(),
|
||||||
|
**plugin,
|
||||||
|
) == "America/Chicago"
|
||||||
|
|
||||||
|
|
||||||
|
def test_present_global_key_still_wins_over_system_zone(plugin, system_zone):
|
||||||
|
system_zone("America/Denver")
|
||||||
|
assert resolve_timezone_name(
|
||||||
|
config={},
|
||||||
|
plugin_manager=_Holder(_RealCoreConfigManager({"timezone": "America/Chicago"})),
|
||||||
|
cache_manager=_Holder(),
|
||||||
|
**plugin,
|
||||||
|
) == "America/Chicago"
|
||||||
|
|
||||||
|
|
||||||
|
def test_plugin_label_is_required():
|
||||||
|
with pytest.raises(TypeError):
|
||||||
|
resolve_timezone_name(config={}) # type: ignore[call-arg]
|
||||||
|
|
||||||
|
|
||||||
|
def test_system_timezone_name_is_a_string_or_none():
|
||||||
|
value = sports_timezone.system_timezone_name()
|
||||||
|
assert value is None or isinstance(value, str)
|
||||||
Reference in New Issue
Block a user