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>
This commit is contained in:
Chuck
2026-09-29 09:05:10 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 5b30052b59
commit fe5bed2886
8 changed files with 1304 additions and 2 deletions
+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) | Unreleased |
| [`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) | Unreleased |
| [`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,
)
)