fix(core): font zip cache, monotonic timers, resolver back-off, and other core/common fixes (#654)

* fix(core): font zip cache, monotonic timers, resolver back-off, and other core/common fixes

- font_manager: a .zip font URL is served as its extracted font after a
  restart (the cached-file check returned the archive first); downloads
  use requests with a 30s timeout into a temp file + os.replace.
- api_helper / sync_manager: rate-limit and heartbeat/leader timeouts use
  time.monotonic(); last_request_time and the status file's ts stay
  wall-clock. set_on_new_cycle docstring no longer claims core uses it.
- logo_helper: the placeholder uses the same scaled box as a real logo.
- permission_utils: one _sudo_bash_candidates() helper (with the sudoers
  exact-argv rationale) shared by sudo_remove_directory, which now retries
  the next bash path on a sudo refusal, and install_requirements_file.
- dynamic_team_resolver: failed/empty fetch backs off 5 min; duplicate
  INFO log and contradictory docstring example fixed.
- element_style: scale default looked up through element aliases.
- background_data_service: cache-hit callback runs outside the lock.
- config_arrays: union-aware type check (["array","null"]); stale
  dotToNested() reference removed.
- auto_update_setup: non-dict auto_update reads as off; temp result file
  unlinked when the write fails.
- exceptions: constructors copy the caller's context dict.
- logging_config: StructuredFormatter json.dumps(default=str).
- error_aggregator: removed unused export_path/export_to_file/_auto_export.
- Docstrings: validate_file_upload max_size_mb, raise_on_errors.

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

* fix(sync): retry the status-file rename like the other atomic writers

On Windows os.replace can fail with "Access is denied" while a scanner
briefly holds the target open; config_manager_atomic._replace already
retries that (and re-raises at once on other platforms). The sync status
writer called os.replace directly, which made
test_concurrent_writers_each_use_their_own_temp_file flaky on Windows.

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-28 10:40:16 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 6f45ff5e63
commit f6c0fe55d9
28 changed files with 596 additions and 171 deletions
+25 -9
View File
@@ -70,7 +70,14 @@ def _read(path):
def is_enabled(config):
return bool((config.get('auto_update') or {}).get('enabled', False))
# Only the {"enabled": true} object turns this on. A hand-edited
# non-dict (e.g. "auto_update": true) raised AttributeError here and
# aborted startup setup; the web UI's save replaces such a value with {}
# (disabled), so read it the same way.
section = config.get('auto_update')
if not isinstance(section, dict):
return False
return bool(section.get('enabled', False))
class UpdateHelperSetup:
@@ -201,17 +208,26 @@ class UpdateHelperSetup:
try:
self.result_file.parent.mkdir(parents=True, exist_ok=True)
fd, tmp = tempfile.mkstemp(dir=str(self.result_file.parent), prefix='.auto_update_setup_')
with os.fdopen(fd, 'w', encoding='utf-8') as f:
json.dump(result, f, indent=2)
os.chmod(tmp, 0o644)
if self._web_ids and hasattr(os, 'chown'):
# Only root can give the file away; the result is readable
# (0644) either way, so a failed chown must not lose it.
try:
with os.fdopen(fd, 'w', encoding='utf-8') as f:
json.dump(result, f, indent=2)
os.chmod(tmp, 0o644)
if self._web_ids and hasattr(os, 'chown'):
# Only root can give the file away; the result is readable
# (0644) either way, so a failed chown must not lose it.
try:
os.chown(tmp, *self._web_ids)
except OSError:
pass
os.replace(tmp, self.result_file)
except BaseException:
# Don't leave a .auto_update_setup_* file behind in the
# project dir every time the write fails.
try:
os.chown(tmp, *self._web_ids)
os.unlink(tmp)
except OSError:
pass
os.replace(tmp, self.result_file)
raise
except OSError as e:
logger.warning("Could not record automatic update setup result: %s", e)
return result
+12 -8
View File
@@ -247,15 +247,19 @@ class BackgroundDataService:
# same object the dict holds.
self.completed_requests[request_id] = result
if callback:
try:
callback(result)
except Exception as e:
logger.error(f"Error in callback for request {request_id}: {e}")
self._release_payload(result)
# The callback runs outside the lock, as on the worker path: it is
# plugin code, and holding the service lock through it blocked
# every worker's result bookkeeping (and any other thread's
# submit) for as long as the callback took.
if callback:
try:
callback(result)
except Exception as e:
logger.error(f"Error in callback for request {request_id}: {e}")
self._release_payload(result)
logger.debug(f"Cache hit for {sport} {year} data")
return request_id
logger.debug(f"Cache hit for {sport} {year} data")
return request_id
# limit above 500 makes an ESPN *scoreboard* return a truncated list
# (src/common/espn_dates.py). Other endpoints need more: /teams has 762
+17 -9
View File
@@ -84,7 +84,11 @@ class APIHelper:
self.session.headers.update({**DEFAULT_HTTP_HEADERS, 'Connection': 'keep-alive'})
# Rate limiting
self._last_request_time = 0
self._last_request_time = 0 # wall clock, reported by get_request_stats()
# The interval is measured on time.monotonic(): a wall-clock step
# back (NTP correcting a Pi with no RTC) made time_since_last
# negative and the "remaining interval" sleep as long as the step.
self._last_request_monotonic: Optional[float] = None
self._min_request_interval = 1.0 # Minimum seconds between requests
def get(self, url: str, params: Optional[Dict] = None,
@@ -333,13 +337,14 @@ class APIHelper:
def _enforce_rate_limit(self) -> None:
"""Enforce rate limiting between requests."""
current_time = time.time()
time_since_last = current_time - self._last_request_time
if time_since_last < self._min_request_interval:
sleep_time = self._min_request_interval - time_since_last
time.sleep(sleep_time)
if self._last_request_monotonic is not None:
time_since_last = time.monotonic() - self._last_request_monotonic
if time_since_last < self._min_request_interval:
sleep_time = self._min_request_interval - time_since_last
time.sleep(sleep_time)
self._last_request_monotonic = time.monotonic()
self._last_request_time = time.time()
def set_rate_limit(self, min_interval: float) -> None:
@@ -362,5 +367,8 @@ class APIHelper:
return {
'min_request_interval': self._min_request_interval,
'last_request_time': self._last_request_time,
'time_since_last_request': time.time() - self._last_request_time
'time_since_last_request': (
time.monotonic() - self._last_request_monotonic
if self._last_request_monotonic is not None
else time.time() - self._last_request_time),
}
+26 -14
View File
@@ -8,7 +8,7 @@ Extracted from LEDMatrix core to provide reusable functionality for plugins.
import logging
import time
from pathlib import Path
from typing import Dict, List, Optional, Union
from typing import Dict, List, Optional, Tuple, Union
import requests
from PIL import Image, ImageDraw
@@ -125,17 +125,7 @@ class LogoHelper:
# Resolve the effective target size BEFORE the cache lookup so the
# key is size-qualified — a panel-size change must not return a
# logo resized for the old dimensions.
if max_width is None:
max_width = int(self.display_width * DEFAULT_LOGO_BOX_FACTOR)
if max_height is None:
max_height = int(self.display_height * DEFAULT_LOGO_BOX_FACTOR)
# Imported here: src.element_style imports src.common (for bdf_font),
# whose __init__ imports this module.
from src.element_style import coerce_scale
scale = coerce_scale(scale, 1.0)
if scale != 1.0:
max_width = max(1, int(round(max_width * scale)))
max_height = max(1, int(round(max_height * scale)))
max_width, max_height, scale = self._scaled_box(max_width, max_height, scale)
# The key carries the scaled box, so two elements scaled differently
# cannot be served each other's image.
cache_key = f"{team_abbr}_{logo_path}_{max_width}x{max_height}"
@@ -236,8 +226,30 @@ class LogoHelper:
# exists to prevent.
self._refresh_stale_placeholder(logo_path)
# Create placeholder if all else fails
return self._create_placeholder_logo(team_abbr, max_width, max_height)
# Create placeholder if all else fails. Sized to the same scaled box
# a real logo gets, so a scaled element doesn't jump in size while
# its logo is missing.
box_width, box_height, _ = self._scaled_box(max_width, max_height, scale)
return self._create_placeholder_logo(team_abbr, box_width, box_height)
def _scaled_box(self, max_width: Optional[int], max_height: Optional[int],
scale: float) -> Tuple[int, int, float]:
"""The logo box after defaults and the user's scale are applied.
Returns ``(width, height, coerced_scale)``.
"""
if max_width is None:
max_width = int(self.display_width * DEFAULT_LOGO_BOX_FACTOR)
if max_height is None:
max_height = int(self.display_height * DEFAULT_LOGO_BOX_FACTOR)
# Imported here: src.element_style imports src.common (for bdf_font),
# whose __init__ imports this module.
from src.element_style import coerce_scale
scale = coerce_scale(scale, 1.0)
if scale != 1.0:
max_width = max(1, int(round(max_width * scale)))
max_height = max(1, int(round(max_height * scale)))
return max_width, max_height, scale
def _invalidate_cached_logo(self, team_abbr: str, logo_path: Path) -> None:
"""Drop every cached size of one logo after its file changed on disk."""
+41 -24
View File
@@ -290,6 +290,26 @@ def get_cache_dir_mode() -> int:
return 0o2775 # rwxrwsr-x (setgid + group writable)
def _sudo_bash_candidates() -> list:
"""Bash paths to try, in order, when running a vetted helper via sudo.
sudoers matches the exact argv, so ``sudo -n <bash> <helper> ...`` only
works if <bash> is the same path configure_web_sudo.sh wrote into the
rule -- whatever ``command -v bash`` said on the machine that ran it.
On merged-/usr systems /usr/bin/bash and /bin/bash are the same file but
different strings to sudo, and the web user's PATH can differ from the
installer's, so no single guess is reliable. Callers try each in turn and
move on only when sudo refused the command line (SUDO_REFUSAL_PHRASES).
The helper is invoked through bash rather than its shebang for the same
reason: the rule names bash, not the script.
"""
candidates = []
for candidate in ("/usr/bin/bash", "/bin/bash", _shutil.which("bash")):
if candidate and candidate not in candidates:
candidates.append(candidate)
return candidates
def sudo_remove_directory(path: Path, allowed_bases: Optional[list] = None) -> bool:
"""
Remove a directory using sudo as a last resort.
@@ -350,22 +370,25 @@ def sudo_remove_directory(path: Path, allowed_bases: Optional[list] = None) -> b
logger.error(f"Safe removal helper not found: {helper_script}")
return False
bash_path = _shutil.which('bash') or '/bin/bash'
try:
result = subprocess.run(
['sudo', '-n', bash_path, str(helper_script), str(resolved)],
capture_output=True,
text=True,
timeout=30
)
if result.returncode == 0 and not resolved.exists():
logger.info(f"Successfully removed {path} via sudo helper")
return True
else:
stderr = result.stderr.strip()
logger.error(f"sudo helper failed for {path}: {stderr}")
return False
for bash_path in _sudo_bash_candidates():
result = subprocess.run(
['sudo', '-n', bash_path, str(helper_script), str(resolved)],
capture_output=True,
text=True,
timeout=30
)
if result.returncode == 0 and not resolved.exists():
logger.info(f"Successfully removed {path} via sudo helper")
return True
# Only a refused command line is worth another bash path; if the
# helper itself ran and failed, a retry would just repeat it.
if result.returncode == 0 or not any(
phrase in (result.stderr or '') for phrase in SUDO_REFUSAL_PHRASES):
break
stderr = (result.stderr or '').strip()
logger.error(f"sudo helper failed for {path}: {stderr}")
return False
except subprocess.TimeoutExpired:
logger.error(f"sudo helper timed out for {path}")
return False
@@ -417,16 +440,10 @@ def install_requirements_file(req_file: Path, timeout: int = 300) -> subprocess.
wrapper = project_root / "scripts" / "fix_perms" / "safe_pip_install.sh"
if wrapper.exists():
# See sudo_remove_directory / configure_web_sudo.sh for why bash must
# be invoked with an explicit, known path rather than relying on the
# wrapper's shebang: sudoers matches the exact command line.
bash_candidates = []
for candidate in ("/usr/bin/bash", "/bin/bash", _shutil.which("bash")):
if candidate and candidate not in bash_candidates:
bash_candidates.append(candidate)
# See _sudo_bash_candidates for why bash is invoked by explicit path
# and why there is more than one to try.
result = None
for bash_path in bash_candidates:
for bash_path in _sudo_bash_candidates():
# bash_path and wrapper are fixed, known-good paths, and
# safe_pip_install.sh independently re-validates req_file is an
# allowed requirements.txt before installing anything as root.
+21 -10
View File
@@ -32,6 +32,7 @@ from typing import Callable, Optional
import numpy as np
from PIL import Image
from src.config_manager_atomic import _replace
from src.display_geometry import DEFAULT_CHAIN_LENGTH, DEFAULT_COLS, DEFAULT_ROWS
# Raw-frame wire format: 8-byte magic + 4-byte header + raw RGB pixels
@@ -127,6 +128,9 @@ class DisplaySyncManager:
self._peer_ip: Optional[str] = None
self._peer_compatible: bool = False
self._peer_chain: int = 0
# time.monotonic() readings, like _last_leader_frame_time: these only
# feed the timeout watchdogs, and a wall-clock step (NTP correcting a
# Pi with no RTC) would otherwise fake or mask a timeout.
self._last_heartbeat_time: float = 0.0
self._leader_width: int = 0 # set by display_controller after init
self._oversized_frame_warned: bool = False
@@ -206,7 +210,7 @@ class DisplaySyncManager:
self._handle_hello(msg, sender_ip)
elif t == "hb":
if self._peer_ip == sender_ip:
self._last_heartbeat_time = time.time()
self._last_heartbeat_time = time.monotonic()
except socket.timeout:
continue
except Exception as exc:
@@ -229,7 +233,7 @@ class DisplaySyncManager:
self._peer_ip = sender_ip
self._peer_compatible = compatible
self._peer_chain = peer_chain
self._last_heartbeat_time = time.time()
self._last_heartbeat_time = time.monotonic()
prev_state = self._leader_state
if compatible:
@@ -273,7 +277,7 @@ class DisplaySyncManager:
while self._running:
time.sleep(1.0)
if self._leader_state == LeaderState.CONNECTED:
if time.time() - self._last_heartbeat_time > PEER_TIMEOUT:
if time.monotonic() - self._last_heartbeat_time > PEER_TIMEOUT:
self.logger.info(
"Sync: follower heartbeat timeout — peer disconnected"
)
@@ -501,7 +505,7 @@ class DisplaySyncManager:
"""Note that the leader at ``sender_ip`` just sent something, and
switch from standalone to follower mode if not already following.
Returns True if this call made the switch."""
self._last_leader_frame_time = time.time()
self._last_leader_frame_time = time.monotonic()
self._leader_ip = sender_ip
if self._follower_state != FollowerState.STANDALONE:
return False
@@ -621,11 +625,13 @@ class DisplaySyncManager:
heartbeat = json.dumps({"t": "hb"}).encode("utf-8")
dest = ("<broadcast>", self.port)
last_hello = 0.0
last_hb = 0.0
# -inf, not 0.0: monotonic time starts near boot, so "now - 0.0" can
# be under the interval and would delay the first announcement.
last_hello = float("-inf")
last_hb = float("-inf")
while self._running:
now = time.time()
now = time.monotonic()
if now - last_hello >= HELLO_INTERVAL:
try:
self._send_sock.sendto(hello, dest)
@@ -644,7 +650,7 @@ class DisplaySyncManager:
while self._running:
time.sleep(1.0)
if self._follower_state == FollowerState.FOLLOWER:
if time.time() - self._last_leader_frame_time > LEADER_TIMEOUT:
if time.monotonic() - self._last_leader_frame_time > LEADER_TIMEOUT:
self.logger.info(
"Sync: leader frame timeout — returning to standalone mode"
)
@@ -670,7 +676,10 @@ class DisplaySyncManager:
def set_on_new_cycle(self, callback: Callable[[], None]) -> None:
"""Follower: register a callback fired when the leader starts a new scroll cycle.
Used to trigger a local start_new_cycle() so both Pis rebuild from same fresh data.
Nothing in core registers one: display_controller follows the leader
through set_on_scroll_image() and the scroll position instead of
rebuilding locally. The hook stays for callers that want the signal.
"""
self._on_new_cycle = callback
@@ -734,7 +743,9 @@ class DisplaySyncManager:
# mkstemp makes it owner-only; the web UI may run as a
# different user from the display service.
os.chmod(tmp, 0o644)
os.replace(tmp, STATUS_FILE)
# _replace: on Windows a rename can briefly fail with
# "Access is denied" while a scanner holds the target open.
_replace(tmp, STATUS_FILE)
tmp = None
except Exception as exc:
self.logger.debug("Sync: status file write error: %s", exc)
+17 -4
View File
@@ -13,7 +13,8 @@ Supported dynamic teams:
Usage:
resolver = DynamicTeamResolver()
resolved_teams = resolver.resolve_teams(["UGA", "AP_TOP_25", "AUB"])
# Returns: ["UGA", "UGA", "AUB", "MICH", "OSU", ...] (AP_TOP_25 teams)
# Returns: ["UGA", "MICH", "OSU", ..., "AUB"] -- AP_TOP_25 expanded in
# place, and UGA (also ranked) kept once, at its first position
"""
import logging
@@ -37,6 +38,11 @@ class DynamicTeamResolver:
_rankings_cache: Dict[str, List[str]] = {}
_cache_timestamp: float = 0
_cache_duration: int = 3600 # 1 hour cache
# A failed or empty fetch is remembered briefly too: during an ESPN
# outage every resolve would otherwise wait out request_timeout (30s)
# again, on each scoreboard's update.
_failure_timestamp: float = 0
_failure_backoff: int = 300 # 5 minutes
# Supported dynamic team patterns
DYNAMIC_PATTERNS = {
@@ -70,8 +76,8 @@ class DynamicTeamResolver:
if team in self.DYNAMIC_PATTERNS:
# Resolve dynamic team
dynamic_teams = self._resolve_dynamic_team(team, sport)
# _resolve_dynamic_team already logs the result.
resolved_teams.extend(dynamic_teams)
self.logger.info(f"Resolved {team} to {len(dynamic_teams)} teams: {dynamic_teams[:5]}{'...' if len(dynamic_teams) > 5 else ''}")
elif self._is_potential_dynamic_team(team):
# Unknown dynamic team, skip it
self.logger.warning(f"Unknown dynamic team '{team}' - skipping")
@@ -138,7 +144,11 @@ class DynamicTeamResolver:
if (self._rankings_cache and
current_time - self._cache_timestamp < self._cache_duration):
return self._rankings_cache
# A recent attempt failed: don't pay the request timeout again yet.
if current_time - self._failure_timestamp < self._failure_backoff:
return {}
try:
self.logger.info("Fetching fresh NCAA Football rankings from ESPN API")
rankings_url = "https://site.api.espn.com/apis/site/v2/sports/football/college-football/rankings"
@@ -185,7 +195,9 @@ class DynamicTeamResolver:
except Exception as e:
self.logger.error(f"Error fetching NCAA Football rankings: {e}")
# On the class, for the same reason as the rankings cache above.
DynamicTeamResolver._failure_timestamp = current_time
return {}
def get_available_dynamic_teams(self) -> List[str]:
@@ -229,6 +241,7 @@ class DynamicTeamResolver:
shadow the shared cache for this instance."""
DynamicTeamResolver._rankings_cache = {}
DynamicTeamResolver._cache_timestamp = 0
DynamicTeamResolver._failure_timestamp = 0
self.logger.info("Cleared dynamic team rankings cache")
+4 -3
View File
@@ -1499,11 +1499,12 @@ class ElementStyleResolver:
mode_config, 'align')
# scale is geometry, so it lives with the offsets rather than in the
# element block -- a logo has a scale and no font.
layout_defaults = self._defaults.get('layout', {})
# The default is looked up through the aliases too, like the value:
# an exact-key lookup missed a default filed under another name, so
# a configured value equal to it counted as a user choice.
scale = self._forced(
self._layout_element(self._customization(), element_key),
layout_defaults.get(element_key, {})
if isinstance(layout_defaults, dict) else {},
_lookup_element(self._defaults.get('layout', {}), element_key),
self._layout_element(self._mode_block(mode), element_key),
'scale')
+3 -40
View File
@@ -6,7 +6,8 @@ for the LEDMatrix system. Enables automatic bug detection by tracking
error frequency, patterns, and context.
This is a local-only implementation with no external dependencies.
Errors are stored in memory with optional JSON export.
Errors are stored in memory; ErrorSnapshotPublisher shares a summary with the
web process through the cache.
"""
import math
@@ -18,7 +19,6 @@ import uuid
from collections import defaultdict
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from pathlib import Path
from typing import Dict, List, Optional, Any, Callable, Tuple
import logging
@@ -105,7 +105,6 @@ class ErrorAggregator:
max_records: int = 1000,
pattern_threshold: int = 5,
pattern_window_minutes: int = 60,
export_path: Optional[Path] = None
):
"""
Initialize the error aggregator.
@@ -114,20 +113,18 @@ class ErrorAggregator:
max_records: Maximum number of error records to keep in memory
pattern_threshold: Number of occurrences to detect a pattern
pattern_window_minutes: Time window for pattern detection
export_path: Optional path for JSON export (auto-export on pattern detection)
"""
self.logger = logging.getLogger(__name__)
self.max_records = max_records
self.pattern_threshold = pattern_threshold
self.pattern_window = timedelta(minutes=pattern_window_minutes)
self.export_path = export_path
self._records: List[ErrorRecord] = []
self._error_counts: Dict[str, int] = defaultdict(int)
self._plugin_error_counts: Dict[str, Dict[str, int]] = defaultdict(lambda: defaultdict(int))
self._patterns: Dict[str, ErrorPattern] = {}
self._pattern_callbacks: List[Callable[[ErrorPattern], None]] = []
self._lock = threading.RLock() # RLock allows nested acquisition for export_to_file
self._lock = threading.RLock() # RLock: build_snapshot and pattern callbacks re-enter
# Track session start for relative timing
self._session_start = datetime.now()
@@ -248,10 +245,6 @@ class ErrorAggregator:
callback(pattern)
except Exception as e:
self.logger.error(f"Pattern callback failed: {e}")
# Auto-export if path configured
if self.export_path:
self._auto_export()
else:
# Update existing pattern
self._patterns[pattern_key].count = count
@@ -424,33 +417,6 @@ class ErrorAggregator:
# turned into a string here rather than failing the write.
return json.loads(json.dumps(summary, default=str))
def export_to_file(self, filepath: Path) -> None:
"""
Export error data to JSON file.
Args:
filepath: Path to export file
"""
with self._lock:
data = {
"exported_at": datetime.now().isoformat(),
"summary": self.get_error_summary(),
"all_records": [r.to_dict() for r in self._records]
}
filepath.parent.mkdir(parents=True, exist_ok=True)
filepath.write_text(json.dumps(data, indent=2))
self.logger.info(f"Exported error data to {filepath}")
def _auto_export(self) -> None:
"""Auto-export on pattern detection (if export_path configured)."""
if self.export_path:
try:
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filepath = self.export_path / f"errors_{timestamp}.json"
self.export_to_file(filepath)
except Exception as e:
self.logger.error(f"Auto-export failed: {e}")
# Global singleton instance
_error_aggregator: Optional[ErrorAggregator] = None
@@ -461,7 +427,6 @@ def get_error_aggregator(
max_records: int = 1000,
pattern_threshold: int = 5,
pattern_window_minutes: int = 60,
export_path: Optional[Path] = None
) -> ErrorAggregator:
"""
Get or create the global error aggregator instance.
@@ -470,7 +435,6 @@ def get_error_aggregator(
max_records: Maximum records to keep (only used on first call)
pattern_threshold: Pattern detection threshold (only used on first call)
pattern_window_minutes: Pattern detection window (only used on first call)
export_path: Export path for auto-export (only used on first call)
Returns:
The global ErrorAggregator instance
@@ -483,7 +447,6 @@ def get_error_aggregator(
max_records=max_records,
pattern_threshold=pattern_threshold,
pattern_window_minutes=pattern_window_minutes,
export_path=export_path
)
return _error_aggregator
+12 -4
View File
@@ -42,7 +42,9 @@ class CacheError(LEDMatrixError):
context: Optional context dictionary
"""
if cache_key:
context = context or {}
# Copy so the caller's dict isn't mutated (a reused context
# dict would otherwise collect every error's keys).
context = dict(context or {})
context['cache_key'] = cache_key
super().__init__(message, context)
self.cache_key = cache_key
@@ -62,7 +64,9 @@ class ConfigError(LEDMatrixError):
context: Optional context dictionary
"""
if config_path or field:
context = context or {}
# Copy so the caller's dict isn't mutated (a reused context
# dict would otherwise collect every error's keys).
context = dict(context or {})
if config_path:
context['config_path'] = config_path
if field:
@@ -85,7 +89,9 @@ class PluginError(LEDMatrixError):
context: Optional context dictionary
"""
if plugin_id:
context = context or {}
# Copy so the caller's dict isn't mutated (a reused context
# dict would otherwise collect every error's keys).
context = dict(context or {})
context['plugin_id'] = plugin_id
super().__init__(message, context)
self.plugin_id = plugin_id
@@ -104,7 +110,9 @@ class DisplayError(LEDMatrixError):
context: Optional context dictionary
"""
if display_mode:
context = context or {}
# Copy so the caller's dict isn't mutated (a reused context
# dict would otherwise collect every error's keys).
context = dict(context or {})
context['display_mode'] = display_mode
super().__init__(message, context)
self.display_mode = display_mode
+57 -16
View File
@@ -28,10 +28,10 @@ for accurate width/height calculations.
import os
import logging
import freetype
import requests
import json
import hashlib
import urllib.parse
import urllib.request
import zipfile
import tempfile
import time
@@ -50,6 +50,9 @@ from src.deprecation import deprecated
logger = logging.getLogger(__name__)
# Seconds before a stalled font download gives up (connect and per-read).
_FONT_DOWNLOAD_TIMEOUT = 30
class FontManager:
"""
Comprehensive font management supporting TTF and BDF fonts with caching,
@@ -320,31 +323,59 @@ class FontManager:
extension = self._get_font_extension(url)
cache_filename = f"{family}_{url_hash}{extension}"
cache_path = self.temp_font_dir / cache_filename
is_zip = url.endswith('.zip')
extract_dir = self.temp_font_dir / f"{family}_{url_hash}"
# Check if already downloaded
if cache_path.exists():
# Check if already downloaded. For a zip the font is the file
# extracted from it, so look there first -- returning the cached
# .zip itself would register the archive as the font after a
# restart.
if is_zip:
extracted = self._find_extracted_font(extract_dir)
if extracted:
logger.info(f"Using cached font: {extracted}")
return extracted
elif cache_path.exists():
logger.info(f"Using cached font: {cache_path}")
return str(cache_path)
# Download font — restrict to http/https to prevent file:// reads
parsed = urllib.parse.urlparse(url)
if parsed.scheme not in ('http', 'https'):
raise ValueError(f"Font URL must use http or https, got: {parsed.scheme!r}")
logger.info(f"Downloading font from {url}")
urllib.request.urlretrieve(url, cache_path) # nosec B310 - scheme validated above
if not cache_path.exists():
# Download font — restrict to http/https to prevent file:// reads
parsed = urllib.parse.urlparse(url)
if parsed.scheme not in ('http', 'https'):
raise ValueError(f"Font URL must use http or https, got: {parsed.scheme!r}")
logger.info(f"Downloading font from {url}")
# Download to a temp file and rename into place, with a
# timeout: writing straight to cache_path left a truncated
# file after a stalled/interrupted download, and the exists()
# check above then served it forever.
fd, tmp_name = tempfile.mkstemp(dir=self.temp_font_dir, suffix='.part')
try:
with os.fdopen(fd, 'wb') as tmp_file:
response = requests.get(url, timeout=_FONT_DOWNLOAD_TIMEOUT, stream=True)
response.raise_for_status()
for chunk in response.iter_content(chunk_size=65536):
if chunk:
tmp_file.write(chunk)
os.replace(tmp_name, cache_path)
except BaseException:
try:
os.unlink(tmp_name)
except OSError:
pass
raise
# Handle zip files
if url.endswith('.zip'):
extract_dir = self.temp_font_dir / f"{family}_{url_hash}"
if is_zip:
extract_dir.mkdir(exist_ok=True)
with zipfile.ZipFile(cache_path, 'r') as zip_ref:
zip_ref.extractall(extract_dir)
# Find the actual font file
for file in extract_dir.iterdir():
if file.suffix.lower() in ['.ttf', '.otf', '.bdf']:
return str(file)
extracted = self._find_extracted_font(extract_dir)
if extracted:
return extracted
return str(cache_path)
@@ -352,6 +383,16 @@ class FontManager:
logger.error(f"Error downloading font from {url}: {e}")
return None
@staticmethod
def _find_extracted_font(extract_dir: Path) -> Optional[str]:
"""Return the first font file in a zip's extract dir, if any."""
if not extract_dir.is_dir():
return None
for file in extract_dir.iterdir():
if file.suffix.lower() in ['.ttf', '.otf', '.bdf']:
return str(file)
return None
def _get_font_extension(self, url: str) -> str:
"""Extract font file extension from URL."""
if '.ttf' in url.lower():
+4 -1
View File
@@ -43,7 +43,10 @@ class StructuredFormatter(logging.Formatter):
if hasattr(record, 'operation_id'):
log_data['operation_id'] = record.operation_id
return json.dumps(log_data)
# default=str: record.context / extras can hold datetimes, Paths,
# exceptions etc.; without it one such value raised TypeError and
# the whole record was dropped by the handler's error path.
return json.dumps(log_data, default=str)
class ContextualFormatter(logging.Formatter):
+14 -5
View File
@@ -318,12 +318,21 @@ class StartupValidator:
def raise_on_errors(self) -> None:
"""
Raise exceptions if validation errors exist.
Raise one exception if validation errors exist; return None if not.
Nothing in core calls this (see the module docstring). Errors are
grouped by a keyword in their message, not by which check produced
them, and only the first non-empty group is raised, in the order
config > cache > plugin: a "plugin ... config" message counts as a
config error, and cache/plugin errors are not reported while a
config error exists. The raised exception's ``context['errors']``
holds that group's messages only.
Raises:
ConfigError: If configuration validation fails
CacheError: If cache validation fails
PluginError: If plugin validation fails
ConfigError: If any message mentions config/configuration, or if
none matches any group
CacheError: If a message mentions cache (and none config)
PluginError: If a message mentions plugin (and none of the above)
"""
if not self.errors:
return
+22 -7
View File
@@ -2,13 +2,29 @@
A list reaches a plugin-config save keyed by position more often than as a
list. The settings form posts one field per element (``feeds.custom_feeds.0.name``),
which ``_set_nested_value`` stores as ``{"0": {"name": ...}}``, and the JSON
path's dotToNested() in the browser builds the same dict. Validation expects
an array there, so the save converts them first.
which ``_set_nested_value`` stores as ``{"0": {"name": ...}}``, and a JSON
save built by flattening then re-nesting dotted keys carries the same dict.
Validation expects an array there, so the save converts them first.
"""
from typing import Any, Dict
def _schema_type_is(prop: Any, wanted: str) -> bool:
"""Whether a schema property is of ``wanted`` type, unions included.
Mirrors ``_schema_type_is`` in ``web_interface/blueprints/api_v3`` (kept
here so src/ doesn't import the Flask blueprint). A union such as
``["array", "null"]`` -- the per-element style overrides, where null means
"inherit" -- is still an array for recombining position-keyed inputs.
"""
if not isinstance(prop, dict):
return False
declared = prop.get('type')
if isinstance(declared, list):
return wanted in declared
return declared == wanted
def _is_index_dict(value: Any) -> bool:
"""True for a dict keyed only by list positions ("0", "1", ...), or empty."""
return isinstance(value, dict) and all(str(k).isdigit() for k in value)
@@ -35,10 +51,9 @@ def coerce_array_shapes(config: Dict[str, Any], schema_props: Dict[str, Any],
for key, prop_schema in schema_props.items():
if key not in config or not isinstance(prop_schema, dict):
continue
prop_type = prop_schema.get('type')
value = config[key]
if prop_type == 'array':
if _schema_type_is(prop_schema, 'array'):
if _is_index_dict(value):
value = config[key] = [value[k] for k in sorted(value, key=lambda k: int(str(k)))]
if not isinstance(value, list):
@@ -50,11 +65,11 @@ def coerce_array_shapes(config: Dict[str, Any], schema_props: Dict[str, Any],
and isinstance(default, list) and len(default) >= min_items):
value = config[key] = list(default)
items_schema = prop_schema.get('items')
if (isinstance(items_schema, dict) and items_schema.get('type') == 'object'
if (_schema_type_is(items_schema, 'object')
and 'properties' in items_schema):
for element in value:
coerce_array_shapes(element, items_schema['properties'],
short_lists_take_default)
elif prop_type == 'object' and 'properties' in prop_schema:
elif _schema_type_is(prop_schema, 'object') and 'properties' in prop_schema:
coerce_array_shapes(value, prop_schema['properties'], short_lists_take_default)
+4 -2
View File
@@ -8,11 +8,13 @@ from pathlib import Path
def validate_file_upload(filename: str, max_size_mb: int = 10,
allowed_extensions: Optional[List[str]] = None) -> Tuple[bool, Optional[str]]:
"""
Validate file upload parameters.
Validate an upload's filename (not its contents or size).
Args:
filename: Name of the file
max_size_mb: Maximum file size in MB
max_size_mb: Unused. Kept so existing callers keep working; this
function only sees the filename, so callers must enforce the
size limit themselves on the uploaded stream.
allowed_extensions: List of allowed file extensions (e.g., ['.ttf', '.otf'])
Returns: