Compare commits

...
2 Commits
Author SHA1 Message Date
ChuckandClaude Opus 5.5 a11412dabb chore: prepare the 3.6.0 release (#666)
Turns the CHANGELOG's Unreleased section into ## 3.6.0 and bumps
src.__version__, the value plugin ledmatrix_min_version floors compare
against. 3.6.0 ships the two modules from #665 (favorite_team_check,
sports_timezone); nothing else has changed since 3.5.0. src/common/README.md
and docs/SPORTS_UNIFICATION.md say 3.6.0 for them instead of Unreleased.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 09:28:21 -04:00
ChuckandClaude Opus 5.5 fe5bed2886 feat(common): favorite_team_check and sports_timezone, promoted from the scoreboards (sports consolidation stage 2) (#665)
* feat(common): favorite_team_check and sports_timezone, promoted from the scoreboards (sports consolidation stage 2)

Two new hardware-free modules, taken from files the scoreboard plugins carry
as copies:

- src/common/favorite_team_check.py: FavoriteTeamCheck(logger, leagues), the
  seven byte-identical <sport>_favorite_check.py copies. Same code; the only
  additions are two type annotations (for the mypy ratchet).
- src/common/sports_timezone.py: resolve_timezone_name(), resolve_timezone(),
  system_timezone_name(), from the ten <sport>_timezone.py copies. They
  differed only in the plugin label named in the nothing-resolved warning and
  the write-back-bug values, which become keyword-only arguments
  (plugin_label, writeback_fixed_in). Same resolution order and log text.

Tests are ported from the plugins' own (test_favorite_check.py,
test_schedule_note_uses_game_dates.py, test_timezone_resolution.py; the
timezone ones run once per plugin's values and pin the exact warning text).
Both modules are on the mypy ratchet, in src/common/README.md, the CHANGELOG's
Unreleased section and SPORTS_UNIFICATION's module table. Nothing in core uses
them yet.

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

* docs(common): bdf_font and json_body shipped in 3.5.0

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

* chore(common): annotate the favourite check's deliberate except/pass for Bandit

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 09:05:10 -04:00
9 changed files with 1308 additions and 3 deletions
+21
View File
@@ -19,6 +19,27 @@ accepts both, but the store flags the old spelling as deprecated
## Unreleased
## 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
New modules a plugin may import via `src.*` (floor on 3.5.0):
+2
View File
@@ -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_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 |
| `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).
+2
View File
@@ -21,6 +21,7 @@ src/common/__init__.py
src/common/api_helper.py
src/common/bdf_font.py
src/common/espn_dates.py
src/common/favorite_team_check.py
src/common/font_layout.py
src/common/frame_timing.py
src/common/json_body.py
@@ -32,6 +33,7 @@ src/common/scroll_config.py
src/common/snapshot_policy.py
src/common/sports_card.py
src/common/sports_scroll.py
src/common/sports_timezone.py
src/config_service.py
src/core_config_keys.py
src/deprecation.py
+1 -1
View File
@@ -4,5 +4,5 @@ LEDMatrix Display System
Core source package for the LED Matrix Display project.
"""
__version__ = "3.5.0"
__version__ = "3.6.0"
+27 -2
View File
@@ -24,11 +24,12 @@ Rules for the package:
| Module | For | Plugins import it? | Since |
|---|---|---|---|
| [`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 |
| [`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 |
| [`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 | — |
| [`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 | — |
@@ -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_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_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 |
| [`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.
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.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
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.py`](sync_manager.py). Core-internal. `DisplaySyncManager`
+325
View File
@@ -0,0 +1,325 @@
"""
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.
if any(event_dates):
upcoming = future(event_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.
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 _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)
+262
View File
@@ -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,
)
)
+355
View File
@@ -0,0 +1,355 @@
"""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)}]}],
}))
+313
View File
@@ -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)