mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-05 06:45:09 +00:00
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:
+57
-16
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user