Files
LEDMatrix/src/base_classes/sports/core.py
T
d6c5f97c13 Test suite overhaul + fixes for the three bugs it uncovered (#441)
* ci: run the whole test tree and make the plugin-safety job assert something real

The unit-tests CI job ran an explicit 24-file allowlist that had rotted:
63 of 90 test files (display, vegas, store manager, web API, web_interface)
never ran on a PR. The job now runs all of test/ (minus test/plugins, which
the plugin-safety job owns) so new test files are enrolled by default and
any exclusion needs a visible, commented --ignore.

The plugin-safety job was a green no-op: plugins/ is empty in CI, so every
test skipped with 'Manifest not found'. It now renders a bundled
deterministic fixture plugin (test/fixtures/plugins/ci-fixture-plugin,
golden images included for all 8 default sizes) via LEDMATRIX_PLUGINS_DIR,
and sets LEDMATRIX_REQUIRE_PLUGINS=1 so discovering zero plugins fails
loudly instead of skipping green. The per-plugin suites document that they
target dev machines with real plugins installed.

Coverage is now measured and enforced in exactly one place — the CI
unit-tests step (--cov=src --cov=web_interface --cov-fail-under=45, from a
measured 47% baseline). pytest.ini previously declared --cov-fail-under=30
but CI always passed --no-cov, so the gate had never run anywhere; local
pytest is now coverage-free and fast.

Enabling the 63 unenrolled files surfaced three cases of test rot, fixed
here: test_display_controller_vegas_tick.py could not collect without the
hardware rgbmatrix module (now uses the emulator convention), the
state-reconciliation unrecoverable-cache tests broke when production added
the is_plugin_uninstalled tombstone check (bare Mock returned truthy),
and test_get_system_status assumed the optional psutil dependency
(now installed via requirements-test.txt and guarded by importorskip).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NohXi78cwsAKtN1sCfxjUh

* test: replace can't-fail tests with real assertions

test_font_manager.py was 5 of 6 tests shaped as 'try: call(); assert True /
except: assert True' — running in CI while unable to fail on any
regression. Rewritten against the real FontManager API and the bundled
assets/fonts: returned font types, cache-hit identity, distinct entries per
size, default-font fallback for unknown families and corrupt files
(recorded in failed_loads), BDF native-size reading, text measurement, and
cache lifecycle.

test_display_manager.py's test_draw_text ended in 'assert True'; it now
renders onto a known-black canvas and asserts pixels were actually lit —
which required un-breaking the fixture's freetype MagicMock so draw_text's
isinstance check doesn't silently swallow the draw.

test_display_controller.py carried a permanently-skipped test whose skip
reason already declared it redundant; deleted.

Both display test files now set EMULATOR=true before importing
display_manager (the same convention as test_display_dirty_tracking.py) so
they collect standalone instead of depending on which test module imports
display_manager first.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NohXi78cwsAKtN1sCfxjUh

* test: cover the untested fragile logic (compatibility gate, secrets, config merges, durations, skin cards)

New unit tests for pure or filesystem-only logic that previously had zero
direct coverage:

- test_compatibility.py: the semver install gate (parse_semver suffix
  handling, every range operator, TRUSTWORTHY_FLOOR behavior for cores
  reporting untrustworthy versions, 'more restrictive wins', and the
  malformed-manifest shapes that used to raise).
- test/web_interface/test_secret_helpers.py: the canonical x-secret
  helpers — find/separate/mask/remove, array-item secrets, no input
  mutation, and a separate->recombine round-trip.
- test/web_interface/test_api_v3_helpers.py: the module-level helpers
  behind the plugin config save endpoint (_is_plugin_update_available,
  _coerce_to_bool including the int==1 quirk, deep_merge including its
  shared-subtree shallowness, _parse_form_value, dotted-key-aware
  _get_schema_property/_set_nested_value).
- test_base_plugin_duration.py: get_display_duration's full coercion
  ladder (instance attr -> config -> 15.0), including the bool-is-int
  quirk where display_duration=True means one second.
- test_config_manager_secrets.py: the secrets round-trip — deep-merge on
  load, strip on save, group pruning, the load fast path — and two
  characterized sharp edges marked SUSPECTED BUG: an unreadable secrets
  file at save time writes secrets into config.json in plaintext, and a
  same-mtime-same-size content swap is served stale.
- test_schema_manager_merge.py: merge_with_defaults branch behavior (None
  replacement vs falsey preservation, dict-vs-scalar mismatches, arrays
  replaced wholesale, defaults never mutated).
- test_skin_system.py (extended): render_skin_card shares _render_game's
  3-strike counter but never resets it on success — the asymmetry is
  pinned in both directions, along with card fallthrough and the disable
  interaction between the two paths.

Suspected bugs are characterized, not fixed — each carries a comment so a
future behavior change is deliberate rather than accidental.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NohXi78cwsAKtN1sCfxjUh

* test: add drift guards for cross-file contracts

Three guard suites that pin contracts spanning multiple files, where one
side changing unilaterally breaks the other silently:

- test_version_comparison_consistency.py: the repo's four version
  comparators (compatibility.parse_semver, api_v3's packaging-based
  _is_plugin_update_available, store_manager update_plugin's raw string
  equality, skin_runtime._major) answer differently on the same inputs.
  A table pins each one's verdict; update_plugin is driven through its
  real code path to show the SUSPECTED BUGs: 'v1.2.0' vs '1.2.0'
  triggers a full reinstall the UI calls unnecessary, and a locally-ahead
  plugin gets downgraded. A pairwise-ordering check keeps parse_semver
  agreeing with packaging on plain X.Y.Z.
- test/web_interface/test_secret_separation_parity.py: api_v3.py carries
  three inline copies of find_secret_fields/separate_secrets that lack
  the canonical module's array-item support. The copy count is asserted
  exact (it may only go down; new copies must import
  src/web_interface/secret_helpers), the missing-array-support gap is
  asserted so it can't grow silently, and the canonical behavior that
  migration will adopt is documented executably.
- test_discovery_path_contract.py: the three 'where is plugin X'
  resolvers (PluginManager discovery, StoreManager._find_plugin_path,
  SchemaManager.get_schema_path) agree on the configured directory, and
  their divergent fallback chains are characterized. Also pins the
  .standalone-backup- naming contract shared by store rollback and
  discovery, and _resolve_skin_target's path-traversal rejection.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NohXi78cwsAKtN1sCfxjUh

* test: address review feedback — fixture lifecycle, test names, ClassVar

- ci-fixture-plugin: call display_manager.clear() before rendering (per
  plugin guidelines — the fixture should model a well-behaved plugin),
  add a class docstring, and document why Pillow is deliberately not
  pinned in its requirements.txt (core dependency; harness installs
  nothing).
- Rename two tests whose names contradicted their assertions:
  test_unparseable_core_version_is_compatible ->
  test_unparseable_core_with_high_floor_is_blocked, and
  test_unreadable_secrets_file... -> test_corrupt_secrets_file...
- Annotate TestGetSchemaProperty.SCHEMA as ClassVar (RUF012).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NohXi78cwsAKtN1sCfxjUh

* ci: allow manual test.yml runs via workflow_dispatch

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NohXi78cwsAKtN1sCfxjUh

* fix: unify version comparison, refuse secret-leaking saves, reset skin strikes on card success

Fixes the three suspected bugs this PR's characterization tests pinned,
flipping those tests to assert the corrected behavior:

- plugins/store: ONE shared update comparator. New
  compatibility.is_update_available() (PEP 440 via packaging) is now used
  by both the web UI's update badge (api_v3._is_plugin_update_available
  is a thin alias) and store_manager.update_plugin's reinstall decision.
  Previously update_plugin used raw string equality: 'v1.2.0' vs '1.2.0'
  triggered a full reinstall the UI called unnecessary, and a locally-
  ahead plugin (2.0.0 installed, registry 1.9.0) was silently DOWNGRADED.
  Now equivalent spellings skip the reinstall and locally-ahead versions
  are never downgraded; unparseable versions still reconcile by
  reinstalling from the registry.

- config: save_config and save_config_atomic now refuse (ConfigError)
  when config_secrets.json exists but cannot be loaded. Both previously
  proceeded without stripping, writing the merged secrets into
  config.json in plaintext. The shared _load_secrets_for_save() helper
  raises with an actionable message instead; a missing secrets file is
  still fine (nothing to strip), and _migrate_config's catch-all keeps
  boot resilient.

- skins: render_skin_card resets _skin_failures on both success paths
  (vegas card returned, or mode renderer handled), mirroring
  _render_game. Transient card failures no longer accumulate across a
  session until they permanently disable a working skin.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NohXi78cwsAKtN1sCfxjUh

* fix: harden shared comparator edges from review

- is_update_available: reject truthy non-string versions (a malformed
  manifest can carry a number; packaging raises TypeError on those) by
  surfacing the mismatch instead of raising.
- store_manager.update_plugin: drop the truthiness gate around the
  comparator so a missing version on either side follows the shared
  'no update' verdict, keeping the store consistent with the UI badge;
  a missing manifest still uses the reinstall recovery path.
- config_manager._load_secrets_for_save: catch only expected read/parse
  failures (OSError/ValueError/RecursionError) so implementation bugs
  propagate as themselves, and log with traceback.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NohXi78cwsAKtN1sCfxjUh

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-07 10:17:30 -04:00

1128 lines
54 KiB
Python

"""SportsCore — the shared fetch/cache/config/render base for the sports
scoreboards. Split out of the former ``src/base_classes/sports.py``; see
docs/SPORTS_UNIFICATION.md for the layering.
"""
import logging
import os
import tempfile
import time
from abc import ABC, abstractmethod
from datetime import datetime, timedelta
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
import pytz
import requests
from PIL import Image, ImageDraw, ImageFont
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
from src.background_data_service import get_background_service
# Import new architecture components (individual classes will import what they need)
from src.base_classes.api_extractors import APIDataExtractor
from src.base_classes.data_sources import DataSource
from src.cache_manager import CacheManager
from src.display_manager import DisplayManager
from src.dynamic_team_resolver import DynamicTeamResolver
from src.logo_downloader import LogoDownloader, download_missing_logo
try:
from src.base_odds_manager import BaseOddsManager as OddsManager
except ImportError:
OddsManager = None
# The core install root, resolved from this module's own location so nothing
# here depends on the process working directory.
#
# The index is the number of directories between this file and the root:
# src/base_classes/sports/core.py -> sports -> base_classes -> src -> root.
# It is defined ONCE, here, because hand-counting it at each use site is
# exactly what broke when this module moved from src/base_classes/sports.py
# into the package (the old depth of 2 silently started resolving to src/).
# If this module ever moves again, this is the single line to update.
_INSTALL_ROOT = Path(__file__).resolve().parents[3]
# Shared element-style resolver. Core always ships src.element_style, so the
# guard is never taken here — it is kept because this module's methods are
# back-copied verbatim into the plugins' bundled `sports.py`, where older
# cores genuinely lack the module (the plugins fall back to the classic
# inline config read below).
try:
from src.element_style import ElementStyleResolver, defaults_from_schema_file
STYLE_AVAILABLE = True
except ImportError: # pragma: no cover - core always ships element_style
STYLE_AVAILABLE = False
# --- Font catalog delegation -------------------------------------------------
# The plugin copies each carried their own alias table mapping font *family*
# names ("press_start") to filenames. That table is a duplicate of the core
# FontManager's `common_fonts` catalog, so resolve through the catalog instead
# and let the two never drift apart. Same lazy module-level shape as
# skin_runtime._get_font_manager / base_plugin._fallback_font_manager: a
# SportsCore host has no plugin_manager to borrow a FontManager from.
_shared_font_catalog: Optional[Any] = None
def _font_catalog() -> Any:
"""Shared read-only FontManager used purely as a font-name catalog."""
global _shared_font_catalog
if _shared_font_catalog is None:
from src.font_manager import FontManager
_shared_font_catalog = FontManager({})
return _shared_font_catalog
def _resolve_font_family_alias(font_name: str) -> str:
"""Resolve a font family alias to its filename, leaving filenames as-is.
A config `font` value may be either a family name from the FontManager
catalog ("press_start", "four_by_six", "five_by_seven") or a literal
filename; the former resolve here, the latter pass through unchanged.
"""
try:
catalogued = _font_catalog().common_fonts.get(font_name)
except Exception:
return font_name
return os.path.basename(catalogued) if catalogued else font_name
def _read_bdf_native_size(bdf_path: str) -> Optional[int]:
"""A BDF file's one true pixel size, delegated to FontManager.
Deliberately NOT reimplemented here: the plugin copies grew a variant
that scans the whole file and returns a partially collected value when
parsing raises, where FontManager's stops at the first STARTCHAR and
returns None on error.
"""
try:
from src.font_manager import FontManager
return FontManager._read_bdf_native_size(bdf_path)
except Exception:
return None
class SportsCore(ABC):
# Which ScoreboardSkin render method this class's display path maps to.
# SportsLive inherits the default; SportsUpcoming/SportsRecent override.
SKIN_MODE = "live"
def __init__(self, config: Dict[str, Any], display_manager: DisplayManager, cache_manager: CacheManager, logger: logging.Logger, sport_key: str):
self.logger = logger
self.config = config
self.cache_manager = cache_manager
self.config_manager = self.cache_manager.config_manager
if OddsManager:
try:
self.odds_manager = OddsManager(
self.cache_manager, self.config_manager)
except Exception as e:
self.logger.warning(f"Failed to initialize OddsManager: {e}")
self.odds_manager = None
else:
self.odds_manager = None
self.logger.warning("OddsManager not available - odds functionality disabled")
self.display_manager = display_manager
self.display_width = self.display_manager.matrix.width
self.display_height = self.display_manager.matrix.height
self.sport_key = sport_key
self.sport = None
self.league = None
# Initialize new architecture components (will be overridden by sport-specific classes)
self.sport_config = None
self.api_extractor: APIDataExtractor
self.data_source: DataSource
self.mode_config = config.get(f"{sport_key}_scoreboard", {}) # Changed config key
self.is_enabled: bool = self.mode_config.get("enabled", False)
self.show_odds: bool = self.mode_config.get("show_odds", False)
# Use LogoDownloader to get the correct default logo directory for this sport
default_logo_dir = Path(LogoDownloader().get_logo_directory(sport_key))
self.logo_dir = self._initialize_logo_dir(default_logo_dir)
self.update_interval: int = self.mode_config.get(
"update_interval_seconds", 60)
self.show_records: bool = self.mode_config.get('show_records', False)
self.show_ranking: bool = self.mode_config.get('show_ranking', False)
# Number of games to show (instead of time-based windows)
self.recent_games_to_show: int = self.mode_config.get(
"recent_games_to_show", 5) # Show last 5 games
self.upcoming_games_to_show: int = self.mode_config.get(
"upcoming_games_to_show", 10) # Show next 10 games
self.show_favorite_teams_only: bool = self.mode_config.get("show_favorite_teams_only", False)
self.show_all_live: bool = self.mode_config.get("show_all_live", False)
self.session = requests.Session()
retry_strategy = Retry(
total=5, # increased number of retries
backoff_factor=1, # increased backoff factor
# added 429 to retry list
status_forcelist=[429, 500, 502, 503, 504],
allowed_methods=["GET", "HEAD", "OPTIONS"]
)
adapter = HTTPAdapter(max_retries=retry_strategy)
self.session.mount("https://", adapter)
self.session.mount("http://", adapter)
self._logo_cache = {}
# Font caches for _load_custom_font_from_element_config: per-frame
# callers (font-ladder walks) resolve the same (name, size) over and
# over, and the BDF strike size means re-reading a file header.
# Both are released in cleanup().
self._font_cache: Dict[Tuple[str, int], Any] = {}
self._bdf_native_size_cache: Dict[str, Optional[int]] = {}
# Set up headers
self.headers = {
'User-Agent': 'LEDMatrix/1.0 (https://github.com/yourusername/LEDMatrix; contact@example.com)',
'Accept': 'application/json',
'Accept-Language': 'en-US,en;q=0.9',
'Accept-Encoding': 'gzip, deflate, br',
'Connection': 'keep-alive'
}
self.last_update = 0
self.current_game = None
# Cooldown clocks for _should_log(), one per warning type. Initialized
# here rather than lazily: _should_log() reads them unguarded, so
# whichever warning fires first would otherwise raise AttributeError
# instead of logging. `_last_warning_time` is the single-clock field
# the plugin copies expose; kept for subclasses that read it.
self._warning_cooldowns: Dict[str, float] = {}
self._last_warning_time = 0
self.fonts = self._load_fonts()
# Optional visual skin (see docs/SKIN_SYSTEM.md). "skin" is either a
# skin id applied to all modes, or a per-mode mapping like
# {"live": "retro", "recent": "built-in"}. Loaded lazily on first
# render so a broken skin can never block startup.
self._skin_config = self.mode_config.get("skin")
self.skin_options = self.mode_config.get("skin_options", {}) or {}
self._skin = None
self._skin_load_attempted = False
self._skin_failures = 0
self._skin_slow_renders = 0
# Initialize dynamic team resolver and resolve favorite teams
self.dynamic_resolver = DynamicTeamResolver()
raw_favorite_teams = self.mode_config.get("favorite_teams", [])
self.favorite_teams = self.dynamic_resolver.resolve_teams(raw_favorite_teams, sport_key)
# Log dynamic team resolution
if raw_favorite_teams != self.favorite_teams:
self.logger.info(f"Resolved dynamic teams: {raw_favorite_teams} -> {self.favorite_teams}")
else:
self.logger.info(f"Favorite teams: {self.favorite_teams}")
self.logger.setLevel(logging.INFO)
# Initialize team rankings cache
self._team_rankings_cache = {}
self._rankings_cache_timestamp = 0
self._rankings_cache_duration = 3600 # Cache rankings for 1 hour
# Initialize background data service with optimized settings
# Hardcoded for memory optimization: 1 worker, 30s timeout, 3 retries
self.background_service = get_background_service(self.cache_manager, max_workers=1)
self.background_fetch_requests = {} # Track background fetch requests
self.background_enabled = True
self.logger.info("Background service enabled with 1 worker (memory optimized)")
def _initialize_logo_dir(self, configured_path: Path) -> Path:
"""Resolve and ensure a writable logo directory, falling back when necessary."""
downloader = LogoDownloader()
resolved_configured = self._resolve_project_path(configured_path)
candidates = [resolved_configured] + self._get_logo_directory_fallbacks(resolved_configured)
for candidate in candidates:
candidate_path = self._resolve_project_path(candidate)
if downloader.ensure_logo_directory(str(candidate_path)):
if candidate_path != resolved_configured:
self.logger.warning(
"Configured logo directory '%s' is not writable; using fallback '%s'",
resolved_configured,
candidate_path,
)
return candidate_path
self.logger.error(
"Unable to find a writable logo directory. Logos may fail to download (last attempted: %s)",
resolved_configured,
)
return resolved_configured
def _resolve_project_path(self, path: Path) -> Path:
"""Convert relative paths to absolute ones rooted at the project directory."""
if path.is_absolute():
return path
return (_INSTALL_ROOT / path).resolve()
def _get_logo_directory_fallbacks(self, configured_dir: Path) -> List[Path]:
"""Return fallback directories to try when the configured directory is not writable."""
fallbacks: List[Path] = []
env_override = os.environ.get("LEDMATRIX_LOGO_DIR")
if env_override:
env_path = Path(env_override)
if not env_path.is_absolute():
env_path = self._resolve_project_path(env_path)
fallbacks.append(env_path / self.sport_key)
cache_dir = getattr(self.cache_manager, "cache_dir", None)
if cache_dir:
fallbacks.append(Path(cache_dir) / "logos" / self.sport_key)
try:
fallbacks.append(Path.home() / ".ledmatrix" / "logos" / self.sport_key)
except RuntimeError as e:
self.logger.debug("Could not resolve home directory (expected for service users): %s", e)
fallbacks.append(Path(tempfile.gettempdir()) / "ledmatrix_logos" / self.sport_key)
unique_fallbacks: List[Path] = []
seen = set()
for candidate in fallbacks:
if candidate == configured_dir:
continue
if candidate not in seen:
unique_fallbacks.append(candidate)
seen.add(candidate)
return unique_fallbacks
def _get_season_schedule_dates(self) -> tuple[str, str]:
return "", ""
def _draw_scorebug_layout(self, game: Dict, force_clear: bool = False) -> None:
"""Placeholder draw method - subclasses should override."""
# This base method will be simple, subclasses provide specifics
try:
img = Image.new('RGB', (self.display_width, self.display_height), (0, 0, 0))
draw = ImageDraw.Draw(img)
status = game.get("status_text", "N/A")
self._draw_text_with_outline(draw, status, (2, 2), self.fonts['status'])
self.display_manager.image.paste(img, (0, 0))
# Don't call update_display here, let subclasses handle it after drawing
except Exception as e:
self.logger.error(f"Error in base _draw_scorebug_layout: {e}", exc_info=True)
def _resolve_skin_id(self) -> Optional[str]:
"""The skin id configured for this instance's mode, or None for the
built-in renderer. Accepts a plain id (all modes) or a per-mode
mapping ({"live": "retro-baseball", "recent": "built-in"})."""
skin_id = self._skin_config
if isinstance(skin_id, dict):
skin_id = skin_id.get(self.SKIN_MODE)
if not skin_id or not isinstance(skin_id, str) or skin_id == "built-in":
return None
return skin_id
def _get_skin(self):
"""Lazily load the configured skin once. Returns None (built-in
renderer) when no skin is configured or loading failed."""
if not self._skin_load_attempted:
self._skin_load_attempted = True
skin_id = self._resolve_skin_id()
if skin_id:
try:
from src.skin_system import skin_runtime
self._skin = skin_runtime.load_skin(
skin_id, sport=self.sport, sport_key=self.sport_key,
options=self.skin_options)
except Exception as e:
self.logger.error(f"Failed to load skin '{skin_id}': {e}", exc_info=True)
self._skin = None
return self._skin
def _render_game(self, game: Dict, force_clear: bool = False) -> None:
"""Render one game: try the configured skin first, fall back to the
built-in _draw_scorebug_layout. A skin that raises 3 times in a row
is disabled for the rest of the session."""
skin = self._get_skin()
if skin is not None and self._skin_failures < 3:
try:
from src.skin_system import skin_runtime
ctx = skin_runtime.build_context(self, game)
render = getattr(skin, f"render_{self.SKIN_MODE}")
started = time.monotonic()
handled = render(ctx, dict(game))
elapsed = time.monotonic() - started
if elapsed > 0.15 and self._skin_slow_renders < 5:
self._skin_slow_renders += 1
self.logger.warning(
f"Skin '{self._resolve_skin_id()}' took {elapsed * 1000:.0f}ms to "
f"render {self.SKIN_MODE} — slow renders stall the whole display loop")
if handled:
self._skin_failures = 0
self.display_manager.image.paste(ctx.canvas, (0, 0))
self.display_manager.update_display()
return
except Exception:
self._skin_failures += 1
outcome = ("disabling skin for this session" if self._skin_failures >= 3
else "falling back to built-in renderer")
self.logger.error(
f"Skin '{self._resolve_skin_id()}' failed rendering {self.SKIN_MODE} "
f"({self._skin_failures}/3); {outcome}", exc_info=True)
self._draw_scorebug_layout(game, force_clear)
def render_skin_card(self, game: Dict, size: tuple) -> Optional[Image.Image]:
"""Render one game as a standalone card via the configured skin —
for vegas mode and previews. Tries render_vegas_card at the given
size, then the mode renderer on a card-sized canvas. Returns None
when no skin is active or the skin declined, so callers can use
their default rendering."""
skin = self._get_skin()
if skin is None or self._skin_failures >= 3:
return None
try:
from src.skin_system import skin_runtime
ctx = skin_runtime.build_context(self, game, size=size)
card = skin.render_vegas_card(ctx, dict(game))
if card is not None:
# A successful render clears accumulated strikes, mirroring
# _render_game — transient failures must not add up across
# the session and disable a working skin.
self._skin_failures = 0
return card
ctx = skin_runtime.build_context(self, game, size=size)
render = getattr(skin, f"render_{self.SKIN_MODE}")
if render(ctx, dict(game)):
self._skin_failures = 0
return ctx.canvas
except Exception:
# Card failures count toward the same 3-strike session disable
# as display failures — a skin broken for vegas shouldn't get
# to throw on every scroll tick forever.
self._skin_failures += 1
self.logger.error(
f"Skin '{self._resolve_skin_id()}' card render failed "
f"({self._skin_failures}/3)", exc_info=True)
return None
def display(self, force_clear: bool = False) -> bool:
"""Common display method for all NCAA FB managers""" # Updated docstring
if not self.is_enabled: # Check if module is enabled
return False
if not self.current_game:
# Clear display if force_clear is True, even when there's no content
# This prevents black screens when switching to modes with no content
if force_clear:
try:
self.display_manager.clear()
self.display_manager.update_display()
except Exception as e:
self.logger.debug(f"Error clearing display when no content: {e}")
current_time = time.time()
if not hasattr(self, '_last_warning_time'):
self._last_warning_time = 0
if current_time - getattr(self, '_last_warning_time', 0) > 300:
self.logger.warning(f"No game data available to display in {self.__class__.__name__}")
setattr(self, '_last_warning_time', current_time)
return False
try:
self._render_game(self.current_game, force_clear)
# display_manager.update_display() should be called within subclass draw methods
# or after calling display() in the main loop. Let's keep it out of the base display.
return True
except Exception as e:
self.logger.error(f"Error during display call in {self.__class__.__name__}: {e}", exc_info=True)
return False
def _load_fonts(self) -> Dict[str, Any]:
"""Load fonts used by the scoreboard.
Paths go through :meth:`_resolve_font_path` so the bundled fonts are
found regardless of the process working directory — a bare
``"assets/fonts/..."`` silently degraded every scoreboard to the PIL
default font whenever the process started elsewhere (the plugin safety
harness on CI being the case that surfaced it).
"""
fonts: Dict[str, Any] = {}
press_start = self._resolve_font_path("PressStart2P-Regular.ttf")
four_by_six = self._resolve_font_path("4x6-font.ttf")
try:
fonts['score'] = ImageFont.truetype(press_start, 10)
fonts['time'] = ImageFont.truetype(press_start, 8)
fonts['team'] = ImageFont.truetype(press_start, 8)
fonts['status'] = ImageFont.truetype(four_by_six, 6) # Using 4x6 for status
fonts['detail'] = ImageFont.truetype(four_by_six, 6) # Added detail font
fonts['rank'] = ImageFont.truetype(press_start, 10)
self.logger.info("Successfully loaded fonts")
except OSError:
# Name the directory we searched: the usual cause is an install
# whose assets/fonts is missing, and the bare message sent people
# hunting for a font-format problem instead.
self.logger.warning(
"Fonts not found under %s, using default PIL font.",
self._font_root(),
)
fonts['score'] = ImageFont.load_default()
fonts['time'] = ImageFont.load_default()
fonts['team'] = ImageFont.load_default()
fonts['status'] = ImageFont.load_default()
fonts['detail'] = ImageFont.load_default()
fonts['rank'] = ImageFont.load_default()
return fonts
def _draw_dynamic_odds(self, draw: ImageDraw.Draw, odds: Dict[str, Any], width: int, height: int) -> None:
"""Draw odds with dynamic positioning - only show negative spread and position O/U based on favored team."""
home_team_odds = odds.get('home_team_odds', {})
away_team_odds = odds.get('away_team_odds', {})
home_spread = home_team_odds.get('spread_odds')
away_spread = away_team_odds.get('spread_odds')
# Get top-level spread as fallback
top_level_spread = odds.get('spread')
# If we have a top-level spread and the individual spreads are None or 0, use the top-level
if top_level_spread is not None:
if home_spread is None or home_spread == 0.0:
home_spread = top_level_spread
if away_spread is None:
away_spread = -top_level_spread
# Determine which team is favored (has negative spread)
home_favored = home_spread is not None and home_spread < 0
away_favored = away_spread is not None and away_spread < 0
# Only show the negative spread (favored team)
favored_spread = None
favored_side = None
if home_favored:
favored_spread = home_spread
favored_side = 'home'
self.logger.debug(f"Home team favored with spread: {favored_spread}")
elif away_favored:
favored_spread = away_spread
favored_side = 'away'
self.logger.debug(f"Away team favored with spread: {favored_spread}")
else:
self.logger.debug("No clear favorite - spreads: home={home_spread}, away={away_spread}")
# Show the negative spread on the appropriate side
if favored_spread is not None:
spread_text = str(favored_spread)
font = self.fonts['detail'] # Use detail font for odds
if favored_side == 'home':
# Home team is favored, show spread on right side
spread_width = draw.textlength(spread_text, font=font)
spread_x = width - spread_width # Top right
spread_y = 0
self._draw_text_with_outline(draw, spread_text, (spread_x, spread_y), font, fill=(0, 255, 0))
self.logger.debug(f"Showing home spread '{spread_text}' on right side")
else:
# Away team is favored, show spread on left side
spread_x = 0 # Top left
spread_y = 0
self._draw_text_with_outline(draw, spread_text, (spread_x, spread_y), font, fill=(0, 255, 0))
self.logger.debug(f"Showing away spread '{spread_text}' on left side")
# Show over/under on the opposite side of the favored team
over_under = odds.get('over_under')
if over_under is not None:
ou_text = f"O/U: {over_under}"
font = self.fonts['detail'] # Use detail font for odds
ou_width = draw.textlength(ou_text, font=font)
if favored_side == 'home':
# Home team is favored, show O/U on left side (opposite of spread)
ou_x = 0 # Top left
ou_y = 0
self.logger.debug(f"Showing O/U '{ou_text}' on left side (home favored)")
elif favored_side == 'away':
# Away team is favored, show O/U on right side (opposite of spread)
ou_x = width - ou_width # Top right
ou_y = 0
self.logger.debug(f"Showing O/U '{ou_text}' on right side (away favored)")
else:
# No clear favorite, show O/U in center
ou_x = (width - ou_width) // 2
ou_y = 0
self.logger.debug(f"Showing O/U '{ou_text}' in center (no clear favorite)")
self._draw_text_with_outline(draw, ou_text, (ou_x, ou_y), font, fill=(0, 255, 0))
def _draw_text_with_outline(self, draw, text, position, font, fill=(255, 255, 255), outline_color=(0, 0, 0)):
"""Draw text with a black outline for better readability."""
x, y = position
for dx, dy in [(-1, -1), (-1, 0), (-1, 1), (0, -1), (0, 1), (1, -1), (1, 0), (1, 1)]:
draw.text((x + dx, y + dy), text, font=font, fill=outline_color)
draw.text((x, y), text, font=font, fill=fill)
def _load_and_resize_logo(self, team_id: str, team_abbrev: str, logo_path: Path, logo_url: str | None ) -> Optional[Image.Image]:
"""Load and resize a team logo, with caching and automatic download if missing."""
self.logger.debug(f"Logo path: {logo_path}")
if team_abbrev in self._logo_cache:
self.logger.debug(f"Using cached logo for {team_abbrev}")
return self._logo_cache[team_abbrev]
try:
# Try different filename variations first (for cases like TA&M vs TAANDM)
actual_logo_path = None
filename_variations = LogoDownloader.get_logo_filename_variations(team_abbrev)
for filename in filename_variations:
test_path = logo_path.parent / filename
if test_path.exists():
actual_logo_path = test_path
self.logger.debug(f"Found logo at alternative path: {actual_logo_path}")
break
# If no variation found, try to download missing logo
if not actual_logo_path and not logo_path.exists():
self.logger.info(f"Logo not found for {team_abbrev} at {logo_path}. Attempting to download.")
# Try to download the logo from ESPN API (this will create placeholder if download fails)
download_missing_logo(self.sport_key, team_id, team_abbrev, logo_path, logo_url)
actual_logo_path = logo_path
# Use the original path if no alternative was found
if not actual_logo_path:
actual_logo_path = logo_path
# Only try to open the logo if the file exists
if os.path.exists(actual_logo_path):
logo = Image.open(actual_logo_path)
else:
self.logger.error(f"Logo file still doesn't exist at {actual_logo_path} after download attempt")
return None
if logo.mode != 'RGBA':
logo = logo.convert('RGBA')
max_width = int(self.display_width * 1.5)
max_height = int(self.display_height * 1.5)
logo.thumbnail((max_width, max_height), Image.Resampling.LANCZOS)
self._logo_cache[team_abbrev] = logo
return logo
except Exception as e:
self.logger.error(f"Error loading logo for {team_abbrev}: {e}", exc_info=True)
return None
def _fetch_odds(self, game: Dict) -> None:
"""Fetch odds for a specific game using the new architecture."""
try:
if not self.show_odds:
return
if not self.odds_manager:
return
# Determine update interval based on game state
is_live = game.get('is_live', False)
update_interval = self.mode_config.get("live_odds_update_interval", 60) if is_live \
else self.mode_config.get("odds_update_interval", 3600)
# Fetch odds using OddsManager
odds_data = self.odds_manager.get_odds(
sport=self.sport,
league=self.league,
event_id=game['id'],
update_interval_seconds=update_interval,
)
if odds_data:
game['odds'] = odds_data
self.logger.debug(f"Successfully fetched and attached odds for game {game['id']}")
else:
self.logger.debug(f"No odds data returned for game {game['id']}")
except Exception as e:
self.logger.error(f"Error fetching odds for game {game.get('id', 'N/A')}: {e}")
def _get_timezone(self):
try:
timezone_str = self.config.get('timezone', 'UTC')
return pytz.timezone(timezone_str)
except pytz.UnknownTimeZoneError:
return pytz.utc
def _should_log(self, warning_type: str, cooldown: int = 60) -> bool:
"""Whether a warning of this kind is outside its cooldown window.
Cooldowns are tracked **per ``warning_type``**. They previously shared
one timestamp, so the parameter was accepted and ignored: an API-error
warning would silence an unrelated cache warning for the next minute,
and whichever fired first won. Nothing in core called this, so no
behavior regressed with the fix — but every caller has always been
entitled to assume its own warning type has its own clock.
"""
current_time = time.time()
if current_time - self._warning_cooldowns.get(warning_type, 0) > cooldown:
self._warning_cooldowns[warning_type] = current_time
# Kept in step for subclasses that read it directly.
self._last_warning_time = current_time
return True
return False
def _fetch_team_rankings(self) -> Dict[str, int]:
"""Fetch team rankings using the new architecture components."""
current_time = time.time()
# Check if we have cached rankings that are still valid
if (self._team_rankings_cache and
current_time - self._rankings_cache_timestamp < self._rankings_cache_duration):
return self._team_rankings_cache
try:
data = self.data_source.fetch_standings(self.sport, self.league)
rankings = {}
rankings_data = data.get('rankings', [])
if rankings_data:
# Use the first ranking (usually AP Top 25)
first_ranking = rankings_data[0]
teams = first_ranking.get('ranks', [])
for team_data in teams:
team_info = team_data.get('team', {})
team_abbr = team_info.get('abbreviation', '')
current_rank = team_data.get('current', 0)
if team_abbr and current_rank > 0:
rankings[team_abbr] = current_rank
# Cache the results
self._team_rankings_cache = rankings
self._rankings_cache_timestamp = current_time
self.logger.debug(f"Fetched rankings for {len(rankings)} teams")
return rankings
except Exception as e:
self.logger.error(f"Error fetching team rankings: {e}")
return {}
def _extract_game_details_common(self, game_event: Dict) -> tuple[Dict | None, Dict | None, Dict | None, Dict | None, Dict | None]:
if not game_event:
return None, None, None, None, None
try:
competition = game_event["competitions"][0]
status = competition["status"]
competitors = competition["competitors"]
game_date_str = game_event["date"]
situation = competition.get("situation")
start_time_utc = None
try:
# Parse the datetime string
if game_date_str.endswith('Z'):
game_date_str = game_date_str.replace('Z', '+00:00')
dt = datetime.fromisoformat(game_date_str)
# Ensure the datetime is UTC-aware (fromisoformat may create timezone-aware but not pytz.UTC)
if dt.tzinfo is None:
# If naive, assume it's UTC
start_time_utc = dt.replace(tzinfo=pytz.UTC)
else:
# Convert to pytz.UTC for consistency
start_time_utc = dt.astimezone(pytz.UTC)
except ValueError:
logging.warning(f"Could not parse game date: {game_date_str}")
home_team = next((c for c in competitors if c.get("homeAway") == "home"), None)
away_team = next((c for c in competitors if c.get("homeAway") == "away"), None)
if not home_team or not away_team:
self.logger.warning(f"Could not find home or away team in event: {game_event.get('id')}")
return None, None, None, None, None
try:
home_abbr = home_team["team"]["abbreviation"]
except KeyError:
home_abbr = home_team["team"]["name"][:3]
try:
away_abbr = away_team["team"]["abbreviation"]
except KeyError:
away_abbr = away_team["team"]["name"][:3]
# Check if this is a favorite team game BEFORE doing expensive logging
is_favorite_game = (home_abbr in self.favorite_teams or away_abbr in self.favorite_teams)
# Only log debug info for favorite team games
if is_favorite_game:
self.logger.debug(f"Processing favorite team game: {game_event.get('id')}")
self.logger.debug(f"Found teams: {away_abbr}@{home_abbr}, Status: {status['type']['name']}, State: {status['type']['state']}")
game_time, game_date = "", ""
if start_time_utc:
local_time = start_time_utc.astimezone(self._get_timezone())
game_time = local_time.strftime("%I:%M%p").lstrip('0')
# Check date format from config
use_short_date_format = self.config.get('display', {}).get('use_short_date_format', False)
if use_short_date_format:
game_date = local_time.strftime("%-m/%-d")
else:
game_date = self.display_manager.format_date_with_ordinal(local_time)
home_record = home_team.get('records', [{}])[0].get('summary', '') if home_team.get('records') else ''
away_record = away_team.get('records', [{}])[0].get('summary', '') if away_team.get('records') else ''
# Don't show "0-0" records - set to blank instead
if home_record in {"0-0", "0-0-0"}:
home_record = ''
if away_record in {"0-0", "0-0-0"}:
away_record = ''
details = {
"id": game_event.get("id"),
"game_time": game_time,
"game_date": game_date,
"start_time_utc": start_time_utc,
"status_text": status["type"]["shortDetail"], # e.g., "Final", "7:30 PM", "Q1 12:34"
"is_live": status["type"]["state"] == "in",
"is_final": status["type"]["state"] == "post",
"is_upcoming": (status["type"]["state"] == "pre" or
status["type"]["name"].lower() in ['scheduled', 'pre-game', 'status_scheduled']),
"is_halftime": status["type"]["state"] == "halftime" or status["type"]["name"] == "STATUS_HALFTIME", # Added halftime check
"is_period_break": status["type"]["name"] == "STATUS_END_PERIOD", # Added Period Break check
"home_abbr": home_abbr,
"home_id": home_team["id"],
"home_score": home_team.get("score", "0"),
"home_logo_path": self.logo_dir / Path(f"{LogoDownloader.normalize_abbreviation(home_abbr)}.png"),
"home_logo_url": home_team["team"].get("logo"),
"home_record": home_record,
"away_record": away_record,
"away_abbr": away_abbr,
"away_id": away_team["id"],
"away_score": away_team.get("score", "0"),
"away_logo_path": self.logo_dir / Path(f"{LogoDownloader.normalize_abbreviation(away_abbr)}.png"),
"away_logo_url": away_team["team"].get("logo"),
"is_within_window": True, # Whether game is within display window
}
return details, home_team, away_team, status, situation
except Exception as e:
# Log the problematic event structure if possible
logging.error(f"Error extracting game details: {e} from event: {game_event.get('id')}", exc_info=True)
return None, None, None, None, None
@abstractmethod
def _extract_game_details(self, game_event: dict) -> dict | None:
details, _, _, _, _ = self._extract_game_details_common(game_event)
return details
@abstractmethod
def _fetch_data(self) -> Optional[Dict]:
pass
def _fetch_todays_games(self) -> Optional[Dict]:
"""Fetch only today's games for live updates (not entire season)."""
try:
tz = pytz.timezone("America/New_York") # Use full name (not "EST") for DST support
now = datetime.now(tz)
yesterday = now - timedelta(days=1)
formatted_date = now.strftime("%Y%m%d")
formatted_date_yesterday = yesterday.strftime("%Y%m%d")
# Fetch todays games only
url = f"https://site.api.espn.com/apis/site/v2/sports/{self.sport}/{self.league}/scoreboard"
response = self.session.get(url, params={"dates": f"{formatted_date_yesterday}-{formatted_date}", "limit": 1000}, headers=self.headers, timeout=10)
response.raise_for_status()
data = response.json()
events = data.get('events', [])
self.logger.info(f"Fetched {len(events)} todays games for {self.sport} - {self.league}")
return {'events': events}
except requests.exceptions.RequestException as e:
self.logger.error(f"API error fetching todays games for {self.sport} - {self.league}: {e}")
return None
def _get_weeks_data(self) -> Optional[Dict]:
"""
Get partial data for immediate display while background fetch is in progress.
This fetches current/recent games only for quick response.
"""
try:
# Fetch current week and next few days for immediate display
now = datetime.now(pytz.utc)
immediate_events = []
start_date = now + timedelta(weeks=-2)
end_date = now + timedelta(weeks=1)
date_str = f"{start_date.strftime('%Y%m%d')}-{end_date.strftime('%Y%m%d')}"
url = f"https://site.api.espn.com/apis/site/v2/sports/{self.sport}/{self.league}/scoreboard"
response = self.session.get(url, params={"dates": date_str, "limit": 1000},headers=self.headers, timeout=10)
response.raise_for_status()
data = response.json()
immediate_events = data.get('events', [])
if immediate_events:
self.logger.info(f"Fetched {len(immediate_events)} events {date_str}")
return {'events': immediate_events}
except requests.exceptions.RequestException as e:
self.logger.warning(f"Error fetching this weeks games for {self.sport} - {self.league} - {date_str}: {e}")
return None
def _custom_scorebug_layout(self, game: dict, draw_overlay: ImageDraw.ImageDraw):
pass
# ------------------------------------------------------------------
# Promoted from the plugin copies (see docs/SPORTS_UNIFICATION.md).
# Everything below is present in all nine bundled `sports.py` copies;
# the canonical form of each is documented on the method.
# ------------------------------------------------------------------
def _favorite_key(self, game: Dict, side: str) -> Optional[str]:
"""Override point: which view-model field identifies a team when
matching against ``favorite_teams``.
``side`` is ``"home"`` or ``"away"``. The default is the team
abbreviation — what eight of the nine scoreboards match on, and what
users type into their favorites list.
NRL overrides this to the team **id**, because NRL abbreviations are
not unique: "NEW" is both Newcastle Knights and New Zealand Warriors,
"CAN" both Canberra Raiders and Canterbury Bulldogs. Matching those by
abbreviation selects the wrong club. It is a seam rather than a branch
precisely so core never has to learn the string "nrl"::
def _favorite_key(self, game, side):
return str(game.get(f"{side}_id"))
An override that stringifies should note that a missing id becomes the
literal ``"None"``, which would spuriously match a favorites list
containing that string. The default returns ``None``, which never
matches.
"""
return game.get(f"{side}_abbr")
def _config_schema_path(self) -> Optional[str]:
"""Override point: the plugin's ``config_schema.json``, used as the
reference for style-resolver defaults.
None (the default) means no schema is available — layout offsets are
then read with the classic inline config lookup, which is exactly what
a plugin that never shipped resolver support does today. A plugin
opting into :mod:`src.element_style` returns its own schema path::
def _config_schema_path(self):
return os.path.join(os.path.dirname(os.path.abspath(__file__)),
'config_schema.json')
It must not be derived from this module's ``__file__``: after
promotion that resolves inside ``src/base_classes/sports/``, where no
``config_schema.json`` exists.
"""
return None
def _font_root(self) -> str:
"""Override point: the directory ``assets/fonts`` resolves against.
Defaults to the core install root, derived from this module's own
location — the same strategy as ``FontManager._resolve_asset_path``.
A plugin bundling its own fonts overrides this to return its plugin
directory. Never a cwd-relative path: fonts must load no matter where
the process was started from (e.g. the plugin safety harness on CI).
"""
return str(_INSTALL_ROOT)
def _resolve_font_path(self, font_name: str) -> str:
"""Locate a font file by filename, independently of the process cwd.
Tries ``assets/fonts/<name>`` relative to the cwd first (preserving
behavior for a process started from an install root), then under
:meth:`_font_root`. Returns the cwd-relative path unchanged when the
file is nowhere to be found, so callers log the familiar path.
"""
relative = os.path.join('assets', 'fonts', font_name)
if os.path.exists(relative):
return relative
candidate = os.path.join(self._font_root(), relative)
if os.path.exists(candidate):
return candidate
return relative
def _get_layout_offset(self, element: str, axis: str, default: int = 0) -> int:
"""
Get layout offset for a specific element and axis.
Args:
element: Element name (e.g., 'home_logo', 'score', 'status_text')
axis: 'x_offset' or 'y_offset' (or 'away_x_offset', 'home_x_offset' for records)
default: Default value if not configured (default: 0)
Returns:
Offset value from config or default (always returns int)
"""
schema_path = self._config_schema_path() if STYLE_AVAILABLE else None
if schema_path:
# Shared resolver (rebuilt if the config dict was swapped out,
# matching the classic path's read-config-on-every-call semantics).
# Note it is stricter than the classic read below: a boolean offset
# degrades to the default instead of counting as 1/0, which is the
# more correct reading of a pixel offset.
resolver = getattr(self, '_style_resolver_cached', None)
if resolver is None or resolver._config is not self.config:
resolver = ElementStyleResolver(
self.config, defaults_from_schema_file(schema_path))
self._style_resolver_cached = resolver
return resolver.offset_value(element, axis, default)
try:
layout_config = self.config.get('customization', {}).get('layout', {})
element_config = layout_config.get(element, {})
offset_value = element_config.get(axis, default)
# Ensure we return an integer (handle float/string from config)
if isinstance(offset_value, (int, float)):
return int(offset_value)
elif isinstance(offset_value, str):
# Try to convert string to int
try:
return int(float(offset_value))
except (ValueError, TypeError):
self.logger.warning(
f"Invalid layout offset value for {element}.{axis}: '{offset_value}', using default {default}"
)
return default
else:
return default
except Exception as e:
# Gracefully handle any config access errors
self.logger.debug(f"Error reading layout offset for {element}.{axis}: {e}, using default {default}")
return default
def _load_custom_font_from_element_config(
self,
element_config: Dict[str, Any],
default_size: int = 8,
default_font: Optional[str] = None,
) -> ImageFont.FreeTypeFont:
"""
Load a custom font from an element configuration dictionary.
Args:
element_config: Configuration dict for a single element containing 'font' and 'font_size' keys
default_size: Default font size if not specified in config
default_font: Default font filename when not specified in config (e.g. '4x6-font.ttf' for odds)
Returns:
PIL ImageFont object
"""
base_default = default_font or "PressStart2P-Regular.ttf"
font_name = element_config.get('font', base_default)
font_size = int(element_config.get('font_size', default_size)) # Ensure integer for PIL
# Resolve family aliases (e.g. "press_start") to real filenames, then
# locate the file against _font_root() rather than the cwd.
resolved_name = _resolve_font_family_alias(font_name)
font_path = self._resolve_font_path(resolved_name)
# Memoized: per-frame callers (font-ladder walks) resolve the same
# (name, size) repeatedly -- return the previously loaded face.
cache_key = (resolved_name, font_size)
cached_font = self._font_cache.get(cache_key)
if cached_font is not None:
return cached_font
# Try to load the font
try:
if os.path.exists(font_path):
# Try loading as TTF first (works for both TTF and some BDF files with PIL)
if font_path.lower().endswith('.ttf'):
font = ImageFont.truetype(font_path, font_size)
self.logger.debug(f"Loaded font: {font_name} at size {font_size}")
self._font_cache[cache_key] = font
return font
elif font_path.lower().endswith('.bdf'):
# BDF fonts are fixed-size bitmaps, not scalable outlines --
# FreeType only accepts the exact pixel size baked into the
# file (its "strike") and raises "invalid pixel size" for
# anything else. Try the requested size first (in case it
# happens to match), then fall back to the file's real
# native size, so a BDF font can still be selected via
# font_size-driven configs without the caller needing to
# know its exact strike size.
#
# This retry is the OLDER lineage's behavior and it is the
# correct one: the newer copies call truetype() on a BDF at
# any size (which simply fails) or refuse BDF outright.
try:
font = ImageFont.truetype(font_path, font_size)
self.logger.debug(f"Loaded BDF font: {font_name} at size {font_size}")
self._font_cache[cache_key] = font
return font
except OSError:
if font_path in self._bdf_native_size_cache:
native_size = self._bdf_native_size_cache[font_path]
else:
native_size = _read_bdf_native_size(font_path)
self._bdf_native_size_cache[font_path] = native_size
if native_size and native_size != font_size:
try:
font = ImageFont.truetype(font_path, native_size)
self.logger.debug(
f"Loaded BDF font: {font_name} at its native size {native_size} "
f"(requested {font_size} isn't a valid strike for this file)"
)
self._font_cache[cache_key] = font
return font
except Exception as retry_exc:
self.logger.debug(
f"BDF font {font_name} also failed to load at native "
f"size {native_size}: {retry_exc}"
)
self.logger.warning(f"Could not load BDF font {font_name} with PIL, using default")
# Fall through to default
else:
self.logger.warning(f"Unknown font file type: {font_name}, using default")
else:
self.logger.warning(f"Font file not found: {font_path}, using default")
except Exception as e:
self.logger.error(f"Error loading font {font_name}: {e}, using default")
# Fall back to default font. Cached under the requested (name, size)
# key too, so a misconfigured or missing font pays the disk cost once
# instead of on every frame of a font-ladder walk.
default_font_path = self._resolve_font_path(
_resolve_font_family_alias(base_default))
try:
if os.path.exists(default_font_path):
font = ImageFont.truetype(default_font_path, font_size)
else:
self.logger.warning("Default font not found, using PIL default")
font = ImageFont.load_default()
except Exception as e:
self.logger.error(f"Error loading default font: {e}")
font = ImageFont.load_default()
self._font_cache[cache_key] = font
return font
def cleanup(self):
"""Clean up resources when plugin is unloaded."""
# Close HTTP session
if hasattr(self, 'session') and self.session:
try:
self.session.close()
except Exception as e:
self.logger.warning(f"Error closing session: {e}")
# Clear caches
if hasattr(self, '_logo_cache'):
self._logo_cache.clear()
# Font caches hold PIL faces; without this they are an unbounded
# per-instance leak across enable/disable cycles.
if hasattr(self, '_font_cache'):
self._font_cache.clear()
if hasattr(self, '_bdf_native_size_cache'):
self._bdf_native_size_cache.clear()
# NOTE: self.background_service is deliberately NOT shut down here.
# get_background_service() returns a PROCESS-WIDE singleton shared by
# every scoreboard; shutting it down from one unloading plugin would
# stop background fetching for all the others. Whoever owns the
# process owns its lifecycle. Do not "fix" this.
self.logger.info(f"{self.__class__.__name__} cleanup completed")