From 05b3fa56cbef0cb3cf92db14f1a0691b551727a0 Mon Sep 17 00:00:00 2001 From: Chuck <33324927+ChuckBuilds@users.noreply.github.com> Date: Fri, 15 May 2026 10:19:55 -0400 Subject: [PATCH] fix: Codacy security fixes, CVE dependency bumps, and code quality cleanup (#331) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * 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 * 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 * 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 * 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 * fix(js): resolve ESLint no-undef warnings across 6 JS files Three distinct patterns: 1. Vendor library globals — htmx is injected by 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 * 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 * 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 * 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 * 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 * 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 * 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 --------- Co-authored-by: Chuck Co-authored-by: Claude Sonnet 4.6 --- docs/PLUGIN_ARCHITECTURE_SPEC.md | 20 ++-- first_time_install.sh | 6 +- plugin-repos/march-madness/requirements.txt | 3 +- plugin-repos/starlark-apps/requirements.txt | 4 +- requirements.txt | 12 +-- run.py | 1 - scripts/add_defaults_to_schemas.py | 2 +- scripts/analyze_plugin_schemas.py | 3 +- scripts/debug/check_imports.py | 2 - scripts/dev/dev_plugin_setup.sh | 2 +- scripts/dev/test_pillow_compat.py | 95 +++++++++++++++++++ scripts/dev/validate_python.py | 1 - scripts/diagnose_plugin_permissions.sh | 1 - scripts/diagnose_web_interface.sh | 2 +- scripts/diagnose_web_ui.sh | 5 +- scripts/fix_perms/fix_assets_permissions.sh | 6 -- scripts/install/install_web_service.sh | 3 - scripts/render_plugin.py | 2 +- scripts/run_plugin_tests.py | 5 - scripts/utils/clear_cache.py | 2 - scripts/utils/start_web_conditionally.py | 2 +- src/background_cache_mixin.py | 3 - src/background_data_service.py | 29 ++---- src/base_classes/api_extractors.py | 4 +- src/base_classes/baseball.py | 1 - src/base_classes/basketball.py | 2 - src/base_classes/data_sources.py | 12 +-- src/base_classes/football.py | 4 +- src/base_classes/hockey.py | 4 - src/base_classes/sports.py | 7 +- src/base_odds_manager.py | 3 - src/cache/disk_cache.py | 9 +- src/cache_manager.py | 3 +- src/common/api_helper.py | 7 +- src/common/basketball_plugin_example.py | 2 - src/common/cli.py | 8 +- src/common/display_helper.py | 8 +- src/common/logo_helper.py | 2 - src/common/permission_utils.py | 2 +- src/common/text_helper.py | 1 - src/common/utils.py | 2 +- src/config_manager.py | 11 +-- src/config_service.py | 9 +- src/display_controller.py | 6 +- src/display_manager.py | 10 +- src/dynamic_team_resolver.py | 3 +- src/font_manager.py | 16 ++-- src/font_test_manager.py | 8 +- src/generic_cache_mixin.py | 1 - src/layout_manager.py | 3 +- src/logo_downloader.py | 6 +- src/plugin_system/health_monitor.py | 2 +- src/plugin_system/operation_queue.py | 3 +- src/plugin_system/operation_types.py | 2 +- src/plugin_system/plugin_executor.py | 18 ++-- src/plugin_system/plugin_manager.py | 3 - src/plugin_system/resource_monitor.py | 3 +- src/plugin_system/saved_repositories.py | 1 - src/plugin_system/schema_manager.py | 1 - src/plugin_system/state_reconciliation.py | 7 +- src/plugin_system/store_manager.py | 13 ++- src/plugin_system/testing/mocks.py | 1 - .../testing/visual_display_manager.py | 3 +- src/startup_validator.py | 3 +- src/vegas_mode/config.py | 2 +- src/vegas_mode/render_pipeline.py | 3 +- src/vegas_mode/stream_manager.py | 3 +- src/web_interface/api_helpers.py | 4 +- src/web_interface/error_handler.py | 1 - src/web_interface/validators.py | 3 +- src/wifi_manager.py | 30 +++--- test/conftest.py | 1 - test/debug_nba_api.py | 1 - test/plugins/conftest.py | 2 +- test/plugins/test_plugin_base.py | 2 - test/plugins/test_visual_rendering.py | 1 - test/test_cache_manager.py | 5 +- test/test_config_manager.py | 3 - test/test_config_service.py | 7 +- test/test_config_validation_edge_cases.py | 4 - test/test_display_controller.py | 17 +--- test/test_display_manager.py | 5 +- test/test_error_aggregator.py | 5 +- test/test_error_handling.py | 10 +- test/test_font_manager.py | 4 +- test/test_layout_manager.py | 5 +- test/test_nba_core_functionality.py | 1 - test/test_nba_data_structure.py | 3 - test/test_nba_integration.py | 1 - test/test_nba_leaderboard_fix.py | 1 - test/test_plugin_loader.py | 4 +- test/test_plugin_loading_failures.py | 5 +- test/test_plugin_system.py | 7 +- test/test_schema_manager.py | 2 - test/test_text_helper.py | 2 +- test/test_web_api.py | 7 +- .../integration/test_plugin_operations.py | 4 +- .../web_interface/test_dedup_unique_arrays.py | 1 - .../test_state_reconciliation.py | 4 +- web_interface/app.py | 17 ++-- web_interface/blueprints/api_v3.py | 7 +- web_interface/blueprints/pages_v3.py | 15 ++- web_interface/logging_config.py | 2 +- web_interface/requirements.txt | 12 +-- web_interface/start.py | 4 +- web_interface/static/v3/app.js | 8 +- web_interface/static/v3/js/htmx-json-enc.js | 1 + web_interface/static/v3/js/htmx-sse.js | 1 + .../static/v3/js/plugins/api_client.js | 2 +- .../static/v3/js/utils/error_handler.js | 3 +- .../static/v3/js/widgets/array-table.js | 2 +- .../static/v3/js/widgets/checkbox-group.js | 2 +- .../static/v3/js/widgets/custom-feeds.js | 9 +- .../static/v3/js/widgets/day-selector.js | 2 +- .../static/v3/js/widgets/file-upload.js | 4 +- .../static/v3/js/widgets/notification.js | 2 +- .../static/v3/js/widgets/schedule-picker.js | 1 - .../static/v3/js/widgets/select-dropdown.js | 1 - .../static/v3/js/widgets/timezone-selector.js | 2 +- 119 files changed, 292 insertions(+), 390 deletions(-) create mode 100755 scripts/dev/test_pillow_compat.py diff --git a/docs/PLUGIN_ARCHITECTURE_SPEC.md b/docs/PLUGIN_ARCHITECTURE_SPEC.md index e21ffe33..fbd45b34 100644 --- a/docs/PLUGIN_ARCHITECTURE_SPEC.md +++ b/docs/PLUGIN_ARCHITECTURE_SPEC.md @@ -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) --- diff --git a/first_time_install.sh b/first_time_install.sh index 9f98c91e..2c46ed7f 100644 --- a/first_time_install.sh +++ b/first_time_install.sh @@ -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 diff --git a/plugin-repos/march-madness/requirements.txt b/plugin-repos/march-madness/requirements.txt index aa49c296..ce14649c 100644 --- a/plugin-repos/march-madness/requirements.txt +++ b/plugin-repos/march-madness/requirements.txt @@ -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 diff --git a/plugin-repos/starlark-apps/requirements.txt b/plugin-repos/starlark-apps/requirements.txt index 7c1dfc12..97cc9de6 100644 --- a/plugin-repos/starlark-apps/requirements.txt +++ b/plugin-repos/starlark-apps/requirements.txt @@ -1,3 +1,3 @@ -Pillow>=10.4.0 +Pillow>=12.2.0 PyYAML>=6.0.2 -requests>=2.32.0 +requests>=2.33.0 diff --git a/requirements.txt b/requirements.txt index defec383..d3d72d34 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 diff --git a/run.py b/run.py index 28afb60c..c5e387c6 100755 --- a/run.py +++ b/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) diff --git a/scripts/add_defaults_to_schemas.py b/scripts/add_defaults_to_schemas.py index 9460c9ca..c0474530 100755 --- a/scripts/add_defaults_to_schemas.py +++ b/scripts/add_defaults_to_schemas.py @@ -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: diff --git a/scripts/analyze_plugin_schemas.py b/scripts/analyze_plugin_schemas.py index c3c63d49..d10a308f 100755 --- a/scripts/analyze_plugin_schemas.py +++ b/scripts/analyze_plugin_schemas.py @@ -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 diff --git a/scripts/debug/check_imports.py b/scripts/debug/check_imports.py index c4593f3a..a1cefae0 100644 --- a/scripts/debug/check_imports.py +++ b/scripts/debug/check_imports.py @@ -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 diff --git a/scripts/dev/dev_plugin_setup.sh b/scripts/dev/dev_plugin_setup.sh index d53fd315..f9f6363b 100755 --- a/scripts/dev/dev_plugin_setup.sh +++ b/scripts/dev/dev_plugin_setup.sh @@ -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 diff --git a/scripts/dev/test_pillow_compat.py b/scripts/dev/test_pillow_compat.py new file mode 100755 index 00000000..590d632c --- /dev/null +++ b/scripts/dev/test_pillow_compat.py @@ -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()) diff --git a/scripts/dev/validate_python.py b/scripts/dev/validate_python.py index 57c864d3..b10a3ee9 100644 --- a/scripts/dev/validate_python.py +++ b/scripts/dev/validate_python.py @@ -15,7 +15,6 @@ Usage: python tools/validate_python.py import ast import sys import os -from pathlib import Path def validate_file(filepath: str) -> bool: """Validate a Python file for common issues.""" diff --git a/scripts/diagnose_plugin_permissions.sh b/scripts/diagnose_plugin_permissions.sh index 64a7bd66..3e3b3104 100755 --- a/scripts/diagnose_plugin_permissions.sh +++ b/scripts/diagnose_plugin_permissions.sh @@ -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 diff --git a/scripts/diagnose_web_interface.sh b/scripts/diagnose_web_interface.sh index 6ae07507..b9b304c3 100644 --- a/scripts/diagnose_web_interface.sh +++ b/scripts/diagnose_web_interface.sh @@ -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}" diff --git a/scripts/diagnose_web_ui.sh b/scripts/diagnose_web_ui.sh index cd5d11cf..e2226f1a 100755 --- a/scripts/diagnose_web_ui.sh +++ b/scripts/diagnose_web_ui.sh @@ -16,11 +16,8 @@ YELLOW='\033[1;33m' NC='\033[0m' # No Color # Check if running as root or with sudo -if [ "$EUID" -ne 0 ]; then +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" diff --git a/scripts/fix_perms/fix_assets_permissions.sh b/scripts/fix_perms/fix_assets_permissions.sh index 604b244e..9000a338 100644 --- a/scripts/fix_perms/fix_assets_permissions.sh +++ b/scripts/fix_perms/fix_assets_permissions.sh @@ -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 diff --git a/scripts/install/install_web_service.sh b/scripts/install/install_web_service.sh index 3653be13..23575080 100644 --- a/scripts/install/install_web_service.sh +++ b/scripts/install/install_web_service.sh @@ -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) diff --git a/scripts/render_plugin.py b/scripts/render_plugin.py index 013828ae..818941a3 100644 --- a/scripts/render_plugin.py +++ b/scripts/render_plugin.py @@ -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)') diff --git a/scripts/run_plugin_tests.py b/scripts/run_plugin_tests.py index 7355a8e6..61e59710 100755 --- a/scripts/run_plugin_tests.py +++ b/scripts/run_plugin_tests.py @@ -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) diff --git a/scripts/utils/clear_cache.py b/scripts/utils/clear_cache.py index 3238f36b..6d4d7b0d 100644 --- a/scripts/utils/clear_cache.py +++ b/scripts/utils/clear_cache.py @@ -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')) diff --git a/scripts/utils/start_web_conditionally.py b/scripts/utils/start_web_conditionally.py index b8f6e351..b3c3c99d 100644 --- a/scripts/utils/start_web_conditionally.py +++ b/scripts/utils/start_web_conditionally.py @@ -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 diff --git a/src/background_cache_mixin.py b/src/background_cache_mixin.py index abdc461a..623a5313 100644 --- a/src/background_cache_mixin.py +++ b/src/background_cache_mixin.py @@ -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: diff --git a/src/background_data_service.py b/src/background_data_service.py index 32ef6444..355c93c4 100644 --- a/src/background_data_service.py +++ b/src/background_data_service.py @@ -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 diff --git a/src/base_classes/api_extractors.py b/src/base_classes/api_extractors.py index 291f142e..97155d86 100644 --- a/src/base_classes/api_extractors.py +++ b/src/base_classes/api_extractors.py @@ -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.""" diff --git a/src/base_classes/baseball.py b/src/base_classes/baseball.py index 6bfeb5aa..9a46dd5a 100644 --- a/src/base_classes/baseball.py +++ b/src/base_classes/baseball.py @@ -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 diff --git a/src/base_classes/basketball.py b/src/base_classes/basketball.py index e00782e1..72c4bf28 100644 --- a/src/base_classes/basketball.py +++ b/src/base_classes/basketball.py @@ -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 diff --git a/src/base_classes/data_sources.py b/src/base_classes/data_sources.py index a7454399..777320ea 100644 --- a/src/base_classes/data_sources.py +++ b/src/base_classes/data_sources.py @@ -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: diff --git a/src/base_classes/football.py b/src/base_classes/football.py index 24d0873b..2974934f 100644 --- a/src/base_classes/football.py +++ b/src/base_classes/football.py @@ -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 diff --git a/src/base_classes/hockey.py b/src/base_classes/hockey.py index c54b5fa8..9c25da35 100644 --- a/src/base_classes/hockey.py +++ b/src/base_classes/hockey.py @@ -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", "") diff --git a/src/base_classes/sports.py b/src/base_classes/sports.py index 74e11fb7..fd983446 100644 --- a/src/base_classes/sports.py +++ b/src/base_classes/sports.py @@ -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: diff --git a/src/base_odds_manager.py b/src/base_odds_manager.py index 8abf3695..3520ce67 100644 --- a/src/base_odds_manager.py +++ b/src/base_odds_manager.py @@ -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: diff --git a/src/cache/disk_cache.py b/src/cache/disk_cache.py index 03768842..13992dfc 100644 --- a/src/cache/disk_cache.py +++ b/src/cache/disk_cache.py @@ -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) diff --git a/src/cache_manager.py b/src/cache_manager.py index 263108c4..7418ed56 100644 --- a/src/cache_manager.py +++ b/src/cache_manager.py @@ -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: diff --git a/src/common/api_helper.py b/src/common/api_helper.py index b185d3e6..9d9b076f 100644 --- a/src/common/api_helper.py +++ b/src/common/api_helper.py @@ -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 diff --git a/src/common/basketball_plugin_example.py b/src/common/basketball_plugin_example.py index 2c3e5710..9921a2fe 100644 --- a/src/common/basketball_plugin_example.py +++ b/src/common/basketball_plugin_example.py @@ -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 ( diff --git a/src/common/cli.py b/src/common/cli.py index 3fc0d19a..ec33caaa 100644 --- a/src/common/cli.py +++ b/src/common/cli.py @@ -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!") diff --git a/src/common/display_helper.py b/src/common/display_helper.py index 414e785b..5d65f0b4 100644 --- a/src/common/display_helper.py +++ b/src/common/display_helper.py @@ -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,8 +215,7 @@ 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() diff --git a/src/common/logo_helper.py b/src/common/logo_helper.py index b4b11f5f..ee73c339 100644 --- a/src/common/logo_helper.py +++ b/src/common/logo_helper.py @@ -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 diff --git a/src/common/permission_utils.py b/src/common/permission_utils.py index f2b09b14..0ce7bbc9 100644 --- a/src/common/permission_utils.py +++ b/src/common/permission_utils.py @@ -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', diff --git a/src/common/text_helper.py b/src/common/text_helper.py index 497f6d1a..5cbce429 100644 --- a/src/common/text_helper.py +++ b/src/common/text_helper.py @@ -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 diff --git a/src/common/utils.py b/src/common/utils.py index 2abfd66c..fc2d9613 100644 --- a/src/common/utils.py +++ b/src/common/utils.py @@ -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 diff --git a/src/config_manager.py b/src/config_manager.py index 00e27b50..cd022b52 100644 --- a/src/config_manager.py +++ b/src/config_manager.py @@ -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 diff --git a/src/config_service.py b/src/config_service.py index 44f16af3..234ff84f 100644 --- a/src/config_service.py +++ b/src/config_service.py @@ -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: """ diff --git a/src/display_controller.py b/src/display_controller.py index d55cafd2..00de2eb2 100644 --- a/src/display_controller.py +++ b/src/display_controller.py @@ -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. diff --git a/src/display_manager.py b/src/display_manager.py index 59e41aeb..43433582 100644 --- a/src/display_manager.py +++ b/src/display_manager.py @@ -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" diff --git a/src/dynamic_team_resolver.py b/src/dynamic_team_resolver.py index b71c6c15..ab969e17 100644 --- a/src/dynamic_team_resolver.py +++ b/src/dynamic_team_resolver.py @@ -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__) diff --git a/src/font_manager.py b/src/font_manager.py index df393363..b217dd25 100644 --- a/src/font_manager.py +++ b/src/font_manager.py @@ -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"} diff --git a/src/font_test_manager.py b/src/font_test_manager.py index 66007ea0..8869ebbd 100644 --- a/src/font_test_manager.py +++ b/src/font_test_manager.py @@ -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)) diff --git a/src/generic_cache_mixin.py b/src/generic_cache_mixin.py index e41ad3ba..3c727eb0 100644 --- a/src/generic_cache_mixin.py +++ b/src/generic_cache_mixin.py @@ -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 diff --git a/src/layout_manager.py b/src/layout_manager.py index c9f43c1b..1d852fc2 100644 --- a/src/layout_manager.py +++ b/src/layout_manager.py @@ -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__) diff --git a/src/logo_downloader.py b/src/logo_downloader.py index ab696355..e4dad335 100644 --- a/src/logo_downloader.py +++ b/src/logo_downloader.py @@ -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}") diff --git a/src/plugin_system/health_monitor.py b/src/plugin_system/health_monitor.py index c3610681..37d6e313 100644 --- a/src/plugin_system/health_monitor.py +++ b/src/plugin_system/health_monitor.py @@ -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 diff --git a/src/plugin_system/operation_queue.py b/src/plugin_system/operation_queue.py index b3684ee8..8ac18080 100644 --- a/src/plugin_system/operation_queue.py +++ b/src/plugin_system/operation_queue.py @@ -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 diff --git a/src/plugin_system/operation_types.py b/src/plugin_system/operation_types.py index bd05c31a..7181263b 100644 --- a/src/plugin_system/operation_types.py +++ b/src/plugin_system/operation_types.py @@ -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 diff --git a/src/plugin_system/plugin_executor.py b/src/plugin_system/plugin_executor.py index aba5fd5c..2fac5c5e 100644 --- a/src/plugin_system/plugin_executor.py +++ b/src/plugin_system/plugin_executor.py @@ -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, diff --git a/src/plugin_system/plugin_manager.py b/src/plugin_system/plugin_manager.py index c6baed69..18ed6b08 100644 --- a/src/plugin_system/plugin_manager.py +++ b/src/plugin_system/plugin_manager.py @@ -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 diff --git a/src/plugin_system/resource_monitor.py b/src/plugin_system/resource_monitor.py index 38b5e1ad..31352a55 100644 --- a/src/plugin_system/resource_monitor.py +++ b/src/plugin_system/resource_monitor.py @@ -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: diff --git a/src/plugin_system/saved_repositories.py b/src/plugin_system/saved_repositories.py index 1773c42f..bd0e1872 100644 --- a/src/plugin_system/saved_repositories.py +++ b/src/plugin_system/saved_repositories.py @@ -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 diff --git a/src/plugin_system/schema_manager.py b/src/plugin_system/schema_manager.py index c61ed290..385b53fd 100644 --- a/src/plugin_system/schema_manager.py +++ b/src/plugin_system/schema_manager.py @@ -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 diff --git a/src/plugin_system/state_reconciliation.py b/src/plugin_system/state_reconciliation.py index af804ec9..d94f42ca 100644 --- a/src/plugin_system/state_reconciliation.py +++ b/src/plugin_system/state_reconciliation.py @@ -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 diff --git a/src/plugin_system/store_manager.py b/src/plugin_system/store_manager.py index 67d4cebf..5d4ba48a 100644 --- a/src/plugin_system/store_manager.py +++ b/src/plugin_system/store_manager.py @@ -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') diff --git a/src/plugin_system/testing/mocks.py b/src/plugin_system/testing/mocks.py index 43615c01..a9b0f2bd 100644 --- a/src/plugin_system/testing/mocks.py +++ b/src/plugin_system/testing/mocks.py @@ -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 diff --git a/src/plugin_system/testing/visual_display_manager.py b/src/plugin_system/testing/visual_display_manager.py index 17afcf45..73d64f3c 100644 --- a/src/plugin_system/testing/visual_display_manager.py +++ b/src/plugin_system/testing/visual_display_manager.py @@ -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 diff --git a/src/startup_validator.py b/src/startup_validator.py index 1fe7a710..86c703f6 100644 --- a/src/startup_validator.py +++ b/src/startup_validator.py @@ -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 diff --git a/src/vegas_mode/config.py b/src/vegas_mode/config.py index dbdb5b33..9c930c1b 100644 --- a/src/vegas_mode/config.py +++ b/src/vegas_mode/config.py @@ -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__) diff --git a/src/vegas_mode/render_pipeline.py b/src/vegas_mode/render_pipeline.py index d2406527..3851a4df 100644 --- a/src/vegas_mode/render_pipeline.py +++ b/src/vegas_mode/render_pipeline.py @@ -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 diff --git a/src/vegas_mode/stream_manager.py b/src/vegas_mode/stream_manager.py index 06082951..85d5abdd 100644 --- a/src/vegas_mode/stream_manager.py +++ b/src/vegas_mode/stream_manager.py @@ -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__) diff --git a/src/web_interface/api_helpers.py b/src/web_interface/api_helpers.py index 740fecda..7ba6567a 100644 --- a/src/web_interface/api_helpers.py +++ b/src/web_interface/api_helpers.py @@ -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( diff --git a/src/web_interface/error_handler.py b/src/web_interface/error_handler.py index 4a3aa31d..c15d373c 100644 --- a/src/web_interface/error_handler.py +++ b/src/web_interface/error_handler.py @@ -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 diff --git a/src/web_interface/validators.py b/src/web_interface/validators.py index a4fb040f..e383ae99 100644 --- a/src/web_interface/validators.py +++ b/src/web_interface/validators.py @@ -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)}" diff --git a/src/wifi_manager.py b/src/wifi_manager.py index 733d5f6c..568d2e50 100644 --- a/src/wifi_manager.py +++ b/src/wifi_manager.py @@ -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 ) diff --git a/test/conftest.py b/test/conftest.py index da076010..5c764b7b 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -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 diff --git a/test/debug_nba_api.py b/test/debug_nba_api.py index 13a4f38c..5840f5e1 100644 --- a/test/debug_nba_api.py +++ b/test/debug_nba_api.py @@ -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 diff --git a/test/plugins/conftest.py b/test/plugins/conftest.py index a6a19c8f..27bb6718 100644 --- a/test/plugins/conftest.py +++ b/test/plugins/conftest.py @@ -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 diff --git a/test/plugins/test_plugin_base.py b/test/plugins/test_plugin_base.py index 15dbc02b..43fe8901 100644 --- a/test/plugins/test_plugin_base.py +++ b/test/plugins/test_plugin_base.py @@ -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 diff --git a/test/plugins/test_visual_rendering.py b/test/plugins/test_visual_rendering.py index 971e0635..3bfffa57 100644 --- a/test/plugins/test_visual_rendering.py +++ b/test/plugins/test_visual_rendering.py @@ -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 diff --git a/test/test_cache_manager.py b/test/test_cache_manager.py index 0666e600..034175e7 100644 --- a/test/test_cache_manager.py +++ b/test/test_cache_manager.py @@ -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 diff --git a/test/test_config_manager.py b/test/test_config_manager.py index e8b748cd..29705f36 100644 --- a/test/test_config_manager.py +++ b/test/test_config_manager.py @@ -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 diff --git a/test/test_config_service.py b/test/test_config_service.py index 8c3a9f6f..a0d601a1 100644 --- a/test/test_config_service.py +++ b/test/test_config_service.py @@ -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 diff --git a/test/test_config_validation_edge_cases.py b/test/test_config_validation_edge_cases.py index 6de4d2ad..040902cd 100644 --- a/test/test_config_validation_edge_cases.py +++ b/test/test_config_validation_edge_cases.py @@ -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 diff --git a/test/test_display_controller.py b/test/test_display_controller.py index 4c662a61..8059a8f4 100644 --- a/test/test_display_controller.py +++ b/test/test_display_controller.py @@ -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,19 +15,12 @@ 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: @@ -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 diff --git a/test/test_display_manager.py b/test/test_display_manager.py index 3cac60bb..9f4406cd 100644 --- a/test/test_display_manager.py +++ b/test/test_display_manager.py @@ -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 diff --git a/test/test_error_aggregator.py b/test/test_error_aggregator.py index 5194fc2a..c1f8e226 100644 --- a/test/test_error_aggregator.py +++ b/test/test_error_aggregator.py @@ -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: diff --git a/test/test_error_handling.py b/test/test_error_handling.py index 9b9f0933..c91e22c3 100644 --- a/test/test_error_handling.py +++ b/test/test_error_handling.py @@ -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: diff --git a/test/test_font_manager.py b/test/test_font_manager.py index 29a544c4..11240724 100644 --- a/test/test_font_manager.py +++ b/test/test_font_manager.py @@ -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 diff --git a/test/test_layout_manager.py b/test/test_layout_manager.py index 43c2236e..c29d2fe4 100644 --- a/test/test_layout_manager.py +++ b/test/test_layout_manager.py @@ -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 diff --git a/test/test_nba_core_functionality.py b/test/test_nba_core_functionality.py index 8566fd34..85112cb4 100644 --- a/test/test_nba_core_functionality.py +++ b/test/test_nba_core_functionality.py @@ -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') diff --git a/test/test_nba_data_structure.py b/test/test_nba_data_structure.py index 2df3f5d6..c4f20768 100644 --- a/test/test_nba_data_structure.py +++ b/test/test_nba_data_structure.py @@ -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') diff --git a/test/test_nba_integration.py b/test/test_nba_integration.py index 462dd3b4..fc3ed3cf 100644 --- a/test/test_nba_integration.py +++ b/test/test_nba_integration.py @@ -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') diff --git a/test/test_nba_leaderboard_fix.py b/test/test_nba_leaderboard_fix.py index 9d04f29a..81e18078 100644 --- a/test/test_nba_leaderboard_fix.py +++ b/test/test_nba_leaderboard_fix.py @@ -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')) diff --git a/test/test_plugin_loader.py b/test/test_plugin_loader.py index 80b48d44..043bf116 100644 --- a/test/test_plugin_loader.py +++ b/test/test_plugin_loader.py @@ -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 diff --git a/test/test_plugin_loading_failures.py b/test/test_plugin_loading_failures.py index af7c0d06..0bb3e0d7 100644 --- a/test/test_plugin_loading_failures.py +++ b/test/test_plugin_loading_failures.py @@ -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 diff --git a/test/test_plugin_system.py b/test/test_plugin_system.py index 605ffada..b78a3156 100644 --- a/test/test_plugin_system.py +++ b/test/test_plugin_system.py @@ -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: diff --git a/test/test_schema_manager.py b/test/test_schema_manager.py index 62933511..3c12ae38 100644 --- a/test/test_schema_manager.py +++ b/test/test_schema_manager.py @@ -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 diff --git a/test/test_text_helper.py b/test/test_text_helper.py index 3a158f24..1e143ddf 100644 --- a/test/test_text_helper.py +++ b/test/test_text_helper.py @@ -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 diff --git a/test/test_web_api.py b/test/test_web_api.py index 8f059a08..8d4064de 100644 --- a/test/test_web_api.py +++ b/test/test_web_api.py @@ -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') diff --git a/test/web_interface/integration/test_plugin_operations.py b/test/web_interface/integration/test_plugin_operations.py index b38bbb2b..35de83cf 100644 --- a/test/web_interface/integration/test_plugin_operations.py +++ b/test/web_interface/integration/test_plugin_operations.py @@ -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 diff --git a/test/web_interface/test_dedup_unique_arrays.py b/test/web_interface/test_dedup_unique_arrays.py index 559dd79a..170f776c 100644 --- a/test/web_interface/test_dedup_unique_arrays.py +++ b/test/web_interface/test_dedup_unique_arrays.py @@ -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 diff --git a/test/web_interface/test_state_reconciliation.py b/test/web_interface/test_state_reconciliation.py index 1fa0de22..85ec656e 100644 --- a/test/web_interface/test_state_reconciliation.py +++ b/test/web_interface/test_state_reconciliation.py @@ -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): diff --git a/web_interface/app.py b/web_interface/app.py index b88df468..57286300 100644 --- a/web_interface/app.py +++ b/web_interface/app.py @@ -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 diff --git a/web_interface/blueprints/api_v3.py b/web_interface/blueprints/api_v3.py index 73181fb0..d3291009 100644 --- a/web_interface/blueprints/api_v3.py +++ b/web_interface/blueprints/api_v3.py @@ -1,4 +1,4 @@ -from flask import Blueprint, request, jsonify, Response, send_from_directory +from flask import Blueprint, request, jsonify, Response import json import os import re @@ -17,10 +17,8 @@ logger = logging.getLogger(__name__) from src.web_interface.api_helpers import success_response, error_response, validate_request_json from src.web_interface.errors import ErrorCode from src.plugin_system.operation_types import OperationType -from src.web_interface.logging_config import log_plugin_operation, log_config_change from src.web_interface.validators import ( - validate_image_url, validate_file_upload, validate_mime_type, - validate_numeric_range, validate_string_length, sanitize_plugin_config + validate_file_upload ) from src.error_aggregator import get_error_aggregator @@ -4129,7 +4127,6 @@ def save_plugin_config(): current_value = array_value # Update for length check below except (ValueError, KeyError, TypeError) as e: logger.debug(f"Failed to convert {prop_key} to array: {e}") - pass # If it's an array, ensure correct types and check minItems if isinstance(current_value, list): diff --git a/web_interface/blueprints/pages_v3.py b/web_interface/blueprints/pages_v3.py index a01f827a..1215b241 100644 --- a/web_interface/blueprints/pages_v3.py +++ b/web_interface/blueprints/pages_v3.py @@ -1,4 +1,5 @@ -from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify +from flask import Blueprint, render_template, flash +from markupsafe import escape import json import logging from pathlib import Path @@ -37,8 +38,6 @@ def index(): secrets_config_json = "{}" main_config_data = {} secrets_config_data = {} - main_config_path = "" - secrets_config_path = "" return render_template('v3/index.html', schedule_config=schedule_config, @@ -97,7 +96,7 @@ def load_plugin_config_partial(plugin_id): try: return _load_plugin_config_partial(plugin_id) except Exception as e: - return f'
Error loading plugin config: {str(e)}
', 500 + return f'
Error loading plugin config: {escape(str(e))}
', 500 def _load_overview_partial(): """Load overview partial with system stats""" @@ -354,7 +353,7 @@ def _load_plugin_config_partial(plugin_id): plugin_info = pages_v3.plugin_manager.get_plugin_info(plugin_id) if not plugin_info: - return f'
Plugin "{plugin_id}" not found
', 404 + return f'
Plugin "{escape(plugin_id)}" not found
', 404 # Get plugin instance (may be None if not loaded) plugin_instance = pages_v3.plugin_manager.get_plugin(plugin_id) @@ -396,8 +395,8 @@ def _load_plugin_config_partial(plugin_id): config['images'] = config.get('images', []) + new_images except Exception as e: print(f"Warning: Could not load metadata for {plugin_id}: {e}") - except Exception: - pass # Will load schema properly below + except Exception as e: # nosec B110 - metadata pre-load is optional; schema loads fully below + logger.debug("Metadata pre-load skipped for plugin %s: %s", plugin_id, e) # Get plugin schema schema = {} @@ -456,7 +455,7 @@ def _load_plugin_config_partial(plugin_id): except Exception as e: import traceback traceback.print_exc() - return f'
Error loading plugin config: {str(e)}
', 500 + return f'
Error loading plugin config: {escape(str(e))}
', 500 def _load_starlark_config_partial(app_id): diff --git a/web_interface/logging_config.py b/web_interface/logging_config.py index ffa948db..70a06c75 100644 --- a/web_interface/logging_config.py +++ b/web_interface/logging_config.py @@ -6,7 +6,7 @@ import logging import json import sys from datetime import datetime -from typing import Any, Dict, Optional +from typing import Optional class JSONFormatter(logging.Formatter): diff --git a/web_interface/requirements.txt b/web_interface/requirements.txt index f0e380ef..dd4a4548 100644 --- a/web_interface/requirements.txt +++ b/web_interface/requirements.txt @@ -3,8 +3,8 @@ # Tested on Raspbian OS 12 (Bookworm) and 13 (Trixie) # Web framework -flask>=3.0.0,<4.0.0 -werkzeug>=3.0.0,<4.0.0 +flask>=3.1.3,<4.0.0 +werkzeug>=3.1.6,<4.0.0 flask-wtf>=1.2.0 # CSRF protection (optional for local-only, but recommended) flask-limiter>=3.5.0 # Rate limiting (prevent accidental abuse) @@ -13,13 +13,13 @@ flask-limiter>=3.5.0 # Rate limiting (prevent accidental abuse) # However, plugins may need websocket support to connect to external services # (e.g., music plugin connecting to YTM Companion server via Socket.IO) # These packages are required for plugin compatibility -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 # Image processing -Pillow>=10.4.0,<12.0.0 +Pillow>=12.2.0,<13.0.0 # System monitoring psutil>=6.0.0,<7.0.0 @@ -32,7 +32,7 @@ freetype-py>=2.5.0,<3.0.0 numpy>=1.24.0 # HTTP requests -requests>=2.32.0,<3.0.0 +requests>=2.33.0,<3.0.0 # Date/time utilities python-dateutil>=2.9.0,<3.0.0 @@ -48,7 +48,7 @@ google-auth-httplib2>=0.2.0,<1.0.0 google-api-python-client>=2.147.0,<3.0.0 # Spotify integration (must match main requirements) -spotipy>=2.24.0,<3.0.0 +spotipy>=2.25.2,<3.0.0 # Text processing (must match main requirements) unidecode>=1.3.8,<2.0.0 diff --git a/web_interface/start.py b/web_interface/start.py index 885bd136..3dc8c03b 100644 --- a/web_interface/start.py +++ b/web_interface/start.py @@ -25,7 +25,7 @@ def get_local_ips(): ) if result.returncode == 0 and result.stdout.strip() == "active": ips.append("192.168.4.1 (AP Mode)") - except Exception: + except Exception: # nosec B110 - AP mode IP detection is non-critical startup info; systemctl may not exist pass # Get IPs from hostname -I @@ -111,7 +111,7 @@ def main(): print("Access the interface at:") for ip in ips: if "AP Mode" in ip: - print(f" - http://192.168.4.1:5000 (AP Mode - connect to LEDMatrix-Setup WiFi)") + print(" - http://192.168.4.1:5000 (AP Mode - connect to LEDMatrix-Setup WiFi)") else: print(f" - http://{ip}:5000") else: diff --git a/web_interface/static/v3/app.js b/web_interface/static/v3/app.js index ad019f10..56b8c985 100644 --- a/web_interface/static/v3/app.js +++ b/web_interface/static/v3/app.js @@ -1,3 +1,4 @@ +/* global showNotification, updateSystemStats */ // LED Matrix v3 JavaScript // Additional helpers for HTMX and Alpine.js integration @@ -44,7 +45,7 @@ document.body.addEventListener('htmx:afterRequest', function(event) { if (data.message) { showNotification(data.message, data.status || 'info'); } - } catch (e) { + } catch { // Not JSON, ignore } } @@ -57,15 +58,14 @@ window.reconnectSSE = function() { window.statsSource = new EventSource('/api/v3/stream/stats'); window.statsSource.onmessage = function(event) { const data = JSON.parse(event.data); - updateSystemStats(data); + if (typeof updateSystemStats === 'function') updateSystemStats(data); }; } if (window.displaySource) { window.displaySource.close(); window.displaySource = new EventSource('/api/v3/stream/display'); - window.displaySource.onmessage = function(event) { - const data = JSON.parse(event.data); + window.displaySource.onmessage = function() { // Handle display updates }; } diff --git a/web_interface/static/v3/js/htmx-json-enc.js b/web_interface/static/v3/js/htmx-json-enc.js index db0aa36f..5e2dcb51 100644 --- a/web_interface/static/v3/js/htmx-json-enc.js +++ b/web_interface/static/v3/js/htmx-json-enc.js @@ -1,3 +1,4 @@ +/* global htmx */ htmx.defineExtension('json-enc', { onEvent: function (name, evt) { if (name === "htmx:configRequest") { diff --git a/web_interface/static/v3/js/htmx-sse.js b/web_interface/static/v3/js/htmx-sse.js index 943d80ac..49fe1b8f 100644 --- a/web_interface/static/v3/js/htmx-sse.js +++ b/web_interface/static/v3/js/htmx-sse.js @@ -1,3 +1,4 @@ +/* global htmx */ /* Server Sent Events Extension ============================ diff --git a/web_interface/static/v3/js/plugins/api_client.js b/web_interface/static/v3/js/plugins/api_client.js index f442dcd8..427cc1e0 100644 --- a/web_interface/static/v3/js/plugins/api_client.js +++ b/web_interface/static/v3/js/plugins/api_client.js @@ -40,7 +40,7 @@ const RequestThrottler = { // Create throttled request with abort support let abortController = null; const promise = new Promise((resolve, reject) => { - const timeoutId = setTimeout(async () => { + setTimeout(async () => { try { const result = await fn(); // Cache successful GET requests diff --git a/web_interface/static/v3/js/utils/error_handler.js b/web_interface/static/v3/js/utils/error_handler.js index d6f297f3..73794741 100644 --- a/web_interface/static/v3/js/utils/error_handler.js +++ b/web_interface/static/v3/js/utils/error_handler.js @@ -1,6 +1,7 @@ +/* global showNotification */ /** * Frontend error handling utilities. - * + * * Provides user-friendly error formatting and display with enhanced UI. */ diff --git a/web_interface/static/v3/js/widgets/array-table.js b/web_interface/static/v3/js/widgets/array-table.js index 9297c5c3..d0fcf617 100644 --- a/web_interface/static/v3/js/widgets/array-table.js +++ b/web_interface/static/v3/js/widgets/array-table.js @@ -179,7 +179,7 @@ const removeButton = document.createElement('button'); removeButton.type = 'button'; removeButton.className = 'text-red-600 hover:text-red-800 px-2 py-1'; - removeButton.onclick = function() { removeArrayTableRow(this); }; + removeButton.onclick = function() { window.removeArrayTableRow(this); }; const removeIcon = document.createElement('i'); removeIcon.className = 'fas fa-trash'; removeButton.appendChild(removeIcon); diff --git a/web_interface/static/v3/js/widgets/checkbox-group.js b/web_interface/static/v3/js/widgets/checkbox-group.js index 837b3590..cbf02ad4 100644 --- a/web_interface/static/v3/js/widgets/checkbox-group.js +++ b/web_interface/static/v3/js/widgets/checkbox-group.js @@ -75,7 +75,7 @@ }); // Update hidden input - updateCheckboxGroupData(fieldId); + window.updateCheckboxGroupData(fieldId); }, handlers: { diff --git a/web_interface/static/v3/js/widgets/custom-feeds.js b/web_interface/static/v3/js/widgets/custom-feeds.js index 1cd4b063..b50dd37c 100644 --- a/web_interface/static/v3/js/widgets/custom-feeds.js +++ b/web_interface/static/v3/js/widgets/custom-feeds.js @@ -142,7 +142,7 @@ fileInput.dataset.index = String(index); fileInput.addEventListener('change', function(e) { const idx = parseInt(e.target.dataset.index || '0', 10); - handleCustomFeedLogoUpload(e, fieldId, idx, pluginId, fullKey); + window.handleCustomFeedLogoUpload(e, fieldId, idx, pluginId, fullKey); }); const uploadButton = document.createElement('button'); @@ -207,7 +207,7 @@ removeButton.type = 'button'; removeButton.className = 'text-red-600 hover:text-red-800 px-2 py-1'; removeButton.addEventListener('click', function() { - removeCustomFeedRow(this); + window.removeCustomFeedRow(this); }); const removeIcon = document.createElement('i'); removeIcon.className = 'fas fa-trash'; @@ -290,7 +290,7 @@ fileInput.dataset.index = String(newIndex); fileInput.addEventListener('change', function(e) { const idx = parseInt(e.target.dataset.index || '0', 10); - handleCustomFeedLogoUpload(e, fieldId, idx, pluginId, fullKey); + window.handleCustomFeedLogoUpload(e, fieldId, idx, pluginId, fullKey); }); const uploadButton = document.createElement('button'); @@ -364,7 +364,6 @@ // Re-index remaining rows const rows = tbody.querySelectorAll('.custom-feed-row'); rows.forEach((r, index) => { - const oldIndex = r.getAttribute('data-index'); r.setAttribute('data-index', index); // Update all input names with new index r.querySelectorAll('input, button').forEach(input => { @@ -449,7 +448,7 @@ fileInput.dataset.index = String(index); fileInput.addEventListener('change', function(e) { const idx = parseInt(e.target.dataset.index || '0', 10); - handleCustomFeedLogoUpload(e, fieldId, idx, pluginId, fullKey); + window.handleCustomFeedLogoUpload(e, fieldId, idx, pluginId, fullKey); }); // Create upload button diff --git a/web_interface/static/v3/js/widgets/day-selector.js b/web_interface/static/v3/js/widgets/day-selector.js index 1280f180..7ce18598 100644 --- a/web_interface/static/v3/js/widgets/day-selector.js +++ b/web_interface/static/v3/js/widgets/day-selector.js @@ -91,7 +91,7 @@ const xOptions = config['x-options'] || config['x_options'] || {}; const requestedFormat = xOptions.format || 'long'; // Validate format exists in DAY_LABELS, default to 'long' if not - const format = DAY_LABELS.hasOwnProperty(requestedFormat) ? requestedFormat : 'long'; + const format = Object.prototype.hasOwnProperty.call(DAY_LABELS, requestedFormat) ? requestedFormat : 'long'; const layout = xOptions.layout || 'horizontal'; const showSelectAll = xOptions.selectAll !== false; diff --git a/web_interface/static/v3/js/widgets/file-upload.js b/web_interface/static/v3/js/widgets/file-upload.js index bc2cfa54..23e40886 100644 --- a/web_interface/static/v3/js/widgets/file-upload.js +++ b/web_interface/static/v3/js/widgets/file-upload.js @@ -294,7 +294,7 @@ if (fileType !== 'json') { formData.append('plugin_id', pluginId); } - validFiles.forEach(file => formData.append('files', file)); + validFiles.forEach(file => { formData.append('files', file); }); try { const response = await fetch(customUploadEndpoint, { @@ -741,7 +741,7 @@ try { const date = new Date(dateString); return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); - } catch (e) { + } catch { return dateString; } }; diff --git a/web_interface/static/v3/js/widgets/notification.js b/web_interface/static/v3/js/widgets/notification.js index 16b4a79e..5fa43f0d 100644 --- a/web_interface/static/v3/js/widgets/notification.js +++ b/web_interface/static/v3/js/widgets/notification.js @@ -199,7 +199,7 @@ */ function clearAll() { const ids = [...activeNotifications]; - ids.forEach(id => removeNotification(id, true)); + ids.forEach(id => { removeNotification(id, true); }); } // Register the widget diff --git a/web_interface/static/v3/js/widgets/schedule-picker.js b/web_interface/static/v3/js/widgets/schedule-picker.js index 80cc058f..df5ff550 100644 --- a/web_interface/static/v3/js/widgets/schedule-picker.js +++ b/web_interface/static/v3/js/widgets/schedule-picker.js @@ -174,7 +174,6 @@ const xOptions = config['x-options'] || config['x_options'] || {}; const showModeToggle = xOptions.showModeToggle !== false; const showEnableToggle = xOptions.showEnableToggle !== false; - const compactMode = xOptions.compactMode === true; const schedule = normalizeSchedule(value); diff --git a/web_interface/static/v3/js/widgets/select-dropdown.js b/web_interface/static/v3/js/widgets/select-dropdown.js index a62f895a..cdbc5e68 100644 --- a/web_interface/static/v3/js/widgets/select-dropdown.js +++ b/web_interface/static/v3/js/widgets/select-dropdown.js @@ -69,7 +69,6 @@ const enumValues = config.enum || xOptions.options || []; const placeholder = xOptions.placeholder || 'Select...'; const labels = xOptions.labels || {}; - const icons = xOptions.icons || {}; const disabled = xOptions.disabled === true; const required = xOptions.required === true; diff --git a/web_interface/static/v3/js/widgets/timezone-selector.js b/web_interface/static/v3/js/widgets/timezone-selector.js index f715d365..b567db8b 100644 --- a/web_interface/static/v3/js/widgets/timezone-selector.js +++ b/web_interface/static/v3/js/widgets/timezone-selector.js @@ -358,7 +358,7 @@ }); timeEl.textContent = formatter.format(now); previewEl.classList.remove('hidden'); - } catch (e) { + } catch { previewEl.classList.add('hidden'); } }