diff --git a/plugins.json b/plugins.json index 41ef668..1ed2eac 100644 --- a/plugins.json +++ b/plugins.json @@ -76,7 +76,7 @@ "last_updated": "2026-07-17", "verified": true, "screenshot": "", - "latest_version": "1.19.3" + "latest_version": "1.20.0" }, { "id": "basketball-scoreboard", diff --git a/plugins/baseball-scoreboard/CHANGELOG.md b/plugins/baseball-scoreboard/CHANGELOG.md index 99286bb..3c58c1c 100644 --- a/plugins/baseball-scoreboard/CHANGELOG.md +++ b/plugins/baseball-scoreboard/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## [1.20.0] - 2026-07-28 + +### Fixed +- **Game start times shown in UTC**: The plugin read the LEDMatrix global timezone only from `cache_manager.config_manager`. On cores that hang `config_manager` off the plugin manager instead, that lookup came back empty and every start time was rendered in UTC — a 6:45pm Central first pitch displayed as `11:45PM` — while plugins that check the plugin manager first (clock-simple, geochron) showed the correct local time on the same device. Timezone resolution now lives in one place (`baseball_timezone.py`) shared by the switch-mode scorebug, the scroll-mode game card and the plugin manager, and tries, in order: the plugin's own `timezone` setting, `plugin_manager.config_manager`, `cache_manager.config_manager`, the host system zone (`TZ`, `/etc/timezone`, `/etc/localtime`), and only then UTC. +- **`timezone` setting was silently discarded**: The key was never declared in `config_schema.json`, which sets `additionalProperties: false`, so hand-editing it in the saved config had no effect. It is now a documented string property under Advanced Settings. +- **Plugin no longer writes a timezone back into your config**: The manager used to assign `self.config["timezone"]`, mutating the dict the core handed it and persisting a bogus `"timezone": "UTC"` into the saved plugin config. The resolved value is now kept on the instance and passed to sub-components via a copy. + +### Added +- `timezone` (Advanced Settings): optional IANA zone override, e.g. `America/Chicago`. Blank (the default) follows the LEDMatrix global timezone. + ## [1.19.0] - 2026-07-09 ### Added diff --git a/plugins/baseball-scoreboard/README.md b/plugins/baseball-scoreboard/README.md index 606ee3c..e03a0f3 100644 --- a/plugins/baseball-scoreboard/README.md +++ b/plugins/baseball-scoreboard/README.md @@ -32,6 +32,10 @@ A plugin for LEDMatrix that displays live, recent, and upcoming baseball games a - `show_records`: Display team win-loss records (default: false) - `show_ranking`: Display team rankings when available (default: false) - `background_service`: Configure API request settings +- `timezone` (Advanced): IANA name used to display game start times, e.g. + `America/Chicago`. Leave blank (the default) to follow the LEDMatrix global + timezone; if that isn't set, the host system's timezone is used, and only if + neither is available do times fall back to UTC. ### Per-League Settings @@ -401,6 +405,12 @@ service. ## Troubleshooting +- **Game times look like UTC** (a 6:45pm Central first pitch showing as + 11:45PM): the plugin couldn't read your global timezone. Set `timezone` + under the plugin's Advanced Settings to your IANA zone, e.g. + `America/Chicago`. If your config already has a `timezone` entry stuck on + `"UTC"` from a version before 1.20.0, clear it or set it to your zone — + an explicit value there overrides everything else. - **No games showing**: Check if leagues are enabled and API endpoints are accessible - **Missing team logos**: Ensure team logo files exist in your assets/sports/ directory - **Slow updates**: Adjust the update interval in league configuration diff --git a/plugins/baseball-scoreboard/baseball_timezone.py b/plugins/baseball-scoreboard/baseball_timezone.py new file mode 100644 index 0000000..1777925 --- /dev/null +++ b/plugins/baseball-scoreboard/baseball_timezone.py @@ -0,0 +1,162 @@ +"""Timezone resolution for the baseball scoreboard plugin. + +Game start times arrive from ESPN/MLB 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 the switch-mode scorebug (``sports.py``), the scroll-mode game card +(``game_renderer.py``) and the plugin manager all agree. + +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 +this plugin 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. + +Module name is plugin-prefixed on purpose -- several plugins ship identically +named top-level modules and the core loads them as bare names (see +``scripts/check_module_collisions.py``). +""" + +import logging +import os +from typing import Any, Dict, Optional + +import pytz + +logger = logging.getLogger(__name__) + + +def _from_config_manager(config_manager: Any, log: logging.Logger) -> Optional[str]: + """Pull a timezone name out of a core ConfigManager, if it offers one.""" + if config_manager is None: + 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) + + # Older cores expose the raw config instead of a get_timezone() helper. + for loader_name in ("load_config", "get_config"): + loader = getattr(config_manager, loader_name, None) + if not callable(loader): + continue + try: + main_config = loader() or {} + name = main_config.get("timezone") + if name: + return name + except Exception: + log.debug("config_manager.%s() failed", loader_name, 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 resolve_timezone_name( + config: Optional[Dict[str, Any]] = None, + plugin_manager: Any = None, + cache_manager: Any = None, + log: Optional[logging.Logger] = 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. + """ + log = log or logger + + def candidates(): + """Yield (source, name) lazily. + + ``SportsCore._get_timezone()`` runs this once per game, and the answer + is almost always the first candidate. Evaluating the sources on demand + keeps the common case from calling into both config managers and + stat-ing the host timezone files every time. + """ + yield "plugin config", (config or {}).get("timezone") + 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() + + for source, name in candidates(): + if not isinstance(name, str): + continue + name = name.strip() + if not name: + continue + try: + pytz.timezone(name) + except Exception: + log.warning("Ignoring invalid timezone %r from %s", name, source) + continue + 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 baseball scoreboard's Advanced Settings to override." + ) + return "UTC" + + +def resolve_timezone( + config: Optional[Dict[str, Any]] = None, + plugin_manager: Any = None, + cache_manager: Any = None, + log: Optional[logging.Logger] = 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, + ) + ) diff --git a/plugins/baseball-scoreboard/config_schema.json b/plugins/baseball-scoreboard/config_schema.json index 1b6605b..6ab6b70 100644 --- a/plugins/baseball-scoreboard/config_schema.json +++ b/plugins/baseball-scoreboard/config_schema.json @@ -31,6 +31,12 @@ "maximum": 60, "description": "Duration in seconds to show each individual game before rotating to the next game within the same mode" }, + "timezone": { + "x-advanced": true, + "type": "string", + "default": "", + "description": "IANA timezone used to display game start times (e.g. America/Chicago). Leave blank to follow the LEDMatrix global timezone, or the system timezone if none is set." + }, "mlb": { "type": "object", "title": "MLB Settings", diff --git a/plugins/baseball-scoreboard/game_renderer.py b/plugins/baseball-scoreboard/game_renderer.py index 0e30022..402441f 100644 --- a/plugins/baseball-scoreboard/game_renderer.py +++ b/plugins/baseball-scoreboard/game_renderer.py @@ -11,9 +11,10 @@ import os from typing import Any, Dict, Optional -import pytz from PIL import Image, ImageDraw, ImageFont +from baseball_timezone import resolve_timezone + # Maps the core FontManager's common-font family aliases (src/font_manager.py # `common_fonts`) to the real files in assets/fonts. A font config value may be # either one of these family names or a literal filename; aliases resolve here, @@ -484,12 +485,7 @@ def _render_upcoming_game(self, game: Dict) -> Image.Image: if start_time: try: dt = datetime.fromisoformat(start_time.replace('Z', '+00:00')) - tz_name = self.config.get('timezone') or 'UTC' - try: - local_tz = pytz.timezone(tz_name) - except pytz.UnknownTimeZoneError: - self.logger.warning("Unknown timezone %r; falling back to UTC", tz_name) - local_tz = pytz.UTC + local_tz = resolve_timezone(config=self.config, log=self.logger) dt_local = dt.astimezone(local_tz) game_date = dt_local.strftime('%b %d') game_time = dt_local.strftime('%-I:%M %p') diff --git a/plugins/baseball-scoreboard/manager.py b/plugins/baseball-scoreboard/manager.py index 7af5cba..daa7fd2 100644 --- a/plugins/baseball-scoreboard/manager.py +++ b/plugins/baseball-scoreboard/manager.py @@ -50,6 +50,7 @@ NCAABaseballUpcomingManager, ) from milb_managers import MiLBLiveManager, MiLBRecentManager, MiLBUpcomingManager +from baseball_timezone import resolve_timezone_name # Import scroll display components try: @@ -92,22 +93,17 @@ def __init__( self.logger = logger - # Resolve timezone: plugin config → global config → UTC. - # Inject into self.config so all sub-components (scroll display, game - # renderer, etc.) can read it via config.get('timezone'). - if not self.config.get("timezone"): - global_tz = None - config_manager = getattr(cache_manager, "config_manager", None) - if config_manager is not None: - try: - global_tz = config_manager.get_timezone() - except (AttributeError, TypeError): - self.logger.debug("Global timezone unavailable; falling back to UTC") - except Exception: - self.logger.exception( - "Failed to read global timezone from config_manager.get_timezone(); falling back to UTC." - ) - self.config["timezone"] = global_tz or "UTC" + # Resolve timezone: plugin override → global config (either manager) → + # system zone → UTC. Kept on the instance rather than written back into + # self.config: mutating the dict the core handed us used to persist a + # bogus "timezone": "UTC" into the user's saved plugin config. + self.timezone_str = resolve_timezone_name( + config=self.config, + plugin_manager=plugin_manager, + cache_manager=cache_manager, + log=self.logger, + ) + self.logger.info(f"Baseball scoreboard using timezone: {self.timezone_str}") # Basic configuration self.is_enabled = config.get("enabled", True) @@ -174,7 +170,7 @@ def __init__( try: self._scroll_manager = ScrollDisplayManager( self.display_manager, - self.config, + self._sub_component_config(), self.logger ) self.logger.info("Scroll display manager initialized") @@ -261,19 +257,12 @@ def on_config_change(self, new_config: Dict[str, Any]) -> None: self.config = new_config or {} # Resolve timezone the same way __init__ does so sub-managers inherit it. - if not self.config.get("timezone"): - global_tz = None - config_manager = getattr(self.cache_manager, "config_manager", None) - if config_manager is not None: - try: - global_tz = config_manager.get_timezone() - except (AttributeError, TypeError): - self.logger.debug("Global timezone unavailable; falling back to UTC") - except Exception: - self.logger.exception( - "Failed to read global timezone from config_manager.get_timezone(); falling back to UTC." - ) - self.config["timezone"] = global_tz or "UTC" + self.timezone_str = resolve_timezone_name( + config=self.config, + plugin_manager=self.plugin_manager, + cache_manager=self.cache_manager, + log=self.logger, + ) # Re-derive scalar settings. self.enabled = self.config.get("enabled", getattr(self, "enabled", True)) @@ -301,7 +290,7 @@ def on_config_change(self, new_config: Dict[str, Any]) -> None: if SCROLL_AVAILABLE and ScrollDisplayManager: try: self._scroll_manager = ScrollDisplayManager( - self.display_manager, self.config, self.logger + self.display_manager, self._sub_component_config(), self.logger ) except Exception as e: self.logger.warning(f"Could not rebuild scroll display manager: {e}") @@ -724,13 +713,9 @@ def _adapt_config_for_manager(self, league: str) -> Dict[str, Any]: } } - # Add global config - get timezone from cache_manager's config_manager if available - timezone_str = self.config.get("timezone") - if not timezone_str and hasattr(self.cache_manager, 'config_manager'): - timezone_str = self.cache_manager.config_manager.get_timezone() - if not timezone_str: - timezone_str = "UTC" - + # Timezone was resolved once at init/config-change time. + timezone_str = self.timezone_str + # Get display config from main config if available display_config = self.config.get("display", {}) if not display_config and hasattr(self.cache_manager, 'config_manager'): @@ -750,7 +735,17 @@ def _adapt_config_for_manager(self, league: str) -> Dict[str, Any]: self.logger.debug(f"Using timezone: {timezone_str} for {league} managers") return manager_config - + + def _sub_component_config(self) -> Dict[str, Any]: + """Plugin config with the resolved timezone folded in. + + Sub-components that receive the whole config (the scroll display manager + and, through it, the game card renderer) read ``config['timezone']``. + Hand them a copy rather than mutating the core's dict, which would leak + the resolved value back into the user's saved config. + """ + return {**self.config, "timezone": self.timezone_str} + def _parse_display_mode_settings(self) -> Dict[str, Dict[str, str]]: """ Parse display mode settings from config. diff --git a/plugins/baseball-scoreboard/manifest.json b/plugins/baseball-scoreboard/manifest.json index c69c690..8ff42bb 100644 --- a/plugins/baseball-scoreboard/manifest.json +++ b/plugins/baseball-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "baseball-scoreboard", "name": "Baseball Scoreboard", - "version": "1.19.3", + "version": "1.20.0", "author": "ChuckBuilds", "description": "Live, recent, and upcoming baseball games across MLB, MiLB, and NCAA Baseball with real-time scores and schedules", "category": "sports", @@ -30,6 +30,12 @@ "branch": "main", "plugin_path": "plugins/baseball-scoreboard", "versions": [ + { + "released": "2026-07-28", + "version": "1.20.0", + "notes": "Fix game start times rendering in UTC. The plugin looked for the LEDMatrix global timezone only on cache_manager.config_manager; on cores that expose config_manager via the plugin manager instead, that lookup found nothing and every start time was drawn in UTC (a 6:45pm Central first pitch showed as 11:45PM) even though the clock plugin was correct on the same device. Timezone resolution now checks the plugin override, then both config managers, then the host system zone, before falling back to UTC. Adds a documented timezone setting under Advanced Settings -- previously the key was rejected by the config schema, so hand-editing it had no effect -- and stops the plugin writing a resolved timezone back into the saved config.", + "ledmatrix_min": "2.0.0" + }, { "released": "2026-07-18", "version": "1.19.3", diff --git a/plugins/baseball-scoreboard/sports.py b/plugins/baseball-scoreboard/sports.py index 3f959ee..ea09363 100644 --- a/plugins/baseball-scoreboard/sports.py +++ b/plugins/baseball-scoreboard/sports.py @@ -38,6 +38,7 @@ def resolve_font_name(font_name: str) -> str: from logo_downloader import LogoDownloader, download_missing_logo from base_odds_manager import BaseOddsManager from data_sources import ESPNDataSource +from baseball_timezone import resolve_timezone class SportsCore(ABC): @@ -704,22 +705,17 @@ def fetch_odds(): ) def _get_timezone(self): - """Get timezone from config, with fallback to cache_manager's config_manager.""" - try: - # First try plugin config - timezone_str = self.config.get("timezone") - # If not in plugin config, try to get from cache_manager's config_manager - if not timezone_str and hasattr(self, 'cache_manager') and hasattr(self.cache_manager, 'config_manager'): - timezone_str = self.cache_manager.config_manager.get_timezone() - # Final fallback to UTC - if not timezone_str: - timezone_str = "UTC" - - self.logger.debug(f"Using timezone: {timezone_str}") - return pytz.timezone(timezone_str) - except pytz.UnknownTimeZoneError: - self.logger.warning(f"Unknown timezone: {timezone_str}, falling back to UTC") - return pytz.utc + """Timezone game start times are rendered in. + + Normally the plugin manager has already resolved this and passed it down + in ``config['timezone']``; the shared resolver re-derives it from the + core config or the host system if it hasn't. + """ + return resolve_timezone( + config=self.config, + cache_manager=getattr(self, "cache_manager", None), + log=self.logger, + ) def _should_log(self, warning_type: str, cooldown: int = 60) -> bool: """Check if we should log a warning based on cooldown period.""" diff --git a/plugins/baseball-scoreboard/test_timezone_resolution.py b/plugins/baseball-scoreboard/test_timezone_resolution.py new file mode 100644 index 0000000..b8a3066 --- /dev/null +++ b/plugins/baseball-scoreboard/test_timezone_resolution.py @@ -0,0 +1,223 @@ +#!/usr/bin/env python3 +""" +Tests for baseball_timezone.resolve_timezone_name -- the resolution order that +decides which zone game start times are rendered in. + +Regression under test: the 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, while plugins that check ``plugin_manager`` first (clock-simple, +geochron) showed the correct local time on the same device. + +Run: /bin/python plugins/baseball-scoreboard/test_timezone_resolution.py +""" + +import sys +from pathlib import Path + +plugin_dir = Path(__file__).parent +sys.path.insert(0, str(plugin_dir)) + +import baseball_timezone # noqa: E402 +from baseball_timezone import resolve_timezone, resolve_timezone_name # noqa: E402 + + +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: + """Records how many times the core was asked for the timezone.""" + + def __init__(self, timezone=None): + self._timezone = timezone + self.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 _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 + + +# Kept so tests that stub system-zone detection can restore it, and so the +# results don't depend on the machine the suite runs on. +_real_system_timezone_name = baseball_timezone.system_timezone_name + + +def test_plugin_config_override_wins(): + name = resolve_timezone_name( + config={"timezone": "America/Denver"}, + plugin_manager=_Holder(_ConfigManager("America/New_York")), + cache_manager=_Holder(_ConfigManager("Europe/London")), + ) + assert name == "America/Denver", name + print("✓ explicit plugin-level timezone wins") + + +def test_lower_priority_sources_are_not_evaluated(): + """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 = [] + + baseball_timezone.system_timezone_name = lambda: system_calls.append(1) or None + try: + name = resolve_timezone_name( + config={"timezone": "America/Chicago"}, + plugin_manager=_Holder(plugin_cm), + cache_manager=_Holder(cache_cm), + ) + finally: + baseball_timezone.system_timezone_name = _real_system_timezone_name + + assert name == "America/Chicago", name + assert plugin_cm.calls == 0, plugin_cm.calls + assert cache_cm.calls == 0, cache_cm.calls + assert system_calls == [], system_calls + print("✓ resolution stops at the first valid source") + + +def test_plugin_manager_config_manager_is_consulted(): + """The regression: cache_manager has no config_manager at all.""" + name = resolve_timezone_name( + config={}, + plugin_manager=_Holder(_ConfigManager("America/Chicago")), + cache_manager=_Holder(), + ) + assert name == "America/Chicago", name + print("✓ global timezone read from plugin_manager.config_manager") + + +def test_cache_manager_config_manager_fallback(): + name = resolve_timezone_name( + config={}, + plugin_manager=_Holder(), + cache_manager=_Holder(_ConfigManager("America/Chicago")), + ) + assert name == "America/Chicago", name + print("✓ global timezone read from cache_manager.config_manager") + + +def test_legacy_load_config_fallback(): + name = resolve_timezone_name( + config={}, + plugin_manager=_Holder(_LegacyConfigManager("America/Chicago")), + cache_manager=_Holder(), + ) + assert name == "America/Chicago", name + print("✓ global timezone read from legacy load_config()") + + +def test_raising_config_manager_falls_through(): + name = resolve_timezone_name( + config={}, + plugin_manager=_Holder(_BrokenConfigManager()), + cache_manager=_Holder(_ConfigManager("America/Chicago")), + ) + assert name == "America/Chicago", name + print("✓ a raising get_timezone() falls through instead of propagating") + + +def test_blank_and_invalid_values_are_skipped(): + name = resolve_timezone_name( + config={"timezone": " "}, + plugin_manager=_Holder(_ConfigManager("Not/AZone")), + cache_manager=_Holder(_ConfigManager("America/Chicago")), + ) + assert name == "America/Chicago", name + print("✓ blank and invalid timezone values are skipped") + + +def test_system_timezone_backstop(): + baseball_timezone.system_timezone_name = lambda: "America/Chicago" + try: + name = resolve_timezone_name(config={}, plugin_manager=_Holder(), cache_manager=_Holder()) + finally: + baseball_timezone.system_timezone_name = _real_system_timezone_name + assert name == "America/Chicago", name + print("✓ system timezone used when no config_manager is reachable") + + +def test_utc_last_resort(): + baseball_timezone.system_timezone_name = lambda: None + try: + name = resolve_timezone_name(config={}, plugin_manager=None, cache_manager=None) + finally: + baseball_timezone.system_timezone_name = _real_system_timezone_name + assert name == "UTC", name + print("✓ falls back to UTC when every source is unavailable") + + +def test_resolve_timezone_returns_tzinfo_and_converts(): + from datetime import datetime + + import pytz + + tz = resolve_timezone(config={"timezone": "America/Chicago"}) + # 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. + utc_start = datetime(2026, 7, 28, 23, 45, tzinfo=pytz.UTC) + local = utc_start.astimezone(tz) + assert local.strftime("%I:%M%p").lstrip("0") == "6:45PM", local + print("✓ resolve_timezone() converts a UTC start time to local") + + +def test_system_timezone_name_is_a_string_or_none(): + value = _real_system_timezone_name() + assert value is None or isinstance(value, str), value + print(f"✓ system_timezone_name() -> {value!r}") + + +def main(): + tests = [ + test_plugin_config_override_wins, + test_lower_priority_sources_are_not_evaluated, + test_plugin_manager_config_manager_is_consulted, + test_cache_manager_config_manager_fallback, + test_legacy_load_config_fallback, + test_raising_config_manager_falls_through, + test_blank_and_invalid_values_are_skipped, + test_system_timezone_backstop, + test_utc_last_resort, + test_resolve_timezone_returns_tzinfo_and_converts, + test_system_timezone_name_is_a_string_or_none, + ] + for test in tests: + test() + print(f"\nAll {len(tests)} timezone resolution tests passed") + return 0 + + +if __name__ == "__main__": + sys.exit(main())