mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-06-15 01:18:38 +00:00
fix: Codacy security fixes, CVE dependency bumps, and code quality cleanup (#331)
* fix(deps): bump minimum versions to address CVEs Pillow 10.4.0 → 12.2.0: CVE-2026-40192 (DoS via FITS decompression bomb), CVE-2026-25990 (OOB write via PSD image), CVE-2026-42311/42308/42310 requests 2.32.0 → 2.33.0: CVE-2026-25645 (temp file security bypass), CVE-2024-47081 (.netrc credentials leak) werkzeug 3.0.0 → 3.1.6: CVE-2023-46136, CVE-2024-49766/49767, CVE-2025-66221, CVE-2026-21860/27199 (DoS, path traversal, safe_join bypass) Flask 3.0.0 → 3.1.3: CVE-2026-27205 (session data caching info disclosure) spotipy 2.24.0 → 2.25.2: CVE-2025-27154, CVE-2025-66040 python-socketio 5.11.0 → 5.14.0: CVE-2025-61765 pytest 7.4.0 → 9.0.3: CVE-2025-71176 (insecure temp dir handling) Updated in requirements.txt, web_interface/requirements.txt, plugin-repos/starlark-apps/requirements.txt, and plugin-repos/march-madness/requirements.txt. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: resolve Pylint errors in executor, data service, and odds call Rename TimeoutError to PluginTimeoutError in plugin_executor.py to avoid shadowing the built-in; no external callers affected. Remove dead try/except in BackgroundDataService.shutdown: executor.shutdown() never accepted a timeout kwarg so the try branch always raised TypeError. Simplify to a direct shutdown(wait=wait) call. Remove is_live kwarg from odds_manager.get_odds() call in sports.py; BaseOddsManager.get_odds() has no such parameter. The live update interval is already encoded in the update_interval_seconds argument passed alongside. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: MD5→SHA-256, shellcheck warnings, and broken doc links config_service.py: replace MD5 with SHA-256 for config change detection; same semantics (equality comparison), no stored hashes affected. Shell scripts — shellcheck warnings: - diagnose_web_interface.sh: remove useless cat (SC2002) - dev_plugin_setup.sh: restructure A&&B||C into if/then (SC2015) - fix_assets_permissions.sh: remove unused REAL_HOME block (SC2034) - install_web_service.sh: remove unused USER_HOME assignment (SC2034) - diagnose_web_ui.sh: remove unused SUDO assignments (SC2034) - diagnose_plugin_permissions.sh: remove unused BLUE color var (SC2034) - first_time_install.sh: remove unused CLEAR var, PACKAGE_NAME assignment, and replace loop variable with _ (SC2034) docs/PLUGIN_ARCHITECTURE_SPEC.md: fix 10 broken TOC anchor links to include section numbers matching the actual headings (MD051). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: remove unused imports and bare exception aliases (pyflakes F401/F841) Remove unused imports across 86 files in src/, web_interface/, test/, and scripts/ using autoflake. No logic changes — only dead import statements and unused names in from-imports are removed. Also remove bare exception aliases where the variable is never referenced in the handler body: - src/cache/disk_cache.py: except (IOError, OSError, PermissionError) as e - src/cache_manager.py: except (OSError, IOError, PermissionError) as perm_error - src/plugin_system/resource_monitor.py: except Exception as e - web_interface/app.py: except Exception as read_err 86 files changed, 205 lines removed, 18 pre-existing test failures unchanged. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: remove unused local variable assignments (pyflakes F841) Dead assignments removed across src/ and web_interface/: - background_data_service: drop future= on fire-and-forget executor.submit - base_classes/baseball: drop font= (all rendering uses self.fonts['time']) - base_classes/hockey: drop status_short= (never referenced after assignment) - common/cli: drop game_helper=/config_helper= bindings in import-test block; constructors called for instantiation-only validation - common/display_helper: drop text_width= (x_position uses display_width directly); drop draw= in create_error_image (uses _draw_centered_text) - config_manager: remove dead secrets_content loading block in migration path (comment already noted save_config_atomic handles secrets internally) - display_manager: drop setup_start= (timing was never completed or read) - font_manager: drop target_path= (catalog uses font_file_path directly); drop face=/font= bindings in validate_font (validation by construction — TypeError on failure is the signal, not the return value) - font_test_manager: drop width=/height= (draw_text uses display_manager directly) - plugin_system/state_reconciliation: drop manager= (only config/disk/state_mgr used) - plugin_system/store_manager: drop result= on pip install subprocess.run (check=True raises on failure; stdout unused) - web_interface/blueprints/pages_v3: drop main_config_path=""/secrets_config_path="" (render_template uses config_manager.get_*_path() inline) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(js): resolve ESLint no-undef warnings across 6 JS files Three distinct patterns: 1. Vendor library globals — htmx is injected by <script> before these extension files load; ESLint lints files in isolation and doesn't know. Fix: add /* global htmx */ to htmx-sse.js and htmx-json-enc.js. 2. Cross-file globals — showNotification is defined as window.showNotification in app.js/notification.js but called bare in app.js and error_handler.js. ESLint doesn't connect window.X = Y with a bare call to X. Fix: add /* global showNotification */ to app.js and error_handler.js. 3. Forward-reference window.* functions — in array-table.js, checkbox-group.js, and custom-feeds.js, functions like removeArrayTableRow are called early inside event-handler closures but assigned to window.* later in the file. At runtime this works (the handler fires after the assignment), but ESLint sees the bare name at the call site. Fix: change bare calls to window.removeArrayTableRow(this) etc. so the reference is explicit and ESLint-safe. Also guard the updateSystemStats call in app.js reconnectSSE: the function is called but defined nowhere in the codebase. Guard with typeof check so it won't throw ReferenceError if the reconnect path is hit. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(js): resolve Biome lint warnings across 9 JS files noUnusedVariables (catch bindings → optional catch syntax): - app.js, file-upload.js, timezone-selector.js: } catch (e) { → } catch { ES2019 optional catch binding; e was unused in all three handlers noUnusedVariables (dead assignments): - app.js: remove const data= in display SSE stub (handler does nothing yet) - api_client.js: remove const timeoutId= (setTimeout ID never used to cancel) - custom-feeds.js: remove const oldIndex= (getAttribute result never read) - schedule-picker.js: remove const compactMode= (never used in HTML build) - select-dropdown.js: remove const icons= (icons not yet rendered in options) noPrototypeBuiltins: - day-selector.js: DAY_LABELS.hasOwnProperty(x) → Object.prototype.hasOwnProperty.call(DAY_LABELS, x) Safe form that works even on null-prototype objects useIterableCallbackReturn: - file-upload.js, notification.js: forEach(x => expr) → forEach(x => { expr; }) — forEach ignores return values; implicit return from arrow body was misleading htmx-sse.js is a vendor extension file with old-style var/== patterns that are correct for it; 18 Biome issues suppressed via Codacy API rather than modifying the vendor source. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(security): escape user input in raw HTML responses in pages_v3.py plugin_id comes directly from the URL path (/partials/plugin-config/<plugin_id>) and was interpolated into an HTML fragment without escaping. A crafted URL like /partials/plugin-config/<script>alert(1)</script> would inject that tag into the DOM via the HTMX partial response. Fix: wrap all user-controlled values in markupsafe.escape() before embedding in raw HTML strings. Affects the plugin-not-found 404 response and both error 500 responses in the plugin config partial. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: address Bandit B108/B110 across production code B110 (try/except/pass): - display_controller.py: narrow 'except Exception' to 'except AttributeError' for get_offset_frame() — plugins not having this optional method is the expected case, not all exceptions - config_manager.py: B110 already resolved by the earlier removal of the dead secrets-loading block (the except/pass was inside it) - All other except/pass blocks in src/ and web_interface/ are intentional (last-resort recovery, best-effort fallbacks, non-critical startup probes). Annotated each with # nosec B110 and a brief inline reason so the decision is explicit for future reviewers. - Test files and plugin-repos B110 suppressed via Codacy API (not prod code). B108 (/tmp usage): - permission_utils.py: /tmp listed to PREVENT permission changes on it — not used as a temp path. Annotated # nosec B108. - display_manager.py: fixed snapshot path is intentional (web UI reads same path); path-check guard also annotated. - wifi_manager.py: named /tmp files match the sudoers allowlist installed with the system (the paths are hard-coded in both places by design). Annotated all six open/cp references # nosec B108. - scripts/render_plugin.py: dev script default overridable by user. Annotated. - web_interface/app.py: reads the same fixed path written by display_manager. Annotated # nosec B108. - Test files suppressed via Codacy API. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: address remaining Codacy security findings Flask debug=True (real fix): - web_interface/app.py: debug=True in __main__ block exposes the Werkzeug interactive debugger (arbitrary code execution). Changed to os.environ.get('FLASK_DEBUG', '0') == '1' — off by default, opt-in via environment variable for local development. nosec annotations (accepted risk with documented rationale): - disk_cache.py: os.chmod(0o660) is intentional — web UI and LED matrix service share a group, 660 gives group write while denying world access (B103 + Semgrep insecure-file-permissions suppressed in Codacy) - wifi_manager.py: urlopen to hardcoded connectivity-check.ubuntu.com URL (B310 — no user input involved) - font_manager.py: urlretrieve URL comes from user's own config file on their local device (B310) - start_web_conditionally.py: os.execvp with both sys.executable and a fixed PROJECT_DIR-relative constant (B606) Confirmed false positives suppressed via Codacy API (15 issues): - SSRF (3x): client-side JS fetch — SSRF is server-side; browser fetch is CORS-restricted to same origin - B105 (3x): test fixtures use dummy secrets by design; store_manager checks for the placeholder string, it is not itself a secret - PMD numeric literal (2x): 10000000 is within Number.MAX_SAFE_INTEGER - Prototype pollution (1x): read-only schema traversal, no writes - no-unsanitized_method (1x): dynamic import() is CORS-restricted - detect-unsafe-regex (1x): operates on server-controlled config values - plugin-repos B103 (1x): vendor code chmod on executable - Semgrep insecure-file-permissions (3x): same disk_cache 0o660 as above Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: remove unnecessary f prefix from f-strings without placeholders (F541) Pyflakes F541 flags f-strings that contain no {} interpolation — they are identical to plain strings but trigger unnecessary string formatting overhead. Fixed in production code: - src/base_classes/data_sources.py (2 debug log calls) - src/logo_downloader.py (1 error log) - src/plugin_system/store_manager.py (5 strings across 3 log calls) - src/web_interface/validators.py (1 return value) - src/wifi_manager.py (4 log/message strings) - web_interface/start.py (1 print) F541 issues in test/, scripts/, and plugin-repos/ suppressed via Codacy API as non-production code. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore(dev): add Pillow compatibility smoke test script Covers all Pillow APIs used in LEDMatrix — image creation, drawing, font metrics, LANCZOS resampling, paste/alpha_composite, and PNG I/O. Run after any Pillow version bump to catch regressions before deploy. python3 scripts/dev/test_pillow_compat.py Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: resolve 8 new Codacy issues introduced by PR changes shellcheck SC2034: - first_time_install.sh: 'type' loop variable also unused in the wifi status loop (we previously fixed 'device' → '_' but left 'type'). Changed to '_ _ state' since neither device nor type is referenced. ESLint no-undef: - app.js: typeof guards don't satisfy no-undef; added updateSystemStats to the /* global */ declaration alongside showNotification. nosec annotation: - web_interface/app.py: app.run(host='0.0.0.0') line changed when we fixed debug=True, giving it a new issue ID. Re-added # nosec B104. pyflakes F401: - scripts/dev/test_pillow_compat.py: ImageFilter was imported but never used in the smoke test. Removed from the import. Codacy API suppressions (false positives on changed lines): - disk_cache.py 0o660 chmod (2x): lines changed when # nosec B103 was added, producing new Semgrep issue IDs. Re-suppressed. - pages_v3.py raw-html-concat: Semgrep does not recognise escape() as a sanitizer; the escape() call IS the correct fix. - app.py flask 0.0.0.0: same line as B104 above; Semgrep rule also re-suppressed. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: address PR review findings Fix (10 of 15 findings): plugin-repos/march-madness/requirements.txt: Add urllib3>=1.26.0 — manager.py directly imports from urllib3; it was an undeclared transitive dependency via requests. scripts/dev/dev_plugin_setup.sh: Restore subshell form (cd "$target_dir" && git pull --rebase) || true so the shell's working directory is not permanently changed after the if-cd block. Previous fix for SC2015 leaked cwd into the remainder of the script. src/base_classes/sports.py: Narrow 'except Exception' to 'except RuntimeError as e' and log via self.logger.debug — Path.home() raises only RuntimeError for service users; other exceptions should not be silently swallowed. src/config_service.py: Fix stale "MD5 checksum" in ConfigVersion.__init__ docstring (line 40); the implementation uses SHA-256 since the Codacy fix. src/wifi_manager.py: Log the last-resort AP enable failure with exc_info=True instead of silently passing — failure here means the device may be unreachable. web_interface/blueprints/pages_v3.py: Log the outer metadata pre-load exception at debug level instead of swallowing it silently; schema still loads fully below. src/background_data_service.py: Remove unused 'timeout' parameter from shutdown() — executor.shutdown() does not accept timeout; update __del__ caller accordingly. src/font_manager.py: Validate URL scheme before urlretrieve — reject non-http/https schemes (e.g. file://) to prevent reading local files from config-supplied URLs. src/plugin_system/plugin_executor.py: Simplify redundant except tuple: (PluginTimeoutError, PluginError, Exception) → Exception, which already covers the others. test/test_display_controller.py: Mark empty test_plugin_discovery_and_loading as @pytest.mark.skip with reason. Move duplicate 'from datetime import datetime' to module header and remove the stray mid-module copy. Skip (5 of 15 findings, with reasons): - pytest 9.0.3 concerns: full suite already verified (467 pass, 18 pre-existing) - Pillow 12.2.0 API concerns: no deprecated APIs in codebase; tests + Pi smoke test pass - diagnose_web_ui.sh sudo validation: set -e already ensures fail-fast on any sudo failure - app.py request-logging except: must stay silent (recursive logging risk); annotated - app.py SSE file-read except: genuinely transient I/O; annotated Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Chuck <chuck@example.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -34,16 +34,16 @@ This document outlines the transformation of the LEDMatrix project into a modula
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Current Architecture Analysis](#current-architecture-analysis)
|
||||
2. [Plugin System Design](#plugin-system-design)
|
||||
3. [Plugin Store & Discovery](#plugin-store--discovery)
|
||||
4. [Web UI Transformation](#web-ui-transformation)
|
||||
5. [Migration Strategy](#migration-strategy)
|
||||
6. [Plugin Developer Guidelines](#plugin-developer-guidelines)
|
||||
7. [Technical Implementation Details](#technical-implementation-details)
|
||||
8. [Best Practices & Standards](#best-practices--standards)
|
||||
9. [Security Considerations](#security-considerations)
|
||||
10. [Implementation Roadmap](#implementation-roadmap)
|
||||
1. [Current Architecture Analysis](#1-current-architecture-analysis)
|
||||
2. [Plugin System Design](#2-plugin-system-design)
|
||||
3. [Plugin Store & Discovery](#3-plugin-store--discovery)
|
||||
4. [Web UI Transformation](#4-web-ui-transformation)
|
||||
5. [Migration Strategy](#5-migration-strategy)
|
||||
6. [Plugin Developer Guidelines](#6-plugin-developer-guidelines)
|
||||
7. [Technical Implementation Details](#7-technical-implementation-details)
|
||||
8. [Best Practices & Standards](#8-best-practices--standards)
|
||||
9. [Security Considerations](#9-security-considerations)
|
||||
10. [Implementation Roadmap](#10-implementation-roadmap)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -259,8 +259,6 @@ else
|
||||
fi
|
||||
|
||||
echo ""
|
||||
CLEAR='
|
||||
'
|
||||
CURRENT_STEP="Install system dependencies"
|
||||
echo "Step 1: Installing system dependencies..."
|
||||
echo "----------------------------------------"
|
||||
@@ -671,8 +669,6 @@ if [ -f "$PROJECT_ROOT_DIR/requirements.txt" ]; then
|
||||
echo "[$PACKAGE_NUM/$TOTAL_PACKAGES] Installing: $line"
|
||||
|
||||
# Check if package is already installed (basic check - may not catch all cases)
|
||||
PACKAGE_NAME=$(echo "$line" | sed -E 's/[<>=!].*$//' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
|
||||
|
||||
# Try installing with verbose output and timeout (if available)
|
||||
# Use --no-cache-dir to avoid cache issues, --verbose for diagnostics
|
||||
INSTALL_OUTPUT=$(mktemp)
|
||||
@@ -1479,7 +1475,7 @@ echo "WiFi Connection Status:"
|
||||
if command -v nmcli >/dev/null 2>&1; then
|
||||
WIFI_STATUS=$(nmcli -t -f DEVICE,TYPE,STATE device status 2>/dev/null | grep -i wifi || echo "")
|
||||
if [ -n "$WIFI_STATUS" ]; then
|
||||
echo "$WIFI_STATUS" | while IFS=':' read -r device type state; do
|
||||
echo "$WIFI_STATUS" | while IFS=':' read -r _ _ state; do
|
||||
if [ "$state" = "connected" ]; then
|
||||
SSID=$(nmcli -t -f active,ssid device wifi 2>/dev/null | grep "^yes:" | cut -d: -f2 | head -1)
|
||||
if [ -n "$SSID" ]; then
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
requests>=2.28.0
|
||||
requests>=2.33.0
|
||||
urllib3>=1.26.0
|
||||
Pillow>=12.2.0
|
||||
pytz>=2022.1
|
||||
numpy>=1.24.0
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
Pillow>=10.4.0
|
||||
Pillow>=12.2.0
|
||||
PyYAML>=6.0.2
|
||||
requests>=2.32.0
|
||||
requests>=2.33.0
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# Tested on Raspbian OS 12 (Bookworm) and 13 (Trixie)
|
||||
|
||||
# Image processing
|
||||
Pillow>=10.4.0,<12.0.0
|
||||
Pillow>=12.2.0,<13.0.0
|
||||
numpy>=1.24.0 # For fast array operations in ScrollHelper (compatible with 2.x)
|
||||
|
||||
# Timezone handling
|
||||
@@ -12,7 +12,7 @@ timezonefinder>=6.5.0,<7.0.0 # Updated for better performance and accuracy
|
||||
geopy>=2.4.1,<3.0.0
|
||||
|
||||
# HTTP requests
|
||||
requests>=2.32.0,<3.0.0
|
||||
requests>=2.33.0,<3.0.0
|
||||
|
||||
# Google API integration
|
||||
google-auth-oauthlib>=1.2.0,<2.0.0
|
||||
@@ -23,10 +23,10 @@ google-api-python-client>=2.147.0,<3.0.0
|
||||
freetype-py>=2.5.1,<3.0.0
|
||||
|
||||
# Spotify integration
|
||||
spotipy>=2.24.0,<3.0.0
|
||||
spotipy>=2.25.2,<3.0.0
|
||||
|
||||
# Flask web framework
|
||||
Flask>=3.0.0,<4.0.0
|
||||
Flask>=3.1.3,<4.0.0
|
||||
|
||||
# Text processing
|
||||
unidecode>=1.3.8,<2.0.0
|
||||
@@ -35,7 +35,7 @@ unidecode>=1.3.8,<2.0.0
|
||||
icalevents>=0.1.27,<1.0.0
|
||||
|
||||
# WebSocket support
|
||||
python-socketio>=5.11.0,<6.0.0
|
||||
python-socketio>=5.14.0,<6.0.0
|
||||
python-engineio>=4.9.0,<5.0.0
|
||||
websockets>=12.0,<14.0
|
||||
websocket-client>=1.8.0,<2.0.0
|
||||
@@ -44,7 +44,7 @@ websocket-client>=1.8.0,<2.0.0
|
||||
jsonschema>=4.20.0,<5.0.0
|
||||
|
||||
# Testing dependencies
|
||||
pytest>=7.4.0,<8.0.0
|
||||
pytest>=9.0.3,<10.0.0
|
||||
pytest-cov>=4.1.0,<5.0.0
|
||||
pytest-mock>=3.11.0,<4.0.0
|
||||
mypy>=1.5.0,<2.0.0
|
||||
|
||||
1
run.py
1
run.py
@@ -51,7 +51,6 @@ if debug_mode:
|
||||
|
||||
# Try to import the plugin system directly to get better error info
|
||||
print("DEBUG: Attempting to import src.plugin_system...", flush=True)
|
||||
from src.plugin_system import PluginManager
|
||||
print("DEBUG: Plugin system import successful", flush=True)
|
||||
except ImportError as e:
|
||||
print(f"DEBUG: Plugin system import failed: {e}", flush=True)
|
||||
|
||||
@@ -9,7 +9,7 @@ and preventing validation errors.
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
from typing import Any, Dict, List
|
||||
|
||||
|
||||
def get_default_for_field(prop: Dict[str, Any]) -> Any:
|
||||
|
||||
@@ -9,9 +9,8 @@ Analyze all plugin config schemas to identify issues:
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Set, Any
|
||||
from typing import Dict, List, Any
|
||||
import jsonschema
|
||||
from jsonschema import Draft7Validator
|
||||
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
Check what imports are actually in the app.py file on the Pi
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
# Read the app.py file and check the import lines
|
||||
|
||||
@@ -203,7 +203,7 @@ link_github_plugin() {
|
||||
log_info "Repository already exists at $target_dir"
|
||||
if [[ -d "$target_dir/.git" ]]; then
|
||||
log_info "Updating repository..."
|
||||
(cd "$target_dir" && git pull --rebase || true)
|
||||
(cd "$target_dir" && git pull --rebase) || true
|
||||
fi
|
||||
else
|
||||
# Clone the repository
|
||||
|
||||
95
scripts/dev/test_pillow_compat.py
Executable file
95
scripts/dev/test_pillow_compat.py
Executable file
@@ -0,0 +1,95 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Pillow compatibility smoke test.
|
||||
|
||||
Exercises the Pillow APIs used throughout LEDMatrix to verify a new
|
||||
Pillow version doesn't break image rendering, font handling, or resize ops.
|
||||
|
||||
Run after upgrading Pillow:
|
||||
python3 scripts/dev/test_pillow_compat.py
|
||||
"""
|
||||
|
||||
import sys
|
||||
|
||||
|
||||
def check(label, fn):
|
||||
try:
|
||||
result = fn()
|
||||
print(f" ✓ {label}" + (f" — {result}" if result is not None else ""))
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f" ✗ {label} — {type(e).__name__}: {e}", file=sys.stderr)
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
import PIL
|
||||
|
||||
print(f"Pillow {PIL.__version__} on Python {sys.version.split()[0]}\n")
|
||||
|
||||
failures = 0
|
||||
|
||||
print("Image creation:")
|
||||
failures += not check("Image.new RGB",
|
||||
lambda: Image.new('RGB', (128, 32), (0, 0, 0)).size)
|
||||
failures += not check("Image.new RGBA",
|
||||
lambda: Image.new('RGBA', (64, 64), (255, 0, 0, 128)).size)
|
||||
failures += not check("Image.new 1-bit",
|
||||
lambda: Image.new('1', (16, 16)).size)
|
||||
|
||||
print("\nDraw operations:")
|
||||
img = Image.new('RGB', (128, 32), (0, 0, 0))
|
||||
draw = ImageDraw.Draw(img)
|
||||
font = ImageFont.load_default()
|
||||
failures += not check("draw.rectangle",
|
||||
lambda: draw.rectangle([0, 0, 127, 31], outline=(255, 0, 0)))
|
||||
failures += not check("draw.text",
|
||||
lambda: draw.text((2, 2), "Hello", fill=(255, 255, 255), font=font))
|
||||
failures += not check("draw.line",
|
||||
lambda: draw.line([0, 0, 127, 31], fill=(0, 255, 0)))
|
||||
|
||||
print("\nFont metrics (used in text_helper, scroll_helper):")
|
||||
failures += not check("draw.textlength",
|
||||
lambda: f"{draw.textlength('Test', font=font):.1f}px")
|
||||
failures += not check("draw.textbbox",
|
||||
lambda: draw.textbbox((0, 0), "Test", font=font))
|
||||
|
||||
print("\nResampling (used in logo_helper, image_utils, sports base):")
|
||||
logo = Image.new('RGBA', (200, 200), (255, 128, 0, 200))
|
||||
failures += not check("Image.Resampling.LANCZOS exists",
|
||||
lambda: str(Image.Resampling.LANCZOS))
|
||||
failures += not check("thumbnail with LANCZOS",
|
||||
lambda: (logo.thumbnail((64, 32), Image.Resampling.LANCZOS), logo.size)[1])
|
||||
big = Image.new('RGB', (300, 300), (0, 128, 255))
|
||||
failures += not check("resize with LANCZOS",
|
||||
lambda: big.resize((128, 32), Image.Resampling.LANCZOS).size)
|
||||
|
||||
print("\nComposite / paste (used in display rendering):")
|
||||
base = Image.new('RGB', (128, 32), (0, 0, 0))
|
||||
overlay = Image.new('RGBA', (32, 32), (255, 0, 0, 128))
|
||||
failures += not check("paste RGBA onto RGB",
|
||||
lambda: (base.paste(overlay.convert('RGB'), (0, 0)), base.size)[1])
|
||||
failures += not check("Image.alpha_composite",
|
||||
lambda: Image.alpha_composite(
|
||||
Image.new('RGBA', (32, 32)), overlay).size)
|
||||
|
||||
print("\nImage I/O:")
|
||||
import io
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format='PNG')
|
||||
buf.seek(0)
|
||||
failures += not check("save/load PNG roundtrip",
|
||||
lambda: Image.open(buf).size)
|
||||
|
||||
print()
|
||||
if failures == 0:
|
||||
print(f"All checks passed. Pillow {PIL.__version__} is compatible.")
|
||||
return 0
|
||||
else:
|
||||
print(f"{failures} check(s) failed — review output above.", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
@@ -15,7 +15,6 @@ Usage: python tools/validate_python.py <python_file>
|
||||
import ast
|
||||
import sys
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
def validate_file(filepath: str) -> bool:
|
||||
"""Validate a Python file for common issues."""
|
||||
|
||||
@@ -13,7 +13,6 @@ echo ""
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Get the actual user
|
||||
|
||||
@@ -41,7 +41,7 @@ if [ -f "$PROJECT_DIR/config/config.json" ]; then
|
||||
echo -e "${GREEN}✓ Config file found${NC}"
|
||||
|
||||
# Check web_display_autostart setting
|
||||
AUTOSTART=$(cat "$PROJECT_DIR/config/config.json" | grep -o '"web_display_autostart"[[:space:]]*:[[:space:]]*[a-z]*' | grep -o '[a-z]*$')
|
||||
AUTOSTART=$(grep -o '"web_display_autostart"[[:space:]]*:[[:space:]]*[a-z]*' "$PROJECT_DIR/config/config.json" | grep -o '[a-z]*$')
|
||||
|
||||
if [ "$AUTOSTART" == "true" ]; then
|
||||
echo -e "${GREEN}✓ web_display_autostart: true${NC}"
|
||||
|
||||
@@ -18,9 +18,6 @@ NC='\033[0m' # No Color
|
||||
# Check if running as root or with sudo
|
||||
if [ "$EUID" -ne 0 ]; then
|
||||
echo -e "${YELLOW}Warning: Some checks require sudo. Running what we can...${NC}"
|
||||
SUDO=""
|
||||
else
|
||||
SUDO=""
|
||||
fi
|
||||
|
||||
PROJECT_DIR="${HOME}/LEDMatrix"
|
||||
|
||||
@@ -7,12 +7,6 @@ echo "Fixing LEDMatrix assets directory permissions..."
|
||||
|
||||
# Get the real user (not root when running with sudo)
|
||||
REAL_USER=${SUDO_USER:-$USER}
|
||||
# Resolve the home directory of the real user robustly
|
||||
if command -v getent >/dev/null 2>&1; then
|
||||
REAL_HOME=$(getent passwd "$REAL_USER" | cut -d: -f6)
|
||||
else
|
||||
REAL_HOME=$(eval echo ~"$REAL_USER")
|
||||
fi
|
||||
REAL_GROUP=$(id -gn "$REAL_USER")
|
||||
|
||||
# Get the project directory
|
||||
|
||||
@@ -14,9 +14,6 @@ else
|
||||
ACTUAL_USER=$(whoami)
|
||||
fi
|
||||
|
||||
# Get the home directory of the actual user
|
||||
USER_HOME=$(eval echo ~$ACTUAL_USER)
|
||||
|
||||
# Determine the Project Root Directory (parent of scripts/install/)
|
||||
PROJECT_ROOT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)
|
||||
|
||||
|
||||
@@ -81,7 +81,7 @@ def main() -> int:
|
||||
help='Plugin config as JSON string')
|
||||
parser.add_argument('--mock-data', '-m', default=None,
|
||||
help='Path to JSON file with mock cache data')
|
||||
parser.add_argument('--output', '-o', default='/tmp/plugin_render.png',
|
||||
parser.add_argument('--output', '-o', default='/tmp/plugin_render.png', # nosec B108 - dev script default; user can override
|
||||
help='Output PNG path (default: /tmp/plugin_render.png)')
|
||||
parser.add_argument('--width', type=int, default=128, help='Display width (default: 128)')
|
||||
parser.add_argument('--height', type=int, default=32, help='Display height (default: 32)')
|
||||
|
||||
@@ -7,9 +7,7 @@ Supports both unittest and pytest.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import argparse
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
@@ -198,17 +196,14 @@ def main():
|
||||
if runner == 'auto':
|
||||
# Try pytest first, fall back to unittest
|
||||
try:
|
||||
import pytest
|
||||
runner = 'pytest'
|
||||
except ImportError:
|
||||
runner = 'unittest'
|
||||
|
||||
# Run tests
|
||||
if runner == 'pytest':
|
||||
import importlib.util
|
||||
return run_pytest_tests(test_files, args.verbose, args.coverage)
|
||||
else:
|
||||
import importlib.util
|
||||
return run_unittest_tests(test_files, args.verbose)
|
||||
|
||||
|
||||
|
||||
@@ -6,9 +6,7 @@ This script allows manual clearing of specific cache keys or all cache data.
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
|
||||
# Add the src directory to the path so we can import our modules
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'src'))
|
||||
|
||||
@@ -111,7 +111,7 @@ def main():
|
||||
# Ensure PYTHONPATH is set correctly if web_interface.py has relative imports to src
|
||||
# The WorkingDirectory in systemd service should handle this for web_interface.py
|
||||
print(f"Launching web interface v3: {sys.executable} {WEB_INTERFACE_SCRIPT}")
|
||||
os.execvp(sys.executable, [sys.executable, WEB_INTERFACE_SCRIPT])
|
||||
os.execvp(sys.executable, [sys.executable, WEB_INTERFACE_SCRIPT]) # nosec B606 - both args are fixed constants
|
||||
except Exception as e:
|
||||
print(f"Failed to exec web interface: {e}")
|
||||
sys.exit(1) # Failed to start
|
||||
|
||||
@@ -7,10 +7,7 @@ where Recent/Upcoming managers consume data from the background service cache.
|
||||
"""
|
||||
|
||||
import time
|
||||
import logging
|
||||
from typing import Dict, Optional, Any, Callable
|
||||
from datetime import datetime
|
||||
import pytz
|
||||
|
||||
|
||||
class BackgroundCacheMixin:
|
||||
|
||||
@@ -14,19 +14,15 @@ Key Features:
|
||||
- Memory-efficient data storage
|
||||
"""
|
||||
|
||||
import os
|
||||
import time
|
||||
import logging
|
||||
import threading
|
||||
import requests
|
||||
from typing import Dict, Any, Optional, List, Callable, Union
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, Any, Optional, Callable
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
import json
|
||||
import queue
|
||||
from concurrent.futures import ThreadPoolExecutor, Future
|
||||
import weakref
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from src.cache_manager import CacheManager
|
||||
# Configure logging
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -227,7 +223,7 @@ class BackgroundDataService:
|
||||
self.stats['cache_misses'] += 1
|
||||
|
||||
# Submit to executor
|
||||
future = self.executor.submit(self._fetch_data_worker, request)
|
||||
self.executor.submit(self._fetch_data_worker, request)
|
||||
|
||||
logger.info(f"Submitted background fetch request {request_id} for {sport} {year}")
|
||||
return request_id
|
||||
@@ -553,13 +549,12 @@ class BackgroundDataService:
|
||||
if to_remove:
|
||||
logger.info(f"Cleared {len(to_remove)} old completed requests")
|
||||
|
||||
def shutdown(self, wait: bool = True, timeout: int = 30):
|
||||
def shutdown(self, wait: bool = True):
|
||||
"""
|
||||
Shutdown the background data service.
|
||||
|
||||
Args:
|
||||
wait: Whether to wait for active requests to complete
|
||||
timeout: Maximum time to wait for shutdown
|
||||
"""
|
||||
logger.info("Shutting down BackgroundDataService...")
|
||||
|
||||
@@ -570,24 +565,14 @@ class BackgroundDataService:
|
||||
for request_id in list(self.active_requests.keys()):
|
||||
self.cancel_request(request_id)
|
||||
|
||||
# Shutdown executor with compatibility for older Python versions
|
||||
try:
|
||||
# Try with timeout parameter (Python 3.9+)
|
||||
self.executor.shutdown(wait=wait, timeout=timeout)
|
||||
except TypeError:
|
||||
# Fallback for older Python versions that don't support timeout
|
||||
if wait and timeout:
|
||||
# For older versions, we can't specify timeout, so just wait
|
||||
self.executor.shutdown(wait=True)
|
||||
else:
|
||||
self.executor.shutdown(wait=wait)
|
||||
self.executor.shutdown(wait=wait)
|
||||
|
||||
logger.info("BackgroundDataService shutdown complete")
|
||||
|
||||
def __del__(self):
|
||||
"""Cleanup when service is destroyed."""
|
||||
if not self._shutdown:
|
||||
self.shutdown(wait=False, timeout=None)
|
||||
self.shutdown(wait=False)
|
||||
|
||||
# Global service instance
|
||||
_background_service: Optional[BackgroundDataService] = None
|
||||
|
||||
@@ -7,7 +7,7 @@ fields and data structures.
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Dict, Any, Optional, List
|
||||
from typing import Dict, Optional
|
||||
import logging
|
||||
from datetime import datetime
|
||||
import pytz
|
||||
@@ -21,12 +21,10 @@ class APIDataExtractor(ABC):
|
||||
@abstractmethod
|
||||
def extract_game_details(self, game_event: Dict) -> Optional[Dict]:
|
||||
"""Extract common game details from raw API data."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_sport_specific_fields(self, game_event: Dict) -> Dict:
|
||||
"""Extract sport-specific fields (downs, innings, periods, etc.)."""
|
||||
pass
|
||||
|
||||
def _extract_common_details(self, game_event: Dict) -> tuple[Dict | None, Dict | None, Dict | None, Dict | None, Dict | None]:
|
||||
"""Extract common game details that work across all sports."""
|
||||
|
||||
@@ -329,7 +329,6 @@ class Baseball(SportsCore):
|
||||
return
|
||||
|
||||
series_summary = game.get("series_summary", "")
|
||||
font = self.fonts.get('detail', ImageFont.load_default())
|
||||
bbox = draw_overlay.textbbox((0, 0), series_summary, font=self.fonts['time'])
|
||||
height = bbox[3] - bbox[1]
|
||||
shots_y = (self.display_height - height) // 2
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
@@ -6,11 +6,10 @@ to support different APIs and data providers.
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Dict, Any, Optional, List
|
||||
from typing import Dict, List
|
||||
import requests
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
class DataSource(ABC):
|
||||
"""Abstract base class for data sources."""
|
||||
@@ -35,17 +34,14 @@ class DataSource(ABC):
|
||||
@abstractmethod
|
||||
def fetch_live_games(self, sport: str, league: str) -> List[Dict]:
|
||||
"""Fetch live games for a sport/league."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def fetch_schedule(self, sport: str, league: str, date_range: tuple) -> List[Dict]:
|
||||
"""Fetch schedule for a sport/league within date range."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def fetch_standings(self, sport: str, league: str) -> Dict:
|
||||
"""Fetch standings for a sport/league."""
|
||||
pass
|
||||
|
||||
def get_headers(self) -> Dict[str, str]:
|
||||
"""Get headers for API requests."""
|
||||
@@ -217,7 +213,7 @@ class MLBAPIDataSource(DataSource):
|
||||
response.raise_for_status()
|
||||
|
||||
data = response.json()
|
||||
self.logger.debug(f"Fetched standings from MLB API")
|
||||
self.logger.debug("Fetched standings from MLB API")
|
||||
return data
|
||||
|
||||
except Exception as e:
|
||||
@@ -296,7 +292,7 @@ class SoccerAPIDataSource(DataSource):
|
||||
response.raise_for_status()
|
||||
|
||||
data = response.json()
|
||||
self.logger.debug(f"Fetched standings from soccer API")
|
||||
self.logger.debug("Fetched standings from soccer API")
|
||||
return data
|
||||
|
||||
except Exception as e:
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
from typing import Dict, Any, Optional, List
|
||||
from typing import Dict, Any, Optional
|
||||
from src.display_manager import DisplayManager
|
||||
from src.cache_manager import CacheManager
|
||||
from datetime import datetime, timezone, timedelta
|
||||
import logging
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
import time
|
||||
from src.base_classes.data_sources import ESPNDataSource
|
||||
from src.base_classes.sports import SportsCore, SportsLive
|
||||
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
@@ -79,8 +77,6 @@ class Hockey(SportsCore):
|
||||
away_shots = round(home_team_saves / home_team_saves_per)
|
||||
if away_team_saves_per > 0:
|
||||
home_shots = round(away_team_saves / away_team_saves_per)
|
||||
status_short = status["type"].get("shortDetail", "")
|
||||
|
||||
if situation and status["type"]["state"] == "in":
|
||||
# Detect scoring events from status detail
|
||||
# status_detail = status["type"].get("detail", "")
|
||||
|
||||
@@ -5,7 +5,7 @@ import time
|
||||
from abc import ABC, abstractmethod
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import pytz
|
||||
import requests
|
||||
@@ -172,8 +172,8 @@ class SportsCore(ABC):
|
||||
|
||||
try:
|
||||
fallbacks.append(Path.home() / ".ledmatrix" / "logos" / self.sport_key)
|
||||
except Exception:
|
||||
pass
|
||||
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)
|
||||
|
||||
@@ -416,7 +416,6 @@ class SportsCore(ABC):
|
||||
league=self.league,
|
||||
event_id=game['id'],
|
||||
update_interval_seconds=update_interval,
|
||||
is_live=is_live
|
||||
)
|
||||
|
||||
if odds_data:
|
||||
|
||||
@@ -11,13 +11,10 @@ Follows LEDMatrix configuration management patterns:
|
||||
- Maintainable: Changes to odds logic affect all plugins
|
||||
"""
|
||||
|
||||
import time
|
||||
import logging
|
||||
import requests
|
||||
import json
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Dict, Any, Optional, List
|
||||
import pytz
|
||||
|
||||
|
||||
class BaseOddsManager:
|
||||
|
||||
9
src/cache/disk_cache.py
vendored
9
src/cache/disk_cache.py
vendored
@@ -13,7 +13,6 @@ import threading
|
||||
from typing import Dict, Any, Optional, Protocol
|
||||
from datetime import datetime
|
||||
|
||||
from src.exceptions import CacheError
|
||||
|
||||
|
||||
class CacheStrategyProtocol(Protocol):
|
||||
@@ -184,7 +183,7 @@ class DiskCache:
|
||||
os.replace(tmp_path, cache_path)
|
||||
# Set proper permissions: 660 (rw-rw----) for group-readable cache files
|
||||
try:
|
||||
os.chmod(cache_path, 0o660)
|
||||
os.chmod(cache_path, 0o660) # nosec B103 - intentional; web UI and service share a group
|
||||
except OSError:
|
||||
pass # Non-critical if chmod fails
|
||||
finally:
|
||||
@@ -202,7 +201,7 @@ class DiskCache:
|
||||
os.fsync(cache_file.fileno())
|
||||
# Set proper permissions: 660 (rw-rw----) for group-readable cache files
|
||||
try:
|
||||
os.chmod(cache_path, 0o660)
|
||||
os.chmod(cache_path, 0o660) # nosec B103 - intentional; web UI and service share a group
|
||||
except OSError:
|
||||
pass # Non-critical if chmod fails
|
||||
self.logger.debug("Wrote cache for %s directly (non-atomic)", key)
|
||||
@@ -210,7 +209,7 @@ class DiskCache:
|
||||
# If direct write also fails, try fallback location
|
||||
self.logger.warning("Direct write failed for key '%s' to %s: %s", key, cache_path, write_error)
|
||||
raise # Re-raise to trigger fallback logic
|
||||
except (IOError, OSError, PermissionError) as e:
|
||||
except (IOError, OSError, PermissionError):
|
||||
# Attempt one-time fallback write to user's home cache directory
|
||||
try:
|
||||
# Try user's home cache directory as fallback
|
||||
@@ -228,7 +227,7 @@ class DiskCache:
|
||||
json.dump(data, tmp_file, indent=4, cls=DateTimeEncoder)
|
||||
# Set proper permissions: 660 (rw-rw----) for group-readable cache files
|
||||
try:
|
||||
os.chmod(fallback_path, 0o660)
|
||||
os.chmod(fallback_path, 0o660) # nosec B103 - intentional; web UI and service share a group
|
||||
except OSError:
|
||||
pass # Non-critical if chmod fails
|
||||
self.logger.debug("Cache wrote to fallback location: %s", fallback_path)
|
||||
|
||||
@@ -7,7 +7,6 @@ from typing import Any, Dict, List, Optional
|
||||
import logging
|
||||
import threading
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from src.exceptions import CacheError
|
||||
from src.cache.memory_cache import MemoryCache
|
||||
from src.cache.disk_cache import DiskCache
|
||||
@@ -111,7 +110,7 @@ class CacheManager:
|
||||
if os.access(system_cache_dir, os.W_OK):
|
||||
self.logger.info(f"Using system cache directory: {system_cache_dir}")
|
||||
return system_cache_dir
|
||||
except (OSError, IOError, PermissionError) as perm_error:
|
||||
except (OSError, IOError, PermissionError):
|
||||
# Permission errors are expected when running as non-root
|
||||
self.logger.debug(f"Could not create system cache directory (permission denied): {system_cache_dir}")
|
||||
except (OSError, IOError, PermissionError) as e:
|
||||
|
||||
@@ -5,13 +5,10 @@ Handles HTTP requests, caching, and ESPN API integration for LED matrix plugins.
|
||||
Extracted from LEDMatrix core to provide reusable functionality for plugins.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Union
|
||||
from urllib.parse import urlencode
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import requests
|
||||
from requests.adapters import HTTPAdapter
|
||||
|
||||
@@ -5,11 +5,9 @@ This example shows how to refactor the basketball plugin to use the
|
||||
ledmatrix-common package for cleaner, more maintainable code.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
# Import common helpers
|
||||
from src.common import (
|
||||
|
||||
@@ -42,7 +42,7 @@ def test_utilities(display_width: int, display_height: int):
|
||||
print(f"Testing LEDMatrix Common utilities with {display_width}x{display_height} display")
|
||||
|
||||
try:
|
||||
from ledmatrix_common import LogoHelper, TextHelper, APIHelper, DisplayHelper, GameHelper, ConfigHelper
|
||||
from ledmatrix_common import LogoHelper, TextHelper, DisplayHelper, GameHelper, ConfigHelper
|
||||
|
||||
# Test LogoHelper
|
||||
print("Testing LogoHelper...")
|
||||
@@ -63,12 +63,12 @@ def test_utilities(display_width: int, display_height: int):
|
||||
|
||||
# Test GameHelper
|
||||
print("Testing GameHelper...")
|
||||
game_helper = GameHelper()
|
||||
GameHelper()
|
||||
print("GameHelper initialized")
|
||||
|
||||
# Test ConfigHelper
|
||||
print("Testing ConfigHelper...")
|
||||
config_helper = ConfigHelper()
|
||||
ConfigHelper()
|
||||
print("ConfigHelper initialized")
|
||||
|
||||
print("All tests passed!")
|
||||
|
||||
@@ -6,7 +6,7 @@ Extracted from LEDMatrix core to provide reusable functionality for plugins.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional, Tuple, Union
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
@@ -166,8 +166,7 @@ class DisplayHelper:
|
||||
img = self.create_base_image(background_color)
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
# Calculate text position (start off-screen to the right)
|
||||
text_width = draw.textlength(text, font=font)
|
||||
# Start text off-screen to the right
|
||||
x_position = self.display_width
|
||||
|
||||
# Draw text
|
||||
@@ -216,7 +215,6 @@ class DisplayHelper:
|
||||
PIL Image with error message
|
||||
"""
|
||||
img = self.create_base_image((50, 0, 0)) # Dark red background
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
# Use default font
|
||||
font = ImageFont.load_default()
|
||||
|
||||
@@ -6,10 +6,8 @@ Extracted from LEDMatrix core to provide reusable functionality for plugins.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Union
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import requests
|
||||
from PIL import Image
|
||||
|
||||
@@ -17,7 +17,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
# System directories that should never have their permissions modified
|
||||
# These directories have special system-level permissions that must be preserved
|
||||
PROTECTED_SYSTEM_DIRECTORIES = {
|
||||
PROTECTED_SYSTEM_DIRECTORIES = { # nosec B108 - these are checked to PREVENT permission changes, not to use as temp paths
|
||||
'/tmp',
|
||||
'/var/tmp',
|
||||
'/dev',
|
||||
|
||||
@@ -6,7 +6,6 @@ Extracted from LEDMatrix core to provide reusable functionality for plugins.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Tuple, Union
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ Extracted from LEDMatrix core to provide reusable functionality for plugins.
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional, Tuple, Union
|
||||
from typing import Union
|
||||
import pytz
|
||||
|
||||
|
||||
|
||||
@@ -313,17 +313,8 @@ class ConfigManager:
|
||||
self._merge_template_defaults(self.config, template_config)
|
||||
|
||||
# Save migrated config using atomic save to preserve permissions
|
||||
# Load secrets if they exist to pass to atomic save
|
||||
secrets_content = {}
|
||||
if os.path.exists(self.secrets_path):
|
||||
try:
|
||||
with open(self.secrets_path, 'r') as f_secrets:
|
||||
secrets_content = json.load(f_secrets)
|
||||
except Exception:
|
||||
pass # Continue without secrets if can't load
|
||||
|
||||
# Use atomic save to preserve file permissions
|
||||
# Note: save_config_atomic handles secrets internally, no need to pass new_secrets
|
||||
# Note: save_config_atomic handles secrets internally
|
||||
result = self.save_config_atomic(
|
||||
new_config_data=self.config,
|
||||
create_backup=False, # Already created backup above
|
||||
|
||||
@@ -12,11 +12,10 @@ This service wraps ConfigManager and adds:
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, Optional, List, Callable, Set
|
||||
from typing import Dict, Any, Optional, List, Callable
|
||||
from datetime import datetime
|
||||
from collections import defaultdict
|
||||
import logging
|
||||
@@ -38,7 +37,7 @@ class ConfigVersion:
|
||||
config: Configuration dictionary
|
||||
version: Version number
|
||||
timestamp: When this version was created
|
||||
checksum: MD5 checksum of the config
|
||||
checksum: SHA-256 hex digest of the config (for change detection)
|
||||
"""
|
||||
self.config: Dict[str, Any] = config
|
||||
self.version: int = version
|
||||
@@ -114,9 +113,9 @@ class ConfigService:
|
||||
self._start_file_watching()
|
||||
|
||||
def _calculate_checksum(self, config: Dict[str, Any]) -> str:
|
||||
"""Calculate MD5 checksum of configuration."""
|
||||
"""Calculate checksum of configuration for change detection."""
|
||||
config_str = json.dumps(config, sort_keys=True)
|
||||
return hashlib.md5(config_str.encode()).hexdigest()
|
||||
return hashlib.sha256(config_str.encode()).hexdigest()
|
||||
|
||||
def _load_config(self) -> bool:
|
||||
"""
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import time
|
||||
import logging
|
||||
import sys
|
||||
import os
|
||||
import json
|
||||
from pathlib import Path
|
||||
@@ -815,8 +813,8 @@ class DisplayController:
|
||||
# 1. Explicit hook — plugin opted in with get_offset_frame()
|
||||
try:
|
||||
follower_frame = plugin_instance.get_offset_frame(offset)
|
||||
except Exception:
|
||||
pass
|
||||
except AttributeError:
|
||||
pass # Most plugins don't implement get_offset_frame; that's expected
|
||||
|
||||
# 2. Auto-detect — plugin has a scroll_helper (standard pattern for all
|
||||
# scroll plugins). Works with zero plugin code changes.
|
||||
|
||||
@@ -6,7 +6,7 @@ else:
|
||||
from contextlib import contextmanager
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
import time
|
||||
from typing import Dict, Any, List, Tuple
|
||||
from typing import Dict, Any, List
|
||||
import logging
|
||||
import math
|
||||
import freetype
|
||||
@@ -32,7 +32,7 @@ class DisplayManager:
|
||||
# When True, update_display() and clear() skip hardware writes (used during off-screen content capture)
|
||||
self._capture_mode_active = False
|
||||
# Snapshot settings for web preview integration (service writes, web reads)
|
||||
self._snapshot_path = "/tmp/led_matrix_preview.png"
|
||||
self._snapshot_path = "/tmp/led_matrix_preview.png" # nosec B108 - fixed path intentional; web UI reads same path
|
||||
self._snapshot_min_interval_sec = 0.2 # max ~5 fps
|
||||
self._last_snapshot_ts = 0.0
|
||||
|
||||
@@ -58,8 +58,6 @@ class DisplayManager:
|
||||
|
||||
def _setup_matrix(self):
|
||||
"""Initialize the RGB matrix with configuration settings."""
|
||||
setup_start = time.time()
|
||||
|
||||
try:
|
||||
# Allow callers (e.g., web UI) to force non-hardware fallback mode
|
||||
if getattr(self, '_force_fallback', False):
|
||||
@@ -152,7 +150,7 @@ class DisplayManager:
|
||||
self.draw.rectangle([0, 0, fallback_width - 1, fallback_height - 1], outline=(255, 0, 0))
|
||||
self.draw.line([0, 0, fallback_width - 1, fallback_height - 1], fill=(0, 255, 0))
|
||||
self.draw.text((2, max(0, (fallback_height // 2) - 4)), "Simulation", fill=(0, 128, 255))
|
||||
except Exception:
|
||||
except Exception: # nosec B110 - best-effort fallback visualization; drawing errors must not crash startup
|
||||
# Best-effort; ignore drawing errors in fallback
|
||||
pass
|
||||
logger.error(f"Matrix initialization failed, using fallback mode with size {fallback_width}x{fallback_height}. Error: {e}")
|
||||
@@ -896,7 +894,7 @@ class DisplayManager:
|
||||
# Never modify /tmp permissions - it has special system permissions (1777)
|
||||
# that must not be changed or it breaks apt and other system tools
|
||||
parent_dir = snapshot_path_obj.parent
|
||||
if parent_dir and str(parent_dir) != '/tmp':
|
||||
if parent_dir and str(parent_dir) != '/tmp': # nosec B108 - guard to skip /tmp for permission ops
|
||||
ensure_directory_permissions(parent_dir, get_assets_dir_mode())
|
||||
# Write atomically: temp then replace
|
||||
tmp_path = f"{self._snapshot_path}.tmp"
|
||||
|
||||
@@ -19,8 +19,7 @@ Usage:
|
||||
import logging
|
||||
import time
|
||||
import requests
|
||||
from typing import Dict, List, Set, Optional, Any
|
||||
from datetime import datetime, timezone
|
||||
from typing import Dict, List
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -3,15 +3,14 @@ import logging
|
||||
import freetype
|
||||
import json
|
||||
import hashlib
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import zipfile
|
||||
import tempfile
|
||||
import shutil
|
||||
import time
|
||||
from pathlib import Path
|
||||
from PIL import ImageFont
|
||||
from typing import Dict, Tuple, Optional, Union, Any, List
|
||||
from functools import lru_cache
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -267,9 +266,12 @@ class FontManager:
|
||||
logger.info(f"Using cached font: {cache_path}")
|
||||
return str(cache_path)
|
||||
|
||||
# Download font
|
||||
# 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)
|
||||
urllib.request.urlretrieve(url, cache_path) # nosec B310 - scheme validated above
|
||||
|
||||
# Handle zip files
|
||||
if url.endswith('.zip'):
|
||||
@@ -699,8 +701,6 @@ class FontManager:
|
||||
fonts_dir = Path("assets/fonts")
|
||||
ensure_directory_permissions(fonts_dir, get_assets_dir_mode())
|
||||
|
||||
target_path = os.path.join(fonts_dir, f"{family_name}.{font_file_path.rsplit('.', 1)[-1]}")
|
||||
|
||||
# Add to catalog
|
||||
self.font_catalog[family_name] = font_file_path
|
||||
self.clear_cache()
|
||||
@@ -746,11 +746,11 @@ class FontManager:
|
||||
|
||||
if font_path.endswith('.bdf'):
|
||||
# Try to load BDF font
|
||||
face = freetype.Face(font_path)
|
||||
freetype.Face(font_path)
|
||||
return {"valid": True, "type": "bdf", "family": "unknown"}
|
||||
elif font_path.endswith('.ttf'):
|
||||
# Try to load TTF font
|
||||
font = ImageFont.truetype(font_path, 12)
|
||||
ImageFont.truetype(font_path, 12)
|
||||
return {"valid": True, "type": "ttf", "family": "unknown"}
|
||||
else:
|
||||
return {"valid": False, "error": "Unsupported font format"}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import os
|
||||
import time
|
||||
import freetype
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
from PIL import ImageDraw, ImageFont
|
||||
import logging
|
||||
from typing import Dict, Any
|
||||
from src.display_manager import DisplayManager
|
||||
@@ -73,7 +72,6 @@ class FontTestManager:
|
||||
|
||||
def update(self):
|
||||
"""No update needed for static display."""
|
||||
pass
|
||||
|
||||
def display(self, force_clear: bool = False):
|
||||
"""Display the font with sample text."""
|
||||
@@ -81,10 +79,6 @@ class FontTestManager:
|
||||
# Clear the display
|
||||
self.display_manager.clear()
|
||||
|
||||
# Get display dimensions
|
||||
width = self.display_manager.matrix.width
|
||||
height = self.display_manager.matrix.height
|
||||
|
||||
# Draw font name at the top
|
||||
self.display_manager.draw_text(self.current_config['display_name'], y=2, color=(255, 255, 255))
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ version of BackgroundCacheMixin that works for weather, stocks, news, etc.
|
||||
"""
|
||||
|
||||
import time
|
||||
import logging
|
||||
from typing import Dict, Optional, Any, Callable
|
||||
|
||||
|
||||
|
||||
@@ -6,9 +6,8 @@ Handles custom layouts, element positioning, and display composition.
|
||||
import json
|
||||
import os
|
||||
import logging
|
||||
from typing import Dict, List, Any, Tuple
|
||||
from typing import Dict, List, Any
|
||||
from datetime import datetime
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import time
|
||||
import logging
|
||||
import requests
|
||||
import json
|
||||
from typing import Dict, Any, List, Optional, Tuple
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
from pathlib import Path
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
from requests.adapters import HTTPAdapter
|
||||
@@ -191,7 +191,7 @@ class LogoDownloader:
|
||||
return True
|
||||
except PermissionError:
|
||||
logger.error(f"Permission denied: Cannot write to directory {path}")
|
||||
logger.error(f"Please run: sudo ./scripts/fix_perms/fix_assets_permissions.sh")
|
||||
logger.error("Please run: sudo ./scripts/fix_perms/fix_assets_permissions.sh")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to test write access to directory {path}: {e}")
|
||||
@@ -248,7 +248,7 @@ class LogoDownloader:
|
||||
|
||||
except PermissionError as e:
|
||||
logger.error(f"Permission denied downloading logo for {team_abbreviation}: {e}")
|
||||
logger.error(f"Please run: sudo ./scripts/fix_perms/fix_assets_permissions.sh")
|
||||
logger.error("Please run: sudo ./scripts/fix_perms/fix_assets_permissions.sh")
|
||||
return False
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error(f"Failed to download logo for {team_abbreviation}: {e}")
|
||||
|
||||
@@ -11,7 +11,7 @@ Builds on existing PluginHealthTracker to provide:
|
||||
import threading
|
||||
import time
|
||||
from typing import Dict, Any, Optional, List, Callable
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@@ -7,9 +7,8 @@ status tracking and cancellation support.
|
||||
|
||||
import threading
|
||||
import queue
|
||||
import time
|
||||
from typing import Dict, Optional, List, Callable, Any
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
import json
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ and their associated data structures.
|
||||
|
||||
from enum import Enum
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Dict, Any, Optional, List
|
||||
from typing import Dict, Any, Optional
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
|
||||
|
||||
@@ -6,9 +6,8 @@ error isolation, and performance monitoring.
|
||||
"""
|
||||
|
||||
import time
|
||||
import signal
|
||||
from typing import Any, Optional, Dict, Callable
|
||||
from threading import Thread, Event
|
||||
from typing import Any, Optional, Callable
|
||||
from threading import Thread
|
||||
import logging
|
||||
|
||||
from src.exceptions import PluginError
|
||||
@@ -16,9 +15,8 @@ from src.logging_config import get_logger
|
||||
from src.error_aggregator import record_error
|
||||
|
||||
|
||||
class TimeoutError(Exception):
|
||||
class PluginTimeoutError(Exception):
|
||||
"""Raised when a plugin operation times out."""
|
||||
pass
|
||||
|
||||
|
||||
class PluginExecutor:
|
||||
@@ -57,7 +55,7 @@ class PluginExecutor:
|
||||
Result of operation
|
||||
|
||||
Raises:
|
||||
TimeoutError: If operation times out
|
||||
PluginTimeoutError: If operation times out
|
||||
PluginError: If operation raises an exception
|
||||
"""
|
||||
timeout = timeout or self.default_timeout
|
||||
@@ -81,7 +79,7 @@ class PluginExecutor:
|
||||
if not result_container['completed']:
|
||||
error_msg = f"{plugin_context} operation timed out after {timeout}s"
|
||||
self.logger.error(error_msg)
|
||||
timeout_error = TimeoutError(error_msg)
|
||||
timeout_error = PluginTimeoutError(error_msg)
|
||||
record_error(timeout_error, plugin_id=plugin_id, operation="timeout")
|
||||
raise timeout_error
|
||||
|
||||
@@ -128,7 +126,7 @@ class PluginExecutor:
|
||||
)
|
||||
|
||||
return True
|
||||
except TimeoutError:
|
||||
except PluginTimeoutError:
|
||||
self.logger.error("Plugin %s update() timed out", plugin_id)
|
||||
return False
|
||||
except PluginError:
|
||||
@@ -204,7 +202,7 @@ class PluginExecutor:
|
||||
# For backward compatibility: if plugin returns None or something else, treat as success
|
||||
self.logger.debug(f"Plugin {plugin_id} display() returned non-boolean: {result}, treating as True")
|
||||
return True
|
||||
except TimeoutError:
|
||||
except PluginTimeoutError:
|
||||
self.logger.error("Plugin %s display() timed out", plugin_id)
|
||||
return False
|
||||
except PluginError:
|
||||
@@ -247,7 +245,7 @@ class PluginExecutor:
|
||||
timeout=timeout,
|
||||
plugin_id=plugin_id
|
||||
)
|
||||
except (TimeoutError, PluginError, Exception) as e:
|
||||
except Exception as e: # covers PluginTimeoutError, PluginError, and unexpected errors
|
||||
self.logger.warning(
|
||||
"Plugin %s %s failed, using default return: %s",
|
||||
plugin_id,
|
||||
|
||||
@@ -7,10 +7,7 @@ Handles dynamic plugin loading from the plugins/ directory.
|
||||
API Version: 1.0.0
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import importlib
|
||||
import importlib.util
|
||||
import sys
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
@@ -20,7 +20,6 @@ except ImportError:
|
||||
|
||||
class ResourceLimitExceeded(Exception):
|
||||
"""Raised when a plugin exceeds its resource limits."""
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -228,7 +227,7 @@ class PluginResourceMonitor:
|
||||
|
||||
except ResourceLimitExceeded:
|
||||
raise
|
||||
except Exception as e:
|
||||
except Exception:
|
||||
# Still record execution time even on error
|
||||
execution_time = time.time() - start_time
|
||||
with self._lock:
|
||||
|
||||
@@ -5,7 +5,6 @@ Manages saved GitHub repository URLs for easy plugin discovery and installation.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Optional
|
||||
|
||||
@@ -8,7 +8,6 @@ Provides utilities for extracting defaults, validating configurations, and manag
|
||||
import copy
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
import jsonschema
|
||||
|
||||
@@ -8,12 +8,12 @@ Detects and fixes inconsistencies between:
|
||||
- State manager state
|
||||
"""
|
||||
|
||||
from typing import Dict, Any, List, Optional, Set
|
||||
from typing import Dict, Any, List, Set
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
|
||||
from src.plugin_system.state_manager import PluginStateManager, PluginState, PluginStateStatus
|
||||
from src.plugin_system.state_manager import PluginStateManager
|
||||
from src.logging_config import get_logger
|
||||
|
||||
|
||||
@@ -234,7 +234,7 @@ class StateReconciliation:
|
||||
'version': manifest.get('version'),
|
||||
'name': manifest.get('name')
|
||||
}
|
||||
except Exception:
|
||||
except Exception: # nosec B110 - corrupt/unreadable manifest; skip this plugin, outer except logs
|
||||
pass
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Error reading disk state: {e}")
|
||||
@@ -285,7 +285,6 @@ class StateReconciliation:
|
||||
|
||||
config = config_state.get(plugin_id, {})
|
||||
disk = disk_state.get(plugin_id, {})
|
||||
manager = manager_state.get(plugin_id, {})
|
||||
state_mgr = state_manager_state.get(plugin_id, {})
|
||||
|
||||
# Check: Plugin exists on disk but not in config
|
||||
|
||||
@@ -23,7 +23,6 @@ import logging
|
||||
from src.common.permission_utils import sudo_remove_directory
|
||||
|
||||
try:
|
||||
import jsonschema
|
||||
from jsonschema import Draft7Validator, ValidationError
|
||||
JSONSCHEMA_AVAILABLE = True
|
||||
except ImportError:
|
||||
@@ -433,9 +432,9 @@ class PluginStoreManager:
|
||||
return stale
|
||||
if not self.github_token:
|
||||
self.logger.warning(
|
||||
f"GitHub API rate limit likely exceeded (403). "
|
||||
f"Add a GitHub personal access token to config/config_secrets.json "
|
||||
f"under 'github.api_token' to increase rate limits from 60 to 5000/hour."
|
||||
"GitHub API rate limit likely exceeded (403). "
|
||||
"Add a GitHub personal access token to config/config_secrets.json "
|
||||
"under 'github.api_token' to increase rate limits from 60 to 5000/hour."
|
||||
)
|
||||
else:
|
||||
self.logger.warning(
|
||||
@@ -1078,7 +1077,7 @@ class PluginStoreManager:
|
||||
# Get the actual plugin ID from manifest (source of truth)
|
||||
manifest_plugin_id = manifest.get('id')
|
||||
if not manifest_plugin_id:
|
||||
self.logger.error(f"Plugin manifest missing 'id' field")
|
||||
self.logger.error("Plugin manifest missing 'id' field")
|
||||
self._safe_remove_directory(plugin_path)
|
||||
return False
|
||||
|
||||
@@ -1729,7 +1728,7 @@ class PluginStoreManager:
|
||||
|
||||
try:
|
||||
self.logger.info(f"Installing dependencies for {plugin_path.name}")
|
||||
result = subprocess.run(
|
||||
subprocess.run(
|
||||
['pip3', 'install', '--break-system-packages', '-r', str(requirements_file)],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
@@ -2404,7 +2403,7 @@ class PluginStoreManager:
|
||||
if not plugin_info_remote:
|
||||
self.logger.warning(f"Plugin {plugin_id} not found in registry and not a git repository; cannot update automatically")
|
||||
if not repo_url:
|
||||
self.logger.warning(f"Plugin may have been installed via ZIP download. Try reinstalling from GitHub URL to enable updates.")
|
||||
self.logger.warning("Plugin may have been installed via ZIP download. Try reinstalling from GitHub URL to enable updates.")
|
||||
return False
|
||||
|
||||
repo_url = plugin_info_remote.get('repo')
|
||||
|
||||
@@ -6,7 +6,6 @@ and plugin_manager for use in plugin unit tests.
|
||||
"""
|
||||
|
||||
from typing import Dict, Any, Optional
|
||||
from unittest.mock import MagicMock
|
||||
from PIL import Image
|
||||
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ import math
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
from typing import Any, List, Optional, Tuple
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
@@ -236,7 +236,6 @@ class VisualTestDisplayManager:
|
||||
Replicated from DisplayManager._draw_bdf_text().
|
||||
"""
|
||||
try:
|
||||
import freetype
|
||||
if isinstance(color, list):
|
||||
color = tuple(color)
|
||||
face = font if font else self.calendar_font
|
||||
|
||||
@@ -6,8 +6,7 @@ Fails fast with clear error messages to prevent runtime issues.
|
||||
"""
|
||||
|
||||
import os
|
||||
import logging
|
||||
from typing import Dict, Any, List, Optional, Tuple
|
||||
from typing import Any, List, Optional, Tuple
|
||||
from pathlib import Path
|
||||
from src.exceptions import ConfigError, PluginError, CacheError
|
||||
from src.logging_config import get_logger
|
||||
|
||||
@@ -6,7 +6,7 @@ plugin ordering, exclusions, scroll speed, and display settings.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Dict, Any, List, Set, Optional
|
||||
from typing import Dict, Any, List, Set
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -11,11 +11,10 @@ import threading
|
||||
from collections import deque
|
||||
from typing import Optional, List, Any, Dict, Deque, TYPE_CHECKING
|
||||
from PIL import Image
|
||||
import numpy as np
|
||||
|
||||
from src.common.scroll_helper import ScrollHelper
|
||||
from src.vegas_mode.config import VegasModeConfig
|
||||
from src.vegas_mode.stream_manager import StreamManager, ContentSegment
|
||||
from src.vegas_mode.stream_manager import StreamManager
|
||||
|
||||
if TYPE_CHECKING:
|
||||
pass
|
||||
|
||||
@@ -14,7 +14,7 @@ Supports three display modes:
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from typing import Optional, List, Dict, Any, Deque, Tuple, TYPE_CHECKING
|
||||
from typing import Optional, List, Dict, Any, Deque, TYPE_CHECKING
|
||||
from collections import deque
|
||||
from dataclasses import dataclass, field
|
||||
from PIL import Image
|
||||
@@ -24,7 +24,6 @@ from src.vegas_mode.plugin_adapter import PluginAdapter
|
||||
from src.plugin_system.base_plugin import VegasDisplayMode
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from src.plugin_system.base_plugin import BasePlugin
|
||||
from src.plugin_system.plugin_manager import PluginManager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -5,11 +5,11 @@ Provides consistent API response formatting across all endpoints.
|
||||
"""
|
||||
|
||||
import time
|
||||
from typing import Any, Optional, Dict, Tuple, Union
|
||||
from typing import Any, Optional, Dict, Tuple
|
||||
from flask import jsonify, request
|
||||
|
||||
from src.web_interface.error_handler import create_error_response, create_success_response
|
||||
from src.web_interface.errors import ErrorCode, ErrorCategory
|
||||
from src.web_interface.errors import ErrorCode
|
||||
|
||||
|
||||
def success_response(
|
||||
|
||||
@@ -5,7 +5,6 @@ Provides decorators and helpers for consistent error handling across API endpoin
|
||||
"""
|
||||
|
||||
import functools
|
||||
import traceback
|
||||
from typing import Callable, Any, Optional
|
||||
from flask import jsonify
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ Input validation utilities for the web interface.
|
||||
Provides validation functions for user inputs to prevent XSS, invalid data, and security issues.
|
||||
"""
|
||||
import re
|
||||
import os
|
||||
from typing import Optional, Tuple, List
|
||||
from urllib.parse import urlparse
|
||||
from pathlib import Path
|
||||
@@ -56,7 +55,7 @@ def validate_image_url(url: str) -> Tuple[bool, Optional[str]]:
|
||||
parsed = urlparse(url)
|
||||
allowed_protocols = ['http', 'https']
|
||||
if parsed.scheme not in allowed_protocols:
|
||||
return False, f"Only http:// and https:// protocols are allowed"
|
||||
return False, "Only http:// and https:// protocols are allowed"
|
||||
return True, None
|
||||
except Exception as e:
|
||||
return False, f"Invalid URL format: {str(e)}"
|
||||
|
||||
@@ -37,7 +37,7 @@ import time
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
from dataclasses import dataclass, field
|
||||
from dataclasses import dataclass
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -689,7 +689,7 @@ class WiFiManager:
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_IP_FORWARD_SAVE_PATH = Path("/tmp/ledmatrix_ip_forward_saved")
|
||||
_IP_FORWARD_SAVE_PATH = Path("/tmp/ledmatrix_ip_forward_saved") # nosec B108 - process-specific named file; device is single-user RPi
|
||||
|
||||
def _validate_ap_config(self) -> Tuple[str, int]:
|
||||
"""Return a sanitized (ssid, channel) pair from config, falling back to defaults."""
|
||||
@@ -890,14 +890,14 @@ class WiFiManager:
|
||||
"""
|
||||
try:
|
||||
content = f"# LEDMatrix captive portal: resolve all hostnames to AP\naddress=/#/{ap_ip}\n"
|
||||
with open("/tmp/ledmatrix-nm-dnsmasq.conf", "w") as f:
|
||||
with open("/tmp/ledmatrix-nm-dnsmasq.conf", "w") as f: # nosec B108 - named file matches sudoers allowlist; single-user device
|
||||
f.write(content)
|
||||
subprocess.run(
|
||||
["sudo", "mkdir", "-p", str(NM_DNSMASQ_SHARED_DIR)],
|
||||
capture_output=True, timeout=5
|
||||
)
|
||||
subprocess.run(
|
||||
["sudo", "cp", "/tmp/ledmatrix-nm-dnsmasq.conf", str(NM_DNSMASQ_SHARED_CONF)],
|
||||
["sudo", "cp", "/tmp/ledmatrix-nm-dnsmasq.conf", str(NM_DNSMASQ_SHARED_CONF)], # nosec B108
|
||||
capture_output=True, timeout=5
|
||||
)
|
||||
logger.info(f"Wrote NM dnsmasq captive-portal config: {NM_DNSMASQ_SHARED_CONF}")
|
||||
@@ -937,7 +937,7 @@ class WiFiManager:
|
||||
pass
|
||||
try:
|
||||
import urllib.request as _ureq
|
||||
_ureq.urlopen("http://connectivity-check.ubuntu.com/", timeout=timeout)
|
||||
_ureq.urlopen("http://connectivity-check.ubuntu.com/", timeout=timeout) # nosec B310 - hardcoded URL, no user input
|
||||
logger.debug("Internet connectivity confirmed via HTTP check")
|
||||
return True
|
||||
except OSError:
|
||||
@@ -1314,7 +1314,7 @@ class WiFiManager:
|
||||
# This ensures a clean switch between networks
|
||||
if original_ssid and original_ssid != ssid:
|
||||
logger.info(f"Switching networks: disconnecting from {original_ssid} before connecting to {ssid}")
|
||||
self._show_led_message(f"Switching networks...", duration=3)
|
||||
self._show_led_message("Switching networks...", duration=3)
|
||||
# Skip AP mode check since we're about to connect to a new network
|
||||
disconnect_success, disconnect_msg = self.disconnect_from_network(skip_ap_check=True)
|
||||
if disconnect_success:
|
||||
@@ -1370,7 +1370,7 @@ class WiFiManager:
|
||||
ap_success, ap_msg = self.enable_ap_mode()
|
||||
if ap_success:
|
||||
logger.info("AP mode enabled as failsafe")
|
||||
return False, f"Connection failed and restoration failed. AP mode enabled."
|
||||
return False, "Connection failed and restoration failed. AP mode enabled."
|
||||
else:
|
||||
logger.error(f"Failed to enable AP mode: {ap_msg}")
|
||||
return False, f"Connection failed, restoration failed, and AP mode failed: {ap_msg}"
|
||||
@@ -1382,7 +1382,7 @@ class WiFiManager:
|
||||
ap_success, ap_msg = self.enable_ap_mode()
|
||||
if ap_success:
|
||||
logger.info("AP mode enabled as failsafe")
|
||||
return False, f"Connection failed. AP mode enabled."
|
||||
return False, "Connection failed. AP mode enabled."
|
||||
else:
|
||||
return False, f"Connection failed and AP mode failed: {ap_msg}"
|
||||
|
||||
@@ -1401,8 +1401,8 @@ class WiFiManager:
|
||||
# Last resort: enable AP mode
|
||||
try:
|
||||
self.enable_ap_mode()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as ap_error: # nosec B110 - last-resort; do not re-raise, but log for debugging
|
||||
logger.error("Last-resort AP mode enable failed in recovery path: %s", ap_error, exc_info=True)
|
||||
return False, str(e)
|
||||
|
||||
def _restore_original_connection(self, connection_name: str, ssid: str) -> bool:
|
||||
@@ -1797,7 +1797,7 @@ class WiFiManager:
|
||||
logger.info("WiFi radio enabled and verified successfully")
|
||||
return True
|
||||
elif attempt < max_retries - 1:
|
||||
logger.warning(f"WiFi radio enable command succeeded but not verified, will retry...")
|
||||
logger.warning("WiFi radio enable command succeeded but not verified, will retry...")
|
||||
time.sleep(1)
|
||||
continue
|
||||
else:
|
||||
@@ -2324,12 +2324,12 @@ ignore_broadcast_ssid=0
|
||||
"""
|
||||
|
||||
# Write config (requires sudo)
|
||||
with open("/tmp/hostapd.conf", 'w') as f:
|
||||
with open("/tmp/hostapd.conf", 'w') as f: # nosec B108 - named file matches sudoers allowlist; single-user device
|
||||
f.write(config_content)
|
||||
|
||||
# Copy to final location with sudo
|
||||
subprocess.run(
|
||||
["sudo", "cp", "/tmp/hostapd.conf", str(HOSTAPD_CONFIG_PATH)],
|
||||
["sudo", "cp", "/tmp/hostapd.conf", str(HOSTAPD_CONFIG_PATH)], # nosec B108
|
||||
timeout=10
|
||||
)
|
||||
|
||||
@@ -2394,12 +2394,12 @@ address=/detectportal.firefox.com/192.168.4.1
|
||||
"""
|
||||
|
||||
# Write config (requires sudo)
|
||||
with open("/tmp/dnsmasq.conf", 'w') as f:
|
||||
with open("/tmp/dnsmasq.conf", 'w') as f: # nosec B108 - named file matches sudoers allowlist; single-user device
|
||||
f.write(config_content)
|
||||
|
||||
# Copy to final location with sudo
|
||||
subprocess.run(
|
||||
["sudo", "cp", "/tmp/dnsmasq.conf", str(DNSMASQ_CONFIG_PATH)],
|
||||
["sudo", "cp", "/tmp/dnsmasq.conf", str(DNSMASQ_CONFIG_PATH)], # nosec B108
|
||||
timeout=10
|
||||
)
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ Provides common fixtures for mocking core components and test setup.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock, MagicMock
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
Diagnostic script to examine NBA API data structure and identify the missing 'id' field issue.
|
||||
"""
|
||||
import requests
|
||||
import json
|
||||
import logging
|
||||
from typing import Dict, Any
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import sys
|
||||
import json
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, Mock
|
||||
from typing import Any, Dict, Generator, Optional
|
||||
from typing import Any, Dict
|
||||
|
||||
# Add project root to path
|
||||
project_root = Path(__file__).parent.parent.parent
|
||||
|
||||
@@ -6,9 +6,7 @@ Provides common test functionality for all plugins.
|
||||
|
||||
import pytest
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from src.plugin_system.plugin_loader import PluginLoader
|
||||
from src.plugin_system.base_plugin import BasePlugin
|
||||
|
||||
@@ -5,7 +5,6 @@ Verifies that the visual display manager actually renders pixels,
|
||||
loads fonts, and can save snapshots.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
from src.plugin_system.testing import VisualTestDisplayManager
|
||||
|
||||
@@ -6,10 +6,7 @@ Tests cache functionality including memory cache, disk cache, strategy, and metr
|
||||
|
||||
import pytest
|
||||
import time
|
||||
import json
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock, MagicMock, patch
|
||||
from unittest.mock import patch
|
||||
from src.cache_manager import CacheManager
|
||||
from src.cache.memory_cache import MemoryCache
|
||||
from src.cache.disk_cache import DiskCache
|
||||
|
||||
@@ -7,9 +7,6 @@ Tests configuration loading, migration, secrets handling, and validation.
|
||||
import pytest
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock, patch, mock_open
|
||||
from src.config_manager import ConfigManager
|
||||
|
||||
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
import time
|
||||
import pytest
|
||||
import threading
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock, MagicMock, patch
|
||||
from unittest.mock import MagicMock, patch
|
||||
from src.config_service import ConfigService
|
||||
from src.config_manager import ConfigManager
|
||||
|
||||
|
||||
@@ -10,11 +10,7 @@ Tests scenarios that commonly cause user configuration errors:
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import json
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock, patch, MagicMock
|
||||
import tempfile
|
||||
import os
|
||||
|
||||
# Add project root to path
|
||||
import sys
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import pytest
|
||||
import time
|
||||
from unittest.mock import MagicMock, patch, ANY
|
||||
from src.display_controller import DisplayController
|
||||
from datetime import datetime
|
||||
from unittest.mock import MagicMock, patch
|
||||
import pytest
|
||||
|
||||
class TestDisplayControllerInitialization:
|
||||
"""Test DisplayController initialization and setup."""
|
||||
@@ -15,20 +15,13 @@ class TestDisplayControllerInitialization:
|
||||
assert test_display_controller.plugin_manager is not None
|
||||
assert test_display_controller.available_modes == []
|
||||
|
||||
@pytest.mark.skip(reason="No assertions; init logic is covered by test_init_success and fixture setup")
|
||||
def test_plugin_discovery_and_loading(self, test_display_controller):
|
||||
"""Test plugin discovery and loading during initialization."""
|
||||
# Mock plugin manager behavior
|
||||
pm = test_display_controller.plugin_manager
|
||||
pm.discover_plugins.return_value = ["plugin1", "plugin2"]
|
||||
pm.get_plugin.return_value = MagicMock()
|
||||
|
||||
# Manually trigger the plugin loading logic that happens in __init__
|
||||
# Since we're using a fixture that mocks __init__ partially, we need to verify
|
||||
# the interactions or simulate the loading if we want to test that specific logic
|
||||
pass
|
||||
# Note: Testing __init__ logic is tricky with the fixture.
|
||||
# We rely on the fixture to give us a usable controller.
|
||||
|
||||
|
||||
class TestDisplayControllerModeRotation:
|
||||
"""Test display mode rotation logic."""
|
||||
@@ -251,5 +244,3 @@ class TestDisplayControllerSchedule:
|
||||
with patch.object(controller.config_service, 'get_config', return_value=schedule_config):
|
||||
controller._check_schedule()
|
||||
assert controller.is_display_active is False
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import pytest
|
||||
import time
|
||||
from unittest.mock import MagicMock, patch, ANY
|
||||
from PIL import Image, ImageDraw
|
||||
from unittest.mock import MagicMock, patch
|
||||
from PIL import ImageDraw
|
||||
from src.display_manager import DisplayManager
|
||||
|
||||
@pytest.fixture
|
||||
|
||||
@@ -9,11 +9,8 @@ Tests:
|
||||
- Thread safety
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock, patch
|
||||
import threading
|
||||
import sys
|
||||
|
||||
@@ -29,7 +26,7 @@ from src.error_aggregator import (
|
||||
get_error_aggregator,
|
||||
record_error
|
||||
)
|
||||
from src.exceptions import PluginError, ConfigError
|
||||
from src.exceptions import PluginError
|
||||
|
||||
|
||||
class TestErrorRecording:
|
||||
|
||||
@@ -1,16 +1,10 @@
|
||||
import pytest
|
||||
import logging
|
||||
import json
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from src.exceptions import CacheError, ConfigError, PluginError, DisplayError, LEDMatrixError
|
||||
from src.exceptions import CacheError, ConfigError, PluginError, DisplayError
|
||||
from src.common.error_handler import (
|
||||
handle_file_operation,
|
||||
handle_json_operation,
|
||||
safe_execute,
|
||||
retry_on_failure,
|
||||
log_and_continue,
|
||||
log_and_raise
|
||||
safe_execute
|
||||
)
|
||||
|
||||
class TestCustomExceptions:
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import pytest
|
||||
import os
|
||||
from unittest.mock import MagicMock, patch, mock_open
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
from src.font_manager import FontManager
|
||||
|
||||
@pytest.fixture
|
||||
|
||||
@@ -6,10 +6,7 @@ Tests layout creation, management, rendering, and element positioning.
|
||||
|
||||
import pytest
|
||||
import json
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch, Mock
|
||||
from datetime import datetime
|
||||
from unittest.mock import MagicMock
|
||||
from src.layout_manager import LayoutManager
|
||||
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import sys
|
||||
import os
|
||||
import logging
|
||||
import json
|
||||
from typing import Dict, Any
|
||||
|
||||
# Set up logging
|
||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
||||
|
||||
@@ -3,11 +3,8 @@
|
||||
Simple test script to verify NBA data structure includes team ID fields.
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
import requests
|
||||
import logging
|
||||
import json
|
||||
from typing import Dict, Any
|
||||
|
||||
# Set up logging
|
||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
||||
|
||||
@@ -6,7 +6,6 @@ import sys
|
||||
import os
|
||||
import logging
|
||||
import json
|
||||
from typing import Dict, Any
|
||||
|
||||
# Set up logging
|
||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
||||
|
||||
@@ -6,7 +6,6 @@ This script simulates the leaderboard manager's data fetching process.
|
||||
import sys
|
||||
import os
|
||||
import logging
|
||||
from typing import Dict, Any
|
||||
|
||||
# Add the src directory to Python path so we can import the leaderboard manager
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'src'))
|
||||
|
||||
@@ -5,9 +5,7 @@ Tests plugin directory discovery, module loading, and class instantiation.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch, Mock, mock_open
|
||||
from unittest.mock import MagicMock, patch
|
||||
from src.plugin_system.plugin_loader import PluginLoader
|
||||
from src.exceptions import PluginError
|
||||
|
||||
|
||||
@@ -15,8 +15,7 @@ Tests various failure modes that can occur during plugin loading:
|
||||
import pytest
|
||||
import json
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock, patch, MagicMock
|
||||
import tempfile
|
||||
from unittest.mock import patch, MagicMock
|
||||
import sys
|
||||
|
||||
# Add project root to path
|
||||
@@ -25,9 +24,7 @@ if str(project_root) not in sys.path:
|
||||
sys.path.insert(0, str(project_root))
|
||||
|
||||
from src.plugin_system.plugin_manager import PluginManager
|
||||
from src.plugin_system.plugin_loader import PluginLoader
|
||||
from src.plugin_system.plugin_state import PluginState
|
||||
from src.exceptions import PluginError
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
import pytest
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from unittest.mock import MagicMock, patch, ANY, call
|
||||
from unittest.mock import MagicMock, patch
|
||||
from pathlib import Path
|
||||
from src.plugin_system.plugin_manager import PluginManager
|
||||
from src.plugin_system.plugin_state import PluginState
|
||||
from src.exceptions import PluginError
|
||||
|
||||
class TestPluginManager:
|
||||
"""Test PluginManager functionality."""
|
||||
@@ -90,7 +86,6 @@ class TestPluginLoader:
|
||||
"""Test dependency checking logic."""
|
||||
# This would test _check_dependencies_installed and _install_plugin_dependencies
|
||||
# which requires mocking subprocess calls and file operations
|
||||
pass
|
||||
|
||||
|
||||
class TestPluginExecutor:
|
||||
|
||||
@@ -6,8 +6,6 @@ Tests schema loading, validation, default extraction, and caching.
|
||||
|
||||
import pytest
|
||||
import json
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch, mock_open
|
||||
from jsonschema import ValidationError
|
||||
from src.plugin_system.schema_manager import SchemaManager
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ Tests text rendering, font loading, and text positioning utilities.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch, Mock
|
||||
from unittest.mock import MagicMock, patch
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
from src.common.text_helper import TextHelper
|
||||
|
||||
|
||||
@@ -6,10 +6,9 @@ Tests Flask routes, request/response handling, and API functionality.
|
||||
|
||||
import pytest
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch, Mock
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
# Add project root to path
|
||||
project_root = Path(__file__).parent.parent
|
||||
@@ -288,7 +287,6 @@ class TestDisplayAPI:
|
||||
@patch('web_interface.blueprints.api_v3._ensure_cache_manager')
|
||||
def test_stop_on_demand_display(self, mock_ensure_cache, client):
|
||||
"""Test stopping on-demand display."""
|
||||
from web_interface.blueprints.api_v3 import api_v3
|
||||
|
||||
# Mock the cache manager returned by _ensure_cache_manager
|
||||
mock_cache_manager = MagicMock()
|
||||
@@ -440,7 +438,6 @@ class TestPluginsAPI:
|
||||
|
||||
def test_get_plugin_schema(self, client):
|
||||
"""Test getting plugin configuration schema."""
|
||||
from web_interface.blueprints.api_v3 import api_v3
|
||||
|
||||
response = client.get('/api/v3/plugins/schema?plugin_id=weather')
|
||||
|
||||
@@ -477,7 +474,6 @@ class TestPluginsAPI:
|
||||
|
||||
def test_get_operation_history(self, client):
|
||||
"""Test getting operation history."""
|
||||
from web_interface.blueprints.api_v3 import api_v3
|
||||
|
||||
response = client.get('/api/v3/plugins/operation/history')
|
||||
|
||||
@@ -487,7 +483,6 @@ class TestPluginsAPI:
|
||||
|
||||
def test_get_plugin_state(self, client):
|
||||
"""Test getting plugin state."""
|
||||
from web_interface.blueprints.api_v3 import api_v3
|
||||
|
||||
response = client.get('/api/v3/plugins/state')
|
||||
|
||||
|
||||
@@ -5,12 +5,10 @@ Integration tests for plugin operations (install, update, uninstall).
|
||||
import unittest
|
||||
import tempfile
|
||||
import shutil
|
||||
import json
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from src.plugin_system.operation_queue import PluginOperationQueue
|
||||
from src.plugin_system.operation_types import OperationType, OperationStatus
|
||||
from src.plugin_system.operation_types import OperationType
|
||||
from src.plugin_system.state_manager import PluginStateManager
|
||||
from src.plugin_system.operation_history import OperationHistory
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ Tests import the production function from src.web_interface.validators
|
||||
to ensure they exercise the real code path.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from src.web_interface.validators import dedup_unique_arrays as _dedup_unique_arrays
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import tempfile
|
||||
import shutil
|
||||
import json
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock, MagicMock, patch
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from src.plugin_system.state_reconciliation import (
|
||||
StateReconciliation,
|
||||
@@ -15,7 +15,7 @@ from src.plugin_system.state_reconciliation import (
|
||||
FixAction,
|
||||
ReconciliationResult
|
||||
)
|
||||
from src.plugin_system.state_manager import PluginStateManager, PluginState, PluginStateStatus
|
||||
from src.plugin_system.state_manager import PluginStateManager, PluginStateStatus
|
||||
|
||||
|
||||
class TestStateReconciliation(unittest.TestCase):
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from flask import Flask, Blueprint, render_template, request, redirect, url_for, flash, jsonify, Response, send_from_directory
|
||||
from flask import Flask, request, redirect, url_for, jsonify, Response, send_from_directory
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
@@ -21,7 +21,6 @@ from src.plugin_system.operation_queue import PluginOperationQueue
|
||||
from src.plugin_system.state_manager import PluginStateManager
|
||||
from src.plugin_system.operation_history import OperationHistory
|
||||
from src.plugin_system.health_monitor import PluginHealthMonitor
|
||||
from src.wifi_manager import WiFiManager
|
||||
|
||||
# Create Flask app
|
||||
app = Flask(__name__)
|
||||
@@ -51,10 +50,8 @@ try:
|
||||
except ImportError:
|
||||
# flask-limiter not installed, rate limiting disabled
|
||||
limiter = None
|
||||
pass
|
||||
|
||||
# Import cache functions from separate module to avoid circular imports
|
||||
from web_interface.cache import get_cached, set_cached, invalidate_cache
|
||||
|
||||
# Initialize plugin managers - read plugins directory from config
|
||||
config = config_manager.load_config()
|
||||
@@ -304,7 +301,6 @@ try:
|
||||
except ImportError:
|
||||
# Logging config not available, use default
|
||||
log_api_request = None
|
||||
pass
|
||||
|
||||
# Request timing and logging middleware
|
||||
@app.before_request
|
||||
@@ -328,7 +324,7 @@ def after_request_logging(response):
|
||||
duration_ms=duration_ms,
|
||||
ip_address=ip_address
|
||||
)
|
||||
except Exception:
|
||||
except Exception: # nosec B110 - request logging must never interrupt a live HTTP response
|
||||
pass # Don't break response if logging fails
|
||||
return response
|
||||
|
||||
@@ -506,7 +502,7 @@ def display_preview_generator():
|
||||
from PIL import Image
|
||||
import io
|
||||
|
||||
snapshot_path = "/tmp/led_matrix_preview.png"
|
||||
snapshot_path = "/tmp/led_matrix_preview.png" # nosec B108 - fixed path matches display_manager; only read here
|
||||
last_modified = None
|
||||
|
||||
# Get display dimensions from config
|
||||
@@ -546,7 +542,7 @@ def display_preview_generator():
|
||||
}
|
||||
last_modified = current_modified
|
||||
yield preview_data
|
||||
except Exception as read_err:
|
||||
except Exception: # nosec B110 - SSE preview file may be mid-write; transient error, skip this update
|
||||
# File might be being written, skip this update
|
||||
pass
|
||||
else:
|
||||
@@ -741,6 +737,9 @@ def check_health_monitor():
|
||||
_threading.Thread(target=_run_startup_reconciliation, daemon=True).start()
|
||||
|
||||
if __name__ == '__main__':
|
||||
import os as _os
|
||||
# threaded=True is Flask's default since 1.0 but stated explicitly so that
|
||||
# long-lived /api/v3/stream/* SSE connections don't starve other requests.
|
||||
app.run(host='0.0.0.0', port=5000, debug=True, threaded=True)
|
||||
# Debug mode is off by default; opt in with FLASK_DEBUG=1 in the environment.
|
||||
_debug = _os.environ.get('FLASK_DEBUG', '0') == '1'
|
||||
app.run(host='0.0.0.0', port=5000, debug=_debug, threaded=True) # nosec B104 - intentional; local network device
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user