Compare commits

..
Author SHA1 Message Date
ChuckandClaude Sonnet 4.6 45bf5db2b1 chore: remove march-madness from bundled plugin-repos
March Madness is now available in the ledmatrix-plugins monorepo store
(ChuckBuilds/ledmatrix-plugins/plugins/march-madness) and should be
installed via the Plugin Store like any other plugin.

Removing the bundled copy so new installs don't automatically include it.
Existing users keep their installed version until they choose to uninstall.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 19:58:10 -04:00
136 changed files with 1766 additions and 15971 deletions
-7
View File
@@ -1,7 +0,0 @@
---
exclude_paths:
- "plugin-repos/**"
- "plugins/**"
- "assets/**"
- "test/**"
- "scripts/debug/**"
-44
View File
@@ -1,44 +0,0 @@
name: Claude Code Review
on:
pull_request:
types: [opened, synchronize, ready_for_review, reopened]
# Optional: Only run on specific file changes
# paths:
# - "src/**/*.ts"
# - "src/**/*.tsx"
# - "src/**/*.js"
# - "src/**/*.jsx"
jobs:
claude-review:
# Optional: Filter by PR author
# if: |
# github.event.pull_request.user.login == 'external-contributor' ||
# github.event.pull_request.user.login == 'new-developer' ||
# github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
issues: read
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Run Claude Code Review
id: claude-review
uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
plugin_marketplaces: 'https://github.com/anthropics/claude-code.git'
plugins: 'code-review@claude-code-plugins'
prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}'
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
# or https://code.claude.com/docs/en/cli-reference for available options
-50
View File
@@ -1,50 +0,0 @@
name: Claude Code
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
issues:
types: [opened, assigned]
pull_request_review:
types: [submitted]
jobs:
claude:
if: |
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
issues: read
id-token: write
actions: read # Required for Claude to read CI results on PRs
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Run Claude Code
id: claude
uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
# This is an optional setting that allows Claude to read CI results on PRs
additional_permissions: |
actions: read
# Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
# prompt: 'Update the pull request description to include a summary of changes.'
# Optional: Add claude_args to customize behavior and configuration
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
# or https://code.claude.com/docs/en/cli-reference for available options
# claude_args: '--allowed-tools Bash(gh pr *)'
-33
View File
@@ -1,33 +0,0 @@
name: Tests
on:
pull_request:
push:
branches: [main]
jobs:
plugin-safety:
name: Plugin safety harness + unit tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0
with:
python-version: "3.12"
cache: pip
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt -r requirements-test.txt
pip install RGBMatrixEmulator
- name: Run harness + visual rendering tests
run: |
pytest --no-cov \
test/plugins/test_harness.py \
test/plugins/test_visual_rendering.py \
test/plugins/test_plugin_matrix.py
-1
View File
@@ -8,7 +8,6 @@ config/config_secrets.json
config/config.json
config/config.json.backup
config/wifi_config.json
config/uninstalled_plugins.json
credentials.json
token.pickle
+3 -13
View File
@@ -1,10 +1,5 @@
# LEDMatrix
[![License](https://img.shields.io/badge/license-GPL--3.0-green)](LICENSE)
[![Discord](https://img.shields.io/badge/Discord-community-5865F2?logo=discord&logoColor=white)](https://discord.gg/RdrC37rEag)
[![GitHub Stars](https://img.shields.io/github/stars/ChuckBuilds/ledmatrix?style=flat&color=yellow)](https://github.com/ChuckBuilds/ledmatrix)
[![Codacy Badge](https://app.codacy.com/project/badge/Grade/77fc9b446a5948e5b0aed7a7aaeb1bab)](https://app.codacy.com/gh/ChuckBuilds/LEDMatrix/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_grade)
## Welcome to LEDMatrix!
Welcome to the LEDMatrix Project! This open-source project enables you to run an information-rich display on a Raspberry Pi connected to an LED RGB Matrix panel. Whether you want to see your calendar, weather forecasts, sports scores, stock prices, or any other information at a glance, LEDMatrix brings it all together.
@@ -132,15 +127,10 @@ The system supports live, recent, and upcoming game information for multiple spo
| This project can be finnicky! RGB LED Matrix displays are not built the same or to a high-quality standard. We have seen many displays arrive dead or partially working in our discord. Please purchase from a reputable vendor. |
### Raspberry Pi
- Raspberry Pi Zero's don't have enough processing power for this project.
- **Raspberry Pi 3B, 4, or 5**
- Raspberry Pi Zero's don't have enough processing power for this project and the Pi 5 is unsupported due to new GPIO output.
- **Raspberry Pi 3B or 4 (NOT RPi 5!)**
[Amazon Affiliate Link Raspberry Pi 4 4GB RAM](https://amzn.to/4dJixuX)
[Amazon Affiliate Link Raspberry Pi 4 8GB RAM](https://amzn.to/4qbqY7F)
- **Pi 5 users**: the installer automatically detects Pi 5 and builds the `rpi-rgb-led-matrix` library with RP1 support. If you previously installed on a Pi 4 and migrated the SD card, or if you see `mmap` errors in the logs, force a fresh library build:
```bash
sudo RPI_RGB_FORCE_REBUILD=1 ./first_time_install.sh
```
- Pi 5 config: leave `rp1_rio` at `0` (PIO mode, default) and set `gpio_slowdown` to `1` or `2`.
### RGB Matrix Bonnet / HAT
@@ -592,7 +582,7 @@ These settings control runtime behavior and GPIO timing:
- **Critical setting**: Must match your Raspberry Pi model for stability
- **Raspberry Pi 3**: Use 3
- **Raspberry Pi 4**: Use 4
- **Raspberry Pi 5**: Use 12 in PIO mode (`rp1_rio: 0`, the default); start with `1` and increase if you see flickering
- **Raspberry Pi 5**: Use 5 (or higher if needed)
- **Raspberry Pi Zero/1**: Use 1-2
- Incorrect values can cause display corruption, flickering, or system instability
- If you experience issues, try adjusting this value up or down by 1
Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

After

Width:  |  Height:  |  Size: 111 KiB

-5
View File
@@ -115,11 +115,6 @@
"gpio_slowdown": 3,
"rp1_rio": 0
},
"double_sided": {
"enabled": false,
"copies": 2,
"axis": "horizontal"
},
"display_durations": {},
"use_short_date_format": true,
"vegas_scroll": {
-136
View File
@@ -1,136 +0,0 @@
# Plugin Safety Harness
Renders a plugin across **every declared screen (mode)** and **a spread of
matrix sizes**, and fails if any combination crashes, draws past the panel edge,
or — for plugins that ship golden images — drifts visually. The goal: change a
plugin without breaking a size or screen you didn't think to test.
## Sizes: a sample, not a fixed list
There is **no fixed set of supported panel sizes** — an RGB matrix build can be
any width/height and configuration (square, rectangle, 2×2, 4×4, 8×2, long
strips, tall stacks). Plugins are expected to read dimensions dynamically
(`self.display_manager.matrix.width/height`) and lay themselves out
accordingly, so a hardcoded coordinate or unscaled font shows up as a failure
here.
The harness therefore renders against a **representative sample** that spans the
axes of variation (`DEFAULT_TEST_SIZES` in `src/plugin_system/testing/sizes.py`),
not an authoritative list:
Each module is 64×32; entries are real panel-grid arrangements (cols × rows):
| Size | Grid | Why it's in the sample |
|---------|------|--------------------------------------------|
| 64×32 | 1×1 | single panel — tightest common rectangle |
| 128×32 | 2×1 | the baseline most plugins are tuned for |
| 64×64 | 1×2 | stacked — tall-narrow centering |
| 128×64 | 2×2 | block — icon scaling / vertical centering |
| 256×32 | 4×1 | long strip — wide horizontal layout |
| 128×96 | 2×3 | tall — vertical overflow |
| 256×128 | 4×4 | large block — both dimensions big at once |
**Override the sizes entirely** to test your actual hardware (or any shape):
```bash
# CLI — one-off:
python scripts/check_plugin.py --plugin clock-simple --sizes 8x16,64x64,256x32
# pytest — force every plugin onto your panel(s):
LEDMATRIX_TEST_SIZES="8x16,128x128" pytest test/plugins/test_plugin_matrix.py
# Per-plugin — declare the shapes a plugin targets in its test/harness.json:
# { "sizes": [[8, 16], [64, 64]] }
```
Precedence: `LEDMATRIX_TEST_SIZES` env (global) → per-plugin `harness.json`
`sizes` → the default sample. Bounds checking adapts to whatever sizes a run
uses — the backing canvas is padded out to the **largest** panel in the run, so
a coordinate meant for a big build is still caught when rendering a small one.
## Quick start
```bash
# Functional + bounds check across all sizes/screens:
python scripts/check_plugin.py --plugin clock-simple
# Every discovered plugin:
python scripts/check_plugin.py --all
# Dump PNGs to eyeball each size/screen:
python scripts/check_plugin.py --plugin ledmatrix-weather --out-dir /tmp/preview
```
Exit code is non-zero if any `(plugin, size, screen)` fails. Plugins are
discovered in `plugin-repos/` and `plugins/` (override with `--plugin-dir`).
## What it checks (Phase 1 — always on)
1. **Loads** and builds its mode list.
2. **Renders every screen** at every size without raising. `update()` may fail
(no network in CI) and is tolerated; a crash in `display()` is a failure —
`display()` must handle the no-data state.
3. **Bounds**: nothing is drawn past the right/bottom edge. Implemented by
`BoundsCheckingDisplayManager`, which backs the declared panel with an
oversized canvas and flags any pixels that land in the margin. (Left/top
overflow at negative coordinates and BDF text are not flagged — golden images
cover those.)
## Golden images (Phase 2 — opt-in per plugin)
A plugin opts in by committing reference PNGs and (usually) a small harness spec:
```
plugins/<id>/test/harness.json # how to render deterministically
plugins/<id>/test/fixtures/mock.json # optional cached data
plugins/<id>/test/golden/<WxH>/<mode>.png
```
`test/harness.json` keys (all optional):
```json
{
"config": { "timezone": "UTC" },
"mock_data": "fixtures/mock.json",
"freeze_time": "2025-08-01 15:25:00",
"skip_update": false,
"sizes": [[128, 32], [128, 64]]
}
```
Generate / refresh goldens after an intentional visual change, then review the
diff before committing:
```bash
python scripts/check_plugin.py --plugin clock-simple --update-golden \
--config '{"timezone":"UTC"}' --freeze-time "2025-08-01 15:25:00"
```
Comparison is exact by default (`compare_images` in `harness.py` accepts a
tolerance for known anti-aliasing noise). Determinism requires a pinned Pillow
and the bundled fonts — keep both stable when regenerating goldens.
## Tests & CI
- `test/plugins/test_harness.py` — unit tests for bounds detection, image
comparison, and mode enumeration (run anywhere).
- `test/plugins/test_plugin_matrix.py` — parametrized over discovered plugins ×
sizes × screens; honors each plugin's `test/harness.json` and goldens. Skips
when no plugins are present (e.g. a fresh core checkout); set
`LEDMATRIX_REQUIRE_PLUGINS=1` in a pipeline where plugins must be present to
turn an empty discovery into a hard failure instead. Point it at the monorepo
with `LEDMATRIX_PLUGINS_DIR=/path/to/ledmatrix-plugins/plugins`.
- `.github/workflows/test.yml` — runs the harness + visual tests on every PR.
The plugin monorepo has its own `Plugin Safety` workflow that runs this harness
against changed plugins on every PR.
## Developer workflow
1. Change the plugin on a branch.
2. `python scripts/check_plugin.py --plugin <id> --out-dir /tmp/preview` and
eyeball the PNGs.
3. Intentional visual change? `--update-golden`, review diffs, commit goldens.
4. (Monorepo) bump `manifest.json` version and let the pre-commit hook sync
`plugins.json`.
5. Push — CI re-runs the harness across all sizes and gates the PR.
-92
View File
@@ -10,98 +10,6 @@ The LEDMatrix Widget Registry system allows plugins to use reusable UI component
## Available Core Widgets
### Plugin File Manager Widget (`plugin-file-manager`)
Full inline file management UI for plugins that manage files via the `web_ui_actions` system. Renders a card grid, upload zone, create/delete modals, and an entry table editor — entirely inline, no iframe.
`plugin_id` is **automatically injected** from template context. File operations call `/api/v3/plugins/action` immediately on user action; no Save Configuration needed.
**Schema Configuration:**
```json
{
"file_manager": {
"type": "null",
"title": "Data Files",
"x-widget": "plugin-file-manager",
"x-widget-config": {
"actions": {
"list": "list-files",
"get": "get-file",
"save": "save-file",
"upload": "upload-file",
"delete": "delete-file",
"create": "create-file",
"toggle": "toggle-category"
},
"upload_hint": "JSON files with day numbers 1365 as keys",
"directory_label": "my_data/",
"create_fields": [
{ "key": "category_name", "label": "Category Name",
"placeholder": "e.g., my_words", "pattern": "^[a-z0-9_]+$",
"hint": "Lowercase letters, numbers, underscores" },
{ "key": "display_name", "label": "Display Name",
"placeholder": "e.g., My Words", "hint": "Optional" }
]
}
}
}
```
**`list` is required** — the widget calls it on render to populate the file grid; omitting it leaves the widget stuck in a loading state. All other actions are optional — omit any key to hide its UI element (e.g., no `create` = no New File button, no `toggle` = no enable/disable switch).
The edit view auto-detects whether file content is tabular (object-of-objects with uniform keys) and shows a paginated table editor with inline cells. Otherwise falls back to a JSON textarea.
**Used by:** of-the-day
---
### Time Picker Widget (`time-picker`)
Single time selection using the browser's native time input. Returns a string in `HH:MM` (24-hour) format. Generic — works in any plugin without configuration.
**Schema Configuration:**
```json
{
"target_time": {
"type": "string",
"x-widget": "time-picker",
"default": "00:00",
"x-options": {
"placeholder": "Select time",
"clearable": true
}
}
}
```
**Used by:** countdown
---
### File Upload Single Widget (`file-upload-single`)
Single-image upload for string fields. Uploads to the plugin's asset folder (`assets/plugins/<plugin_id>/uploads/`) and sets the string field value to the returned relative path. Shows a thumbnail preview and a clear button. The `plugin_id` is **automatically injected** from the template context — no need to specify it in the schema.
**Schema Configuration:**
```json
{
"image_path": {
"type": "string",
"x-widget": "file-upload-single",
"x-upload-config": {
"allowed_types": ["image/png", "image/jpeg", "image/bmp", "image/gif"],
"max_size_mb": 5
}
}
}
```
Note: Unlike `file-upload` (array-level), this widget is for a single `string` field. It is ideal for per-item images inside `array-table` rows.
**Used by:** countdown
---
### File Upload Widget (`file-upload`)
Upload and manage image files with drag-and-drop support, preview, delete, and scheduling.
+23 -131
View File
@@ -15,8 +15,8 @@ on_error() {
echo "✗ An error occurred during: $CURRENT_STEP (line $line_no, exit $exit_code)" >&2
if [ -n "${LOG_FILE:-}" ]; then
echo "See the log for details: $LOG_FILE" >&2
echo "-- Last 100 lines from log --" >&2
tail -n 100 "$LOG_FILE" >&2 || true
echo "-- Last 50 lines from log --" >&2
tail -n 50 "$LOG_FILE" >&2 || true
fi
echo "\nCommon fixes:" >&2
echo "- Ensure the Pi is online (try: ping -c1 8.8.8.8)." >&2
@@ -36,17 +36,9 @@ if [ -r /proc/device-tree/model ]; then
DEVICE_MODEL=$(tr -d '\0' </proc/device-tree/model)
echo "Detected device: $DEVICE_MODEL"
else
DEVICE_MODEL=""
echo "⚠ Could not detect Raspberry Pi model (continuing anyway)"
fi
# Detect Pi 5 for hardware-specific install decisions (RP1 library verification)
IS_PI5=0
if echo "${DEVICE_MODEL:-}" | grep -qi "Raspberry Pi 5"; then
IS_PI5=1
echo "Raspberry Pi 5 detected — will verify RP1 library support."
fi
# Check OS version - must be Raspberry Pi OS Lite (Trixie)
echo ""
echo "Checking operating system requirements..."
@@ -202,33 +194,8 @@ retry() {
done
}
# Wait for another apt/dpkg process (commonly unattended-upgrades running
# shortly after first boot) to release its lock before we try apt ourselves.
# Without this, apt_update/apt_install can fail outright in the first couple
# minutes after a fresh Pi OS boot with a generic "Command failed after 3
# attempts" error.
wait_for_apt_lock() {
command -v flock >/dev/null 2>&1 || return 0
local lock_file="/var/lib/dpkg/lock-frontend"
local max_wait=180
local waited=0
local printed=0
while ! flock -n "$lock_file" -c true 2>/dev/null; do
if [ "$printed" -eq 0 ]; then
echo "⚠ Waiting for another apt/dpkg process to finish (e.g. unattended-upgrades on first boot)..."
printed=1
fi
if [ "$waited" -ge "$max_wait" ]; then
echo "⚠ Still waiting after ${max_wait}s; proceeding anyway."
break
fi
sleep 5
waited=$((waited+5))
done
}
apt_update() { wait_for_apt_lock; retry apt-get -o DPkg::Lock::Timeout=180 update; }
apt_install() { wait_for_apt_lock; retry apt-get -o DPkg::Lock::Timeout=180 install -y "$@"; }
apt_update() { retry apt update; }
apt_install() { retry apt install -y "$@"; }
apt_remove() { apt-get remove -y "$@" || true; }
check_network() {
@@ -247,22 +214,6 @@ check_network() {
exit 1
}
check_disk_space() {
command -v df >/dev/null 2>&1 || return 0
local available_mb
available_mb=$(df -m "$PROJECT_ROOT_DIR" | awk 'NR==2{print $4}')
available_mb=${available_mb:-0}
if [ "$available_mb" -lt 500 ]; then
echo "✗ ERROR: Insufficient disk space: ${available_mb}MB available (need at least 500MB)"
echo " Free up space first, e.g.: sudo apt clean && sudo apt autoremove"
exit 1
elif [ "$available_mb" -lt 1024 ]; then
echo "⚠ Limited disk space: ${available_mb}MB available (recommend at least 1GB for the rpi-rgb-led-matrix build in Step 6)"
else
echo "✓ Disk space sufficient: ${available_mb}MB available"
fi
}
echo ""
echo "This script will perform the following steps:"
echo "1. Install system dependencies"
@@ -312,9 +263,8 @@ CURRENT_STEP="Install system dependencies"
echo "Step 1: Installing system dependencies..."
echo "----------------------------------------"
# Pre-flight checks before APT operations
# Ensure network is available before APT operations
check_network
check_disk_space
# Update package list
apt_update
@@ -726,11 +676,7 @@ if [ -f "$PROJECT_ROOT_DIR/requirements.txt" ]; then
if command -v timeout >/dev/null 2>&1; then
# Use timeout if available (10 minutes = 600 seconds)
# --ignore-installed: apt-managed packages (e.g. python3-requests)
# ship no pip RECORD file, so upgrading them would otherwise abort
# with "uninstall-no-record-file"; this lays the new version down
# alongside instead of trying to uninstall the apt copy first.
if timeout 600 python3 -m pip install --break-system-packages --no-cache-dir --prefer-binary --ignore-installed --verbose "$line" > "$INSTALL_OUTPUT" 2>&1; then
if timeout 600 python3 -m pip install --break-system-packages --no-cache-dir --prefer-binary --verbose "$line" > "$INSTALL_OUTPUT" 2>&1; then
INSTALL_SUCCESS=true
else
EXIT_CODE=$?
@@ -738,7 +684,7 @@ if [ -f "$PROJECT_ROOT_DIR/requirements.txt" ]; then
echo "✗ Timeout (10 minutes) installing: $line"
echo " This package may require building from source, which can be slow on Raspberry Pi."
echo " You can try installing it manually later with:"
echo " python3 -m pip install --break-system-packages --no-cache-dir --prefer-binary --ignore-installed --verbose '$line'"
echo " python3 -m pip install --break-system-packages --no-cache-dir --prefer-binary --verbose '$line'"
else
echo "✗ Failed to install: $line (exit code: $EXIT_CODE)"
fi
@@ -746,7 +692,7 @@ if [ -f "$PROJECT_ROOT_DIR/requirements.txt" ]; then
else
# No timeout command available, install without timeout
echo " Note: timeout command not available, installation may take a while..."
if python3 -m pip install --break-system-packages --no-cache-dir --prefer-binary --ignore-installed --verbose "$line" > "$INSTALL_OUTPUT" 2>&1; then
if python3 -m pip install --break-system-packages --no-cache-dir --prefer-binary --verbose "$line" > "$INSTALL_OUTPUT" 2>&1; then
INSTALL_SUCCESS=true
else
EXIT_CODE=$?
@@ -798,7 +744,7 @@ if [ -f "$PROJECT_ROOT_DIR/requirements.txt" ]; then
echo " 1. Ensure you have enough disk space: df -h"
echo " 2. Check available memory: free -h"
echo " 3. Try installing failed packages individually with verbose output:"
echo " python3 -m pip install --break-system-packages --no-cache-dir --prefer-binary --ignore-installed --verbose <package>"
echo " python3 -m pip install --break-system-packages --no-cache-dir --prefer-binary --verbose <package>"
echo " 4. For packages that build from source (like numpy), consider:"
echo " - Installing pre-built wheels: python3 -m pip install --only-binary :all: <package>"
echo " - Or installing via apt if available: sudo apt install python3-<package>"
@@ -820,10 +766,7 @@ echo ""
# Install web interface dependencies
echo "Installing web interface dependencies..."
if [ -f "$PROJECT_ROOT_DIR/web_interface/requirements.txt" ]; then
# --ignore-installed: apt-managed packages (e.g. python3-requests) ship no
# pip RECORD file, so upgrading them to the version pinned here would
# otherwise abort the whole install with "uninstall-no-record-file".
if python3 -m pip install --break-system-packages --prefer-binary --ignore-installed -r "$PROJECT_ROOT_DIR/web_interface/requirements.txt"; then
if python3 -m pip install --break-system-packages --prefer-binary -r "$PROJECT_ROOT_DIR/web_interface/requirements.txt"; then
echo "✓ Web interface dependencies installed"
# Create marker file to indicate dependencies are installed
touch "$PROJECT_ROOT_DIR/.web_deps_installed"
@@ -840,54 +783,29 @@ CURRENT_STEP="Build and install rpi-rgb-led-matrix"
echo "Step 6: Building and installing rpi-rgb-led-matrix..."
echo "-----------------------------------------------------"
# On Pi 5, also check that the installed library has rp1_rio support.
# A library built before Pi 5 support was added imports fine but maps to the
# Pi 3 peripheral bus address (0x3f000000) instead of the RP1 chip at runtime.
_HAS_RP1=0
if python3 -c 'from rgbmatrix import RGBMatrixOptions; assert hasattr(RGBMatrixOptions(), "rp1_rio")' >/dev/null 2>&1; then
_HAS_RP1=1
fi
_SKIP_BUILD=0
# If already installed and not forcing rebuild, skip expensive build
if python3 -c 'from rgbmatrix import RGBMatrix, RGBMatrixOptions' >/dev/null 2>&1 && [ "${RPI_RGB_FORCE_REBUILD:-0}" != "1" ]; then
if [ "$IS_PI5" = "1" ] && [ "$_HAS_RP1" = "0" ]; then
echo "⚠ Pi 5 detected: installed rgbmatrix lacks rp1_rio support (older build)."
echo " Forcing rebuild to get Pi 5 RP1 support..."
else
_SKIP_BUILD=1
fi
fi
if [ "$_SKIP_BUILD" = "1" ]; then
_skip_suffix=""
if [ "$IS_PI5" = "1" ]; then _skip_suffix=" with Pi 5 RP1 support"; fi
echo "rgbmatrix already installed${_skip_suffix}; skipping build (set RPI_RGB_FORCE_REBUILD=1 to force rebuild)."
echo "rgbmatrix Python package already available; skipping build (set RPI_RGB_FORCE_REBUILD=1 to force rebuild)."
else
# Ensure rpi-rgb-led-matrix submodule is initialized
# Wrapper used with retry(): removes any partial clone dir before each attempt
# so git clone doesn't fail with "destination path already exists".
_clone_rpi_rgb() {
rm -rf "$PROJECT_ROOT_DIR/rpi-rgb-led-matrix-master"
git clone https://github.com/hzeller/rpi-rgb-led-matrix.git rpi-rgb-led-matrix-master
}
if [ ! -d "$PROJECT_ROOT_DIR/rpi-rgb-led-matrix-master" ]; then
echo "rpi-rgb-led-matrix-master not found. Initializing git submodule..."
cd "$PROJECT_ROOT_DIR"
# Try to initialize submodule if .gitmodules exists
if [ -f "$PROJECT_ROOT_DIR/.gitmodules" ] && grep -q "rpi-rgb-led-matrix" "$PROJECT_ROOT_DIR/.gitmodules"; then
echo "Initializing rpi-rgb-led-matrix submodule..."
if ! retry git submodule update --init --recursive rpi-rgb-led-matrix-master; then
if ! git submodule update --init --recursive rpi-rgb-led-matrix-master 2>&1; then
echo "⚠ Submodule init failed, cloning directly from GitHub..."
retry _clone_rpi_rgb
git clone https://github.com/hzeller/rpi-rgb-led-matrix.git rpi-rgb-led-matrix-master
fi
else
# Fallback: clone directly if submodule not configured
echo "Submodule not configured, cloning directly from GitHub..."
retry _clone_rpi_rgb
git clone https://github.com/hzeller/rpi-rgb-led-matrix.git rpi-rgb-led-matrix-master
fi
fi
# Build and install rpi-rgb-led-matrix Python bindings
if [ -d "$PROJECT_ROOT_DIR/rpi-rgb-led-matrix-master" ]; then
# Check if submodule is properly initialized (not empty)
@@ -896,34 +814,23 @@ else
cd "$PROJECT_ROOT_DIR"
rm -rf rpi-rgb-led-matrix-master
if [ -f "$PROJECT_ROOT_DIR/.gitmodules" ] && grep -q "rpi-rgb-led-matrix" "$PROJECT_ROOT_DIR/.gitmodules"; then
retry git submodule update --init --recursive rpi-rgb-led-matrix-master
git submodule update --init --recursive rpi-rgb-led-matrix-master
else
retry _clone_rpi_rgb
git clone https://github.com/hzeller/rpi-rgb-led-matrix.git rpi-rgb-led-matrix-master
fi
fi
pushd "$PROJECT_ROOT_DIR/rpi-rgb-led-matrix-master" >/dev/null
echo "Installing rpi-rgb-led-matrix Python package (scikit-build-core + cmake)..."
echo " Build deps required: python-dev-is-python3 cmake"
echo " This compiles C++ — may take 2-5 minutes on Pi 4/5..."
BUILD_OUTPUT=$(mktemp)
BUILD_SUCCESS=false
if python3 -m pip install --break-system-packages . > "$BUILD_OUTPUT" 2>&1; then
BUILD_SUCCESS=true
fi
cat "$BUILD_OUTPUT" >> "$LOG_FILE"
if [ "$BUILD_SUCCESS" != true ]; then
if ! python3 -m pip install --break-system-packages .; then
echo "✗ Failed to install rpi-rgb-led-matrix Python package"
echo " Ensure build tools are installed:"
echo " sudo apt install -y python-dev-is-python3 cmake build-essential"
echo ""
echo "-- Last 50 lines of build output --"
tail -n 50 "$BUILD_OUTPUT"
rm -f "$BUILD_OUTPUT"
popd >/dev/null
exit 1
fi
rm -f "$BUILD_OUTPUT"
popd >/dev/null
else
echo "✗ rpi-rgb-led-matrix-master directory not found at $PROJECT_ROOT_DIR"
@@ -945,17 +852,6 @@ except Exception as e:
PY
then
echo "✓ rpi-rgb-led-matrix installed and verified"
# Pi 5: confirm the freshly-built library has rp1_rio support
if [ "$IS_PI5" = "1" ]; then
if python3 -c 'from rgbmatrix import RGBMatrixOptions; assert hasattr(RGBMatrixOptions(), "rp1_rio")' >/dev/null 2>&1; then
echo "✓ Pi 5 RP1 (rp1_rio) support confirmed"
else
echo "⚠ rp1_rio not found after rebuild — the submodule may be an older version."
echo " Try updating the submodule and rebuilding:"
echo " git submodule update --remote rpi-rgb-led-matrix-master"
echo " sudo RPI_RGB_FORCE_REBUILD=1 ./first_time_install.sh"
fi
fi
else
echo "✗ rpi-rgb-led-matrix import test failed"
exit 1
@@ -978,15 +874,11 @@ else
# Try to install dependencies using the smart installer if available
if [ -f "$PROJECT_ROOT_DIR/scripts/install_dependencies_apt.py" ]; then
echo "Using smart dependency installer..."
# -u: unbuffered stdout/stderr so output is captured in $LOG_FILE in
# real time and in order relative to this script's own echo statements
python3 -u "$PROJECT_ROOT_DIR/scripts/install_dependencies_apt.py"
python3 "$PROJECT_ROOT_DIR/scripts/install_dependencies_apt.py"
else
echo "Using pip to install dependencies..."
if [ -f "$PROJECT_ROOT_DIR/requirements_web_v2.txt" ]; then
# --ignore-installed: see the Step 5 web_interface/requirements.txt
# install above — same apt/pip RECORD-file conflict applies here.
python3 -m pip install --break-system-packages --prefer-binary --ignore-installed -r requirements_web_v2.txt
python3 -m pip install --break-system-packages --prefer-binary -r requirements_web_v2.txt
else
echo "⚠ requirements_web_v2.txt not found; skipping web dependency install"
fi
+1 -2
View File
@@ -22,6 +22,5 @@
"Pillow>=10.0.0",
"PyYAML>=6.0",
"requests>=2.31.0"
],
"local_only": true
]
}
-9
View File
@@ -1,9 +0,0 @@
# Test-only dependencies for the plugin safety harness and pytest suite.
# Install alongside requirements.txt: pip install -r requirements.txt -r requirements-test.txt
#
# pytest, pytest-cov, pytest-mock, and jsonschema are already pinned (with
# major-version caps) in requirements.txt, so they are intentionally NOT
# repeated here — re-pinning pytest to <9 collided with requirements.txt's
# pytest>=9.0.3,<10 and made the two files impossible to install together.
# Only declare what requirements.txt doesn't already provide.
freezegun>=1.2,<2 # deterministic time for golden-image tests
-3
View File
@@ -43,9 +43,6 @@ websocket-client>=1.8.0,<2.0.0
# JSON Schema validation
jsonschema>=4.20.0,<5.0.0
# Requirement specifier parsing (plugin dependency satisfaction checks)
packaging>=23.0,<27.0
# Testing dependencies
pytest>=9.0.3,<10.0.0
pytest-cov>=4.1.0,<5.0.0
-232
View File
@@ -1,232 +0,0 @@
#!/usr/bin/env python3
"""
Plugin safety checker.
Renders a plugin across every declared screen (mode) and every supported matrix
size, and fails if any screen crashes, overflows the panel, or (for plugins with
committed golden images) drifts visually.
Usage:
# Functional + bounds check across all sizes/modes:
python scripts/check_plugin.py --plugin clock-simple
# Every discovered plugin:
python scripts/check_plugin.py --all
# Dump PNGs for each size/mode so you can eyeball them:
python scripts/check_plugin.py --plugin ledmatrix-weather --out-dir /tmp/preview
# Refresh committed golden images after an intentional visual change:
python scripts/check_plugin.py --plugin clock-simple --update-golden \
--mock-data plugins/clock-simple/test/fixtures/mock.json
Exit code is non-zero if any (plugin, size, mode) fails.
"""
import argparse
import json
import os
import sys
from pathlib import Path
from typing import Dict, List, Optional
PROJECT_ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(PROJECT_ROOT))
os.environ['EMULATOR'] = 'true'
from src.logging_config import get_logger # noqa: E402
from src.plugin_system.testing.loading import ( # noqa: E402
find_plugin_dir, load_config_defaults, load_harness_spec,
)
from src.plugin_system.testing.harness import ( # noqa: E402
RenderResult, render_plugin_matrix, compare_to_goldens, write_goldens,
)
from src.plugin_system.testing.sizes import ( # noqa: E402
parse_size_token, resolve_test_sizes, safe_mode_filename, size_label,
)
logger = get_logger("[Check Plugin]")
DEFAULT_SEARCH_DIRS = [
str(PROJECT_ROOT / 'plugins'),
str(PROJECT_ROOT / 'plugin-repos'),
]
def discover_plugins(search_dirs: List[str]) -> List[str]:
"""All plugin ids found across the search dirs (dirs containing manifest.json)."""
found = []
for d in search_dirs:
base = Path(d)
if not base.exists():
continue
for child in sorted(base.iterdir()):
if (child / 'manifest.json').exists() and child.name not in found:
found.append(child.name)
return found
def parse_sizes(spec: Optional[str]):
if not spec:
return None
sizes = []
for token in spec.split(','):
if not token.strip():
continue
try:
sizes.append(parse_size_token(token))
except ValueError as exc:
raise SystemExit(str(exc)) from exc
return sizes
def check_one(plugin_id: str, search_dirs: List[str], sizes, mock_data: Dict,
config: Dict, run_update: bool, out_dir: Optional[Path],
update_golden: bool, golden_dir_override: Optional[Path],
freeze_time: Optional[str]) -> List[RenderResult]:
plugin_dir = find_plugin_dir(plugin_id, search_dirs)
if not plugin_dir:
logger.error("Plugin '%s' not found in: %s", plugin_id, search_dirs)
return [RenderResult(plugin_id, 0, 0, "<not-found>", error="plugin directory not found")]
# Per-plugin test/harness.json holds the deterministic settings the committed
# goldens were generated with (config, mock data, frozen time, sizes). Load
# them so the CLI/CI render reproduces the golden the same way the pytest
# matrix path does; explicit CLI flags still override the file.
spec = load_harness_spec(plugin_dir)
# config_schema defaults (real-install behavior), then harness.json config,
# then CLI --config — most specific wins.
full_config = {"enabled": True}
full_config.update(load_config_defaults(plugin_dir))
full_config.update(spec.get("config", {}))
full_config.update(config)
# Precedence: CLI flag > LEDMATRIX_TEST_SIZES env > harness.json > default.
effective_sizes = sizes if sizes else resolve_test_sizes(spec.get("sizes"))
# CLI value wins when provided, else fall back to the harness.json setting.
effective_mock_data = mock_data or spec.get("mock_data_contents", {})
effective_freeze = freeze_time or spec.get("freeze_time")
effective_run_update = run_update and not spec.get("skip_update", False)
results = render_plugin_matrix(
plugin_id=plugin_id, plugin_dir=plugin_dir, config=full_config,
mock_data=effective_mock_data, sizes=effective_sizes,
run_update=effective_run_update, freeze_time=effective_freeze,
)
golden_dir = golden_dir_override or (plugin_dir / 'test' / 'golden')
if update_golden:
written = write_goldens(results, golden_dir)
logger.info("Wrote %d golden image(s) for %s to %s", written, plugin_id, golden_dir)
else:
compare_to_goldens(results, golden_dir)
if out_dir:
for r in results:
if r.image is None:
continue
dest = out_dir / plugin_id / size_label(r.width, r.height)
dest.mkdir(parents=True, exist_ok=True)
r.image.save(dest / f"{safe_mode_filename(r.mode)}.png", format="PNG")
return results
def print_report(all_results: Dict[str, List[RenderResult]]) -> bool:
"""Print a per-plugin grid. Returns True if everything passed."""
everything_ok = True
for plugin_id, results in all_results.items():
print(f"\n=== {plugin_id} ===")
for r in results:
if r.ok:
status = "PASS"
detail = ""
if r.golden_checked:
detail = " (golden ✓)"
if r.update_error is not None:
detail += f" (update warn: {r.update_error})"
else:
everything_ok = False
if r.error is not None:
status, detail = "FAIL", f" error={r.error}"
elif r.overflow is not None:
status, detail = "FAIL", f" overflow bbox={r.overflow}"
elif r.golden_ok is False:
status = "FAIL"
detail = f" golden drift: {r.golden_diff_pixels}px (max Δ={r.golden_max_delta})"
else:
status, detail = "FAIL", ""
print(f" [{status}] {r.size_label:>7} {r.mode}{detail}")
print()
return everything_ok
def main() -> int:
parser = argparse.ArgumentParser(description="Check a plugin renders safely across sizes & screens")
group = parser.add_mutually_exclusive_group(required=True)
group.add_argument('--plugin', '-p', help='Plugin id to check')
group.add_argument('--all', action='store_true', help='Check every discovered plugin')
parser.add_argument('--plugin-dir', '-d', default=None, help='Directory to search for plugins')
parser.add_argument('--sizes', default=None, help='Comma-separated WxH list (default: all supported)')
parser.add_argument('--config', '-c', default='{}', help='Plugin config overrides as JSON')
parser.add_argument('--mock-data', '-m', default=None, help='Path to JSON file with mock cache data')
parser.add_argument('--out-dir', '-o', default=None, help='Also dump rendered PNGs here')
parser.add_argument('--skip-update', action='store_true', help='Skip calling update()')
parser.add_argument('--update-golden', action='store_true', help='Write/refresh golden images')
parser.add_argument('--golden-dir', default=None, help='Override golden dir (default: <plugin>/test/golden)')
parser.add_argument('--freeze-time', default=None,
help='Freeze wall clock, e.g. "2025-08-01 15:25:00" (for time-dependent plugins)')
args = parser.parse_args()
search_dirs = [args.plugin_dir] if args.plugin_dir else DEFAULT_SEARCH_DIRS
sizes = parse_sizes(args.sizes)
try:
config = json.loads(args.config)
except json.JSONDecodeError as e:
logger.error("Invalid --config JSON: %s", e)
return 2
if not isinstance(config, dict):
logger.error("--config must be a JSON object, got %s", type(config).__name__)
return 2
mock_data = {}
if args.mock_data:
mock_path = Path(args.mock_data)
if not mock_path.exists():
logger.error("Mock data file not found: %s", args.mock_data)
return 2
with open(mock_path) as f:
mock_data = json.load(f)
if not isinstance(mock_data, dict):
logger.error("--mock-data must be a JSON object (key -> cache value), got %s",
type(mock_data).__name__)
return 2
plugin_ids = discover_plugins(search_dirs) if args.all else [args.plugin]
if not plugin_ids:
logger.error("No plugins found in: %s", search_dirs)
return 2
out_dir = Path(args.out_dir) if args.out_dir else None
golden_dir_override = Path(args.golden_dir) if args.golden_dir else None
all_results: Dict[str, List[RenderResult]] = {}
for plugin_id in plugin_ids:
all_results[plugin_id] = check_one(
plugin_id=plugin_id, search_dirs=search_dirs, sizes=sizes,
mock_data=mock_data, config=config, run_update=not args.skip_update,
out_dir=out_dir, update_golden=args.update_golden,
golden_dir_override=golden_dir_override, freeze_time=args.freeze_time,
)
# When refreshing goldens we skip drift comparison, but a crash or overflow
# still means the plugin is broken — never let --update-golden mask that.
ok = print_report(all_results)
return 0 if ok else 1
if __name__ == '__main__':
sys.exit(main())
+29
View File
@@ -0,0 +1,29 @@
#!/bin/bash
# Clear all plugin dependency markers to force fresh dependency check
# Useful after updating plugins or troubleshooting dependency issues
echo "Clearing plugin dependency markers..."
# Check both possible cache locations
CACHE_DIRS=(
"/var/cache/ledmatrix"
"$HOME/.cache/ledmatrix"
)
for CACHE_DIR in "${CACHE_DIRS[@]}"; do
if [ -d "$CACHE_DIR" ]; then
echo "Checking $CACHE_DIR..."
marker_count=$(find "$CACHE_DIR" -name "plugin_*_deps_installed" 2>/dev/null | wc -l)
if [ "$marker_count" -gt 0 ]; then
echo "Found $marker_count dependency marker(s) in $CACHE_DIR"
find "$CACHE_DIR" -name "plugin_*_deps_installed" -delete
echo "Cleared $marker_count marker(s)"
else
echo "No dependency markers found in $CACHE_DIR"
fi
fi
done
echo "Done! Dependency markers cleared."
echo "Next startup will check and install dependencies as needed."
+2 -3
View File
@@ -67,9 +67,8 @@ def main():
print(" 📍 Will run on: http://0.0.0.0:5000")
print(" ⏹️ Press Ctrl+C to stop")
# Run the app (debug mode controlled by env var to satisfy security scanners)
_debug = os.environ.get('LEDMATRIX_FLASK_DEBUG', '0') == '1'
app.run(host='0.0.0.0', port=5000, debug=_debug)
# Run the app (this should start the server)
app.run(host='0.0.0.0', port=5000, debug=True)
except KeyboardInterrupt:
print("\n ⏹️ Server stopped by user")
-74
View File
@@ -1,74 +0,0 @@
#!/bin/bash
# safe_pip_install.sh — Install a requirements.txt as root after validating
# that the resolved path is the project's own requirements.txt or a plugin's
# requirements.txt under plugin-repos/ or plugins/.
#
# This script is intended to be called via sudo from the web interface, so
# that packages a plugin declares end up visible to ledmatrix.service (which
# runs as root) rather than only to whichever non-root user runs the web
# interface. Plugin code already runs as root once loaded, so installing its
# declared dependencies as root is not a new trust boundary.
#
# Usage: safe_pip_install.sh <requirements_txt_path>
set -euo pipefail
if [ $# -ne 1 ]; then
echo "Usage: $0 <requirements_txt_path>" >&2
exit 1
fi
TARGET="$1"
# Determine the project root (parent of scripts/fix_perms/)
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
# Allowed locations (resolved, no trailing slash):
# - the project's own requirements.txt
# - any requirements.txt under plugin-repos/ or plugins/
ALLOWED_EXACT="$(realpath --canonicalize-missing "$PROJECT_ROOT/requirements.txt")"
ALLOWED_BASES=(
"$(realpath --canonicalize-missing "$PROJECT_ROOT/plugin-repos")"
"$(realpath --canonicalize-missing "$PROJECT_ROOT/plugins")"
)
# Resolve the target path (follow symlinks); works even if it doesn't exist.
RESOLVED_TARGET="$(realpath --canonicalize-missing "$TARGET")"
# Must be named requirements.txt — never install from an arbitrary file.
if [ "$(basename "$RESOLVED_TARGET")" != "requirements.txt" ]; then
echo "DENIED: $RESOLVED_TARGET is not a requirements.txt file" >&2
exit 2
fi
ALLOWED=false
if [ "$RESOLVED_TARGET" = "$ALLOWED_EXACT" ]; then
ALLOWED=true
else
for BASE in "${ALLOWED_BASES[@]}"; do
if [[ "$RESOLVED_TARGET" == "$BASE/"* ]]; then
ALLOWED=true
break
fi
done
fi
if [ "$ALLOWED" = false ]; then
echo "DENIED: $RESOLVED_TARGET is not an allowed requirements.txt location" >&2
echo "Allowed: $ALLOWED_EXACT, or any requirements.txt under: ${ALLOWED_BASES[*]}" >&2
exit 2
fi
if [ ! -f "$RESOLVED_TARGET" ]; then
echo "ERROR: $RESOLVED_TARGET does not exist" >&2
exit 3
fi
PYTHON_PATH="$(command -v python3)"
# --ignore-installed: root's site-packages often has apt/dpkg-managed copies
# of common libraries (requests, urllib3, ...) with no pip RECORD file, which
# pip refuses to uninstall in place ("Cannot uninstall: no RECORD file was
# found"). This tells pip to install the newer version alongside rather than
# aborting the whole requirements.txt install over one such conflict.
exec "$PYTHON_PATH" -m pip install --break-system-packages --ignore-installed -r "$RESOLVED_TARGET"
+5 -28
View File
@@ -33,7 +33,6 @@ POWEROFF_PATH=$(command -v poweroff) || true
BASH_PATH=$(command -v bash) || true
JOURNALCTL_PATH=$(command -v journalctl) || true
SAFE_RM_PATH="$PROJECT_ROOT/scripts/fix_perms/safe_plugin_rm.sh"
SAFE_PIP_INSTALL_PATH="$PROJECT_ROOT/scripts/fix_perms/safe_pip_install.sh"
# Validate required commands (systemctl, bash, python3 are essential)
for CMD_NAME in SYSTEMCTL_PATH BASH_PATH PYTHON_PATH; do
@@ -49,15 +48,11 @@ if [ ${#MISSING_CMDS[@]} -gt 0 ]; then
exit 1
fi
# Validate helper scripts exist
# Validate helper script exists
if [ ! -f "$SAFE_RM_PATH" ]; then
echo "Error: Safe plugin removal helper not found: $SAFE_RM_PATH" >&2
exit 1
fi
if [ ! -f "$SAFE_PIP_INSTALL_PATH" ]; then
echo "Error: Safe pip install helper not found: $SAFE_PIP_INSTALL_PATH" >&2
exit 1
fi
echo "Command paths:"
echo " Python: $PYTHON_PATH"
@@ -67,7 +62,6 @@ echo " Poweroff: ${POWEROFF_PATH:-(not found, skipping)}"
echo " Bash: $BASH_PATH"
echo " Journalctl: ${JOURNALCTL_PATH:-(not found, skipping)}"
echo " Safe plugin rm: $SAFE_RM_PATH"
echo " Safe pip install: $SAFE_PIP_INSTALL_PATH"
# Create a temporary sudoers file
TEMP_SUDOERS="/tmp/ledmatrix_web_sudoers_$$"
@@ -107,22 +101,13 @@ TEMP_SUDOERS="/tmp/ledmatrix_web_sudoers_$$"
fi
# Required: python3, bash
# NOTE: display_controller.py/start_display.sh/stop_display.sh live at the
# project root, not under scripts/install/ (where this script lives) —
# must use PROJECT_ROOT here, not PROJECT_DIR.
echo "$WEB_USER ALL=(ALL) NOPASSWD: $PYTHON_PATH $PROJECT_ROOT/display_controller.py"
echo "$WEB_USER ALL=(ALL) NOPASSWD: $BASH_PATH $PROJECT_ROOT/start_display.sh"
echo "$WEB_USER ALL=(ALL) NOPASSWD: $BASH_PATH $PROJECT_ROOT/stop_display.sh"
echo "$WEB_USER ALL=(ALL) NOPASSWD: $PYTHON_PATH $PROJECT_DIR/display_controller.py"
echo "$WEB_USER ALL=(ALL) NOPASSWD: $BASH_PATH $PROJECT_DIR/start_display.sh"
echo "$WEB_USER ALL=(ALL) NOPASSWD: $BASH_PATH $PROJECT_DIR/stop_display.sh"
echo ""
echo "# Allow web user to remove plugin directories via vetted helper script"
echo "# The helper validates that the target path resolves inside plugin-repos/ or plugins/"
echo "$WEB_USER ALL=(ALL) NOPASSWD: $BASH_PATH $SAFE_RM_PATH *"
echo ""
echo "# Allow web user to install a plugin's requirements.txt as root via vetted"
echo "# helper script, so packages are visible to root-run ledmatrix.service"
echo "# (not just the web interface's own user). The helper validates the target"
echo "# is requirements.txt at the project root or under plugin-repos/ or plugins/."
echo "$WEB_USER ALL=(ALL) NOPASSWD: $BASH_PATH $SAFE_PIP_INSTALL_PATH *"
} > "$TEMP_SUDOERS"
echo ""
@@ -141,7 +126,6 @@ echo "- Run display_controller.py directly"
echo "- Execute start_display.sh and stop_display.sh"
echo "- Reboot and shutdown the system"
echo "- Remove plugin directories (for update/uninstall when root-owned files block deletion)"
echo "- Install plugin/base requirements.txt as root (so ledmatrix.service can see them)"
echo ""
# Ask for confirmation
@@ -163,13 +147,6 @@ fi
if ! sudo chmod 755 "$SAFE_RM_PATH"; then
echo "Warning: Could not set permissions on $SAFE_RM_PATH"
fi
echo "Hardening safe_pip_install.sh ownership..."
if ! sudo chown root:root "$SAFE_PIP_INSTALL_PATH"; then
echo "Warning: Could not set ownership on $SAFE_PIP_INSTALL_PATH"
fi
if ! sudo chmod 755 "$SAFE_PIP_INSTALL_PATH"; then
echo "Warning: Could not set permissions on $SAFE_PIP_INSTALL_PATH"
fi
if sudo cp "$TEMP_SUDOERS" /etc/sudoers.d/ledmatrix_web; then
echo "Configuration applied successfully!"
@@ -183,7 +160,7 @@ if sudo cp "$TEMP_SUDOERS" /etc/sudoers.d/ledmatrix_web; then
echo "✗ systemctl status ledmatrix.service - Failed"
fi
if sudo -n test -f "$PROJECT_ROOT/start_display.sh"; then
if sudo -n test -f "$PROJECT_DIR/start_display.sh"; then
echo "✓ File access test - OK"
else
echo "✗ File access test - Failed"
+2 -8
View File
@@ -340,14 +340,9 @@ main() {
echo ""
# Execute with proper error handling and non-interactive mode
# Temporarily disable errexit AND the ERR trap to capture exit code instead of
# exiting immediately. `set +e` alone does not suppress the ERR trap, so without
# `trap '' ERR` a non-zero exit from first_time_install.sh would trigger on_error
# here with the generic "Main installation" message instead of the detailed
# if/else handling below.
# Temporarily disable errexit to capture exit code instead of exiting immediately
set +e
trap '' ERR
# Check /tmp permissions - only fix if actually wrong (common in automated scenarios)
# When running manually, /tmp usually has correct permissions (1777)
TMP_PERMS=$(stat -c '%a' /tmp 2>/dev/null || echo "unknown")
@@ -375,7 +370,6 @@ main() {
sudo -E env TMPDIR=/tmp LEDMATRIX_ASSUME_YES=1 bash ./first_time_install.sh -y </dev/null
fi
INSTALL_EXIT_CODE=$?
trap 'on_error $LINENO' ERR # Re-enable ERR trap
set -e # Re-enable errexit
if [ $INSTALL_EXIT_CODE -eq 0 ]; then
+82 -149
View File
@@ -6,143 +6,82 @@ then falls back to pip with --break-system-packages
import subprocess
import sys
import tempfile
import warnings
from collections import deque
from pathlib import Path
from typing import List, Tuple
# How many trailing lines of a failed command's output to keep for the
# end-of-run failure summary. Keeps the root cause near the end of the log,
# which is where first_time_install.sh's error handler tails from.
ERROR_TAIL_LINES = 15
def _run(cmd: List[str]) -> Tuple[bool, str]:
"""Run a command, streaming combined stdout/stderr to a temp file.
Returns (success, output) instead of raising, so callers can report
*why* a command failed rather than just that it failed. `output` is
bounded to the last ERROR_TAIL_LINES lines so failures from very
chatty commands (e.g. pip build logs) don't get buffered in memory.
"""
with tempfile.TemporaryFile(mode='w+b') as f:
result = subprocess.run(cmd, stdout=f, stderr=subprocess.STDOUT) # nosec B603 B607 - hardcoded apt/pip args # nosemgrep
f.seek(0)
# Stream line-by-line so only the last ERROR_TAIL_LINES are ever held
# in memory, regardless of how much output the command produced.
tail = deque(
(line.decode('utf-8', errors='replace').rstrip('\n') for line in f),
maxlen=ERROR_TAIL_LINES,
)
return result.returncode == 0, '\n'.join(tail)
def install_via_apt(package_name: str) -> Tuple[bool, str]:
"""Try to install a package via apt. Returns (success, output)."""
# Map pip package names to apt package names
apt_package_map = {
'flask': 'python3-flask',
'PIL': 'python3-pil',
'freetype': 'python3-freetype',
'psutil': 'python3-psutil',
'werkzeug': 'python3-werkzeug',
'numpy': 'python3-numpy',
'requests': 'python3-requests',
'python-dateutil': 'python3-dateutil',
'pytz': 'python3-tz',
'geopy': 'python3-geopy',
'unidecode': 'python3-unidecode',
'websockets': 'python3-websockets',
'websocket-client': 'python3-websocket-client'
}
apt_package = apt_package_map.get(package_name, f'python3-{package_name}')
print(f"Trying to install {apt_package} via apt...")
success, output = _run(['sudo', 'apt-get', '-o', 'DPkg::Lock::Timeout=180', 'install', '-y', apt_package])
if success:
def install_via_apt(package_name):
"""Try to install a package via apt."""
try:
# Map pip package names to apt package names
apt_package_map = {
'flask': 'python3-flask',
'PIL': 'python3-pil',
'freetype': 'python3-freetype',
'psutil': 'python3-psutil',
'werkzeug': 'python3-werkzeug',
'numpy': 'python3-numpy',
'requests': 'python3-requests',
'python-dateutil': 'python3-dateutil',
'pytz': 'python3-tz',
'geopy': 'python3-geopy',
'unidecode': 'python3-unidecode',
'websockets': 'python3-websockets',
'websocket-client': 'python3-websocket-client'
}
apt_package = apt_package_map.get(package_name, f'python3-{package_name}')
print(f"Trying to install {apt_package} via apt...")
subprocess.check_call([
'sudo', 'apt', 'update'
], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
subprocess.check_call([
'sudo', 'apt', 'install', '-y', apt_package
], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
print(f"Successfully installed {apt_package} via apt")
return True, ""
return True
except subprocess.CalledProcessError:
print(f"Failed to install {package_name} via apt, will try pip")
return False
print(f"Failed to install {apt_package} via apt, will try pip")
return False, output
def install_via_pip(package_name: str) -> Tuple[bool, str]:
def install_via_pip(package_name):
"""Install a package via pip with --break-system-packages and --prefer-binary.
--break-system-packages allows pip to install into the system Python on
Debian/Ubuntu-based systems without a virtual environment.
--prefer-binary prefers pre-built wheels over source distributions to avoid
exhausting /tmp space during compilation.
--ignore-installed stops pip from trying to *uninstall* packages that were
installed by apt (e.g. python3-requests). Those Debian packages ship no
pip RECORD file, so an uninstall attempt fails with "uninstall-no-record-file"
and aborts the whole install. With --ignore-installed, pip lays the new
version down in /usr/local where it shadows the apt copy instead of removing
it. This matters when a pip dependency (google-api-python-client pulls a
newer requests) needs to upgrade an apt-managed package.
Returns (success, output).
"""
print(f"Installing {package_name} via pip...")
success, output = _run([
sys.executable, '-m', 'pip', 'install',
'--break-system-packages', '--prefer-binary', '--ignore-installed', package_name
])
if success:
try:
print(f"Installing {package_name} via pip...")
subprocess.check_call([
sys.executable, '-m', 'pip', 'install', '--break-system-packages', '--prefer-binary', package_name
])
print(f"Successfully installed {package_name} via pip")
return True, ""
return True
except subprocess.CalledProcessError as e:
print(f"Failed to install {package_name} via pip: {e}")
return False
print(f"Failed to install {package_name} via pip (see failure summary at end of log)")
return False, output
# Distribution (pip/apt) names whose importable module name differs.
IMPORT_NAME_MAP = {
'python-dateutil': 'dateutil',
'websocket-client': 'websocket',
}
def check_package_installed(package_name: str) -> bool:
def check_package_installed(package_name):
"""Check if a package is already installed."""
import_name = IMPORT_NAME_MAP.get(package_name, package_name)
# Suppress deprecation warnings when checking if packages are installed
# (we're just checking, not using them)
with warnings.catch_warnings():
warnings.filterwarnings('ignore', category=DeprecationWarning)
try:
__import__(import_name)
__import__(package_name)
return True
except ImportError:
return False
def print_failure_summary(failed_packages: List[str], failure_details: dict) -> None:
print("\n" + "=" * 60)
print("DEPENDENCY INSTALLATION FAILURES - DETAILS")
print("=" * 60)
for package in failed_packages:
print(f"\nPackage: {package}")
print("-" * 40)
output = failure_details.get(package, "").strip()
if not output:
print(" (no output captured)")
continue
for line in output.splitlines()[-ERROR_TAIL_LINES:]:
print(f" {line}")
print("=" * 60)
def main():
"""Main installation function."""
print("Installing dependencies for LED Matrix Web Interface V2...")
print("Refreshing apt package index...")
_run(['sudo', 'apt', 'update']) # best-effort; individual installs surface their own errors
# List of required packages
required_packages = [
'flask',
@@ -159,23 +98,19 @@ def main():
'websockets',
'websocket-client'
]
failed_packages = []
failure_details = {}
for package in required_packages:
if check_package_installed(package):
print(f"{package} is already installed")
continue
# Try apt first, then pip
ok, apt_output = install_via_apt(package)
if not ok:
ok, pip_output = install_via_pip(package)
if not ok:
if not install_via_apt(package):
if not install_via_pip(package):
failed_packages.append(package)
failure_details[package] = pip_output or apt_output
# Install packages that don't have apt equivalents
special_packages = [
'timezonefinder>=6.5.0,<7.0.0',
@@ -187,49 +122,47 @@ def main():
'python-socketio>=5.11.0,<6.0.0',
'python-engineio>=4.9.0,<5.0.0'
]
for package in special_packages:
ok, pip_output = install_via_pip(package)
if not ok:
if not install_via_pip(package):
failed_packages.append(package)
failure_details[package] = pip_output
# Install rgbmatrix module from local source (optional - may already be installed in Step 6)
# Check if already installed first
if check_package_installed('rgbmatrix'):
print("rgbmatrix module already installed, skipping...")
else:
print("Installing rgbmatrix module from local source...")
# Get project root (parent of scripts directory)
PROJECT_ROOT = Path(__file__).parent.parent
rgbmatrix_path = PROJECT_ROOT / 'rpi-rgb-led-matrix-master' / 'bindings' / 'python'
if rgbmatrix_path.exists():
# Check if the module has been built (look for setup.py)
setup_py = rgbmatrix_path / 'setup.py'
if setup_py.exists():
# Try installing - use regular install, not editable mode
# This is optional for web interface and should already be installed in Step 6
ok, output = _run([sys.executable, '-m', 'pip', 'install', '--break-system-packages', str(rgbmatrix_path)])
if ok:
try:
# Get project root (parent of scripts directory)
PROJECT_ROOT = Path(__file__).parent.parent
rgbmatrix_path = PROJECT_ROOT / 'rpi-rgb-led-matrix-master' / 'bindings' / 'python'
if rgbmatrix_path.exists():
# Check if the module has been built (look for setup.py)
setup_py = rgbmatrix_path / 'setup.py'
if setup_py.exists():
# Try installing - use regular install, not editable mode
# This is optional for web interface and should already be installed in Step 6
subprocess.check_call([
sys.executable, '-m', 'pip', 'install', '--break-system-packages', str(rgbmatrix_path)
], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
print("rgbmatrix module installed successfully")
else:
# Don't fail the whole installation - rgbmatrix is optional for web interface
# and should be installed in Step 6 of first_time_install.sh
print("Warning: Failed to install rgbmatrix module:")
for line in output.strip().splitlines()[-ERROR_TAIL_LINES:]:
print(f" {line}")
print(" This is normal if rgbmatrix hasn't been built yet (Step 6).")
print(" The web interface will work without it.")
print("Warning: rgbmatrix setup.py not found, module may need to be built first")
print(" This is normal if Step 6 hasn't completed yet.")
else:
print("Warning: rgbmatrix setup.py not found, module may need to be built first")
print(" This is normal if Step 6 hasn't completed yet.")
else:
print("Warning: rgbmatrix source not found (this is normal if Step 6 hasn't run yet)")
print("Warning: rgbmatrix source not found (this is normal if Step 6 hasn't run yet)")
except subprocess.CalledProcessError as e:
# Don't fail the whole installation - rgbmatrix is optional for web interface
# and should be installed in Step 6 of first_time_install.sh
print(f"Warning: Failed to install rgbmatrix module: {e}")
print(" This is normal if rgbmatrix hasn't been built yet (Step 6).")
print(" The web interface will work without it.")
# Don't add to failed_packages since it's optional
if failed_packages:
print(f"\nFailed to install the following packages: {failed_packages}")
print("You may need to install them manually or check your system configuration.")
print_failure_summary(failed_packages, failure_details)
return False
else:
print("\nAll dependencies installed successfully!")
+38 -3
View File
@@ -17,6 +17,7 @@ import os
import json
import argparse
from pathlib import Path
from typing import Any, Dict, Optional, Sequence, Union
# Add project root to path
PROJECT_ROOT = Path(__file__).resolve().parent.parent
@@ -27,15 +28,49 @@ os.environ['EMULATOR'] = 'true'
# Import logger after path setup so src.logging_config is importable
from src.logging_config import get_logger # noqa: E402
from src.plugin_system.testing.loading import ( # noqa: E402
find_plugin_dir, load_manifest, load_config_defaults,
)
logger = get_logger("[Render Plugin]")
MIN_DIMENSION = 1
MAX_DIMENSION = 512
def find_plugin_dir(plugin_id: str, search_dirs: Sequence[Union[str, Path]]) -> Optional[Path]:
"""Find a plugin directory by searching multiple paths."""
from src.plugin_system.plugin_loader import PluginLoader
loader = PluginLoader()
for search_dir in search_dirs:
search_path = Path(search_dir)
if not search_path.exists():
continue
result = loader.find_plugin_directory(plugin_id, search_path)
if result:
return Path(result)
return None
def load_manifest(plugin_dir: Path) -> Dict[str, Any]:
"""Load and return manifest.json from plugin directory."""
manifest_path = plugin_dir / 'manifest.json'
if not manifest_path.exists():
raise FileNotFoundError(f"No manifest.json in {plugin_dir}")
with open(manifest_path, 'r') as f:
return json.load(f)
def load_config_defaults(plugin_dir: Path) -> Dict[str, Any]:
"""Extract default values from config_schema.json."""
schema_path = plugin_dir / 'config_schema.json'
if not schema_path.exists():
return {}
with open(schema_path, 'r') as f:
schema = json.load(f)
defaults: Dict[str, Any] = {}
for key, prop in schema.get('properties', {}).items():
if 'default' in prop:
defaults[key] = prop['default']
return defaults
def main() -> int:
"""Load a plugin, call update() + display(), and save the result as a PNG image."""
parser = argparse.ArgumentParser(description='Render a plugin display to a PNG image')
+4 -4
View File
@@ -410,8 +410,8 @@ def validate_backup(zip_path: Path) -> Tuple[bool, str, Dict[str, Any]]:
try:
manifest_raw = zf.read(MANIFEST_NAME).decode("utf-8")
manifest = json.loads(manifest_raw)
except (OSError, UnicodeDecodeError, json.JSONDecodeError):
return False, "Invalid manifest.json", {}
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as e:
return False, f"Invalid manifest.json: {e}", {}
if not isinstance(manifest, dict) or "schema_version" not in manifest:
return False, "Invalid manifest structure", {}
@@ -456,8 +456,8 @@ def validate_backup(zip_path: Path) -> Tuple[bool, str, Dict[str, Any]]:
return True, "", result_manifest
except zipfile.BadZipFile:
return False, "File is not a valid ZIP archive", {}
except OSError:
return False, "Could not read backup", {}
except OSError as e:
return False, f"Could not read backup: {e}", {}
# ---------------------------------------------------------------------------
+5 -12
View File
@@ -68,15 +68,14 @@ class DiskCache:
return None
return os.path.join(self.cache_dir, f"{key}.json")
def get(self, key: str, max_age: Optional[int] = 300) -> Optional[Dict[str, Any]]:
def get(self, key: str, max_age: int = 300) -> Optional[Dict[str, Any]]:
"""
Get data from disk cache.
Args:
key: Cache key
max_age: Maximum age in seconds; None disables age-based expiry
(the record never counts as stale). Mirrors MemoryCache.get.
max_age: Maximum age in seconds
Returns:
Cached data or None if not found or expired
"""
@@ -106,13 +105,7 @@ class DiskCache:
record_ts = None
now = time.time()
# max_age=None means "never expires" (mirrors MemoryCache and the
# cache_manager docstring). Guard it explicitly — otherwise the
# comparison below raises TypeError and the record is treated as a
# miss, which silently breaks callers that persist long-lived state
# via get(key, max_age=None) (e.g. plugin health/metrics that must
# survive restarts and be read cross-process).
if record_ts is None or max_age is None or (now - record_ts) <= max_age:
if record_ts is None or (now - record_ts) <= max_age:
return record
else:
# Stale on disk; keep file for potential diagnostics but treat as miss
+3 -41
View File
@@ -1,28 +1,3 @@
"""
Cache Manager multi-tier response cache for the LEDMatrix application.
:class:`CacheManager` provides a unified caching layer used by all plugins
to reduce external API calls and survive network outages gracefully.
Two storage tiers
-----------------
* **Memory tier** (:class:`~src.cache.memory_cache.MemoryCache`): fast LRU
cache (up to 1 000 entries by default). Hit on this tier before touching
disk.
* **Disk tier** (:class:`~src.cache.disk_cache.DiskCache`): filesystem-backed
persistent store that survives process restarts.
Data written to cache is serialised as JSON. :class:`DateTimeEncoder` handles
``datetime`` objects transparently so callers don't have to pre-serialise them.
Typical plugin usage::
data = self.cache_manager.get_cached_data('my_key', max_age=300)
if data is None:
data = fetch_from_api()
self.cache_manager.save_cache('my_key', data)
"""
import json
import os
import time
@@ -40,10 +15,7 @@ from src.cache.cache_metrics import CacheMetrics
from src.logging_config import get_logger
class DateTimeEncoder(json.JSONEncoder):
"""JSON encoder that serialises ``datetime`` objects as ISO-8601 strings."""
def default(self, obj):
"""Return ISO-8601 string for datetime; delegate all other types to the base encoder."""
if isinstance(obj, datetime):
return obj.isoformat()
return super().default(obj)
@@ -574,19 +546,9 @@ class CacheManager:
}
return self.save_cache(data_type, cache_data)
def get(self, key: str, max_age: Optional[int] = 300,
memory_ttl: Optional[int] = None) -> Optional[Dict[str, Any]]:
"""Get data from cache if it exists and is not stale.
Args:
key: Cache key
max_age: Max age (seconds) for the on-disk entry; None never expires.
memory_ttl: Max age (seconds) for the in-memory entry. Pass 0 to
bypass the memory tier and force a fresh read from disk used by
cross-process readers that must observe another process's latest
write rather than a stale first snapshot. Defaults to max_age.
"""
cached_data = self.get_cached_data(key, max_age, memory_ttl=memory_ttl)
def get(self, key: str, max_age: int = 300) -> Optional[Dict[str, Any]]:
"""Get data from cache if it exists and is not stale."""
cached_data = self.get_cached_data(key, max_age)
if cached_data and 'data' in cached_data:
return cached_data['data']
return cached_data
-137
View File
@@ -8,34 +8,13 @@ files that need to be accessible by both root service and web user.
import os
import logging
import re
import shutil as _shutil
import subprocess
import sys
from pathlib import Path
from typing import Optional
logger = logging.getLogger(__name__)
# Matches the credentials portion of a "scheme://user:pass@host" URL, so pip's
# own error output can be logged/displayed without echoing back a private
# index URL's embedded basic-auth secret verbatim (e.g. from a
# requirements.txt --index-url line or the PIP_INDEX_URL env var).
_URL_CREDENTIALS_RE = re.compile(r'://[^/\s@:]+:[^/\s@]+@')
def _redact_url_credentials(text: Optional[str]) -> str:
"""Replace embedded user:pass@ URL credentials in text with a placeholder.
Safe to call on any subprocess output destined for logs: it only ever
shortens/replaces the credential substring, never changes the presence
or absence of the specific fixed phrases callers check for
(e.g. "a password is required"), so it can't affect control flow.
"""
if not text:
return text or ""
return _URL_CREDENTIALS_RE.sub('://***:***@', text)
# System directories that should never have their permissions modified
# These directories have special system-level permissions that must be preserved
PROTECTED_SYSTEM_DIRECTORIES = { # nosec B108 - these are checked to PREVENT permission changes, not to use as temp paths
@@ -308,119 +287,3 @@ def sudo_remove_directory(path: Path, allowed_bases: Optional[list] = None) -> b
logger.error(f"Unexpected error during sudo helper for {path}: {e}")
return False
def install_requirements_file(req_file: Path, timeout: int = 300) -> subprocess.CompletedProcess:
"""
Install a requirements.txt file for a plugin (or the project itself).
Prefers the vetted sudo wrapper (scripts/fix_perms/safe_pip_install.sh) so
packages end up visible to root-run ledmatrix.service, not just to
whichever non-root user happens to run the calling process (e.g. the web
interface). Falls back to installing with the calling process's own
interpreter if the wrapper isn't set up yet (the admin hasn't run
scripts/install/configure_web_sudo.sh), so dependency installation still
does *something* useful rather than hard-failing.
Always installs with the interpreter that will actually run the code
(``sys.executable`` in the fallback path, the wrapper's ``python3`` in the
sudo path) rather than a bare ``pip``/``pip3`` off PATH, which can
silently resolve to a different Python installation (e.g. system Python
vs. a virtualenv) than the one importing the package at runtime.
Args:
req_file: Path to a requirements.txt file
timeout: Subprocess timeout in seconds
Returns:
subprocess.CompletedProcess from the pip (or wrapper) invocation.
Never raises on a non-zero exit; callers should check ``returncode``.
``stdout`` is prefixed with an explanatory note when the root wrapper
was unavailable and the fallback path was used.
"""
project_root = Path(__file__).resolve().parent.parent.parent
wrapper = project_root / "scripts" / "fix_perms" / "safe_pip_install.sh"
if wrapper.exists():
# See sudo_remove_directory / configure_web_sudo.sh for why bash must
# be invoked with an explicit, known path rather than relying on the
# wrapper's shebang: sudoers matches the exact command line.
bash_candidates = []
for candidate in ("/usr/bin/bash", "/bin/bash", _shutil.which("bash")):
if candidate and candidate not in bash_candidates:
bash_candidates.append(candidate)
result = None
for bash_path in bash_candidates:
# bash_path and wrapper are fixed, known-good paths, and
# safe_pip_install.sh independently re-validates req_file is an
# allowed requirements.txt before installing anything as root.
result = subprocess.run( # nosec B603 - no shell invoked (list-form argv) # nosemgrep
["sudo", "-n", bash_path, str(wrapper), str(req_file)],
capture_output=True, text=True, timeout=timeout, cwd=str(project_root)
)
# Redact immediately: pip can echo a private index URL's embedded
# basic-auth credentials back in its own error/progress output
# (e.g. from a requirements.txt --index-url line). Doesn't affect
# the fixed-phrase "denied" check below -- those phrases never
# overlap with URL syntax.
result.stderr = _redact_url_credentials(result.stderr)
result.stdout = _redact_url_credentials(result.stdout)
if result.returncode == 0:
return result
# Distinguish "sudo rejected this exact command line" (worth
# trying the next bash candidate) from "sudo ran it but pip
# itself failed" (a real error — stop and surface it).
denied = any(
phrase in result.stderr
for phrase in ("a password is required", "is not allowed to run", "no tty present")
)
if not denied:
# Deliberately don't interpolate req_file or the pip output here:
# this log line is scanner-visible, and a static analyzer can't
# tell "already redacted above" from "still raw" just by looking
# at this call in isolation. The full (redacted) text is still
# available to callers via the returned CompletedProcess.
logger.warning(
"Root pip install failed (rc=%s); see the returned "
"CompletedProcess.stderr for details.",
result.returncode,
)
return result
# Same reasoning as above: no req_file / pip-output interpolation in
# this log line, only in the returned note/CompletedProcess.
logger.warning(
"Root pip install wrapper denied via sudo for all candidates; "
"falling back to user-level install. See the returned "
"CompletedProcess.stderr for details."
)
note = (
f"[Root install unavailable ({(result.stderr.strip() if result else 'sudo denied') or 'sudo denied'}); "
"installed for the current process's user only. Packages may not be "
"visible to ledmatrix.service if it runs as a different user — "
"run scripts/install/configure_web_sudo.sh to fix this.]\n"
)
else:
logger.warning(
"safe_pip_install.sh not found; falling back to user-level install."
)
note = (
"[safe_pip_install.sh not found; installed for the current process's "
"user only. Run scripts/install/configure_web_sudo.sh to enable "
"root installs visible to ledmatrix.service.]\n"
)
# sys.executable is this process's own interpreter (not
# attacker-influenced), and req_file is a Path built internally by callers
# (store_manager.py plugin paths, PROJECT_ROOT/requirements.txt), never
# raw external/user input. --ignore-installed matches safe_pip_install.sh:
# apt-managed packages (e.g. python3-requests) ship no pip RECORD file, so
# upgrading them would otherwise abort with "uninstall-no-record-file".
result = subprocess.run( # nosec B603 - no shell invoked (list-form argv) # nosemgrep
[sys.executable, "-m", "pip", "install", "--break-system-packages", "--ignore-installed", "-r", str(req_file)],
capture_output=True, text=True, timeout=timeout, cwd=str(project_root)
)
result.stderr = _redact_url_credentials(result.stderr)
result.stdout = note + _redact_url_credentials(result.stdout)
return result
+22 -28
View File
@@ -347,40 +347,34 @@ class ScrollHelper:
return self._get_visible_portion_integer(start_x_int, end_x_int)
def _get_visible_portion_integer(self, start_x: int, end_x: int) -> Image.Image:
"""Fast integer pixel extraction (no interpolation).
Uses Image.frombytes instead of Image.fromarray: frombytes skips
numpy's array-protocol overhead and is ~50% faster for the display-sized
slices (128×32 = 12 KB) used here.
"""
_size = (self.display_width, self.display_height)
img_w = self.cached_image.width
if end_x <= img_w:
# Normal case: single contiguous slice (fastest path)
frame_array = np.ascontiguousarray(self.cached_array[:, start_x:end_x])
return Image.frombytes('RGB', _size, frame_array.tobytes())
"""Fast integer pixel extraction (no interpolation)."""
# Fast numpy array slicing for normal case (no wrap-around)
if end_x <= self.cached_image.width:
# Normal case: single slice - fastest path
frame_array = self.cached_array[:, start_x:end_x]
# Convert to PIL Image (minimal overhead)
return Image.fromarray(frame_array)
else:
# Ensure frame buffer is allocated for all non-simple paths
if self._frame_buffer is None or self._frame_buffer.shape != (self.display_height, self.display_width, 3):
self._frame_buffer = np.zeros((self.display_height, self.display_width, 3), dtype=np.uint8)
width1 = img_w - start_x
# Wrap-around case: combine two slices using numpy
width1 = self.cached_image.width - start_x
if width1 > 0:
# Wrap-around: tail of image + head of image
# Use pre-allocated buffer for output
if self._frame_buffer is None or self._frame_buffer.shape != (self.display_height, self.display_width, 3):
self._frame_buffer = np.zeros((self.display_height, self.display_width, 3), dtype=np.uint8)
# First part from end of image (fast numpy slice)
self._frame_buffer[:, :width1] = self.cached_array[:, start_x:]
# Second part from beginning of image
remaining_width = self.display_width - width1
self._frame_buffer[:, width1:] = self.cached_array[:, :remaining_width]
# Convert combined buffer to PIL Image
return Image.fromarray(self._frame_buffer)
else:
# Edge case: start_x at or past image end — show from beginning,
# clamped to available width (scroll_position should wrap before
# reaching this state in normal operation).
available = min(self.display_width, img_w)
self._frame_buffer[:, :available] = self.cached_array[:, :available]
if available < self.display_width:
self._frame_buffer[:, available:] = 0
return Image.frombytes('RGB', _size, self._frame_buffer.tobytes())
# Edge case: start_x >= image width, wrap to beginning
frame_array = self.cached_array[:, :self.display_width]
return Image.fromarray(frame_array)
def _get_visible_portion_subpixel(self, start_x_int: int, fractional: float) -> Image.Image:
"""
-35
View File
@@ -1,29 +1,3 @@
"""
Config Manager reads, writes, and validates ``config/config.json``.
:class:`ConfigManager` is the single owner of the on-disk configuration
files:
* ``config/config.json`` main user-editable configuration.
* ``config/config_secrets.json`` sensitive values (API keys, tokens).
All writes go through :class:`~src.config_manager_atomic.AtomicConfigManager`
which performs a backup before overwriting, validates the result, and rolls
back on error. This makes config corruption essentially impossible.
Plugin configuration
--------------------
Plugin configs are stored inside ``config.json`` under the plugin's ID key
and survive plugin reinstalls. Use :meth:`ConfigManager.update_plugin_config`
to write plugin settings; never write directly to the plugin directory.
Hot-reload
----------
:class:`~src.config_service.ConfigService` wraps ``ConfigManager`` and
detects file changes, broadcasting the new config to registered listeners
without requiring a restart.
"""
import json
import os
import logging
@@ -43,13 +17,6 @@ from src.common.permission_utils import (
)
class ConfigManager:
"""
Reads and writes the main application configuration files.
Wraps :class:`~src.config_manager_atomic.AtomicConfigManager` for safe
atomic writes with automatic backup and rollback. Also exposes helpers
for plugin configuration persistence and secret-field masking.
"""
def __init__(self, config_path: Optional[str] = None, secrets_path: Optional[str] = None) -> None:
# Use current working directory as base
self.config_path: str = config_path or "config/config.json"
@@ -62,11 +29,9 @@ class ConfigManager:
self._atomic_manager: Optional[AtomicConfigManager] = None
def get_config_path(self) -> str:
"""Return the path to the main config file (``config/config.json``)."""
return self.config_path
def get_secrets_path(self) -> str:
"""Return the path to the secrets file (``config/config_secrets.json``)."""
return self.secrets_path
def _get_atomic_manager(self) -> AtomicConfigManager:
+121 -501
View File
@@ -1,30 +1,8 @@
"""
Display Controller top-level orchestration for the LEDMatrix application.
This module owns the main run loop that drives the LED display. It ties
together every major subsystem:
- ConfigManager / ConfigService loads config.json, hot-reloads on change
- DisplayManager hardware (or emulator) output interface
- FontManager TTF/BDF font loading and caching
- CacheManager multi-tier API response cache
- PluginManager plugin lifecycle (load, update, display)
- DisplaySyncManager optional leader/follower multi-Pi sync
- VegasModeCoordinator optional continuous Vegas scroll mode
The main loop inside :meth:`DisplayController.run` rotates through enabled
plugin display modes, respecting schedule windows, brightness dim schedules,
on-demand overrides, and live-priority interrupts.
Entry point: :func:`main` instantiates :class:`DisplayController` and calls
:meth:`~DisplayController.run`.
"""
import time
import os
import json
from pathlib import Path
from typing import Dict, Any, List, Optional, Callable
from typing import Dict, Any, List, Optional
from datetime import datetime
from concurrent.futures import ThreadPoolExecutor, as_completed # pylint: disable=no-name-in-module
import pytz
@@ -50,24 +28,6 @@ DEFAULT_DYNAMIC_DURATION_CAP = 180.0
WIFI_STATUS_FILE = None # Will be initialized in __init__
class DisplayController:
"""
Top-level controller that owns the LED display run loop.
Responsibilities
----------------
* Initialise and wire together all subsystems at startup.
* Rotate through plugin display modes in :meth:`run`.
* Honour schedule windows (active/inactive hours) and dim schedules.
* Handle on-demand override requests (external callers can pin a
specific plugin/mode for a fixed duration via the cache bus).
* Coordinate with a follower Pi when multi-display sync is configured.
* Delegate all actual content to the plugin system this class contains
no display logic of its own.
There is exactly one instance per process; call :func:`main` to create
it and start the run loop.
"""
def __init__(self):
start_time = time.time()
logger.info("Starting DisplayController initialization")
@@ -163,13 +123,6 @@ class DisplayController:
self.plugin_modes = {} # mode -> plugin_instance mapping for plugin-first dispatch
self.mode_to_plugin_id: Dict[str, str] = {}
self.plugin_display_modes: Dict[str, List[str]] = {}
# Per-plugin config-change callbacks, kept so we can unsubscribe a
# plugin when it is disabled live.
self._plugin_config_callbacks: Dict[str, Callable] = {}
# Set by the config-watcher thread when the enabled-plugin set changes;
# the main run loop reconciles (loads/unloads) on its own thread so
# mutating available_modes never races with rendering.
self._pending_plugin_reconcile = False
self.on_demand_active = False
self.on_demand_mode: Optional[str] = None
self.on_demand_modes: List[str] = [] # All modes for the on-demand plugin
@@ -185,11 +138,7 @@ class DisplayController:
self.on_demand_last_event: Optional[str] = None
self.on_demand_schedule_override = False
self.rotation_resume_index: Optional[int] = None
# Saved rotation position when a live-priority plugin preempts the
# rotation, so it resumes where it left off (not after the live plugin)
# once live priority ends.
self._live_resume_index: Optional[int] = None
# WiFi status message tracking
global WIFI_STATUS_FILE
if WIFI_STATUS_FILE is None:
@@ -199,11 +148,7 @@ class DisplayController:
self.wifi_status_file = WIFI_STATUS_FILE
self.wifi_status_active = False
self.wifi_status_expires_at: Optional[float] = None
# Plugin display() signature cache — must be initialised before the plugin
# loading loop below so the .pop() invalidation at load time is always safe.
self._plugin_accepts_display_mode: Dict[str, bool] = {}
try:
logger.info("Attempting to import plugin system...")
from src.plugin_system import PluginManager
@@ -230,24 +175,7 @@ class DisplayController:
cache_manager=self.cache_manager,
font_manager=self.font_manager
)
# Activate the plugin health/metrics subsystem. PluginManager leaves
# health_tracker/resource_monitor as None by default; wiring real
# instances here turns on the circuit breaker (a repeatedly-failing
# plugin's update() is skipped after consecutive failures, then
# retried after a cooldown) and per-plugin execution-time metrics.
# Both persist to the shared cache so the web UI can surface them.
# Done before discovery/loading so load-time schema warnings have a
# tracker to record against.
try:
from src.plugin_system.plugin_health import PluginHealthTracker
from src.plugin_system.resource_monitor import PluginResourceMonitor
self.plugin_manager.health_tracker = PluginHealthTracker(self.cache_manager)
self.plugin_manager.resource_monitor = PluginResourceMonitor(self.cache_manager)
logger.info("Plugin health tracking and resource monitoring enabled")
except Exception as e:
logger.warning("Could not enable plugin health/resource monitoring: %s", e)
# Validate plugins after plugin manager is created
try:
from src.startup_validator import StartupValidator
@@ -355,10 +283,45 @@ class DisplayController:
logger.info("✓ Loaded plugin %s in %.3f seconds (%d/%d)",
plugin_id, result['load_time'], loaded_count, enabled_count)
# Register the loaded plugin's modes, config subscription
# and dispatch maps (shared with live enable hot-reload).
self._register_loaded_plugin(plugin_id)
# Get plugin instance and manifest
plugin_instance = self.plugin_manager.get_plugin(plugin_id)
manifest = self.plugin_manager.plugin_manifests.get(plugin_id, {})
# Prefer plugin's modes attribute if available (dynamic based on enabled leagues)
# Fall back to manifest display_modes if plugin doesn't provide modes
if plugin_instance and hasattr(plugin_instance, 'modes') and plugin_instance.modes:
display_modes = list(plugin_instance.modes)
logger.debug("Using plugin.modes for %s: %s", plugin_id, display_modes)
else:
display_modes = manifest.get('display_modes', [plugin_id])
logger.debug("Using manifest display_modes for %s: %s", plugin_id, display_modes)
if isinstance(display_modes, list) and display_modes:
self.plugin_display_modes[plugin_id] = list(display_modes)
else:
display_modes = [plugin_id]
self.plugin_display_modes[plugin_id] = list(display_modes)
# Subscribe plugin to config changes for hot-reload
if hasattr(self, 'config_service') and hasattr(plugin_instance, 'on_config_change'):
def config_change_callback(old_config: Dict[str, Any], new_config: Dict[str, Any]) -> None:
"""Callback for plugin config changes."""
try:
plugin_instance.on_config_change(new_config)
logger.debug("Plugin %s notified of config change", plugin_id)
except Exception as e:
logger.error("Error in plugin %s config change handler: %s", plugin_id, e, exc_info=True)
self.config_service.subscribe(config_change_callback, plugin_id=plugin_id)
logger.debug("Subscribed plugin %s to config changes", plugin_id)
# Add plugin modes to available modes
for mode in display_modes:
self.available_modes.append(mode)
self.plugin_modes[mode] = plugin_instance
self.mode_to_plugin_id[mode] = plugin_id
logger.debug(" Added mode: %s", mode)
# Show progress
progress_pct = int((loaded_count / enabled_count) * 100)
elapsed = time.time() - plugin_time
@@ -404,43 +367,11 @@ class DisplayController:
self.is_display_active = True
self._was_display_active = True # Track previous state for schedule change detection
# --- Opt #2: cached config values ---
# Avoids chained dict.get() with temporary {} defaults on every hot path call.
# Refreshed via _refresh_config_cache() on every hot-reload.
self._normal_brightness: int = (
self.config.get('display', {}).get('hardware', {}).get('brightness', 90)
)
self._scroll_speed: float = (
self.config.get('display', {}).get('vegas_scroll', {}).get('scroll_speed', 75)
)
# Brightness state tracking for dim schedule
self.current_brightness = self._normal_brightness
self.current_brightness = self.config.get('display', {}).get('hardware', {}).get('brightness', 90)
self.is_dimmed = False
self._was_dimmed = False
# --- Opt #3: schedule minute-gate ---
# Both _check_schedule and _check_dim_schedule re-evaluated at most once per
# clock minute. Storing the (hour, minute) tuple that was last evaluated lets
# the methods skip all timezone / strptime work within the same minute.
# Reset to None on config change so the next call re-evaluates immediately.
self._tz = None # pytz timezone, lazily built from config
self._schedule_checked_minute: Optional[tuple] = None
self._dim_checked_minute: Optional[tuple] = None
self._cached_target_brightness: int = self._normal_brightness
# Register controller-level hot-reload callback so cached config values
# (_normal_brightness, _scroll_speed, _tz, minute-gates) stay in sync
# when the user saves settings via the web UI.
def _controller_config_change(old_config: Dict[str, Any], new_config: Dict[str, Any]) -> None:
self._refresh_config_cache(new_config)
# If a plugin was enabled/disabled, flag a reconcile for the main
# loop to apply (loading/unloading off the watcher thread is unsafe).
if self._enabled_set_changed(old_config, new_config):
self._pending_plugin_reconcile = True
self.config_service.subscribe(_controller_config_change)
# Publish initial on-demand state
try:
self._publish_on_demand_state()
@@ -602,24 +533,17 @@ class DisplayController:
logger.debug("Schedule is disabled - display always active")
return
# Lazily build the timezone object once; reuse on every subsequent call.
if self._tz is None:
timezone_str = self.config.get('timezone', 'UTC')
try:
self._tz = pytz.timezone(timezone_str)
except pytz.UnknownTimeZoneError:
logger.warning("Unknown timezone '%s', using UTC", timezone_str)
self._tz = pytz.UTC
# Get configured timezone, default to UTC
timezone_str = self.config.get('timezone', 'UTC')
try:
tz = pytz.timezone(timezone_str)
except pytz.UnknownTimeZoneError:
logger.warning(f"Unknown timezone '{timezone_str}', using UTC")
tz = pytz.UTC
current_time = datetime.now(self._tz)
# Gate: schedule state can only change on a minute boundary, so skip
# all the strptime / comparison work if we already evaluated this minute.
current_minute_key = (current_time.hour, current_time.minute)
if current_minute_key == self._schedule_checked_minute:
return
self._schedule_checked_minute = current_minute_key
current_day = current_time.strftime('%A').lower() # e.g. 'monday'
# Use timezone-aware current time
current_time = datetime.now(tz)
current_day = current_time.strftime('%A').lower() # Get day name (monday, tuesday, etc.)
current_time_only = current_time.time()
# Check if per-day schedule is configured
@@ -708,8 +632,8 @@ class DisplayController:
Target brightness level (dim_brightness if in dim period,
normal brightness otherwise)
"""
# Opt #2: use cached brightness rather than re-traversing config dict
normal_brightness = self._normal_brightness
# Get normal brightness from config
normal_brightness = self.config.get('display', {}).get('hardware', {}).get('brightness', 90)
# If display is OFF via schedule, don't process dim schedule
if not self.is_display_active:
@@ -723,21 +647,15 @@ class DisplayController:
self.is_dimmed = False
return normal_brightness
# Opt #3: lazily build timezone; gate full re-parse to once per clock minute
if self._tz is None:
timezone_str = self.config.get('timezone', 'UTC')
try:
self._tz = pytz.timezone(timezone_str)
except pytz.UnknownTimeZoneError:
logger.warning("Unknown timezone '%s' in dim schedule, using UTC", timezone_str)
self._tz = pytz.UTC
current_time = datetime.now(self._tz)
current_minute_key = (current_time.hour, current_time.minute)
if current_minute_key == self._dim_checked_minute:
return self._cached_target_brightness
self._dim_checked_minute = current_minute_key
# Get configured timezone
timezone_str = self.config.get('timezone', 'UTC')
try:
tz = pytz.timezone(timezone_str)
except pytz.UnknownTimeZoneError:
logger.warning(f"Unknown timezone '{timezone_str}' in dim schedule, using UTC")
tz = pytz.UTC
current_time = datetime.now(tz)
current_day = current_time.strftime('%A').lower()
current_time_only = current_time.time()
@@ -785,12 +703,10 @@ class DisplayController:
logger.info(f"Dim schedule deactivated: brightness restored to {target_brightness}%")
self._was_dimmed = self.is_dimmed
self._cached_target_brightness = target_brightness # persist for minute-gate
return target_brightness
except ValueError as e:
logger.warning("Invalid dim schedule time format: %s", e)
self._cached_target_brightness = normal_brightness # persist for minute-gate
logger.warning(f"Invalid dim schedule time format: {e}")
return normal_brightness
def _update_modules(self):
@@ -1466,107 +1382,38 @@ class DisplayController:
except Exception as e:
logger.debug(f"Error logging memory stats: {e}")
def _apply_live_priority(self, live_priority_mode):
"""Switch to a live-priority mode, or resume rotation when it ends.
When a live-priority plugin preempts the rotation, the position the
rotation had reached is saved so that, once live priority ends, the
rotation resumes from there instead of continuing after the live
plugin's mode (which would skip every mode between the two). The save
happens only on the initial switch, not on each re-check while the
live hold continues.
def _check_live_priority(self):
"""
if live_priority_mode:
if self.current_display_mode != live_priority_mode:
logger.info("Live content detected - switching immediately to %s", live_priority_mode)
if self._live_resume_index is None:
self._live_resume_index = self.current_mode_index
self.current_display_mode = live_priority_mode
self.force_change = True
# Update mode index to match the new mode
try:
self.current_mode_index = self.available_modes.index(live_priority_mode)
except ValueError:
pass
elif self._live_resume_index is not None and self.available_modes:
# Live priority ended — resume rotation where it was interrupted.
self.current_mode_index = self._live_resume_index % len(self.available_modes)
self.current_display_mode = self.available_modes[self.current_mode_index]
self.force_change = True
logger.info("Live priority ended - resuming rotation at %s", self.current_display_mode)
self._live_resume_index = None
def _collect_live_modes(self):
"""Return every currently live-priority mode, in registration order.
Scans all registered plugin modes; for each plugin that has live
priority *and* live content, collects the specific live mode(s) it
reports via get_live_modes() (only those actually registered), falling
back to the scanned mode name when it ends in '_live'. Deduplicated,
preserving order. A plugin registered under several mode keys (the
sports plugins register one per league) contributes each live mode once.
Check all plugins for live priority content.
Returns the mode that should be displayed if live content is found, None otherwise.
"""
live = []
seen = set()
for mode_name, plugin_instance in self.plugin_modes.items():
if not (hasattr(plugin_instance, 'has_live_priority')
and hasattr(plugin_instance, 'has_live_content')):
continue
try:
if not (plugin_instance.has_live_priority()
and plugin_instance.has_live_content()):
continue
resolved = []
if hasattr(plugin_instance, 'get_live_modes'):
for suggested_mode in (plugin_instance.get_live_modes() or []):
if suggested_mode in self.plugin_modes:
resolved.append(suggested_mode)
if not resolved and mode_name.endswith('_live'):
resolved.append(mode_name)
for m in resolved:
if m not in seen:
seen.add(m)
live.append(m)
except Exception as e:
logger.warning("Error checking live priority for %s: %s", mode_name, e)
return live
def _check_live_priority(self, advance=False):
"""Return the live-priority mode to display, or None if nothing is live.
When several plugins report live content at once (e.g. a baseball game
and a soccer match), this round-robins between them so the display
alternates each dwell instead of pinning to whichever plugin is first in
registration order.
advance=False (default): a non-advancing peek returns the live mode
already on screen if it is still live, otherwise the first live mode.
Used by the Vegas coordinator and the vegas-active check, which only
need to know whether *any* game is live (and must not spin the cursor).
advance=True: the rotation pick returns the live mode *after* the one
currently shown, so each dwell advances to the next live game. The
currently-displayed mode is the cursor, so this stays correct as games
start and end (no separate index to keep in sync).
"""
live_modes = self._collect_live_modes()
if not live_modes:
return None
if self.current_display_mode in live_modes:
if advance:
idx = live_modes.index(self.current_display_mode)
return live_modes[(idx + 1) % len(live_modes)]
return self.current_display_mode
return live_modes[0]
if hasattr(plugin_instance, 'has_live_priority') and hasattr(plugin_instance, 'has_live_content'):
try:
if plugin_instance.has_live_priority() and plugin_instance.has_live_content():
# Get the specific live mode from the plugin if available
if hasattr(plugin_instance, 'get_live_modes'):
live_modes = plugin_instance.get_live_modes()
if live_modes and len(live_modes) > 0:
# Verify the mode actually exists before returning it
for suggested_mode in live_modes:
if suggested_mode in self.plugin_modes:
return suggested_mode
# If suggested modes don't exist, fall through to check current mode
# Fallback: if this mode ends with _live, return it
if mode_name.endswith('_live'):
return mode_name
except Exception as e:
logger.warning("Error checking live priority for %s: %s", mode_name, e)
return None
def run(self):
"""Run the display controller, switching between displays."""
if not self.available_modes:
logger.warning(
"No display modes are enabled at startup; idling until a "
"plugin is enabled via the web UI."
)
logger.warning("No display modes are enabled. Exiting.")
self.display_manager.cleanup()
return
try:
# Initialize with cached data for fast startup - let background updates refresh naturally
logger.info("Starting display with cached data (fast startup mode)")
@@ -1574,25 +1421,6 @@ class DisplayController:
logger.info(f"Initial mode set to: {self.current_display_mode} (index: {self.current_mode_index}, total modes: {len(self.available_modes)})")
while True:
# Apply plugin enable/disable edits saved via the web UI. The
# config-watcher thread only sets the flag; loading/unloading and
# rebuilding available_modes happens here on the render thread so
# it can't race with rendering. Deferred while on-demand is active
# (the flag stays set) so we don't fight its temporary-enable.
if self._pending_plugin_reconcile and not self.on_demand_active:
# Only clear the flag on success -- a retryable failure
# (e.g. discovery) leaves it set so the request isn't lost.
if self._reconcile_enabled_plugins():
self._pending_plugin_reconcile = False
if not self.available_modes:
# Nothing to render yet. Re-check _pending_plugin_reconcile
# every ~1s (rather than a long sleep) so enabling a plugin
# via the web UI is picked up about as promptly as it would
# be once modes exist and the loop is iterating per-frame.
self._sleep_with_plugin_updates(1)
continue
# Handle on-demand commands before rendering
self._poll_on_demand_requests()
self._check_on_demand_expiration()
@@ -1655,8 +1483,12 @@ class DisplayController:
rp = vc.render_pipeline if (vc and vc.render_pipeline) else None
width = self.display_manager.width
# Opt #2: use pre-cached scroll speed (constant for the run)
vegas_speed = self._scroll_speed
# Advance local position at Vegas scroll speed (px/s → px/tick)
vegas_speed = (
self.config.get('display', {})
.get('vegas_scroll', {})
.get('scroll_speed', 75)
)
local_x = getattr(self, '_follower_local_x', None)
if local_x is None:
local_x = float(width) # safe start (past pre-roll guard)
@@ -1738,12 +1570,18 @@ class DisplayController:
# Display failed, clear the status and continue normally
wifi_status_data = None
# Check for live priority content and switch to it immediately.
# advance=True so multiple simultaneously-live games take turns
# (round-robin) instead of pinning to the first plugin.
# Check for live priority content and switch to it immediately
if not self.on_demand_active and not wifi_status_data:
live_priority_mode = self._check_live_priority(advance=True)
self._apply_live_priority(live_priority_mode)
live_priority_mode = self._check_live_priority()
if live_priority_mode and self.current_display_mode != live_priority_mode:
logger.info("Live content detected - switching immediately to %s", live_priority_mode)
self.current_display_mode = live_priority_mode
self.force_change = True
# Update mode index to match the new mode
try:
self.current_mode_index = self.available_modes.index(live_priority_mode)
except ValueError:
pass
# Vegas scroll mode - continuous ticker across all plugins
# Priority: on-demand > wifi-status > live-priority > vegas > normal rotation
@@ -1790,8 +1628,7 @@ class DisplayController:
manager_to_display = None
logger.info("Processing mode: %s (%d available)", active_mode, len(self.available_modes))
logger.debug("Loaded plugin modes: %s", list(self.plugin_modes.keys()))
logger.info(f"Processing mode: {active_mode}, available_modes: {len(self.available_modes)}, plugin_modes: {list(self.plugin_modes.keys())}")
# Handle plugin-based display modes
if active_mode in self.plugin_modes:
@@ -1827,22 +1664,17 @@ class DisplayController:
try:
logger.debug(f"Calling display() for {active_mode} with force_clear={self.force_change}")
if hasattr(manager_to_display, 'display'):
# Opt #1: look up (or compute once) whether display() accepts display_mode
_cache_key = plugin_id
if _cache_key not in self._plugin_accepts_display_mode:
import inspect as _inspect
self._plugin_accepts_display_mode[_cache_key] = (
'display_mode' in _inspect.signature(manager_to_display.display).parameters
)
_accepts_display_mode = self._plugin_accepts_display_mode[_cache_key]
# Check if plugin accepts display_mode parameter
import inspect
sig = inspect.signature(manager_to_display.display)
# Use PluginExecutor for safe execution with timeout
if self.plugin_manager and hasattr(self.plugin_manager, 'plugin_executor'):
result = self.plugin_manager.plugin_executor.execute_display(
manager_to_display,
plugin_id,
force_clear=self.force_change,
display_mode=active_mode if _accepts_display_mode else None
display_mode=active_mode if 'display_mode' in sig.parameters else None
)
# execute_display returns bool, convert to expected format
if result:
@@ -1851,7 +1683,7 @@ class DisplayController:
result = False # Failed
else:
# Fallback to direct call if executor not available
if _accepts_display_mode:
if 'display_mode' in sig.parameters:
result = manager_to_display.display(display_mode=active_mode, force_clear=self.force_change)
else:
result = manager_to_display.display(force_clear=self.force_change)
@@ -1988,9 +1820,9 @@ class DisplayController:
min_duration = base_duration
if dynamic_enabled:
# Try to get plugin-calculated cycle duration first
logger.debug("Attempting to get cycle duration for mode %s", active_mode)
logger.info("Attempting to get cycle duration for mode %s", active_mode)
plugin_cycle_duration = self._plugin_cycle_duration(manager_to_display, active_mode)
logger.debug("Got cycle duration: %s", plugin_cycle_duration)
logger.info("Got cycle duration: %s", plugin_cycle_duration)
# Get caps for validation
plugin_cap = self._plugin_dynamic_cap(manager_to_display)
@@ -2130,7 +1962,7 @@ class DisplayController:
if needs_high_fps:
# Ultra-smooth FPS for scrolling plugins (8ms = 125 FPS)
display_interval = 0.008
logger.debug(
logger.info(
"Entering high-FPS loop for %s with display_interval=%.3fs (%.1f FPS)",
active_mode,
display_interval,
@@ -2140,7 +1972,7 @@ class DisplayController:
while True:
try:
# Pass display_mode to maintain sticky manager state
if _accepts_display_mode:
if 'display_mode' in sig.parameters:
result = manager_to_display.display(display_mode=active_mode, force_clear=False)
else:
result = manager_to_display.display(force_clear=False)
@@ -2182,7 +2014,7 @@ class DisplayController:
else:
# Normal FPS for other plugins (1 second)
display_interval = 1.0
logger.debug(
logger.info(
"Entering normal FPS loop for %s with display_interval=%.3fs",
active_mode,
display_interval
@@ -2204,7 +2036,7 @@ class DisplayController:
try:
# Pass display_mode to maintain sticky manager state
if _accepts_display_mode:
if 'display_mode' in sig.parameters:
result = manager_to_display.display(display_mode=active_mode, force_clear=False)
else:
result = manager_to_display.display(force_clear=False)
@@ -2237,23 +2069,6 @@ class DisplayController:
loop_completed = True
break
# LOAD-BEARING: if current_display_mode changed mid-loop (on-demand
# activation, live priority, etc.), restart the main loop now instead
# of falling into the "honour minimum duration" sleep below. That sleep
# can run for up to the *previous* mode's full display_duration (default
# 30s) and doesn't poll on-demand requests or re-check the mode, so a
# freshly-requested mode switch would sit invisible for up to 30s — or
# get clobbered by a queued stop request — before ever rendering.
#
# This guard was added in #298 (live priority interrupting long display
# durations) and was accidentally dropped in #330 as collateral damage of
# an unrelated time.monotonic() -> time.time() cleanup in the same hunk.
# Removing it again will silently reintroduce both issues. _activate_on_demand
# already sets force_change=True and clears the display, so the next loop
# iteration renders the new mode immediately.
if self.current_display_mode != active_mode:
continue
# Ensure we honour minimum duration when not dynamic and loop ended early
if (
not dynamic_enabled
@@ -2330,7 +2145,7 @@ class DisplayController:
except Exception as e:
logger.warning("Error checking live priority for %s: %s", active_mode, e)
if should_rotate and self.available_modes:
if should_rotate:
self.current_mode_index = (self.current_mode_index + 1) % len(self.available_modes)
self.current_display_mode = self.available_modes[self.current_mode_index]
self.last_mode_change = time.time()
@@ -2518,200 +2333,6 @@ class DisplayController:
self.wifi_status_active = False
self.wifi_status_expires_at = None
def _register_loaded_plugin(self, plugin_id: str) -> List[str]:
"""Register an already-loaded plugin's display modes, config-change
subscription and dispatch maps with the controller.
Shared by startup loading and live enable hot-reload so both paths
build identical controller state. Returns the registered modes.
"""
plugin_instance = self.plugin_manager.get_plugin(plugin_id)
manifest = self.plugin_manager.plugin_manifests.get(plugin_id, {})
# Prefer the plugin's dynamic modes attribute (e.g. based on enabled
# leagues), else fall back to manifest display_modes, else the id.
if plugin_instance is not None and getattr(plugin_instance, 'modes', None):
display_modes = list(plugin_instance.modes)
logger.debug("Using plugin.modes for %s: %s", plugin_id, display_modes)
else:
display_modes = manifest.get('display_modes', [plugin_id])
logger.debug("Using manifest display_modes for %s: %s", plugin_id, display_modes)
if not (isinstance(display_modes, list) and display_modes):
display_modes = [plugin_id]
self.plugin_display_modes[plugin_id] = list(display_modes)
# Subscribe to config changes for per-plugin hot-reload. Bind plugin_id
# and instance as defaults so each plugin's callback targets its own
# instance (avoids late-binding when registering many plugins), and
# remember the callback so we can unsubscribe on disable.
if hasattr(self, 'config_service') and hasattr(plugin_instance, 'on_config_change'):
def config_change_callback(old_config: Dict[str, Any], new_config: Dict[str, Any],
_pid: str = plugin_id, _plugin: Any = plugin_instance) -> None:
"""Callback for plugin config changes."""
try:
_plugin.on_config_change(new_config)
logger.debug("Plugin %s notified of config change", _pid)
except Exception as e:
logger.error("Error in plugin %s config change handler: %s", _pid, e, exc_info=True)
self.config_service.subscribe(config_change_callback, plugin_id=plugin_id)
self._plugin_config_callbacks[plugin_id] = config_change_callback
logger.debug("Subscribed plugin %s to config changes", plugin_id)
# Add modes to the dispatch maps.
for mode in display_modes:
if mode not in self.available_modes:
self.available_modes.append(mode)
self.plugin_modes[mode] = plugin_instance
self.mode_to_plugin_id[mode] = plugin_id
logger.debug(" Added mode: %s", mode)
# Invalidate signature cache so the new instance is re-inspected.
self._plugin_accepts_display_mode.pop(plugin_id, None)
return display_modes
def _unregister_plugin(self, plugin_id: str) -> None:
"""Remove a plugin's modes, config subscription and instance, then
unload it. Used by live disable hot-reload."""
modes = self.plugin_display_modes.pop(plugin_id, [])
for mode in modes:
if mode in self.available_modes:
self.available_modes.remove(mode)
self.plugin_modes.pop(mode, None)
self.mode_to_plugin_id.pop(mode, None)
# Unsubscribe the plugin's config-change callback. Pop only on a
# successful unsubscribe -- if it raises, keep our reference so a
# later retry (or at least cleanup) still has the real callback
# instead of a lost one.
callback = self._plugin_config_callbacks.get(plugin_id)
if callback is not None and hasattr(self, 'config_service'):
try:
self.config_service.unsubscribe(callback, plugin_id=plugin_id)
except Exception as e:
logger.debug("Error unsubscribing plugin %s from config changes: %s", plugin_id, e)
else:
self._plugin_config_callbacks.pop(plugin_id, None)
else:
self._plugin_config_callbacks.pop(plugin_id, None)
self._plugin_accepts_display_mode.pop(plugin_id, None)
# Tear down the instance (cleanup + on_disable + module unload).
try:
self.plugin_manager.unload_plugin(plugin_id)
except Exception as e:
logger.error("Error unloading plugin %s: %s", plugin_id, e, exc_info=True)
logger.info("Disabled plugin %s live (removed modes: %s)", plugin_id, modes)
def _enabled_set_changed(self, old_config: Dict[str, Any], new_config: Dict[str, Any]) -> bool:
"""True if any top-level section's ``enabled`` flag differs between two
configs. A cheap watcher-thread check that gates the full reconcile.
Non-plugin sections (e.g. schedule) may match too; the reconcile
no-ops for anything that isn't a discovered plugin."""
def enabled_map(cfg: Dict[str, Any]) -> Dict[str, bool]:
return {
key: bool(value.get('enabled', False))
for key, value in cfg.items()
if isinstance(value, dict)
}
return enabled_map(old_config) != enabled_map(new_config)
def _reconcile_enabled_plugins(self) -> bool:
"""Load/unload plugins so the running set matches the enabled set in
config. Runs on the main display thread (never the config-watcher
thread) so mutating available_modes is race-free against rendering.
Returns True if reconciliation completed (including a no-op), or
False on a retryable failure -- the caller keeps the pending-reconcile
flag set in that case so the request isn't silently dropped."""
if self.plugin_manager is None:
return True
try:
config = self.config_service.get_config()
except Exception as e:
logger.warning("Plugin reconcile: falling back to cached config: %s", e)
config = self.config
try:
discovered = set(self.plugin_manager.discover_plugins())
except Exception as e:
logger.error("Plugin reconcile: discovery failed: %s", e, exc_info=True)
return False
for p in discovered:
if p in config and not isinstance(config.get(p), dict):
logger.warning(
"Plugin reconcile: config for %s is a %s, not a dict; treating as disabled",
p, type(config.get(p)).__name__
)
desired = {
p for p in discovered
if isinstance(config.get(p), dict) and config.get(p, {}).get('enabled', False)
}
current = set(self.plugin_display_modes.keys())
to_add = desired - current
to_remove = current - desired
if not to_add and not to_remove:
return True
previous_mode = self.current_display_mode
for plugin_id in to_remove:
self._unregister_plugin(plugin_id)
for plugin_id in to_add:
try:
if self.plugin_manager.load_plugin(plugin_id):
modes = self._register_loaded_plugin(plugin_id)
logger.info("Enabled plugin %s live (modes: %s)", plugin_id, modes)
else:
logger.warning("Plugin reconcile: failed to load %s", plugin_id)
except Exception as e:
logger.error("Plugin reconcile: error enabling %s: %s", plugin_id, e, exc_info=True)
self._resync_mode_index_after_change(previous_mode)
logger.info("Plugin reconcile complete: +%s -%s (%d modes)",
sorted(to_add), sorted(to_remove), len(self.available_modes))
return True
def _resync_mode_index_after_change(self, previous_mode: Optional[str]) -> None:
"""Clamp rotation state after available_modes changed. Stays on the
previous mode if it survived, otherwise restarts cleanly within range."""
if not self.available_modes:
self.current_mode_index = 0
self.current_display_mode = None
return
if previous_mode in self.available_modes:
self.current_mode_index = self.available_modes.index(previous_mode)
else:
self.current_mode_index %= len(self.available_modes)
self.current_display_mode = self.available_modes[self.current_mode_index]
def _refresh_config_cache(self, new_config: Dict[str, Any]) -> None:
"""Refresh all config-derived caches when a hot-reload fires.
Called by the controller-level ConfigService subscriber. Keeps
``_normal_brightness``, ``_scroll_speed``, the cached timezone, and the
schedule minute-gates consistent with the live config so callers never
read stale values after the user saves settings via the web UI.
"""
self.config = new_config
self._normal_brightness = (
self.config.get('display', {}).get('hardware', {}).get('brightness', 90)
)
self._scroll_speed = (
self.config.get('display', {}).get('vegas_scroll', {}).get('scroll_speed', 75)
)
# Force the timezone to be re-derived from the new config on next schedule check
self._tz = None
# Invalidate minute-gates so the new schedule/dim times take effect immediately
self._schedule_checked_minute = None
self._dim_checked_minute = None
self._cached_target_brightness = self._normal_brightness
logger.debug("Config cache refreshed (brightness=%s, scroll_speed=%s)",
self._normal_brightness, self._scroll_speed)
def cleanup(self):
"""Clean up resources."""
# Shutdown config service if it exists
@@ -2726,7 +2347,6 @@ class DisplayController:
logger.info("Cleanup complete.")
def main():
"""Application entry point — create a DisplayController and run until interrupted."""
controller = DisplayController()
controller.run()
+22 -258
View File
@@ -1,28 +1,3 @@
"""
Display Manager hardware abstraction layer for the RGB LED matrix.
This module provides :class:`DisplayManager`, the single interface between
application code and the physical (or emulated) LED panel.
Key responsibilities
--------------------
* Initialise the ``RGBMatrix`` (hardware) or ``RGBMatrixEmulator`` depending
on the ``EMULATOR`` environment variable.
* Expose a PIL ``Image``/``ImageDraw`` canvas that plugins draw into, then
flush it to the matrix via double-buffering (:meth:`DisplayManager.update_display`).
* Load and cache TTF/BDF fonts; expose ``draw_text`` for consistent text rendering.
* Provide ``width`` / ``height`` properties always use these instead of
hard-coding display dimensions.
* Write periodic PNG snapshots to ``/tmp/led_matrix_preview.png`` for the
web-interface live preview.
* Track scrolling state and gate deferred updates so plugins don't race with
an in-progress scroll.
Singleton: only one ``DisplayManager`` instance exists per process. The
first call to ``DisplayManager(config)`` creates it; subsequent calls return
the same object.
"""
import json
import os
import tempfile
@@ -33,8 +8,7 @@ else:
from contextlib import contextmanager
from PIL import Image, ImageDraw, ImageFont
import time
from collections import OrderedDict
from typing import Dict, Any, List, Optional, Tuple
from typing import Dict, Any, List
import logging
import math
import freetype
@@ -43,125 +17,7 @@ import freetype
logger = logging.getLogger(__name__)
logger.setLevel(logging.INFO) # Set to INFO level
class _LogicalMatrix:
"""Proxy that reports a logical (per-screen) size for a physical matrix.
In double-sided mode the physical panel chain shows N identical copies of a
smaller logical screen. Plugins size themselves from ``matrix.width`` /
``matrix.height`` (the documented convention, used at 30+ call sites), so
this proxy reports the logical dimensions while delegating every real
operation ``CreateFrameCanvas``, ``SwapOnVSync``, ``brightness``,
``Clear`` and so on to the underlying physical matrix. The duplication
itself happens once per frame in :meth:`DisplayManager.update_display`.
"""
__slots__ = ("_logical_height", "_logical_width", "_matrix")
def __init__(self, matrix: RGBMatrix, logical_width: int, logical_height: int) -> None:
object.__setattr__(self, "_matrix", matrix)
object.__setattr__(self, "_logical_width", logical_width)
object.__setattr__(self, "_logical_height", logical_height)
@property
def width(self) -> int:
"""Logical (per-screen) width reported to plugins."""
return self._logical_width
@property
def height(self) -> int:
"""Logical (per-screen) height reported to plugins."""
return self._logical_height
def __getattr__(self, name: str) -> Any:
"""Forward any non-overridden attribute access to the physical matrix.
Reached only when normal lookup fails (i.e. not width/height/_*).
"""
return getattr(object.__getattribute__(self, "_matrix"), name)
def __setattr__(self, name: str, value: Any) -> None:
"""Forward attribute writes (e.g. ``matrix.brightness = 80``) to it."""
setattr(object.__getattribute__(self, "_matrix"), name, value)
def _resolve_double_sided(physical_width: int, physical_height: int,
ds_config: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""Validate the ``display.double_sided`` config against the physical size.
Returns a dict ``{copies, axis, logical_width, logical_height}`` when the
feature is enabled and the physical panel divides evenly into ``copies``
along the chosen axis, otherwise ``None`` (single-screen behaviour). Bad
config is logged and disabled rather than raised a misconfigured panel
should still light up.
"""
if not isinstance(ds_config, dict) or not ds_config.get('enabled', False):
return None
copies = ds_config.get('copies', 2)
if not isinstance(copies, int) or copies < 2:
logger.warning(
"double_sided: 'copies' must be an integer >= 2 (got %r); "
"disabling double-sided mode", copies)
return None
axis = ds_config.get('axis', 'horizontal')
if axis not in ('horizontal', 'vertical'):
logger.warning(
"double_sided: 'axis' must be 'horizontal' or 'vertical' "
"(got %r); defaulting to 'horizontal'", axis)
axis = 'horizontal'
# Horizontal splits the chain (panels side by side); vertical splits the
# parallel outputs (panels stacked). The split axis must divide evenly.
if axis == 'horizontal':
if physical_width % copies != 0:
logger.warning(
"double_sided: physical width %d is not divisible by copies "
"%d; disabling double-sided mode", physical_width, copies)
return None
logical_width = physical_width // copies
logical_height = physical_height
else:
if physical_height % copies != 0:
logger.warning(
"double_sided: physical height %d is not divisible by copies "
"%d; disabling double-sided mode", physical_height, copies)
return None
logical_width = physical_width
logical_height = physical_height // copies
logger.info(
"double_sided enabled: %d copies on %s axis — logical screen %dx%d "
"tiled across physical %dx%d", copies, axis, logical_width,
logical_height, physical_width, physical_height)
return {
'copies': copies,
'axis': axis,
'logical_width': logical_width,
'logical_height': logical_height,
}
class DisplayManager:
"""
Singleton hardware abstraction layer for the RGB LED matrix.
Plugins should never interact with ``RGBMatrix`` directly; they use this
class to draw content and call :meth:`update_display` to push frames to
the panel.
Typical plugin usage::
canvas = Image.new('RGB', (self.display_manager.width,
self.display_manager.height), (0, 0, 0))
draw = ImageDraw.Draw(canvas)
# ... draw content ...
self.display_manager.image = canvas
self.display_manager.draw = ImageDraw.Draw(self.display_manager.image)
self.display_manager.update_display()
"""
_instance = None
_initialized = False
@@ -177,29 +33,10 @@ class DisplayManager:
self._suppress_test_pattern = suppress_test_pattern
# When True, update_display() and clear() skip hardware writes (used during off-screen content capture)
self._capture_mode_active = False
# Double-sided mode state (resolved in _setup_matrix). When disabled,
# the logical image is blitted to the matrix unchanged.
self._double_sided = None # dict {copies, axis, logical_width, logical_height} or None
self._physical_image = None # full-chain buffer reused each frame when tiling
# Text-width measurement cache: (text, id(font)) -> (width, font_ref)
# Avoids re-measuring the same string+font on every display() call.
# LRU-bounded: keys embed the TEXT, so changing strings (a clock, a
# live score) would otherwise grow it forever on a 24/7 service.
# Entries hold a strong reference to the font so its id() can't be
# recycled by a different font object — an id-keyed cache without
# the reference can return the WRONG width after garbage collection.
# Cleared on _load_fonts() so stale entries don't survive a font reload.
self._text_width_cache: "OrderedDict[tuple, Tuple[int, Any]]" = OrderedDict()
self._TEXT_WIDTH_CACHE_MAX = 1024
# Snapshot settings for web preview integration (service writes, web reads)
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
# Snapshot failures are logged as warnings, rate-limited so a
# persistent failure (e.g. an unwritable file) can't spam the log —
# but is never silent: the snapshot's mtime doubles as the web UI's
# hardware-liveness signal, so a quiet failure makes health checks lie.
self._snapshot_fail_log_ts = 0.0
# Scrolling state tracking for graceful updates
self._scrolling_state = {
@@ -273,10 +110,9 @@ class DisplayManager:
options.rp1_rio = runtime_config.get('rp1_rio')
else:
logger.warning(
"rp1_rio is set in config but the installed rgbmatrix library does "
"not support it — the library was likely built without Pi 5 RP1 "
"support (mmap to 0x3f000000 instead of RP1 chip). "
"Fix: sudo RPI_RGB_FORCE_REBUILD=1 ./first_time_install.sh"
"rp1_rio is set in config but the current RGBMatrixOptions "
"implementation does not support it (RGBMatrixEmulator or older "
"library version) — value will be ignored"
)
logger.info(f"Initializing RGB Matrix with settings: rows={options.rows}, cols={options.cols}, chain_length={options.chain_length}, parallel={options.parallel}, hardware_mapping={options.hardware_mapping}")
@@ -284,26 +120,13 @@ class DisplayManager:
# Initialize the matrix
self.matrix = RGBMatrix(options=options)
logger.info("RGB Matrix initialized successfully")
# Create double buffer for smooth updates. The canvases are always
# full physical size — they back the real chain regardless of mode.
# Create double buffer for smooth updates
self.offscreen_canvas = self.matrix.CreateFrameCanvas()
self.current_canvas = self.matrix.CreateFrameCanvas()
logger.info("Frame canvases created successfully")
# Double-sided mode: wrap the physical matrix so plugins see the
# logical (per-screen) size, and keep a full-chain buffer to tile
# the rendered screen into once per frame.
ds_config = self.config.get('display', {}).get('double_sided', {})
ds = _resolve_double_sided(self.matrix.width, self.matrix.height, ds_config)
self._double_sided = ds
if ds is not None:
self._physical_image = Image.new(
'RGB', (self.matrix.width, self.matrix.height))
self.matrix = _LogicalMatrix(
self.matrix, ds['logical_width'], ds['logical_height'])
# Create image with the (logical) display dimensions
# Create image with full chain width
self.image = Image.new('RGB', (self.matrix.width, self.matrix.height))
self.draw = ImageDraw.Draw(self.image)
logger.info(f"Image canvas created with dimensions: {self.matrix.width}x{self.matrix.height}")
@@ -330,16 +153,8 @@ class DisplayManager:
rows = int(hardware_config.get('rows', 32))
cols = int(hardware_config.get('cols', 64))
chain_length = int(hardware_config.get('chain_length', 2))
parallel = int(hardware_config.get('parallel', 1))
fallback_width = max(1, cols * chain_length)
fallback_height = max(1, rows * parallel)
# Mirror double-sided in fallback so the preview shows one screen.
ds_config = self.config.get('display', {}).get('double_sided', {}) if self.config else {}
ds = _resolve_double_sided(fallback_width, fallback_height, ds_config)
self._double_sided = ds
if ds is not None:
fallback_width = ds['logical_width']
fallback_height = ds['logical_height']
fallback_height = max(1, rows)
except Exception:
fallback_width, fallback_height = 128, 32
@@ -374,7 +189,7 @@ class DisplayManager:
json.dump(_hw_status, _f)
_f.flush()
os.fsync(_f.fileno())
os.chmod(_tmp_path, 0o644)
os.chmod(_tmp_path, 0o600)
os.replace(_tmp_path, _status_path)
except Exception:
try:
@@ -501,25 +316,6 @@ class DisplayManager:
finally:
self._capture_mode_active = False
def _composite_double_sided(self):
"""Tile the logical screen across the full physical chain.
Renders once into ``self._physical_image`` by pasting the rendered
logical image ``copies`` times along the configured axis. The paste is
a single memcpy per copy, so the per-frame cost is negligible and the
plugin render path is untouched.
"""
ds = self._double_sided
phys = self._physical_image
lw = ds['logical_width']
lh = ds['logical_height']
for i in range(ds['copies']):
if ds['axis'] == 'vertical':
phys.paste(self.image, (0, i * lh))
else:
phys.paste(self.image, (i * lw, 0))
return phys
def update_display(self):
"""Update the display using double buffering with proper sync."""
try:
@@ -533,12 +329,8 @@ class DisplayManager:
if self._capture_mode_active:
return # Skip hardware write — content is being captured off-screen
# Copy the current image to the offscreen canvas. In double-sided
# mode the logical screen is first tiled across the full chain.
if self._double_sided is not None:
self.offscreen_canvas.SetImage(self._composite_double_sided())
else:
self.offscreen_canvas.SetImage(self.image)
# Copy the current image to the offscreen canvas
self.offscreen_canvas.SetImage(self.image)
# Swap buffers immediately
self.matrix.SwapOnVSync(self.offscreen_canvas)
@@ -644,9 +436,6 @@ class DisplayManager:
def _load_fonts(self):
"""Load fonts with proper error handling."""
# Font objects get new id()s after reload, so the text-width cache would
# return stale measurements keyed on the old ids. Clear it here.
self._text_width_cache.clear()
try:
# Load Press Start 2P font
self.regular_font = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 8)
@@ -707,37 +496,22 @@ class DisplayManager:
def get_text_width(self, text, font):
"""Get the width of text when rendered with the given font.
Results are cached by (text, font identity) so plugins that measure
the same string every frame (e.g. to centre a score) pay only one
measurement per unique (text, font) pair. The entry keeps the font
alive so its id() can't be recycled, and the cache is LRU-bounded so
ever-changing text (clocks, tickers) can't grow it without limit.
"""
cache_key = (text, id(font))
cached = self._text_width_cache.get(cache_key)
if cached is not None:
self._text_width_cache.move_to_end(cache_key)
return cached[0]
"""Get the width of text when rendered with the given font."""
try:
if isinstance(font, freetype.Face):
# For FreeType faces, calculate width using freetype
width = 0
for char in text:
font.load_char(char)
width += font.glyph.advance.x >> 6
return width
else:
# For PIL fonts, use textbbox
bbox = self.draw.textbbox((0, 0), text, font=font)
width = bbox[2] - bbox[0]
except (AttributeError, TypeError, ValueError, OSError) as e:
logger.error("Error getting text width: %s", e)
return 0
self._text_width_cache[cache_key] = (width, font)
while len(self._text_width_cache) > self._TEXT_WIDTH_CACHE_MAX:
self._text_width_cache.popitem(last=False)
return width
return bbox[2] - bbox[0]
except Exception as e:
logger.error(f"Error getting text width: {e}")
return 0 # Return 0 as fallback
def get_font_height(self, font):
"""Get the height of the given font for line spacing purposes."""
@@ -1181,15 +955,5 @@ class DisplayManager:
pass
self._last_snapshot_ts = now
except Exception as e:
# Snapshot failures must never break displaybut they must not
# be silent either: the snapshot's mtime is the web UI's display
# mirror AND its hardware-liveness proxy, so a quietly failing
# write freezes the mirror and makes health checks lie (seen in
# the field: a stale root-owned /tmp file froze it for a day).
# Warn at most once per 5 minutes to avoid log spam.
if (now - self._snapshot_fail_log_ts) > 300:
self._snapshot_fail_log_ts = now
logger.warning("Snapshot write failing (web preview/health "
"mirror is stale): %s", e)
else:
logger.debug(f"Snapshot write skipped: {e}")
# Snapshot failures should never break display; log at debug to avoid noise
logger.debug(f"Snapshot write skipped: {e}")
+5 -45
View File
@@ -1,30 +1,3 @@
"""
Font Manager TTF/BDF font loading, caching, and dynamic registration.
:class:`FontManager` serves two purposes:
1. **System fonts** loads the configured small/medium/large TTF fonts (and
their BDF bitmap equivalents) at startup, caches metrics, and exposes them
via ``DisplayManager`` attributes (``small_font``, ``medium_font``, etc.).
2. **Plugin fonts** lets plugins register their own fonts at runtime via
:meth:`FontManager.register_manager_font` and resolve them later via
:meth:`FontManager.resolve_font`. Registered fonts are namespaced by
plugin ID so they cannot collide.
Font sources
------------
* Local paths relative to the project root.
* Remote URLs downloaded once, cached to disk, and never re-fetched while
the cached copy is fresh.
BDF fallback
------------
Pixel-accurate LED fonts are stored as ``.bdf`` (Bitmap Distribution Format)
files. When PIL cannot measure BDF glyphs natively, ``freetype-py`` is used
for accurate width/height calculations.
"""
import os
import logging
import freetype
@@ -35,7 +8,6 @@ import urllib.request
import zipfile
import tempfile
import time
from collections import OrderedDict
from pathlib import Path
from PIL import ImageFont
from typing import Dict, Tuple, Optional, Union, Any, List
@@ -59,13 +31,7 @@ class FontManager:
# Font discovery and catalog
self.font_catalog: Dict[str, str] = {} # family_name -> file_path
self.font_cache: Dict[str, Union[ImageFont.FreeTypeFont, freetype.Face]] = {} # (family, size) -> font
# (text, id(font)) -> ((width, height, baseline), font_ref).
# LRU-bounded — keys embed the measured TEXT, so changing strings
# (clocks, live scores) would otherwise grow it forever. Entries
# keep the font alive so its id() can't be recycled by a different
# font object (which would silently return wrong metrics).
self.metrics_cache: "OrderedDict[Any, Tuple[Tuple[int, int, int], Any]]" = OrderedDict()
self._METRICS_CACHE_MAX = 1024
self.metrics_cache: Dict[str, Tuple[int, int, int]] = {} # (text, font_id) -> (width, height, baseline)
# Plugin font management
self.plugin_fonts: Dict[str, Dict[str, Any]] = {} # plugin_id -> font_manifest
@@ -514,14 +480,10 @@ class FontManager:
Returns:
Tuple of (width, height, baseline_offset)
"""
# Key on the text itself (hash(text) could collide) + font identity;
# the entry below keeps the font referenced so the id stays valid.
cache_key = (text, id(font))
cache_key = f"{hash(text)}_{id(font)}"
cached = self.metrics_cache.get(cache_key)
if cached is not None:
self.metrics_cache.move_to_end(cache_key)
return cached[0]
if cache_key in self.metrics_cache:
return self.metrics_cache[cache_key]
try:
if isinstance(font, freetype.Face):
@@ -558,9 +520,7 @@ class FontManager:
baseline = 10
result = (width, height, baseline)
self.metrics_cache[cache_key] = (result, font)
while len(self.metrics_cache) > self._METRICS_CACHE_MAX:
self.metrics_cache.popitem(last=False)
self.metrics_cache[cache_key] = result
return result
def get_font_height(self, font: Union[ImageFont.FreeTypeFont, freetype.Face]) -> int:
+11 -53
View File
@@ -52,18 +52,11 @@ class PluginHealthTracker:
"""Get cache key for plugin health data."""
return f"plugin_health:{plugin_id}"
def _load_health_state(self, plugin_id: str, force_reload: bool = False) -> Dict[str, Any]:
"""Load health state from cache or return defaults.
``force_reload=True`` bypasses the cache manager's in-memory tier so a
read-only consumer (e.g. the web process) observes the writer process's
latest persisted state instead of a stale first snapshot.
"""
def _load_health_state(self, plugin_id: str) -> Dict[str, Any]:
"""Load health state from cache or return defaults."""
cache_key = self._get_health_key(plugin_id)
cached = self.cache_manager.get(
cache_key, max_age=None, memory_ttl=0 if force_reload else None
)
cached = self.cache_manager.get(cache_key, max_age=None)
if cached:
return cached
@@ -86,17 +79,10 @@ class PluginHealthTracker:
self.cache_manager.set(cache_key, state) # Persist indefinitely
self._health_state[plugin_id] = state
def get_health_state(self, plugin_id: str, force_reload: bool = False) -> Dict[str, Any]:
"""Get current health state for a plugin.
``force_reload=True`` re-reads the persisted state from the cache,
bypassing the in-memory copy needed by cross-process readers that
would otherwise be pinned to the first snapshot they loaded.
"""
if force_reload or plugin_id not in self._health_state:
self._health_state[plugin_id] = self._load_health_state(
plugin_id, force_reload=force_reload
)
def get_health_state(self, plugin_id: str) -> Dict[str, Any]:
"""Get current health state for a plugin."""
if plugin_id not in self._health_state:
self._health_state[plugin_id] = self._load_health_state(plugin_id)
return self._health_state[plugin_id]
def record_success(self, plugin_id: str) -> None:
@@ -153,28 +139,6 @@ class PluginHealthTracker:
self._save_health_state(plugin_id, state)
def set_degraded(self, plugin_id: str, reason: Optional[str]) -> None:
"""Flag (or clear) a plugin as degraded without touching the circuit breaker.
Used for non-fatal issues e.g. a config that no longer satisfies the
plugin's schema — that should be surfaced to the user but must NOT cause
the plugin to be skipped or counted as a runtime failure. Passing
``reason=None`` clears the flag. The write is skipped when nothing
actually changes, so calling this on every load is cheap.
Args:
plugin_id: Plugin identifier
reason: Human-readable reason string, or None to clear the flag
"""
state = self.get_health_state(plugin_id)
new_degraded = bool(reason)
new_reason = reason if reason else None
if state.get('degraded', False) == new_degraded and state.get('degraded_reason') == new_reason:
return # No change — avoid a redundant cache write
state['degraded'] = new_degraded
state['degraded_reason'] = new_reason
self._save_health_state(plugin_id, state)
def should_skip_plugin(self, plugin_id: str) -> bool:
"""
Check if plugin should be skipped due to circuit breaker.
@@ -217,13 +181,9 @@ class PluginHealthTracker:
return False
def get_health_summary(self, plugin_id: str, force_reload: bool = False) -> Dict[str, Any]:
"""Get health summary for a plugin.
``force_reload=True`` refreshes from the persisted cache first so
cross-process readers reflect the writer's latest state.
"""
state = self.get_health_state(plugin_id, force_reload=force_reload)
def get_health_summary(self, plugin_id: str) -> Dict[str, Any]:
"""Get health summary for a plugin."""
state = self.get_health_state(plugin_id)
total_calls = state.get('total_successes', 0) + state.get('total_failures', 0)
success_rate = 0.0
@@ -241,8 +201,6 @@ class PluginHealthTracker:
'last_failure_time': state.get('last_failure_time'),
'last_error': state.get('last_error'),
'is_healthy': state.get('circuit_state') == CircuitState.CLOSED.value,
'degraded': state.get('degraded', False),
'degraded_reason': state.get('degraded_reason'),
'circuit_opened_time': state.get('circuit_opened_time'),
'half_open_start_time': state.get('half_open_start_time')
}
+40 -246
View File
@@ -5,11 +5,9 @@ Handles plugin module imports, dependency installation, and class instantiation.
Extracted from PluginManager to improve separation of concerns.
"""
import importlib
import importlib.metadata
import importlib.util
import json
import os
import importlib
import importlib.util
import sys
import subprocess
import threading
@@ -17,101 +15,12 @@ from pathlib import Path
from typing import Dict, Any, Optional, Tuple, Type
import logging
from packaging.requirements import InvalidRequirement, Requirement
from src.exceptions import PluginError
from src.logging_config import get_logger
def requirements_has_real_deps(requirements_file: str) -> bool:
"""
Check whether a requirements.txt actually specifies anything to install.
Plugins that ship all their dependencies with LEDMatrix core often keep a
requirements.txt where every line is commented out, for documentation
purposes only. Running pip against such a file still pays the full
subprocess/resolver cost for zero effect, so callers should skip the
install step entirely when this returns False.
"""
try:
with open(requirements_file, 'r', encoding='utf-8') as fh:
for line in fh:
line = line.strip()
if line and not line.startswith('#'):
return True
except OSError:
# Let the caller's own file handling report the error.
return True
return False
def requirements_are_satisfied(requirements_file: str) -> bool:
"""
Check whether every real requirement line in requirements.txt is already
satisfied by packages installed in the current interpreter.
This replaces marker-file tracking with a direct fact check, so it's
immune to stale/missing/corrupted markers: it looks at what's actually
importable right now rather than trusting a hash comparison from a
previous run. Anything ambiguous (pip options, unparseable lines,
extras, unresolvable versions) conservatively returns False so the
caller falls through to running pip this check only ever saves work,
never masks a real install.
"""
try:
with open(requirements_file, 'r', encoding='utf-8') as fh:
lines = fh.readlines()
except OSError:
return False
for raw_line in lines:
line = raw_line.strip()
if not line or line.startswith('#'):
continue
if line.startswith('-'):
return False # pip option (-r, --index-url, ...), can't verify
try:
req = Requirement(line)
except InvalidRequirement:
return False
if req.extras:
return False # verifying extras' sub-dependencies isn't worth it here
if req.marker is not None and not req.marker.evaluate():
continue # not applicable on this platform/interpreter
try:
installed_version = importlib.metadata.version(req.name)
except importlib.metadata.PackageNotFoundError:
return False
if req.specifier and not req.specifier.contains(installed_version, prereleases=True):
return False
return True
def find_trusted_subdir(trusted_dir: str, name: str) -> Optional[str]:
"""Return `name` if it names an actual subdirectory of trusted_dir, else None.
Used as a containment check for a directory name derived from untrusted
input (a manifest-declared plugin id, an externally-supplied plugin
path): the returned value always comes from enumerating trusted_dir
itself via os.scandir(), so a caller that builds a path by joining
trusted_dir with this return value is joining against a name the
filesystem produced under a trusted root -- not the caller's original
string, which could otherwise smuggle a traversal sequence through.
"""
try:
with os.scandir(trusted_dir) as entries:
for entry in entries:
if entry.name == name and entry.is_dir():
return entry.name
except OSError:
pass
return None
from src.common.permission_utils import (
ensure_file_permissions,
get_plugin_file_mode
)
class PluginLoader:
@@ -159,11 +68,6 @@ class PluginLoader:
Returns:
Path to plugin directory or None if not found
"""
# Sanitize plugin_id — os.path.basename is a CodeQL-recognized path sanitizer
plugin_id = os.path.basename(plugin_id or '')
if not plugin_id:
return None
# Strategy 1: Use mapping from discovery
if plugin_directories and plugin_id in plugin_directories:
plugin_dir = plugin_directories[plugin_id]
@@ -171,16 +75,14 @@ class PluginLoader:
self.logger.debug("Using plugin directory from discovery mapping: %s", plugin_dir)
return plugin_dir
# Strategy 2: Direct paths — resolve and validate they stay within plugins_dir
plugins_dir_resolved = plugins_dir.resolve()
for _candidate_name in (plugin_id, f"ledmatrix-{plugin_id}"):
_candidate = (plugins_dir_resolved / _candidate_name).resolve()
try:
_candidate.relative_to(plugins_dir_resolved)
except ValueError:
continue
if _candidate.exists():
return _candidate
# Strategy 2: Direct paths
plugin_dir = plugins_dir / plugin_id
if plugin_dir.exists():
return plugin_dir
plugin_dir = plugins_dir / f"ledmatrix-{plugin_id}"
if plugin_dir.exists():
return plugin_dir
# Strategy 3: Case-insensitive search
normalized_id = plugin_id.lower()
@@ -221,140 +123,58 @@ class PluginLoader:
except (json.JSONDecodeError, Exception) as e:
self.logger.debug("Skipping %s due to manifest error: %s", item.name, e)
continue
return None
def install_dependencies(
self,
plugin_dir: Path,
plugin_id: str,
plugins_dir: Path,
timeout: int = 300
) -> bool:
"""
Install plugin dependencies from requirements.txt.
Args:
plugin_dir: Plugin directory path
plugin_id: Plugin identifier
plugins_dir: Trusted base plugins directory for path containment check.
Required (not optional) so every caller reconstructs the plugin
path through the sanitiser below rather than trusting plugin_dir
directly -- CodeQL's path-injection query (and a malicious
manifest/plugin_id in practice) can't tell a legitimate
plugin_dir from one crafted to traverse outside plugins_dir.
timeout: Installation timeout in seconds
Returns:
True if dependencies installed or not needed, False on error
"""
plugin_id = os.path.basename(plugin_id or '')
if not plugin_id:
return False
# Resolve to a canonical absolute path (normalises .. and symlinks)
plugin_dir_real = os.path.realpath(str(plugin_dir))
plugins_dir_real = os.path.realpath(str(plugins_dir))
requested_name = os.path.basename(plugin_dir_real)
# Match the requested directory against an entry actually enumerated
# from the trusted plugins_dir, and build the path from that entry --
# not from requested_name. A name that came out of os.scandir() on a
# trusted root carries no taint regardless of what the caller asked
# for, so this is a real containment guarantee (an allowlist check
# against a trusted source), not a string-sanitisation of untrusted
# input that a static analyzer has to trust blindly.
matched_name = find_trusted_subdir(plugins_dir_real, requested_name)
if matched_name is None:
self.logger.error(
"Plugin directory for %s not found inside plugins dir", plugin_id
)
return False
safe_plugin_dir = os.path.join(plugins_dir_real, matched_name)
requirements_file = os.path.join(safe_plugin_dir, "requirements.txt")
if not os.path.isfile(requirements_file):
requirements_file = plugin_dir / "requirements.txt"
if not requirements_file.exists():
return True # No dependencies needed
if not requirements_has_real_deps(requirements_file):
self.logger.debug(
"requirements.txt for %s has no real dependencies (comments/blank only), skipping pip",
plugin_id
)
# Check if already installed
marker_path = plugin_dir / ".dependencies_installed"
if marker_path.exists():
self.logger.debug("Dependencies already installed for %s", plugin_id)
return True
if requirements_are_satisfied(requirements_file):
self.logger.debug(
"Dependencies for %s already satisfied in current environment, skipping pip",
plugin_id
)
return True
try:
self.logger.info("Installing dependencies for plugin %s...", plugin_id)
result = subprocess.run(
[sys.executable, "-m", "pip", "install", "--break-system-packages", "-r", requirements_file],
[sys.executable, "-m", "pip", "install", "--break-system-packages", "-r", str(requirements_file)],
capture_output=True,
text=True,
timeout=timeout,
check=False
)
if result.returncode == 0:
# Mark as installed
marker_path.touch()
# Set proper file permissions after creating marker
ensure_file_permissions(marker_path, get_plugin_file_mode())
self.logger.info("Dependencies installed successfully for %s", plugin_id)
return True
else:
stderr = result.stderr or ""
# uninstall-no-record-file means a system-managed copy of a package
# (e.g. apt's python3-requests, which ships no pip RECORD file) is in
# the way of the version this requirements.txt pins. Retry with
# --ignore-installed so pip lays the pinned version down alongside
# the system copy instead of trying to replace it — matching the
# retry already used by install_dependencies_apt.py / safe_pip_install.sh.
# Without this retry, the plugin would silently keep running against
# whatever version the system happened to ship.
if "uninstall-no-record-file" in stderr:
self.logger.warning(
"Dependencies for %s conflict with a system-managed package "
"(no pip RECORD); retrying with --ignore-installed: %s",
plugin_id, stderr.strip()
)
# Wrapped in its own try/except so a retry timeout is
# tolerated the same way as a retry failure, instead of
# propagating to the outer handler and returning False
# (which would contradict the "assume satisfied" fallback
# below).
try:
# sys.executable is this process's own interpreter (not
# attacker-influenced), and requirements_file is a path
# built internally by find_plugin_directory, never raw
# external input.
retry_result = subprocess.run( # nosec B603 - no shell invoked (list-form argv) # nosemgrep
[sys.executable, "-m", "pip", "install", "--break-system-packages",
"--ignore-installed", "-r", requirements_file],
capture_output=True,
text=True,
timeout=timeout,
check=False
)
if retry_result.returncode != 0:
self.logger.warning(
"Retry with --ignore-installed also failed for %s; assuming the "
"system-managed version satisfies the requirement: %s",
plugin_id, (retry_result.stderr or "").strip()
)
except subprocess.TimeoutExpired:
self.logger.warning(
"Retry with --ignore-installed timed out for %s; assuming the "
"system-managed version satisfies the requirement",
plugin_id
)
return True
self.logger.warning(
"Dependency installation returned non-zero exit code for %s: %s",
plugin_id,
stderr
result.stderr
)
return False
except subprocess.TimeoutExpired:
@@ -529,20 +349,9 @@ class PluginLoader:
Returns:
Loaded module or None on error
"""
plugin_id = os.path.basename(plugin_id or '')
if not plugin_id:
raise PluginError("Invalid plugin ID")
try:
plugin_dir_resolved = plugin_dir.resolve(strict=True)
except OSError:
raise PluginError("Plugin directory not found", plugin_id=plugin_id)
entry_file = (plugin_dir_resolved / entry_point).resolve()
try:
entry_file.relative_to(plugin_dir_resolved)
except ValueError:
raise PluginError("Invalid entry point path", plugin_id=plugin_id)
entry_file = plugin_dir / entry_point
if not entry_file.exists():
error_msg = f"Entry point file not found for plugin {plugin_id}"
error_msg = f"Entry point file not found: {entry_file} for plugin {plugin_id}"
self.logger.error(error_msg)
raise PluginError(error_msg, plugin_id=plugin_id, context={'entry_file': str(entry_file)})
@@ -692,12 +501,11 @@ class PluginLoader:
display_manager: Any,
cache_manager: Any,
plugin_manager: Any,
install_deps: bool = True,
plugins_dir: Optional[Path] = None,
install_deps: bool = True
) -> Tuple[Any, Any]:
"""
Complete plugin loading process.
Args:
plugin_id: Plugin identifier
manifest: Plugin manifest
@@ -707,30 +515,16 @@ class PluginLoader:
cache_manager: Cache manager instance
plugin_manager: Plugin manager instance
install_deps: Whether to install dependencies
plugins_dir: Trusted base plugins directory forwarded to install_dependencies
Returns:
Tuple of (plugin_instance, module)
Raises:
PluginError: If loading fails
"""
# Install dependencies if needed
if install_deps:
if plugins_dir is None:
raise PluginError(
f"plugins_dir is required to install dependencies for plugin {plugin_id} "
"(needed for path containment; pass install_deps=False if the caller "
"doesn't have a trusted plugins directory to supply)",
plugin_id=plugin_id,
context={'plugin_dir': str(plugin_dir)},
)
if not self.install_dependencies(plugin_dir, plugin_id, plugins_dir=plugins_dir):
raise PluginError(
f"Dependency installation failed for plugin {plugin_id} in {plugin_dir}",
plugin_id=plugin_id,
context={'plugin_dir': str(plugin_dir)},
)
self.install_dependencies(plugin_dir, plugin_id)
# Load module
entry_point = manifest.get('entry_point', 'manager.py')
+118 -119
View File
@@ -9,13 +9,13 @@ API Version: 1.0.0
import json
import sys
import subprocess
import time
import threading
import types
from pathlib import Path
from typing import Dict, List, Optional, Any
import logging
from src.exceptions import PluginError, ConfigError
from src.exceptions import PluginError
from src.logging_config import get_logger
from src.plugin_system.plugin_loader import PluginLoader
from src.plugin_system.plugin_executor import PluginExecutor
@@ -81,13 +81,7 @@ class PluginManager:
self.plugin_manifests: Dict[str, Dict[str, Any]] = {}
self.plugin_modules: Dict[str, Any] = {}
self.plugin_last_update: Dict[str, float] = {}
# Cached data-fetch intervals per plugin_id.
# _get_plugin_update_interval falls back to config_manager.get_config()
# (a full dict copy) when the manifest lacks an interval — caching avoids
# that copy on every 30-fps tick. Cleared on load/unload.
self._update_interval_cache: Dict[str, Optional[float]] = {}
# Health tracking (optional, set by display_controller if available)
self.health_tracker = None
self.resource_monitor = None
@@ -177,6 +171,90 @@ class PluginManager:
return plugin_ids
def _get_dependency_marker_path(self, plugin_id: str) -> Path:
"""Get path to dependency installation marker file."""
plugin_dir = self.plugins_dir / plugin_id
if not plugin_dir.exists():
# Try with ledmatrix- prefix
plugin_dir = self.plugins_dir / f"ledmatrix-{plugin_id}"
return plugin_dir / ".dependencies_installed"
def _check_dependencies_installed(self, plugin_id: str) -> bool:
"""Check if dependencies are already installed for a plugin."""
marker_path = self._get_dependency_marker_path(plugin_id)
return marker_path.exists()
def _mark_dependencies_installed(self, plugin_id: str) -> None:
"""Mark dependencies as installed for a plugin."""
marker_path = self._get_dependency_marker_path(plugin_id)
try:
marker_path.touch()
# Set proper file permissions after creating marker
from src.common.permission_utils import (
ensure_file_permissions,
get_plugin_file_mode
)
ensure_file_permissions(marker_path, get_plugin_file_mode())
except (OSError, PermissionError) as e:
self.logger.warning("Could not create dependency marker for %s: %s", plugin_id, e)
def _remove_dependency_marker(self, plugin_id: str) -> None:
"""Remove dependency installation marker."""
marker_path = self._get_dependency_marker_path(plugin_id)
try:
if marker_path.exists():
marker_path.unlink()
except (OSError, PermissionError) as e:
self.logger.warning("Could not remove dependency marker for %s: %s", plugin_id, e)
def _install_plugin_dependencies(self, requirements_file: Path) -> bool:
"""
Install plugin dependencies from requirements.txt.
Args:
requirements_file: Path to requirements.txt
Returns:
True if installation succeeded or not needed, False on error
"""
try:
self.logger.info("Installing dependencies from %s", requirements_file)
result = subprocess.run(
[sys.executable, "-m", "pip", "install", "--break-system-packages", "--no-cache-dir", "-r", str(requirements_file)],
capture_output=True,
text=True,
timeout=300,
check=False
)
if result.returncode == 0:
self.logger.info("Dependencies installed successfully")
return True
else:
self.logger.warning("Dependency installation returned non-zero exit code: %s", result.stderr)
return False
except subprocess.TimeoutExpired:
self.logger.error("Dependency installation timed out")
return False
except FileNotFoundError as e:
self.logger.warning("Command not found: %s. Skipping dependency installation", e)
return True
except (BrokenPipeError, OSError) as e:
# Handle broken pipe errors (errno 32) which can occur during pip downloads
# Often caused by network interruptions or output buffer issues
if isinstance(e, OSError) and e.errno == 32:
self.logger.error(
"Broken pipe error during dependency installation. "
"This usually indicates a network interruption or pip output buffer issue. "
"Try installing again or check your network connection."
)
else:
self.logger.error("OS error during dependency installation: %s", e)
return False
except Exception as e:
self.logger.error("Unexpected error installing dependencies: %s", e, exc_info=True)
return True
def load_plugin(self, plugin_id: str) -> bool:
"""
Load a plugin by ID.
@@ -272,8 +350,7 @@ class PluginManager:
display_manager=self.display_manager,
cache_manager=self.cache_manager,
plugin_manager=self,
install_deps=True,
plugins_dir=self.plugins_dir,
install_deps=True
)
# Store module
@@ -306,20 +383,10 @@ class PluginManager:
self.logger.error("Error validating plugin %s config: %s", plugin_id, e, exc_info=True)
self.state_manager.set_state(plugin_id, PluginState.ERROR, error=e)
return False
# Schema validation (warn/degrade only — never blocks loading).
# A config that violates the plugin's JSON schema is surfaced to the
# user (log warning + degraded flag in the health tracker) but the
# plugin still loads exactly as it does today. This deliberately does
# NOT change load_plugin()'s pass/fail behaviour for any plugin that
# loads under the current code.
self._validate_config_schema_soft(plugin_id, config)
# Store plugin instance
self.plugins[plugin_id] = plugin_instance
self.plugin_last_update[plugin_id] = 0.0
# Invalidate cached interval so next tick re-derives it for this plugin
self._update_interval_cache.pop(plugin_id, None)
# Update state based on enabled status
if config.get('enabled', True):
@@ -343,59 +410,6 @@ class PluginManager:
self.state_manager.set_state(plugin_id, PluginState.ERROR, error=e)
return False
def _validate_config_schema_soft(self, plugin_id: str, config: Dict[str, Any]) -> None:
"""Validate a plugin's config against its JSON schema — warn/degrade only.
On a schema violation this logs a warning and marks the plugin degraded
in the health tracker (when one is wired), so the problem is visible in
the web UI. It never raises, never changes plugin state, and never
affects whether the plugin loads. ``config`` here has already been
merged with schema defaults by the caller, so fields that ship a default
never appear "missing" only genuinely user-supplied required fields
(e.g. an API key) can trip the required-field check.
"""
try:
schema = self.schema_manager.load_schema(plugin_id)
except Exception as e: # pragma: no cover - defensive
self.logger.debug("Could not load schema for %s: %s", plugin_id, e)
return
if not schema:
# No schema shipped — nothing to validate. Clear any stale flag.
self._set_degraded_safe(plugin_id, None)
return
try:
is_valid, errors = self.schema_manager.validate_config_against_schema(
config, schema, plugin_id
)
except Exception as e: # pragma: no cover - defensive
# Validation machinery itself failed — do not penalise the plugin.
self.logger.debug("Schema validation raised for %s: %s", plugin_id, e)
return
if is_valid or not errors:
self._set_degraded_safe(plugin_id, None)
return
summary = "; ".join(errors[:5])
if len(errors) > 5:
summary += f" (+{len(errors) - 5} more)"
self.logger.warning(
"Plugin %s config does not match its schema (loading anyway): %s",
plugin_id, summary,
)
self._set_degraded_safe(plugin_id, f"Config schema: {summary}")
def _set_degraded_safe(self, plugin_id: str, reason: Optional[str]) -> None:
"""Best-effort ``health_tracker.set_degraded`` that never raises."""
if not self.health_tracker:
return
try:
self.health_tracker.set_degraded(plugin_id, reason)
except Exception as e: # pragma: no cover - defensive
self.logger.debug("Could not set degraded flag for %s: %s", plugin_id, e)
def unload_plugin(self, plugin_id: str) -> bool:
"""
Unload a plugin by ID.
@@ -429,8 +443,8 @@ class PluginManager:
# Remove from active plugins
del self.plugins[plugin_id]
self.plugin_last_update.pop(plugin_id, None)
self._update_interval_cache.pop(plugin_id, None)
if plugin_id in self.plugin_last_update:
del self.plugin_last_update[plugin_id]
# Remove main module from sys.modules if present
module_name = f"plugin_{plugin_id.replace('-', '_')}"
@@ -624,46 +638,41 @@ class PluginManager:
def _get_plugin_update_interval(self, plugin_id: str, plugin_instance: Any) -> Optional[float]:
"""
Get the data-fetch interval for a plugin (seconds between update() calls).
Result is cached per plugin_id after the first lookup to avoid calling
config_manager.get_config() which returns a full dict copy on every
tick of the 30-fps display loop. The cache is invalidated when a plugin
is loaded or unloaded.
Get the update interval for a plugin.
Args:
plugin_id: Plugin identifier
plugin_instance: Plugin instance
Returns:
Update interval in seconds or None if not configured
"""
if plugin_id in self._update_interval_cache:
return self._update_interval_cache[plugin_id]
interval: Optional[float] = None
# 1. Manifest (immutable after load — preferred source)
# Check manifest first
manifest = self.plugin_manifests.get(plugin_id, {})
raw = manifest.get('update_interval')
if raw is not None:
update_interval = manifest.get('update_interval')
if update_interval:
try:
interval = float(raw)
return float(update_interval)
except (ValueError, TypeError):
pass
# 2. Plugin config (mutable; only read once and then cached)
if interval is None and self.config_manager:
# Check plugin config
if self.config_manager:
try:
config = self.config_manager.get_config()
raw = config.get(plugin_id, {}).get('update_interval')
if raw is not None:
plugin_config = config.get(plugin_id, {})
update_interval = plugin_config.get('update_interval')
if update_interval:
try:
interval = float(raw)
return float(update_interval)
except (ValueError, TypeError):
pass
except (ConfigError, OSError, ValueError, TypeError) as e:
except Exception as e:
self.logger.debug("Could not get update interval from config: %s", e)
# 3. Default
if interval is None:
interval = 60.0
self._update_interval_cache[plugin_id] = interval
return interval
# Default: 60 seconds
return 60.0
def _record_update_failure(
self,
@@ -744,18 +753,8 @@ class PluginManager:
# If resource monitor exists, wrap the call
def monitored_update():
self.resource_monitor.monitor_call(plugin_id, plugin_instance.update)
# SimpleNamespace stores `update` as an *instance*
# attribute, so attribute lookup returns the plain
# function object as-is. A dynamically-built class
# (`type(..., {'update': monitored_update})`) instead
# stores it as a *class* attribute, which the
# descriptor protocol turns into a bound method on
# access -- silently prepending the instance as an
# implicit first argument to a function that takes
# none, raising "monitored_update() takes 0
# positional arguments but 1 was given" on every call.
success = self.plugin_executor.execute_update(
types.SimpleNamespace(update=monitored_update),
type('obj', (object,), {'update': monitored_update})(),
plugin_id
)
else:
@@ -823,7 +822,7 @@ class PluginManager:
# Get health tracker metrics if available
if self.health_tracker:
health_info = self.health_tracker.get_health_summary(plugin_id)
health_info = self.health_tracker.get_plugin_health(plugin_id)
plugin_metrics['health'] = health_info
else:
plugin_metrics['health'] = {'status': 'unknown'}
@@ -848,7 +847,7 @@ class PluginManager:
# Get resource monitor metrics if available
if self.resource_monitor:
resource_info = self.resource_monitor.get_metrics_summary(plugin_id)
resource_info = self.resource_monitor.get_plugin_metrics(plugin_id)
plugin_metrics['resources'] = resource_info
else:
plugin_metrics['resources'] = {'status': 'unknown'}
+20 -50
View File
@@ -71,32 +71,17 @@ class PluginResourceMonitor:
self.cache_manager = cache_manager
self.enable_monitoring = enable_monitoring and PSUTIL_AVAILABLE
self.logger = logging.getLogger(__name__)
# Resource metrics per plugin
self._metrics: Dict[str, ResourceMetrics] = {}
self._limits: Dict[str, ResourceLimits] = {}
# Thread-local storage for execution tracking
self._local = threading.local()
# Lock for thread-safe access
self._lock = threading.Lock()
# Cache a single psutil.Process handle. Reusing the same handle is what
# lets cpu_percent() be read non-blocking (interval=None): psutil returns
# the utilisation since the *previous* call on that same object. Creating
# a fresh Process() per call would force interval-based sampling that
# blocks the caller — unacceptable on the display loop's update path.
self._process = None
if self.enable_monitoring:
try:
self._process = psutil.Process()
# Prime cpu_percent so the first real measurement returns a
# meaningful delta instead of 0.0.
self._process.cpu_percent(interval=None)
except Exception: # pragma: no cover - psutil edge cases
self._process = None
if not PSUTIL_AVAILABLE and enable_monitoring:
self.logger.warning(
"psutil not available - resource monitoring will be limited to execution time only"
@@ -110,21 +95,13 @@ class PluginResourceMonitor:
"""Get cache key for plugin limits."""
return f"plugin_limits:{plugin_id}"
def get_metrics(self, plugin_id: str, force_reload: bool = False) -> ResourceMetrics:
"""Get current metrics for a plugin.
``force_reload=True`` bypasses both the in-memory copy and the cache
manager's memory tier so a read-only consumer (e.g. the web process)
sees the writer process's latest persisted metrics rather than a stale
first snapshot.
"""
def get_metrics(self, plugin_id: str) -> ResourceMetrics:
"""Get current metrics for a plugin."""
with self._lock:
if force_reload or plugin_id not in self._metrics:
if plugin_id not in self._metrics:
# Try to load from cache
cache_key = self._get_metrics_key(plugin_id)
cached = self.cache_manager.get(
cache_key, max_age=None, memory_ttl=0 if force_reload else None
)
cached = self.cache_manager.get(cache_key, max_age=None)
if cached:
metrics = ResourceMetrics(**cached)
else:
@@ -160,24 +137,21 @@ class PluginResourceMonitor:
def _get_process_memory_mb(self) -> float:
"""Get current process memory usage in MB."""
if not self.enable_monitoring or self._process is None:
if not self.enable_monitoring:
return 0.0
try:
return self._process.memory_info().rss / 1024 / 1024
process = psutil.Process()
return process.memory_info().rss / 1024 / 1024
except Exception:
return 0.0
def _get_process_cpu_percent(self) -> float:
"""Get current process CPU usage percentage (non-blocking).
Reads cpu_percent(interval=None) against the cached process handle, so
it returns immediately with the utilisation observed since the previous
call rather than blocking to sample a fresh interval.
"""
if not self.enable_monitoring or self._process is None:
def _get_process_cpu_percent(self, interval: float = 0.1) -> float:
"""Get current process CPU usage percentage."""
if not self.enable_monitoring:
return 0.0
try:
return self._process.cpu_percent(interval=None)
process = psutil.Process()
return process.cpu_percent(interval=interval)
except Exception:
return 0.0
@@ -307,13 +281,9 @@ class PluginResourceMonitor:
self.logger.error(error_msg)
raise ResourceLimitExceeded(error_msg)
def get_metrics_summary(self, plugin_id: str, force_reload: bool = False) -> Dict[str, Any]:
"""Get metrics summary for a plugin.
``force_reload=True`` refreshes from the persisted cache first so
cross-process readers reflect the writer's latest metrics.
"""
metrics = self.get_metrics(plugin_id, force_reload=force_reload)
def get_metrics_summary(self, plugin_id: str) -> Dict[str, Any]:
"""Get metrics summary for a plugin."""
metrics = self.get_metrics(plugin_id)
limits = self.get_limits(plugin_id)
avg_execution_time = 0.0
+13 -36
View File
@@ -185,19 +185,13 @@ class StateReconciliation:
message=f"Reconciliation failed: {str(e)}"
)
# Top-level config keys that are NOT plugins.
# Includes both config.json structural keys and config_secrets.json top-level
# keys (load_config() deep-merges secrets in, so secrets keys appear here too).
# Top-level config keys that are NOT plugins
_SYSTEM_CONFIG_KEYS = frozenset({
'web_display_autostart', 'timezone', 'location', 'display',
'plugin_system', 'vegas_scroll_speed', 'vegas_separator_width',
'vegas_target_fps', 'vegas_buffer_ahead', 'vegas_plugin_order',
'vegas_excluded_plugins', 'vegas_scroll_enabled', 'logging',
'dim_schedule', 'network', 'system', 'schedule',
# Multi-display sync config (config.json structural key)
'sync',
# Secrets file top-level keys (merged in by load_config)
'github', 'youtube',
})
def _get_config_state(self) -> Dict[str, Dict[str, Any]]:
@@ -322,19 +316,10 @@ class StateReconciliation:
and hasattr(self.store_manager, 'was_recently_uninstalled')
and self.store_manager.was_recently_uninstalled(plugin_id)
)
# Also refuse to resurrect a plugin the user has persistently
# uninstalled. Unlike the in-memory race guard above, this record
# survives restarts, so the user's removal sticks across updates.
persistently_uninstalled = (
self.store_manager is not None
and hasattr(self.store_manager, 'is_plugin_uninstalled')
and self.store_manager.is_plugin_uninstalled(plugin_id)
)
can_repair = (
self.store_manager is not None
and not previously_unrecoverable
and not recently_uninstalled
and not persistently_uninstalled
)
inconsistencies.append(Inconsistency(
plugin_id=plugin_id,
@@ -349,15 +334,15 @@ class StateReconciliation:
# Check: Enabled state mismatch
config_enabled = config.get('enabled', False)
state_mgr_enabled = state_mgr.get('enabled')
if state_mgr_enabled is not None and config_enabled != state_mgr_enabled:
inconsistencies.append(Inconsistency(
plugin_id=plugin_id,
inconsistency_type=InconsistencyType.PLUGIN_ENABLED_MISMATCH,
description=f"Plugin {plugin_id} enabled state mismatch: config={config_enabled}, state_manager={state_mgr_enabled}",
fix_action=FixAction.AUTO_FIX,
current_state={'enabled': state_mgr_enabled},
expected_state={'enabled': config_enabled},
current_state={'enabled': config_enabled},
expected_state={'enabled': state_mgr_enabled},
can_auto_fix=True
))
@@ -380,23 +365,15 @@ class StateReconciliation:
return self._auto_repair_missing_plugin(inconsistency.plugin_id)
elif inconsistency.inconsistency_type == InconsistencyType.PLUGIN_ENABLED_MISMATCH:
# config.json is the user-editable source of truth for enabled state.
# Bring the state manager in sync with config rather than the reverse,
# so that manual config edits (or the state left behind after an
# uninstall+reinstall cycle) don't silently override the user's intent.
config_enabled = inconsistency.expected_state.get('enabled')
success = self.state_manager.set_plugin_enabled(inconsistency.plugin_id, config_enabled)
if success:
self.logger.info(
f"Fixed: Synced state manager enabled={config_enabled} for "
f"{inconsistency.plugin_id} to match config"
)
else:
self.logger.warning(
f"Failed to sync state manager enabled={config_enabled} for "
f"{inconsistency.plugin_id}"
)
return success
# Sync enabled state from state manager to config
expected_enabled = inconsistency.expected_state.get('enabled')
config = self.config_manager.load_config()
if inconsistency.plugin_id not in config:
config[inconsistency.plugin_id] = {}
config[inconsistency.plugin_id]['enabled'] = expected_enabled
self.config_manager.save_config(config)
self.logger.info(f"Fixed: Synced enabled state for {inconsistency.plugin_id}")
return True
except Exception as e:
self.logger.error(f"Error fixing inconsistency: {e}", exc_info=True)
+73 -277
View File
@@ -6,12 +6,10 @@ from both the official registry and custom GitHub repositories.
"""
import os
import re
import json
import stat
import subprocess
import shutil
import threading
import zipfile
import tempfile
import requests
@@ -19,15 +17,10 @@ import time
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime
from pathlib import Path
from typing import List, Dict, Optional, Any, Tuple, Set
from typing import List, Dict, Optional, Any, Tuple
import logging
from urllib.parse import urlparse
from src.common.permission_utils import sudo_remove_directory, install_requirements_file
from src.plugin_system.plugin_loader import (
requirements_has_real_deps, requirements_are_satisfied, find_trusted_subdir
)
from src.common.permission_utils import sudo_remove_directory
try:
from jsonschema import Draft7Validator, ValidationError
@@ -46,24 +39,13 @@ class PluginStoreManager:
"""
REGISTRY_URL = "https://raw.githubusercontent.com/ChuckBuilds/ledmatrix-plugins/main/plugins.json"
# A valid plugin id is a single path component: starts alphanumeric, then
# alphanumerics / dot / dash / underscore. Used to keep the uninstall
# registry from ever turning a corrupt or hand-edited entry (e.g. "",
# "..", "../x") into a filesystem path that purge_uninstalled_plugins
# would delete — an empty id resolves to the plugins root itself.
_PLUGIN_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
def __init__(self, plugins_dir: str = "plugins",
uninstalled_registry_path: Optional[str] = None):
def __init__(self, plugins_dir: str = "plugins"):
"""
Initialize the plugin store manager.
Args:
plugins_dir: Directory where plugins are installed
uninstalled_registry_path: Path to the JSON file recording plugins
the user has uninstalled. Defaults to
``config/uninstalled_plugins.json`` under the project root.
"""
self.plugins_dir = Path(plugins_dir)
self.logger = logging.getLogger(__name__)
@@ -98,25 +80,6 @@ class PluginStoreManager:
self._uninstall_tombstones: Dict[str, float] = {}
self._uninstall_tombstone_ttl = 300 # 5 minutes
# Persistent record of plugins the user has uninstalled. Unlike the
# in-memory tombstones above (a short-lived race guard), this survives
# restarts so that a core ``git pull`` update cannot resurrect a
# built-in plugin the user removed. Built-in plugins (e.g.
# ``web-ui-info``, ``starlark-apps``) are committed into the repo under
# ``plugin-repos/``, so a plain ``git pull`` restores their files even
# after the user deleted them. ``purge_uninstalled_plugins`` re-removes
# any such resurrected directory; ``install_plugin`` clears the record
# when the user deliberately reinstalls. The file is gitignored.
if uninstalled_registry_path is not None:
self._uninstalled_registry_path = Path(uninstalled_registry_path)
else:
self._uninstalled_registry_path = (
Path(__file__).parent.parent.parent / "config" / "uninstalled_plugins.json"
)
# Serializes read-modify-write of the registry file so concurrent
# install/uninstall requests can't lose updates.
self._uninstalled_registry_lock = threading.Lock()
# Cache for _get_local_git_info: {plugin_path_str: (signature, data)}
# where ``signature`` is a tuple of (head_mtime, resolved_ref_mtime,
# head_contents) so a fast-forward update to the current branch
@@ -137,10 +100,6 @@ class PluginStoreManager:
# handlers. Bumping the cached-entry timestamp on failure serves
# the stale payload cheaply until the backoff expires.
self._failure_backoff_seconds = 60
# Prevents concurrent callers from each firing a network request when
# the registry cache expires. Only one thread fetches; others wait and
# then get the result from the warm cache (double-checked locking).
self._registry_fetch_lock = threading.Lock()
# Ensure plugins directory exists
self.plugins_dir.mkdir(exist_ok=True)
@@ -176,135 +135,6 @@ class PluginStoreManager:
return False
return True
def _is_valid_plugin_id(self, plugin_id: Any) -> bool:
"""Return True if ``plugin_id`` is a safe single-component plugin id.
Rejects empty strings, anything with a path separator, and traversal
sequences like ``..`` so a registry entry can never escape (or target
the root of) ``self.plugins_dir`` during a purge.
"""
return isinstance(plugin_id, str) and bool(self._PLUGIN_ID_RE.match(plugin_id))
def _read_uninstalled_registry(self) -> Set[str]:
"""Read the persistent set of uninstalled plugin IDs.
Returns an empty set if the file is missing, unreadable, or corrupt
a broken registry must never block normal plugin operations. Invalid
ids are dropped here so callers never turn them into paths.
"""
try:
if not self._uninstalled_registry_path.exists():
return set()
with open(self._uninstalled_registry_path, 'r', encoding='utf-8') as f:
data = json.load(f)
if not isinstance(data, list):
self.logger.warning(
"Uninstalled-plugin registry at %s is not a list; ignoring it",
self._uninstalled_registry_path,
)
return set()
valid: Set[str] = set()
for pid in data:
if self._is_valid_plugin_id(pid):
valid.add(pid)
else:
self.logger.warning(
"Ignoring invalid plugin id in uninstall registry: %r", pid
)
return valid
except (OSError, ValueError) as e:
self.logger.warning(
"Could not read uninstalled-plugin registry at %s: %s",
self._uninstalled_registry_path, e,
)
return set()
def _write_uninstalled_registry(self, plugin_ids: Set[str]) -> None:
"""Persist the set of uninstalled plugin IDs (sorted, atomically)."""
path = self._uninstalled_registry_path
try:
path.parent.mkdir(parents=True, exist_ok=True)
tmp_path = path.with_suffix(path.suffix + ".tmp")
with open(tmp_path, 'w', encoding='utf-8') as f:
json.dump(sorted(plugin_ids), f, indent=2)
os.replace(tmp_path, path)
except OSError as e:
self.logger.error(
"Failed to write uninstalled-plugin registry at %s: %s", path, e
)
def record_uninstalled_plugin(self, plugin_id: str) -> None:
"""Persistently record that the user uninstalled ``plugin_id``.
Survives restarts so a core update cannot resurrect the plugin.
"""
if not self._is_valid_plugin_id(plugin_id):
self.logger.error("Refusing to record invalid plugin id: %r", plugin_id)
return
with self._uninstalled_registry_lock:
recorded = self._read_uninstalled_registry()
if plugin_id not in recorded:
recorded.add(plugin_id)
self._write_uninstalled_registry(recorded)
self.logger.info("Recorded %s as uninstalled (persistent)", plugin_id)
def forget_uninstalled_plugin(self, *plugin_ids: str) -> None:
"""Drop ``plugin_ids`` from the persistent uninstall registry.
Called when a plugin is deliberately (re)installed so future updates
keep it.
"""
with self._uninstalled_registry_lock:
recorded = self._read_uninstalled_registry()
to_remove = {pid for pid in plugin_ids if pid in recorded}
if to_remove:
self._write_uninstalled_registry(recorded - to_remove)
self.logger.info(
"Cleared uninstall record for %s", ", ".join(sorted(to_remove))
)
def get_uninstalled_plugins(self) -> Set[str]:
"""Return the persistent set of user-uninstalled plugin IDs."""
return self._read_uninstalled_registry()
def is_plugin_uninstalled(self, plugin_id: str) -> bool:
"""Return True if ``plugin_id`` is in the persistent uninstall registry."""
return plugin_id in self._read_uninstalled_registry()
def purge_uninstalled_plugins(self) -> List[str]:
"""Remove on-disk directories for plugins the user has uninstalled.
Built-in plugins committed into the repo are restored on disk by a
core ``git pull``; this re-removes any that the user previously
uninstalled. The registry entries are kept so the purge is idempotent
across every future update (until the user reinstalls). Returns the
list of plugin IDs whose directories were actually removed.
"""
removed: List[str] = []
plugins_root = self.plugins_dir.resolve()
for plugin_id in sorted(self._read_uninstalled_registry()):
plugin_path = self.plugins_dir / plugin_id
# Defense in depth: ids are already validated on read, but never
# remove anything that isn't a direct child of the plugins root.
resolved = plugin_path.resolve()
if resolved == plugins_root or resolved.parent != plugins_root:
self.logger.error(
"Refusing to purge unsafe plugin path for id %r", plugin_id
)
continue
if not plugin_path.exists():
continue
self.logger.info(
"Purging resurrected uninstalled plugin: %s", plugin_id
)
if self._safe_remove_directory(plugin_path):
removed.append(plugin_id)
else:
self.logger.error(
"Failed to purge resurrected plugin directory: %s", plugin_path
)
return removed
def _load_github_token(self) -> Optional[str]:
"""
Load GitHub API token from config_secrets.json if available.
@@ -521,8 +351,7 @@ class PluginStoreManager:
# Extract owner/repo from URL
try:
# Handle different URL formats
_parsed_url = urlparse(repo_url)
if _parsed_url.hostname in ('github.com', 'www.github.com'):
if 'github.com' in repo_url:
parts = repo_url.strip('/').split('/')
if len(parts) >= 2:
owner = parts[-2]
@@ -684,10 +513,9 @@ class PluginStoreManager:
# Try to find plugins.json in common locations
# First try root directory
registry_urls = []
# Extract owner/repo from URL
_parsed_repo_url = urlparse(repo_url)
if _parsed_repo_url.hostname in ('github.com', 'www.github.com'):
if 'github.com' in repo_url:
parts = repo_url.split('/')
if len(parts) >= 2:
owner = parts[-2]
@@ -747,50 +575,41 @@ class PluginStoreManager:
(current_time - self.registry_cache_time) < self.registry_cache_timeout):
return self.registry_cache
with self._registry_fetch_lock:
# Re-check inside the lock — a concurrent caller that was waiting
# may have already populated the cache while we blocked.
current_time = time.time()
if (self.registry_cache and self.registry_cache_time and
not force_refresh and
(current_time - self.registry_cache_time) < self.registry_cache_timeout):
try:
self.logger.info(f"Fetching plugin registry from {self.REGISTRY_URL}")
response = self._http_get_with_retries(self.REGISTRY_URL, timeout=10)
response.raise_for_status()
self.registry_cache = response.json()
self.registry_cache_time = current_time
self.logger.info(f"Fetched registry with {len(self.registry_cache.get('plugins', []))} plugins")
return self.registry_cache
except requests.RequestException as e:
self.logger.error(f"Error fetching registry: {e}")
if raise_on_failure:
raise
# Prefer stale cache over an empty list so the plugin list UI
# keeps working on a flaky connection (e.g. Pi on WiFi). Bump
# registry_cache_time into a short backoff window so the next
# request serves the stale payload cheaply instead of
# re-hitting the network on every request (matches the
# pattern used by github_cache / commit_info_cache).
if self.registry_cache:
self.logger.warning("Falling back to stale registry cache")
self.registry_cache_time = (
time.time() + self._failure_backoff_seconds - self.registry_cache_timeout
)
return self.registry_cache
try:
self.logger.info(f"Fetching plugin registry from {self.REGISTRY_URL}")
response = self._http_get_with_retries(self.REGISTRY_URL, timeout=10)
response.raise_for_status()
self.registry_cache = response.json()
self.registry_cache_time = current_time
self.logger.info(f"Fetched registry with {len(self.registry_cache.get('plugins', []))} plugins")
return {"plugins": []}
except json.JSONDecodeError as e:
self.logger.error(f"Error parsing registry JSON: {e}")
if raise_on_failure:
raise
if self.registry_cache:
self.registry_cache_time = (
time.time() + self._failure_backoff_seconds - self.registry_cache_timeout
)
return self.registry_cache
except requests.RequestException as e:
self.logger.error(f"Error fetching registry: {e}")
if raise_on_failure:
raise
# Prefer stale cache over an empty list so the plugin list UI
# keeps working on a flaky connection (e.g. Pi on WiFi). Bump
# registry_cache_time into a short backoff window so the next
# request serves the stale payload cheaply instead of
# re-hitting the network on every request (matches the
# pattern used by github_cache / commit_info_cache).
if self.registry_cache:
self.logger.warning("Falling back to stale registry cache")
self.registry_cache_time = (
time.time() + self._failure_backoff_seconds - self.registry_cache_timeout
)
return self.registry_cache
return {"plugins": []}
except json.JSONDecodeError as e:
self.logger.error(f"Error parsing registry JSON: {e}")
if raise_on_failure:
raise
if self.registry_cache:
self.registry_cache_time = (
time.time() + self._failure_backoff_seconds - self.registry_cache_timeout
)
return self.registry_cache
return {"plugins": []}
return {"plugins": []}
def search_plugins(self, query: str = "", category: str = "", tags: List[str] = None, fetch_commit_info: bool = True, include_saved_repos: bool = True, saved_repositories_manager = None) -> List[Dict]:
"""
@@ -942,8 +761,7 @@ class PluginStoreManager:
try:
# Convert repo URL to raw content URL
# https://github.com/user/repo -> https://raw.githubusercontent.com/user/repo/branch/manifest.json
_parsed_manifest_url = urlparse(repo_url)
if _parsed_manifest_url.hostname in ('github.com', 'www.github.com'):
if 'github.com' in repo_url:
# Handle different URL formats
repo_url = repo_url.rstrip('/')
if repo_url.endswith('.git'):
@@ -1186,10 +1004,6 @@ class PluginStoreManager:
branch_info = f" (branch: {branch})" if branch else " (latest branch head)"
self.logger.info(f"Installing plugin: {plugin_id}{branch_info}")
# Remember the originally-requested id so we can clear its uninstall
# record on success even if the manifest renames the directory below.
requested_id = plugin_id
plugin_info = self.get_plugin_info(plugin_id, fetch_latest_from_github=True, force_refresh=True)
if not plugin_info:
self.logger.error(f"Plugin not found in registry: {plugin_id}")
@@ -1328,9 +1142,6 @@ class PluginStoreManager:
branch_display = branch_used or plugin_info.get('branch') or plugin_info.get('default_branch', 'unknown')
self.logger.info(f"Successfully installed plugin: {plugin_id} (branch {branch_display})")
# User deliberately (re)installed this plugin — clear any persistent
# uninstall record so future core updates keep it.
self.forget_uninstalled_plugin(requested_id, plugin_id)
return True
except Exception as e:
@@ -1902,63 +1713,34 @@ class PluginStoreManager:
def _install_dependencies(self, plugin_path: Path) -> bool:
"""
Install Python dependencies from requirements.txt.
Args:
plugin_path: Path to plugin directory
Returns:
True if successful or no requirements file
"""
# Reconstruct the plugin path from the trusted self.plugins_dir base +
# an entry actually enumerated from it, rather than trusting
# plugin_path directly -- callers ultimately derive it from a
# plugin-supplied manifest "id" field (see install_plugin_from_url),
# so without this a malicious manifest could point requirements_file
# outside plugins_dir. find_trusted_subdir()'s return value always
# comes from os.scandir() on the trusted root, so building the path
# from it (not from the caller's string) is a real containment
# guarantee, matching the pattern in PluginLoader.install_dependencies().
plugin_dir_real = os.path.realpath(str(plugin_path))
plugins_dir_real = os.path.realpath(str(self.plugins_dir))
requested_name = os.path.basename(plugin_dir_real)
matched_name = find_trusted_subdir(plugins_dir_real, requested_name)
if matched_name is None:
self.logger.error("Plugin directory not found inside plugins dir for dependency install")
return False
safe_plugin_path = Path(os.path.join(plugins_dir_real, matched_name))
requirements_file = safe_plugin_path / "requirements.txt"
requirements_file = plugin_path / "requirements.txt"
if not requirements_file.exists():
self.logger.debug(f"No requirements.txt found in {plugin_path.name}")
return True
if not requirements_has_real_deps(str(requirements_file)):
self.logger.debug(f"requirements.txt for {plugin_path.name} has no real dependencies, skipping pip")
return True
if requirements_are_satisfied(str(requirements_file)):
self.logger.debug(f"Dependencies for {plugin_path.name} already satisfied, skipping pip")
return True
try:
self.logger.info(f"Installing dependencies for {plugin_path.name}")
# Routed through the shared root-visible installer (same one the
# web UI's "Reinstall Plugin Deps" tool uses) rather than a bare
# `pip`/`pip3` off PATH: a bare pip binary can silently resolve to
# a different Python installation than the one that actually runs
# ledmatrix.service, so pip reports success while the package
# stays invisible to the running plugin (e.g. missing `astral`
# for the weather plugin even though "install" succeeded).
result = install_requirements_file(requirements_file, timeout=300)
if result.returncode != 0:
self.logger.error(
f"Error installing dependencies for {plugin_path.name}: {result.stderr}"
)
return False
subprocess.run(
['pip3', 'install', '--break-system-packages', '-r', str(requirements_file)],
check=True,
capture_output=True,
text=True,
timeout=300
)
self.logger.info(f"Dependencies installed successfully for {plugin_path.name}")
return True
except subprocess.CalledProcessError as e:
self.logger.error(f"Error installing dependencies: {e.stderr}")
return False
except subprocess.TimeoutExpired:
self.logger.error("Dependency installation timed out")
return False
@@ -2454,6 +2236,19 @@ class PluginStoreManager:
file_path = line[3:].strip()
untracked_files.append(file_path)
# Remove marker files that are safe to delete (they'll be regenerated)
safe_to_remove = ['.dependencies_installed']
removed_files = []
for file_name in safe_to_remove:
file_path = plugin_path / file_name
if file_path.exists() and file_name in untracked_files:
try:
file_path.unlink()
removed_files.append(file_name)
self.logger.info(f"Removed marker file {file_name} from {plugin_id} before update")
except Exception as e:
self.logger.warning(f"Could not remove {file_name} from {plugin_id}: {e}")
# Check for tracked file changes
status_result = subprocess.run(
['git', '-C', str(plugin_path), 'status', '--porcelain', '--untracked-files=no'],
@@ -2464,9 +2259,10 @@ class PluginStoreManager:
)
has_changes = bool(status_result.stdout.strip())
# If there are untracked files, stash them
if untracked_files:
self.logger.info(f"Found {len(untracked_files)} untracked files in {plugin_id}, will stash them")
# If there are remaining untracked files (not safe to remove), stash them
remaining_untracked = [f for f in untracked_files if f not in removed_files]
if remaining_untracked:
self.logger.info(f"Found {len(remaining_untracked)} untracked files in {plugin_id}, will stash them")
has_changes = True
except subprocess.TimeoutExpired:
# If status check times out, assume there might be changes and proceed
-9
View File
@@ -7,22 +7,13 @@ Provides base classes and utilities for testing LEDMatrix plugins.
from .plugin_test_base import PluginTestCase
from .mocks import MockDisplayManager, MockCacheManager, MockConfigManager, MockPluginManager
from .visual_display_manager import VisualTestDisplayManager
from .bounds_display_manager import BoundsCheckingDisplayManager
from .sizes import (
DEFAULT_TEST_SIZES, SUPPORTED_SIZES, resolve_test_sizes, size_label,
)
__all__ = [
'PluginTestCase',
'VisualTestDisplayManager',
'BoundsCheckingDisplayManager',
'MockDisplayManager',
'MockCacheManager',
'MockConfigManager',
'MockPluginManager',
'DEFAULT_TEST_SIZES',
'SUPPORTED_SIZES',
'resolve_test_sizes',
'size_label',
]
@@ -1,129 +0,0 @@
"""
Bounds-checking display manager.
A VisualTestDisplayManager that draws onto an oversized canvas (the declared
panel size plus a right/bottom margin) while still reporting the declared size
to the plugin. Content that a plugin draws past the right or bottom edge lands
in the margin instead of being silently clipped by PIL, so the harness can
detect overflow the classic symptom of hardcoded coordinates or fonts/icons
that don't scale down to a smaller panel.
Limitations (documented on purpose):
- Overflow past the LEFT or TOP edge (negative coordinates) is still clipped by
PIL and not detected here. The dominant real-world breakage is content that is
too wide/tall for a smaller panel, which this catches.
- BDF text is clipped to the declared bounds by the parent's bitmap drawer, so
BDF overflow is not flagged. Golden-image regression covers those plugins.
- If a plugin replaces the canvas with its own image (display_manager.image = ...),
the margin can't be measured and overflow is reported as undetermined (None).
"""
from typing import Optional, Tuple
from .sizes import DEFAULT_TEST_SIZES
from .visual_display_manager import VisualTestDisplayManager, _MatrixProxy
# Smallest extra band kept on the right/bottom so a few pixels of overflow are
# still visible even on the largest panel in a run.
_BASE_MARGIN = 16
# Fallback overflow reference when a caller doesn't pass one: the largest shape
# in the default sample. We extend every (smaller) canvas out to at least this
# size so content drawn at a coordinate meant for a bigger build — e.g. x=200 on
# a 64-wide panel — lands in the padded region and is flagged, instead of being
# clipped off-canvas and read as a false pass.
_DEFAULT_EXTENT_WIDTH = max(w for w, _ in DEFAULT_TEST_SIZES)
_DEFAULT_EXTENT_HEIGHT = max(h for _, h in DEFAULT_TEST_SIZES)
class BoundsCheckingDisplayManager(VisualTestDisplayManager):
"""Detects drawing that overflows the declared panel size."""
# Kept for backwards compatibility; real padding is computed per-axis below.
MARGIN = _BASE_MARGIN
def __init__(self, width: int = 128, height: int = 32,
overflow_extent: Optional[Tuple[int, int]] = None):
self._declared_width = int(width)
self._declared_height = int(height)
# Pad the canvas out to at least `overflow_extent` (the largest panel
# this run cares about) plus a base margin, so coordinates meant for a
# bigger build are caught — not clipped — when rendering a smaller panel.
# Defaults to the largest shape in the sample when no run is known.
ext_w, ext_h = overflow_extent or (_DEFAULT_EXTENT_WIDTH, _DEFAULT_EXTENT_HEIGHT)
self._canvas_width = max(self._declared_width, int(ext_w)) + _BASE_MARGIN
self._canvas_height = max(self._declared_height, int(ext_h)) + _BASE_MARGIN
# Parent builds the (oversized) backing canvas + fonts.
super().__init__(self._canvas_width, self._canvas_height)
# Plugins must see the DECLARED size, not the padded canvas size.
self.matrix = _MatrixProxy(self._declared_width, self._declared_height)
# -- declared dimensions (override parent's image-derived properties) --
@property
def width(self) -> int:
return self._declared_width
@property
def height(self) -> int:
return self._declared_height
@property
def display_width(self) -> int:
return self._declared_width
@property
def display_height(self) -> int:
return self._declared_height
# -- overflow detection --
def _canvas_is_padded(self) -> bool:
return self.image.size == (self._canvas_width, self._canvas_height)
def check_overflow(self) -> Optional[Tuple[int, int, int, int]]:
"""Bounding box (in full-canvas coords) of any drawing beyond the
declared panel, or None if nothing overflowed / undetermined."""
if not self._canvas_is_padded():
return None
exp_w = self._canvas_width
exp_h = self._canvas_height
boxes = []
right = self.image.crop((self._declared_width, 0, exp_w, exp_h)).getbbox()
if right:
boxes.append((right[0] + self._declared_width, right[1],
right[2] + self._declared_width, right[3]))
bottom = self.image.crop((0, self._declared_height, exp_w, exp_h)).getbbox()
if bottom:
boxes.append((bottom[0], bottom[1] + self._declared_height,
bottom[2], bottom[3] + self._declared_height))
if not boxes:
return None
return (
min(b[0] for b in boxes), min(b[1] for b in boxes),
max(b[2] for b in boxes), max(b[3] for b in boxes),
)
# -- snapshot/image accessors return the cropped, true-panel image --
def declared_image(self):
"""The visible panel: the canvas cropped to the declared size."""
if self._canvas_is_padded():
return self.image.crop((0, 0, self._declared_width, self._declared_height))
return self.image
def save_snapshot(self, path: str) -> None:
self.declared_image().save(path, format='PNG')
def get_image(self):
return self.declared_image()
def get_image_base64(self) -> str:
import base64
import io
buffer = io.BytesIO()
self.declared_image().save(buffer, format='PNG')
return base64.b64encode(buffer.getvalue()).decode('utf-8')
-314
View File
@@ -1,314 +0,0 @@
"""
Plugin safety harness.
Renders a plugin across every declared screen (mode) and every supported matrix
size, capturing crashes and overflow. Used by scripts/check_plugin.py and the
pytest matrix test to guarantee a plugin change doesn't break a screen at a size
the author didn't try.
The render flow mirrors scripts/render_plugin.py (same PluginLoader call), but
this module adds: multi-size iteration, per-mode rendering, overflow detection
via BoundsCheckingDisplayManager, and golden-image comparison.
"""
import contextlib
import http.client
import inspect
import socket
import ssl
import urllib.error
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
from PIL import Image, ImageChops
from src.logging_config import get_logger
from .bounds_display_manager import BoundsCheckingDisplayManager
from .loading import load_config_defaults, load_manifest
from .sizes import DEFAULT_TEST_SIZES, safe_mode_filename, size_label
logger = get_logger("[Plugin Harness]")
def _tolerated_update_errors() -> Tuple[type, ...]:
"""Exception types from update() we treat as a tolerated no-connectivity
failure (expected in CI / headless dev) rather than a real plugin bug.
Anything NOT in this set is a genuine regression a plugin that lets a
non-network exception escape update() should fail the harness, not pass
green because display() happened to survive.
"""
types: List[type] = [
ConnectionError, TimeoutError, # builtins
socket.gaierror, socket.timeout, # DNS / socket timeouts
ssl.SSLError,
urllib.error.URLError,
http.client.HTTPException,
]
try: # requests is optional; cover its whole error tree when present
import requests
types.append(requests.exceptions.RequestException)
except ImportError: # pragma: no cover - requests not installed
logger.debug("requests not installed; its connectivity errors won't be specifically tolerated")
return tuple(types)
_TOLERATED_UPDATE_ERRORS = _tolerated_update_errors()
@dataclass
class RenderResult:
"""Outcome of rendering one (size, mode) of a plugin."""
plugin_id: str
width: int
height: int
mode: str
image: Optional[Image.Image] = None
error: Optional[str] = None # fatal: load/display crash, or a non-network update() error
update_error: Optional[str] = None # tolerated: connectivity error from update() (no network in CI)
overflow: Optional[Tuple[int, int, int, int]] = None # bbox past the panel
# golden comparison (populated only when a golden was provided)
golden_checked: bool = False
golden_ok: Optional[bool] = None
golden_diff_pixels: int = 0
golden_max_delta: int = 0
@property
def size_label(self) -> str:
return size_label(self.width, self.height)
@property
def ok(self) -> bool:
"""Phase-1 pass: rendered without crashing and without overflow, and if a
golden was checked it matched."""
if self.error is not None or self.overflow is not None:
return False
if self.golden_checked and self.golden_ok is False:
return False
return True
def list_modes(plugin_instance: Any, manifest: Dict[str, Any], plugin_id: str) -> List[str]:
"""Enumerate a plugin's screens: instance.modes wins, then manifest
display_modes, then the plugin id as a single mode."""
modes = getattr(plugin_instance, "modes", None)
if modes:
return [str(m) for m in modes]
declared = manifest.get("display_modes")
if declared:
return [str(m) for m in declared]
return [plugin_id]
def _instantiate(plugin_id: str, manifest: Dict[str, Any], plugin_dir: Path,
config: Dict[str, Any], mock_data: Dict[str, Any],
display_manager: Any) -> Any:
"""Load and construct a plugin instance with mocked managers."""
from src.plugin_system.plugin_loader import PluginLoader
from src.plugin_system.testing import MockCacheManager, MockPluginManager
cache_manager = MockCacheManager()
for key, value in (mock_data or {}).items():
cache_manager.set(key, value)
loader = PluginLoader()
plugin_instance, _module = loader.load_plugin(
plugin_id=plugin_id,
manifest=manifest,
plugin_dir=plugin_dir,
config=config,
display_manager=display_manager,
cache_manager=cache_manager,
plugin_manager=MockPluginManager(),
install_deps=False,
)
return plugin_instance
def _render_mode(plugin_instance: Any, mode: str) -> None:
"""Render a specific screen. Prefer an explicit display_mode kwarg; otherwise
drive the plugin's internal mode state machine (first display() call renders
modes[current_mode_index] when current_display_mode is None)."""
sig = inspect.signature(plugin_instance.display)
if "display_mode" in sig.parameters:
plugin_instance.display(force_clear=True, display_mode=mode)
return
modes = getattr(plugin_instance, "modes", None)
if modes and mode in modes:
plugin_instance.current_mode_index = list(modes).index(mode)
if hasattr(plugin_instance, "current_display_mode"):
plugin_instance.current_display_mode = None
plugin_instance.display(force_clear=False)
def _freeze(freeze_time: Optional[str]):
"""Context manager that freezes wall-clock time when freeze_time is given,
so time-dependent plugins (clocks, countdowns) render deterministic goldens."""
if not freeze_time:
return contextlib.nullcontext()
try:
from freezegun import freeze_time as _ft
except ImportError as e: # pragma: no cover - only hit without the dep
raise RuntimeError(
"freeze_time requires the 'freezegun' package (pip install freezegun)"
) from e
return _ft(freeze_time)
def render_plugin_matrix(
plugin_id: str,
plugin_dir: Path,
config: Optional[Dict[str, Any]] = None,
mock_data: Optional[Dict[str, Any]] = None,
sizes: Optional[List[Tuple[int, int]]] = None,
run_update: bool = True,
freeze_time: Optional[str] = None,
) -> List[RenderResult]:
"""Render every (size, mode) combination for a plugin.
Returns a flat list of RenderResult. A fresh plugin instance is built per
(size, mode) so state never leaks between screens. Pass freeze_time (e.g.
"2025-08-01 15:25:00") to make time-dependent plugins reproducible.
"""
plugin_dir = Path(plugin_dir)
manifest = load_manifest(plugin_dir)
# Start from config_schema.json defaults so the plugin behaves like a real
# install; explicit caller config still wins over a schema default.
config = {"enabled": True, **load_config_defaults(plugin_dir), **(config or {})}
sizes = sizes or DEFAULT_TEST_SIZES
results: List[RenderResult] = []
# The largest panel in this run. Every (smaller) canvas is padded out to it
# so a coordinate meant for the biggest configuration is still caught when
# rendering a smaller one, instead of being clipped into a false pass.
extent = (max(w for w, _ in sizes), max(h for _, h in sizes))
with _freeze(freeze_time):
for width, height in sizes:
results.extend(_render_size(
plugin_id, manifest, plugin_dir, config, mock_data or {},
width, height, run_update, extent,
))
return results
def _render_size(plugin_id, manifest, plugin_dir, config, mock_data,
width, height, run_update, extent) -> List[RenderResult]:
"""Render every mode at one size. A fresh instance per mode avoids state leaks."""
results: List[RenderResult] = []
# Discover modes once per size (instance build can depend on config).
try:
probe_dm = BoundsCheckingDisplayManager(width=width, height=height, overflow_extent=extent)
probe = _instantiate(plugin_id, manifest, plugin_dir, config, mock_data, probe_dm)
modes = list_modes(probe, manifest, plugin_id)
except Exception as e: # noqa: BLE001 — surface any load failure as a result
return [RenderResult(plugin_id, width, height, "<load>", error=repr(e))]
for mode in modes:
result = RenderResult(plugin_id, width, height, mode)
dm = BoundsCheckingDisplayManager(width=width, height=height, overflow_extent=extent)
try:
inst = _instantiate(plugin_id, manifest, plugin_dir, config, mock_data, dm)
if run_update:
try:
inst.update()
except _TOLERATED_UPDATE_ERRORS as e:
# Expected when CI / headless dev has no network: record it
# (surfaced in the report) but don't fail the run.
result.update_error = repr(e)
logger.debug("update() connectivity error for %s [%s]: %s", plugin_id, mode, e)
except Exception as e: # noqa: BLE001 — a non-network update() failure is a real bug
# A regression in update() must not pass green just because
# display() survives, so treat it as a failure of this render.
result.error = repr(e)
logger.warning("update() raised a non-connectivity error for %s [%s]: %s",
plugin_id, mode, e)
if result.error is None:
_render_mode(inst, mode)
result.image = dm.get_image()
result.overflow = dm.check_overflow()
except Exception as e: # noqa: BLE001 — a display crash is a real failure
result.error = repr(e)
results.append(result)
return results
# ---------------------------------------------------------------------------
# Golden-image comparison
# ---------------------------------------------------------------------------
def compare_images(rendered: Image.Image, golden: Image.Image,
max_delta: int = 0, max_diff_pixels: int = 0) -> Tuple[bool, int, int]:
"""Compare two images. Returns (ok, diff_pixel_count, max_per_channel_delta).
Tolerances default to exact match; bump them only to absorb known platform
anti-aliasing noise (requires a pinned Pillow + bundled fonts for stability).
"""
if rendered.size != golden.size:
return False, rendered.size[0] * rendered.size[1], 255
a = rendered.convert("RGB")
b = golden.convert("RGB")
diff = ImageChops.difference(a, b)
bbox = diff.getbbox()
if bbox is None:
return True, 0, 0
# Count pixels whose largest per-channel delta exceeds the allowed tolerance,
# and track the worst delta seen (for reporting).
diff_pixels = 0
observed_max = 0
for px in diff.crop(bbox).getdata():
m = max(px) if isinstance(px, tuple) else px
if m > observed_max:
observed_max = m
if m > max_delta:
diff_pixels += 1
# Pass when the number of out-of-tolerance pixels is within budget.
ok = diff_pixels <= max_diff_pixels
return ok, diff_pixels, observed_max
def golden_path(golden_dir: Path, width: int, height: int, mode: str) -> Path:
"""Location of a golden image: <golden_dir>/<WxH>/<mode>.png.
The mode is sanitized to a safe basename so a mode name with '/' or '..'
can't read or write outside the golden directory.
"""
return Path(golden_dir) / size_label(width, height) / f"{safe_mode_filename(mode)}.png"
def compare_to_goldens(results: List[RenderResult], golden_dir: Path,
max_delta: int = 0, max_diff_pixels: int = 0) -> List[RenderResult]:
"""Compare rendered results against committed goldens, mutating each result's
golden_* fields. Results with no golden file on disk are left unchecked."""
for r in results:
if r.image is None:
continue
gp = golden_path(golden_dir, r.width, r.height, r.mode)
if not gp.exists():
continue
r.golden_checked = True
with Image.open(gp) as g:
ok, diff_pixels, observed_max = compare_images(
r.image, g, max_delta=max_delta, max_diff_pixels=max_diff_pixels)
r.golden_ok = ok
r.golden_diff_pixels = diff_pixels
r.golden_max_delta = observed_max
return results
def write_goldens(results: List[RenderResult], golden_dir: Path) -> int:
"""Write each successfully-rendered result to its golden path. Returns count."""
written = 0
for r in results:
if r.image is None or r.error is not None:
continue
gp = golden_path(golden_dir, r.width, r.height, r.mode)
gp.parent.mkdir(parents=True, exist_ok=True)
r.image.save(gp, format="PNG")
written += 1
return written
-82
View File
@@ -1,82 +0,0 @@
"""
Shared helpers for loading a plugin headlessly.
Used by scripts/render_plugin.py, scripts/check_plugin.py, and the harness so
plugin discovery / manifest / config-default logic lives in exactly one place.
"""
import json
from pathlib import Path
from typing import Any, Dict, Optional, Sequence, Union
def find_plugin_dir(plugin_id: str, search_dirs: Sequence[Union[str, Path]]) -> Optional[Path]:
"""Find a plugin directory by searching multiple paths."""
from src.plugin_system.plugin_loader import PluginLoader
loader = PluginLoader()
for search_dir in search_dirs:
search_path = Path(search_dir)
if not search_path.exists():
continue
result = loader.find_plugin_directory(plugin_id, search_path)
if result:
return Path(result)
return None
def load_manifest(plugin_dir: Union[str, Path]) -> Dict[str, Any]:
"""Load and return manifest.json from a plugin directory."""
manifest_path = Path(plugin_dir) / 'manifest.json'
if not manifest_path.exists():
raise FileNotFoundError(f"No manifest.json in {plugin_dir}")
with open(manifest_path, 'r') as f:
return json.load(f)
def load_config_defaults(plugin_dir: Union[str, Path]) -> Dict[str, Any]:
"""Extract default values from a plugin's config_schema.json (empty if none)."""
schema_path = Path(plugin_dir) / 'config_schema.json'
if not schema_path.exists():
return {}
with open(schema_path, 'r') as f:
schema = json.load(f)
defaults: Dict[str, Any] = {}
for key, prop in schema.get('properties', {}).items():
if isinstance(prop, dict) and 'default' in prop:
defaults[key] = prop['default']
return defaults
def load_harness_spec(plugin_dir: Union[str, Path]) -> Dict[str, Any]:
"""Optional per-plugin harness settings from <plugin>/test/harness.json.
Lets a plugin opt into golden-image testing by declaring how to render it
deterministically. All keys optional:
{
"config": {...}, # config overrides
"mock_data": "fixtures/mock.json", # path (relative to plugin dir) to cache fixtures
"freeze_time": "2025-08-01 15:25:00",
"skip_update": false
}
Returns {} when no harness.json exists.
"""
spec_path = Path(plugin_dir) / 'test' / 'harness.json'
if not spec_path.exists():
return {}
with open(spec_path, 'r') as f:
spec = json.load(f)
# Resolve mock_data path and inline its contents for convenience.
mock_rel = spec.get('mock_data')
if mock_rel:
mock_path = Path(plugin_dir) / mock_rel
if not mock_path.exists():
# A declared-but-missing fixture is a harness config error: failing
# loudly beats silently rendering the plugin with no mock data.
raise FileNotFoundError(
f"harness.json references mock_data '{mock_rel}' but "
f"{mock_path} does not exist"
)
with open(mock_path, 'r') as mf:
spec['mock_data_contents'] = json.load(mf)
return spec
-12
View File
@@ -63,23 +63,11 @@ class MockCacheManager:
"""Mock cache manager for testing."""
def __init__(self):
import shutil
import tempfile
import weakref
self._cache: Dict[str, Any] = {}
self._cache_timestamps: Dict[str, float] = {}
self.get_calls = []
self.set_calls = []
self.delete_calls = []
# Real temp dir for plugins that write/read files under cache_dir.
# Registered for cleanup so each mock instance doesn't leak a tmp dir.
self.cache_dir = tempfile.mkdtemp(prefix="ledmatrix-mock-cache-")
self._finalizer = weakref.finalize(
self, shutil.rmtree, self.cache_dir, ignore_errors=True)
def cleanup(self) -> None:
"""Remove the temp cache directory created for this instance."""
self._finalizer()
def get(self, key: str, max_age: Optional[float] = None) -> Optional[Any]:
"""Get a value from cache."""
-120
View File
@@ -1,120 +0,0 @@
"""
LED matrix sizes the plugin safety harness renders against.
There is no fixed set of "supported" panel sizes an RGB matrix build can be
any width/height and configuration (square, rectangle, 2x2, 4x4, 8x2, long
strips, tall stacks, ...). Plugins are expected to read width/height
dynamically and lay themselves out accordingly, so the harness's job is to
prove a plugin survives a *spread* of shapes, not a canonical list.
`DEFAULT_TEST_SIZES` is therefore a representative SAMPLE chosen to span the
axes of variation (narrow, wide, square, tall, small, long), not an
exhaustive or authoritative list. Callers can override it entirely:
- CLI: scripts/check_plugin.py --sizes 8x16,64x64,256x32
- pytest: LEDMATRIX_TEST_SIZES="8x16,64x64" env var (all plugins), or
per-plugin test/harness.json {"sizes": [[8, 16], [64, 64]]}
so anyone can point the harness at the exact panel(s) their build uses.
"""
import os
from typing import Iterable, List, Optional, Sequence, Tuple, Union
# A spread of real panel-grid arrangements (each module is 64x32), not a list of
# "blessed" sizes. Each entry exercises a different layout assumption a plugin
# might accidentally bake in. Annotations are the panel grid (cols x rows).
DEFAULT_TEST_SIZES: List[Tuple[int, int]] = [
(64, 32), # 1x1 — single panel, the tightest common rectangle
(128, 32), # 2x1 — the baseline most plugins are tuned for
(64, 64), # 1x2 — stacked, exercises tall-narrow centering
(128, 64), # 2x2 — block, icon scaling / vertical centering
(256, 32), # 4x1 — long strip, wide horizontal layout
(128, 96), # 2x3 — tall, exercises vertical overflow
(256, 128), # 4x4 — large block, both dimensions big at once
]
# Backwards-compatible alias. Prefer DEFAULT_TEST_SIZES in new code — the old
# name implied these were the only valid panel sizes, which they are not.
SUPPORTED_SIZES = DEFAULT_TEST_SIZES
def size_label(width: int, height: int) -> str:
"""Human/path-friendly label for a size, e.g. '128x32'."""
return f"{width}x{height}"
def parse_size_token(token: str) -> Tuple[int, int]:
"""Parse a single 'WxH' token into an (int, int) pair.
Raises ValueError (with a user-friendly message) on malformed input so
callers can surface it however they like.
"""
cleaned = token.strip().lower()
if "x" not in cleaned:
raise ValueError(f"Invalid size '{token}' (expected WxH, e.g. 128x32)")
w, h = cleaned.split("x", 1)
try:
width, height = int(w), int(h)
except ValueError as exc:
raise ValueError(
f"Invalid size '{token}' (expected numeric WxH, e.g. 128x32)"
) from exc
if width <= 0 or height <= 0:
raise ValueError(
f"Invalid size '{token}' (width and height must be positive, e.g. 128x32)"
)
return (width, height)
def coerce_sizes(
value: Union[str, Iterable[Sequence[int]], None]
) -> Optional[List[Tuple[int, int]]]:
"""Normalize a size spec into a list of (w, h) tuples, or None if empty.
Accepts a comma-separated 'WxH,WxH' string (CLI / env var) or an iterable
of [w, h] / (w, h) pairs (harness.json). Returns None when value is falsy
so callers can fall back to the default sample.
"""
if not value:
return None
if isinstance(value, str):
return [parse_size_token(tok) for tok in value.split(",") if tok.strip()]
sizes: List[Tuple[int, int]] = []
for pair in value:
w, h = pair # raises if not a 2-element sequence
width, height = int(w), int(h)
if width <= 0 or height <= 0:
raise ValueError(f"Invalid size pair {pair!r} (width and height must be positive)")
sizes.append((width, height))
return sizes or None
def resolve_test_sizes(
spec_sizes: Union[str, Iterable[Sequence[int]], None] = None,
) -> List[Tuple[int, int]]:
"""Decide which sizes to render, by precedence:
1. LEDMATRIX_TEST_SIZES env var a global "test on my hardware" override
that wins for every plugin.
2. spec_sizes e.g. a per-plugin harness.json "sizes" list.
3. DEFAULT_TEST_SIZES the representative sample.
"""
env = coerce_sizes(os.environ.get("LEDMATRIX_TEST_SIZES"))
if env:
return env
spec = coerce_sizes(spec_sizes)
if spec:
return spec
return list(DEFAULT_TEST_SIZES)
def safe_mode_filename(mode: str) -> str:
"""A filesystem-safe basename for a plugin mode.
Mode names come from plugin metadata/render state, so a value containing
'/' or '..' could otherwise escape the intended output directory. Collapse
anything that isn't alphanumeric / dash / underscore to '_'.
"""
cleaned = "".join(ch if ch.isalnum() or ch in ("-", "_") else "_" for ch in mode)
return cleaned or "mode"
@@ -454,18 +454,6 @@ class VisualTestDisplayManager:
"""Check if display is currently scrolling."""
return self._scrolling_state['is_scrolling']
def process_deferred_updates(self):
"""Process any deferred updates (no-op for testing).
Several ticker-style plugins (news, odds-ticker, leaderboard,
stock-news, stocks) call this unconditionally between
set_scrolling_state() and their scroll-position update, mirroring the
real display_manager's deferred-update queue. This double has no such
queue, so there is nothing to process the no-op just lets those
plugins render under the harness instead of raising AttributeError.
"""
pass
# ------------------------------------------------------------------
# Utility methods
# ------------------------------------------------------------------
-76
View File
@@ -279,19 +279,6 @@ class PluginAdapter:
# Copy the image to prevent modification
img = cached_image.copy()
# Plugins that build their own ticker image via this shared
# ScrollHelper's create_scrolling_image() get a solid-black
# leading margin exactly `display_width` columns wide baked in
# (scroll_helper.py's "initial gap before first item"). Vegas mode
# adds its own leading gap/separator around every item already,
# so leaving this in stacks a second, uncontrolled blank margin on
# top of vegas_scroll.separator_width — making this plugin's
# transitions look inconsistent with plugins that provide content
# via get_vegas_content() (which carries no such margin). Strip it
# here so every plugin contributes only its real content and the
# gap between items is governed solely by separator_width.
img = self._strip_scroll_padding(img, scroll_helper, plugin_id)
# Ensure correct height
if img.height != self.display_height:
logger.info(
@@ -319,69 +306,6 @@ class PluginAdapter:
logger.exception("[%s] Error getting scroll_helper content", plugin_id)
return None
def _strip_scroll_padding(
self, img: Image.Image, scroll_helper: Any, plugin_id: str
) -> Image.Image:
"""
Crop off a plugin's own leading/trailing blank margins, if present.
create_scrolling_image() always pads the *start* of its cached image
with exactly `scroll_helper.display_width` columns of solid black
(0, 0, 0) ("initial gap before first item"). Some ticker-style plugins
also pad the *end* of their own cached image (e.g. so their standalone
display exits cleanly before looping). Vegas mode already adds its own
gap/separator around every item, so either margin left in place stacks
an extra, uncontrolled blank stretch on top of `separator_width`
only when running inside Vegas mode does this matter, since the
plugin's own standalone display still wants that margin. Detect solid
black margins up to `scroll_helper.display_width` wide on each edge and
crop them here. Images built via set_scrolling_image() (no such
margins) are left untouched.
Args:
img: Captured scroll_helper.cached_image (already copied)
scroll_helper: The plugin's ScrollHelper instance
plugin_id: Plugin identifier for logging
Returns:
img, cropped on whichever edge(s) had a matching blank margin
"""
pad_width = getattr(scroll_helper, 'display_width', None)
if not isinstance(pad_width, int) or pad_width <= 0 or pad_width >= img.width:
return img
def is_solid_black(strip: Image.Image) -> bool:
return strip.convert('RGB').getextrema() == ((0, 0), (0, 0), (0, 0))
left = pad_width if is_solid_black(img.crop((0, 0, pad_width, img.height))) else 0
right = (
pad_width
if is_solid_black(img.crop((img.width - pad_width, 0, img.width, img.height)))
else 0
)
if not left and not right:
return img
# Degenerate case (e.g. an all-black cached image): don't crop past
# zero width, just leave the image as-is.
if left + right >= img.width:
return img
cropped = img.crop((left, 0, img.width - right, img.height))
# Both edges matching at once is a much stronger signal of genuine
# baked-in padding than a single edge (which has a small chance of
# coinciding with real all-black content, e.g. a dark logo touching
# one boundary). Log that case at warning level so an unexpected
# double-edge crop is easy to spot in the field.
log = logger.warning if (left and right) else logger.info
log(
"[%s] Stripping scroll_helper padding (left=%dpx, right=%dpx): %dpx -> %dpx",
plugin_id, left, right, img.width, cropped.width
)
return cropped
def _trigger_scroll_content_generation(
self, plugin: 'BasePlugin', plugin_id: str, scroll_helper: Any
) -> Optional[Image.Image]:
+40 -187
View File
@@ -150,18 +150,6 @@ class WiFiManager:
logger.info(f"WiFi Manager initialized - nmcli: {self.has_nmcli}, iwlist: {self.has_iwlist}, "
f"hostapd: {self.has_hostapd}, dnsmasq: {self.has_dnsmasq}, "
f"interface: {self._wifi_interface}, trixie: {self._is_trixie}")
# Once per process: remove a stale force-AP flag left by a prior crash.
# Guard with a class-level flag so the nmcli AP-state check only runs
# once even though WiFiManager is instantiated per-request.
if not WiFiManager._startup_cleanup_done:
WiFiManager._startup_cleanup_done = True
if self._FORCE_AP_FLAG_PATH.exists() and not self._is_ap_mode_active():
try:
self._FORCE_AP_FLAG_PATH.unlink(missing_ok=True)
logger.debug("Removed stale force-AP flag on startup (AP not active)")
except OSError as exc:
logger.warning(f"Could not remove stale force-AP flag: {exc}")
def _show_led_message(self, message: str, duration: int = 5):
"""
@@ -486,10 +474,7 @@ class WiFiManager:
if result.returncode == 0:
for line in result.stdout.strip().split('\n'):
if '/' in line:
# nmcli -t output is "IP4.ADDRESS[1]:x.x.x.x/prefix";
# bare "x.x.x.x/prefix" is also accepted defensively.
_, sep, rest = line.partition(':')
ip_address = (rest if sep else line).split('/')[0].strip()
ip_address = line.split('/')[0].strip()
break
# Final fallback: Get signal strength by matching SSID in WiFi list
@@ -515,13 +500,6 @@ class WiFiManager:
# Check if AP mode is active
ap_active = self._is_ap_mode_active()
# wlan0 shows as "connected" in AP mode; clear client-station fields so
# callers don't mistake the AP for an outbound WiFi connection.
if ap_active and wifi_connected:
wifi_connected = False
ssid = None
ip_address = None
logger.debug(f"{wlan_device} is in AP mode — overriding wifi_connected to False")
return WiFiStatus(
connected=wifi_connected,
@@ -712,10 +690,6 @@ class WiFiManager:
# ---------------------------------------------------------------------------
_IP_FORWARD_SAVE_PATH = Path("/tmp/ledmatrix_ip_forward_saved") # nosec B108 - process-specific named file; device is single-user RPi
# Written when AP mode is manually force-enabled; prevents daemon auto-disable
_FORCE_AP_FLAG_PATH = Path("/tmp/ledmatrix_force_ap_active") # nosec B108 - process-specific named file; device is single-user RPi
# Ensures the startup stale-flag cleanup runs once per process, not per instantiation
_startup_cleanup_done: bool = False
def _validate_ap_config(self) -> Tuple[str, int]:
"""Return a sanitized (ssid, channel) pair from config, falling back to defaults."""
@@ -1393,7 +1367,7 @@ class WiFiManager:
logger.error(f"Failed to restore original connection: {original_ssid}")
# Trigger AP mode as last resort
self._show_led_message("Enabling AP mode...", duration=5)
ap_success, ap_msg = self.enable_ap_mode(force=True)
ap_success, ap_msg = self.enable_ap_mode()
if ap_success:
logger.info("AP mode enabled as failsafe")
return False, "Connection failed and restoration failed. AP mode enabled."
@@ -1405,7 +1379,7 @@ class WiFiManager:
elif not success:
logger.warning(f"Connection to {ssid} failed and no original connection to restore")
self._show_led_message("Enabling AP mode...", duration=5)
ap_success, ap_msg = self.enable_ap_mode(force=True)
ap_success, ap_msg = self.enable_ap_mode()
if ap_success:
logger.info("AP mode enabled as failsafe")
return False, "Connection failed. AP mode enabled."
@@ -1426,7 +1400,7 @@ class WiFiManager:
logger.error(f"Failed to restore after exception: {restore_error}")
# Last resort: enable AP mode
try:
self.enable_ap_mode(force=True)
self.enable_ap_mode()
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)
@@ -1490,29 +1464,26 @@ class WiFiManager:
# Show LED message
self._show_led_message(f"Connecting to {ssid}...", duration=10)
# Find existing NM connection for this SSID.
# 802-11-wireless.ssid is not a valid column in 'nmcli connection show',
# so list all wifi connections then query each one's SSID individually.
list_result = subprocess.run( # nosec B603 B607 - fixed args, no user input
["nmcli", "-t", "-f", "NAME,TYPE", "connection", "show"],
capture_output=True, text=True, timeout=5
# First, check if connection already exists and try to activate it
# NetworkManager connection names might not match SSID exactly, so search by SSID
check_result = subprocess.run(
["nmcli", "-t", "-f", "NAME,802-11-wireless.ssid", "connection", "show"],
capture_output=True,
text=True,
timeout=5
)
existing_conn_name = None
if list_result.returncode == 0:
for line in list_result.stdout.strip().split('\n'):
if ':' not in line:
continue
parts = line.split(':')
if len(parts) < 2 or parts[1].strip() != '802-11-wireless':
continue
conn_name = parts[0].strip()
ssid_r = subprocess.run( # nosec B603 B607 - conn_name from nmcli output, not user input
["nmcli", "-g", "802-11-wireless.ssid", "connection", "show", conn_name],
capture_output=True, text=True, timeout=5
)
if ssid_r.returncode == 0 and ssid_r.stdout.strip() == ssid:
existing_conn_name = conn_name
break
if check_result.returncode == 0:
for line in check_result.stdout.strip().split('\n'):
if ':' in line:
parts = line.split(':')
if len(parts) >= 2:
conn_name = parts[0].strip()
conn_ssid = parts[1].strip() if len(parts) > 1 else ""
if conn_ssid == ssid:
existing_conn_name = conn_name
break
# Also try direct lookup by SSID (in case connection name matches SSID)
if not existing_conn_name:
@@ -1883,97 +1854,8 @@ class WiFiManager:
logger.warning(f"Failed to enable WiFi radio after {max_retries} attempts")
return False
def get_wifi_radio_state(self) -> Dict:
"""
Report whether the WiFi radio is currently enabled, plus whether a wired
fallback exists. Used by the web UI's radio toggle so it can warn before
an action that could disconnect the browser.
Returns:
{
'enabled': Optional[bool], # True/False, or None if undeterminable
'ethernet_connected': bool, # wired fallback present
'available': bool, # nmcli present / radio state readable
}
"""
ethernet_connected = self._is_ethernet_connected()
enabled: Optional[bool] = None
available = False
try:
result = subprocess.run(
["nmcli", "radio", "wifi"],
capture_output=True,
text=True,
timeout=5
)
if result.returncode == 0:
status = result.stdout.strip().lower()
if status in ("enabled", "disabled"):
enabled = status == "enabled"
available = True
except Exception as e:
logger.debug(f"Could not read WiFi radio state: {e}")
return {
'enabled': enabled,
'ethernet_connected': ethernet_connected,
'available': available,
}
def set_wifi_radio(self, enabled: bool, force: bool = False) -> Tuple[bool, str, Optional[str]]:
"""
Turn the WiFi radio on or off.
Turning the radio OFF from the web interface is dangerous: if the device
is reachable only over WiFi, disabling it disconnects the very page that
issued the request. To prevent that lockout, disabling is refused unless a
wired (Ethernet) fallback is present, or the caller explicitly passes
force=True to acknowledge the risk.
Enabling reuses the hardened _ensure_wifi_radio_enabled() path (handles
rfkill soft-blocks + retries). Both directions rely only on
`nmcli radio wifi on|off`, which is already covered by the passwordless
sudoers allowlist (configure_wifi_permissions.sh) no new privileged
command is introduced.
Returns:
(success, human-readable message, reason_code). reason_code is
'no_ethernet' when a disable is refused for lockout safety, or a
short failure code otherwise; None on success. The web UI keys on
'no_ethernet' to decide whether to offer a force-off prompt.
"""
if enabled:
if self._ensure_wifi_radio_enabled():
return True, "WiFi radio enabled.", None
return False, "Failed to enable WiFi radio. Check logs for details.", 'enable_failed'
# Disabling — guard against locking the user out of the web interface.
if not force and not self._is_ethernet_connected():
return False, (
"Refusing to disable WiFi: no wired (Ethernet) connection was "
"detected, so turning off WiFi would disconnect you from this "
"page. Connect Ethernet first, or force it if you're sure."
), 'no_ethernet'
try:
result = subprocess.run(
["sudo", "nmcli", "radio", "wifi", "off"],
capture_output=True,
text=True,
timeout=10
)
if result.returncode == 0:
logger.info("WiFi radio disabled via web interface (force=%s)", force)
return True, "WiFi radio disabled.", None
logger.warning("Failed to disable WiFi radio: %s", result.stderr.strip())
return False, "Failed to disable WiFi radio. Check logs for details.", 'command_failed'
except subprocess.TimeoutExpired:
return False, "Command timed out while disabling WiFi radio.", 'timeout'
except (OSError, subprocess.SubprocessError) as e:
logger.error("Error disabling WiFi radio: %s", e, exc_info=True)
return False, "An error occurred while disabling WiFi radio.", 'error'
def enable_ap_mode(self, force: bool = False) -> Tuple[bool, str]:
def enable_ap_mode(self) -> Tuple[bool, str]:
"""
Enable access point mode
@@ -1995,29 +1877,20 @@ class WiFiManager:
if not self._ensure_wifi_radio_enabled():
return False, "WiFi radio is disabled and could not be enabled"
# Check if WiFi is connected (skip when force=True)
# Check if WiFi is connected
status = self.get_wifi_status()
if not force and status.connected:
if status.connected:
return False, "Cannot enable AP mode while WiFi is connected"
# Check if Ethernet is connected (skip when force=True)
if not force and self._is_ethernet_connected():
# Check if Ethernet is connected
if self._is_ethernet_connected():
return False, "Cannot enable AP mode while Ethernet is connected"
if force:
logger.debug(f"enable_ap_mode: force=True — WiFi/Ethernet guards bypassed; will create {self._FORCE_AP_FLAG_PATH}")
# Try hostapd/dnsmasq first (captive portal mode)
if self.has_hostapd and self.has_dnsmasq:
result = self._enable_ap_mode_hostapd()
if result[0]:
self._ap_enabled_at = time.time()
if force:
try:
self._FORCE_AP_FLAG_PATH.touch()
logger.debug(f"Force-AP flag created: {self._FORCE_AP_FLAG_PATH}")
except OSError as exc:
logger.warning(f"Failed to create force-AP flag {self._FORCE_AP_FLAG_PATH}: {exc}")
return result
# Fallback to nmcli hotspot (simpler, no captive portal)
@@ -2027,12 +1900,6 @@ class WiFiManager:
result = self._enable_ap_mode_nmcli_hotspot()
if result[0]:
self._ap_enabled_at = time.time()
if force:
try:
self._FORCE_AP_FLAG_PATH.touch()
logger.debug(f"Force-AP flag created: {self._FORCE_AP_FLAG_PATH}")
except OSError as exc:
logger.warning(f"Failed to create force-AP flag {self._FORCE_AP_FLAG_PATH}: {exc}")
return result
return False, "No WiFi tools available (nmcli, hostapd, or dnsmasq required)"
@@ -2224,14 +2091,8 @@ class WiFiManager:
self._clear_led_message()
return False, "AP started but captive-portal redirect setup failed"
# Verify the AP is actually running (retry up to 5x with 2s delay for NM async activation)
status = {}
for _attempt in range(5):
status = self._get_ap_status_nmcli()
if status.get('active'):
break
logger.debug(f"AP verification attempt {_attempt + 1}/5 not yet active, waiting 2s")
time.sleep(2)
# Verify the AP is actually running
status = self._get_ap_status_nmcli()
if status.get('active'):
ip = status.get('ip', '192.168.4.1')
logger.info(f"AP mode confirmed active at {ip} (open network, no password)")
@@ -2429,7 +2290,6 @@ class WiFiManager:
logger.warning("WiFi radio may be disabled after nmcli AP cleanup")
self._ap_enabled_at = None
self._FORCE_AP_FLAG_PATH.unlink(missing_ok=True)
logger.info("AP mode disabled successfully")
return True, "AP mode disabled"
except Exception as e:
@@ -2618,29 +2478,22 @@ address=/detectportal.firefox.com/192.168.4.1
else:
logger.warning(f"Failed to enable AP mode: {message}")
elif not should_have_ap and ap_active:
# Should not have AP but do - check if it was manually force-enabled
force_active = self._FORCE_AP_FLAG_PATH.exists()
if status.connected:
# WiFi connected: always disable AP (user successfully configured WiFi)
# Should not have AP but do - disable AP mode
# Always disable if WiFi or Ethernet connects, regardless of auto_enable setting
if status.connected or ethernet_connected:
success, message = self.disable_ap_mode()
if success:
logger.info("Auto-disabled AP mode (WiFi connected)")
self._disconnected_checks = 0
if status.connected:
logger.info("Auto-disabled AP mode (WiFi connected)")
elif ethernet_connected:
logger.info("Auto-disabled AP mode (Ethernet connected)")
self._disconnected_checks = 0 # Reset counter
return True
else:
logger.warning(f"Failed to auto-disable AP mode: {message}")
elif ethernet_connected and not force_active:
# Ethernet connected, AP not manually forced: auto-disable
success, message = self.disable_ap_mode()
if success:
logger.info("Auto-disabled AP mode (Ethernet connected)")
self._disconnected_checks = 0
return True
else:
logger.warning(f"Failed to auto-disable AP mode: {message}")
elif ethernet_connected and force_active:
logger.debug("AP mode is force-active; Ethernet connected but auto-disable suppressed")
elif not auto_enable:
# AP is active but auto_enable is disabled - this means it was manually enabled
# Don't disable it automatically, let it stay active
logger.debug("AP mode is active (manually enabled), keeping active")
# Idle-timeout check: disable AP if no client has connected within the window.
+1 -5
View File
@@ -38,11 +38,7 @@ def mock_cache_manager():
mock._memory_cache_timestamps = {}
mock.cache_dir = "/tmp/test_cache"
def mock_get(key: str, max_age: Optional[int] = 300,
memory_ttl: Optional[int] = None) -> Optional[Dict]:
# Signature mirrors CacheManager.get — keep in sync or callers
# passing keyword args (health tracker, resource monitor) break
# only in tests, hiding real-API compatibility.
def mock_get(key: str, max_age: int = 300) -> Optional[Dict]:
return mock._memory_cache.get(key)
def mock_set(key: str, data: Dict, ttl: Optional[int] = None) -> None:
+3 -4
View File
@@ -49,10 +49,9 @@ class TestBasketballScoreboardPlugin(PluginTestBase):
"""Test that plugin has display modes."""
manifest = self.load_plugin_manifest(plugin_id)
assert 'display_modes' in manifest
# Manifest uses league-prefixed modes (nba_, wnba_, ncaam_, ncaaw_)
assert 'nba_live' in manifest['display_modes']
assert 'nba_recent' in manifest['display_modes']
assert 'nba_upcoming' in manifest['display_modes']
assert 'basketball_live' in manifest['display_modes']
assert 'basketball_recent' in manifest['display_modes']
assert 'basketball_upcoming' in manifest['display_modes']
def test_plugin_has_get_display_modes(self, plugin_id):
"""Test that plugin can return display modes."""
-255
View File
@@ -1,255 +0,0 @@
"""
Unit tests for the plugin safety harness primitives:
bounds detection, image comparison, and mode enumeration.
These don't load real plugins, so they run anywhere (including core CI where
plugin-repos is empty).
"""
import importlib.util
import json
from pathlib import Path
import pytest
from PIL import Image
from src.plugin_system.testing.bounds_display_manager import BoundsCheckingDisplayManager
from src.plugin_system.testing.harness import (
_TOLERATED_UPDATE_ERRORS, compare_images, list_modes,
)
from src.plugin_system.testing.sizes import (
DEFAULT_TEST_SIZES, coerce_sizes, parse_size_token, resolve_test_sizes,
)
class TestBoundsDetection:
def test_reports_declared_size_not_canvas_size(self):
dm = BoundsCheckingDisplayManager(width=64, height=32)
assert dm.width == 64 and dm.height == 32
assert dm.matrix.width == 64 and dm.matrix.height == 32
# Backing canvas is padded out past the declared panel so far-overshoot
# coordinates land on-canvas and get flagged instead of clipped.
canvas_w, canvas_h = dm.image.size
assert canvas_w > 64 and canvas_h > 32
def test_far_overshoot_on_small_panel_is_detected(self):
# A coordinate meant for a wide build (x past 64) must still be caught
# when the declared panel is only 64 wide.
dm = BoundsCheckingDisplayManager(width=64, height=32)
dm.draw.rectangle([200, 5, 210, 10], fill=(255, 0, 0))
bbox = dm.check_overflow()
assert bbox is not None
assert bbox[0] >= 64
def test_in_bounds_drawing_has_no_overflow(self):
dm = BoundsCheckingDisplayManager(width=64, height=32)
dm.draw.rectangle([0, 0, 63, 31], fill=(255, 255, 255))
assert dm.check_overflow() is None
def test_right_overflow_is_detected(self):
dm = BoundsCheckingDisplayManager(width=64, height=32)
# Draw a few pixels past the right edge.
dm.draw.rectangle([60, 5, 70, 10], fill=(255, 0, 0))
bbox = dm.check_overflow()
assert bbox is not None
assert bbox[0] >= 64 # overflow starts at or past the declared width
def test_bottom_overflow_is_detected(self):
dm = BoundsCheckingDisplayManager(width=64, height=32)
dm.draw.rectangle([5, 30, 10, 40], fill=(0, 255, 0))
bbox = dm.check_overflow()
assert bbox is not None
assert bbox[3] > 32 # overflow extends past the declared height
def test_declared_image_is_cropped_to_panel(self):
dm = BoundsCheckingDisplayManager(width=64, height=32)
assert dm.get_image().size == (64, 32)
def test_snapshot_saves_cropped_panel(self, tmp_path):
dm = BoundsCheckingDisplayManager(width=128, height=32)
out = tmp_path / "snap.png"
dm.save_snapshot(str(out))
with Image.open(out) as img:
assert img.size == (128, 32)
class TestArbitraryPanelSizes:
"""The harness must handle any panel shape, not a fixed supported list."""
def test_overflow_extent_pads_to_largest_in_run(self):
# A wide run (extent 256) means content at x=200 on a 64-wide panel is
# caught; the same draw with a small extent would be clipped (false pass).
wide = BoundsCheckingDisplayManager(width=64, height=32, overflow_extent=(256, 32))
wide.draw.rectangle([200, 5, 210, 10], fill=(255, 0, 0))
assert wide.check_overflow() is not None
tight = BoundsCheckingDisplayManager(width=64, height=32, overflow_extent=(64, 32))
tight.draw.rectangle([200, 5, 210, 10], fill=(255, 0, 0))
assert tight.check_overflow() is None # clipped beyond the small canvas
def test_unusual_shapes_report_their_declared_size(self):
for w, h in [(8, 2), (6, 6), (200, 8), (64, 96)]:
dm = BoundsCheckingDisplayManager(width=w, height=h)
assert dm.width == w and dm.height == h
assert dm.matrix.width == w and dm.matrix.height == h
class TestUpdateErrorClassification:
"""update() may fail for lack of network (tolerated) but a logic bug must
not pass green just because display() survives."""
def test_connectivity_errors_are_tolerated(self):
import socket
import urllib.error
for exc in (ConnectionError("x"), TimeoutError("x"), socket.gaierror("x"),
urllib.error.URLError("x")):
assert isinstance(exc, _TOLERATED_UPDATE_ERRORS)
def test_logic_errors_are_not_tolerated(self):
for exc in (ValueError("x"), KeyError("x"), AttributeError("x"), TypeError("x")):
assert not isinstance(exc, _TOLERATED_UPDATE_ERRORS)
class TestSizeParsing:
def test_parse_size_token_ok(self):
assert parse_size_token(" 128X32 ") == (128, 32)
def test_parse_size_token_rejects_garbage(self):
with pytest.raises(ValueError):
parse_size_token("128xabc")
with pytest.raises(ValueError):
parse_size_token("128-32")
def test_rejects_non_positive_dimensions(self):
for bad in ("0x32", "-64x32", "64x0", "64x-1"):
with pytest.raises(ValueError):
parse_size_token(bad)
with pytest.raises(ValueError):
coerce_sizes([[0, 32]])
with pytest.raises(ValueError):
coerce_sizes("64x-1")
def test_coerce_sizes_from_string_and_pairs(self):
assert coerce_sizes("8x16,64x64") == [(8, 16), (64, 64)]
assert coerce_sizes([[8, 16], (64, 64)]) == [(8, 16), (64, 64)]
assert coerce_sizes(None) is None
assert coerce_sizes("") is None
def test_resolve_precedence_env_then_spec_then_default(self, monkeypatch):
monkeypatch.delenv("LEDMATRIX_TEST_SIZES", raising=False)
assert resolve_test_sizes(None) == list(DEFAULT_TEST_SIZES)
assert resolve_test_sizes([[8, 16]]) == [(8, 16)]
monkeypatch.setenv("LEDMATRIX_TEST_SIZES", "5x5")
# env wins over a per-plugin spec
assert resolve_test_sizes([[8, 16]]) == [(5, 5)]
class TestCompareImages:
def test_identical_images_match(self):
a = Image.new("RGB", (16, 16), (10, 20, 30))
b = a.copy()
ok, diff_pixels, max_delta = compare_images(a, b)
assert ok and diff_pixels == 0 and max_delta == 0
def test_different_images_fail_at_zero_tolerance(self):
a = Image.new("RGB", (16, 16), (0, 0, 0))
b = a.copy()
b.putpixel((1, 1), (255, 255, 255))
ok, diff_pixels, max_delta = compare_images(a, b)
assert not ok and diff_pixels == 1 and max_delta == 255
def test_tolerance_absorbs_small_noise(self):
a = Image.new("RGB", (16, 16), (100, 100, 100))
b = a.copy()
b.putpixel((2, 2), (103, 100, 100)) # delta 3
ok, _, max_delta = compare_images(a, b, max_delta=5, max_diff_pixels=0)
assert ok and max_delta == 3
def test_size_mismatch_fails(self):
a = Image.new("RGB", (16, 16))
b = Image.new("RGB", (32, 16))
ok, _, _ = compare_images(a, b)
assert not ok
class TestListModes:
def test_instance_modes_take_precedence(self):
inst = type("P", (), {"modes": ["a", "b"]})()
assert list_modes(inst, {"display_modes": ["x"]}, "pid") == ["a", "b"]
def test_falls_back_to_manifest_display_modes(self):
inst = type("P", (), {})()
assert list_modes(inst, {"display_modes": ["x", "y"]}, "pid") == ["x", "y"]
def test_falls_back_to_plugin_id(self):
inst = type("P", (), {})()
assert list_modes(inst, {}, "pid") == ["pid"]
def _load_check_plugin_cli():
"""Load scripts/check_plugin.py by path (it isn't an importable package)."""
root = Path(__file__).resolve().parents[2]
path = root / "scripts" / "check_plugin.py"
spec = importlib.util.spec_from_file_location("check_plugin_cli", path)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
def _make_fixture_plugin(tmp_path, harness):
"""Create a minimal plugin dir with a test/harness.json; return its parent
(the search dir)."""
pdir = tmp_path / "plugins" / "demo-clock"
(pdir / "test").mkdir(parents=True)
(pdir / "manifest.json").write_text(json.dumps({
"id": "demo-clock", "name": "Demo Clock", "version": "1.0.0",
"author": "test", "entry_point": "manager.py", "class_name": "DemoClock",
"display_modes": ["demo-clock"], "compatible_versions": ["*"],
}))
(pdir / "test" / "harness.json").write_text(json.dumps(harness))
return pdir.parent
class TestCheckPluginHonorsHarnessJson:
"""Regression: check_plugin.py (the CI tool) must apply test/harness.json so
its render reproduces the committed goldens otherwise time/data-dependent
plugins drift on every CI run."""
def test_harness_json_supplies_render_settings(self, tmp_path, monkeypatch):
mod = _load_check_plugin_cli()
search = _make_fixture_plugin(tmp_path, {
"config": {"timezone": "UTC"},
"freeze_time": "2025-08-01 15:25:00",
"sizes": [[128, 32]],
})
captured = {}
monkeypatch.setattr(mod, "render_plugin_matrix",
lambda **kw: captured.update(kw) or [])
monkeypatch.setattr(mod, "compare_to_goldens", lambda *a, **k: [])
mod.check_one(
plugin_id="demo-clock", search_dirs=[str(search)], sizes=None,
mock_data={}, config={}, run_update=True, out_dir=None,
update_golden=False, golden_dir_override=None, freeze_time=None,
)
assert captured["freeze_time"] == "2025-08-01 15:25:00"
assert captured["config"]["timezone"] == "UTC"
assert captured["sizes"] == [(128, 32)]
def test_cli_flags_override_harness_json(self, tmp_path, monkeypatch):
mod = _load_check_plugin_cli()
search = _make_fixture_plugin(tmp_path, {
"config": {"timezone": "UTC"},
"freeze_time": "2025-08-01 15:25:00",
})
captured = {}
monkeypatch.setattr(mod, "render_plugin_matrix",
lambda **kw: captured.update(kw) or [])
monkeypatch.setattr(mod, "compare_to_goldens", lambda *a, **k: [])
mod.check_one(
plugin_id="demo-clock", search_dirs=[str(search)], sizes=None,
mock_data={}, config={"timezone": "America/New_York"},
run_update=True, out_dir=None, update_golden=False,
golden_dir_override=None, freeze_time="2030-01-01 00:00:00",
)
assert captured["freeze_time"] == "2030-01-01 00:00:00"
assert captured["config"]["timezone"] == "America/New_York"
-115
View File
@@ -1,115 +0,0 @@
"""
Cross-size / cross-screen plugin safety test.
For every discovered plugin, render every declared screen at every supported
matrix size and assert it: loads, renders without crashing, stays within the
panel bounds, and for plugins that ship golden images matches them.
Plugin discovery (first match wins):
- $LEDMATRIX_PLUGINS_DIR (os.pathsep-separated list of dirs), else
- <project_root>/plugin-repos and <project_root>/plugins
A plugin opts into golden-image checks by adding test/golden/<WxH>/<mode>.png
(and usually test/harness.json for deterministic config / mock data / time).
"""
import os
from pathlib import Path
from typing import Dict, List
import pytest
from src.plugin_system.testing.harness import (
render_plugin_matrix, compare_to_goldens,
)
from src.plugin_system.testing.loading import load_config_defaults, load_harness_spec
from src.plugin_system.testing.sizes import resolve_test_sizes
PROJECT_ROOT = Path(__file__).resolve().parents[2]
# Set LEDMATRIX_REQUIRE_PLUGINS=1 in any CI/hardware pipeline where plugins are
# expected to be present, so a discovery drift (empty search path) fails loudly
# instead of silently skipping and losing this safety signal.
_REQUIRE_PLUGINS = os.environ.get("LEDMATRIX_REQUIRE_PLUGINS") == "1"
def _plugin_search_dirs() -> List[Path]:
env = os.environ.get("LEDMATRIX_PLUGINS_DIR")
if env:
return [Path(p) for p in env.split(os.pathsep) if p]
return [PROJECT_ROOT / "plugin-repos", PROJECT_ROOT / "plugins"]
def _discover() -> Dict[str, Path]:
"""Map plugin_id -> plugin_dir for all plugins on the search path."""
found: Dict[str, Path] = {}
for base in _plugin_search_dirs():
if not base.exists():
continue
for child in sorted(base.iterdir()):
if (child / "manifest.json").exists() and child.name not in found:
found[child.name] = child
return found
_PLUGINS = _discover()
@pytest.mark.plugin
def test_plugins_were_discovered() -> None:
"""Guard against silently skipping the whole matrix when discovery drifts.
Local dev and the plugin-less core CI legitimately have no plugins, so we
skip there; but when LEDMATRIX_REQUIRE_PLUGINS=1 an empty search path is a
hard failure rather than a green no-op.
"""
if _PLUGINS:
return
search = [str(p) for p in _plugin_search_dirs()]
if _REQUIRE_PLUGINS:
pytest.fail(
"LEDMATRIX_REQUIRE_PLUGINS=1 but no plugins were discovered on the "
f"search path: {search}"
)
pytest.skip(f"no plugins found on the search path: {search}")
@pytest.mark.plugin
@pytest.mark.skipif(not _PLUGINS, reason="no plugins found on the search path")
@pytest.mark.parametrize("plugin_id", sorted(_PLUGINS))
def test_plugin_renders_across_sizes_and_screens(plugin_id: str) -> None:
plugin_dir = _PLUGINS[plugin_id]
spec = load_harness_spec(plugin_dir)
config = {"enabled": True}
config.update(load_config_defaults(plugin_dir))
config.update(spec.get("config", {}))
# Sizes: LEDMATRIX_TEST_SIZES env (test on real hardware) wins, then the
# plugin's own harness.json "sizes", else the default representative sample.
sizes = resolve_test_sizes(spec.get("sizes"))
results = render_plugin_matrix(
plugin_id=plugin_id,
plugin_dir=plugin_dir,
config=config,
mock_data=spec.get("mock_data_contents", {}),
sizes=sizes,
run_update=not spec.get("skip_update", False),
freeze_time=spec.get("freeze_time"),
)
compare_to_goldens(results, plugin_dir / "test" / "golden")
failures = []
for r in results:
if r.error is not None:
failures.append(f"{r.size_label} {r.mode}: crashed: {r.error}")
elif r.overflow is not None:
failures.append(f"{r.size_label} {r.mode}: overflow past panel bbox={r.overflow}")
elif r.golden_checked and r.golden_ok is False:
failures.append(
f"{r.size_label} {r.mode}: golden drift {r.golden_diff_pixels}px "
f"(max Δ={r.golden_max_delta})"
)
assert not failures, f"{plugin_id} failed:\n " + "\n ".join(failures)
-10
View File
@@ -172,16 +172,6 @@ class TestVisualDisplayManager:
vdm.set_scrolling_state(False)
assert vdm.is_currently_scrolling() is False
def test_process_deferred_updates_is_noop(self):
# Ticker-style plugins (news, odds-ticker, leaderboard, stock-news,
# stocks) call this unconditionally alongside set_scrolling_state();
# it must exist and be harmless so those plugins render under the
# harness instead of raising AttributeError.
vdm = VisualTestDisplayManager(width=128, height=32)
vdm.set_scrolling_state(True)
vdm.process_deferred_updates() # should not raise
assert vdm.is_currently_scrolling() is True
def test_format_date_with_ordinal(self):
from datetime import datetime
vdm = VisualTestDisplayManager(width=128, height=32)
-342
View File
@@ -1,342 +0,0 @@
"""
Tests for src/base_classes/api_extractors.py
Covers ESPNFootballExtractor, ESPNBaseballExtractor, ESPNHockeyExtractor,
SoccerAPIExtractor, and the shared _extract_common_details logic.
"""
import logging
import pytest
from src.base_classes.api_extractors import (
ESPNFootballExtractor,
ESPNBaseballExtractor,
ESPNHockeyExtractor,
SoccerAPIExtractor,
)
# ---------------------------------------------------------------------------
# Shared test data factories
# ---------------------------------------------------------------------------
def _make_espn_event(state: str = "in", home_abbr: str = "KC", away_abbr: str = "BUF",
home_score: str = "14", away_score: str = "7",
date_str: str = "2024-01-15T20:00:00Z",
include_situation: bool = False,
situation: dict | None = None,
status_detail: str = "2nd Qtr 8:42",
period: int = 2) -> dict:
"""Build a minimal ESPN-style game event dict."""
comp_status = {
"type": {
"state": state,
"shortDetail": status_detail,
"detail": status_detail,
"name": "STATUS_IN_PROGRESS",
},
"period": period,
"displayClock": "8:42",
}
comp = {
"status": comp_status,
"competitors": [
{
"homeAway": "home",
"team": {"abbreviation": home_abbr, "displayName": f"{home_abbr} Team"},
"score": home_score,
},
{
"homeAway": "away",
"team": {"abbreviation": away_abbr, "displayName": f"{away_abbr} Team"},
"score": away_score,
},
],
}
if include_situation:
comp["situation"] = situation or {}
return {
"id": "test-game-1",
"date": date_str,
"competitions": [comp],
}
def _make_logger() -> logging.Logger:
return logging.getLogger("test_extractor")
# ---------------------------------------------------------------------------
# ESPNFootballExtractor
# ---------------------------------------------------------------------------
class TestESPNFootballExtractor:
def setup_method(self):
self.extractor = ESPNFootballExtractor(_make_logger())
def test_extract_live_game_basic_fields(self):
event = _make_espn_event(state="in", home_score="14", away_score="7")
result = self.extractor.extract_game_details(event)
assert result is not None
assert result["home_abbr"] == "KC"
assert result["away_abbr"] == "BUF"
assert result["home_score"] == "14"
assert result["away_score"] == "7"
assert result["is_live"] is True
assert result["is_final"] is False
assert result["is_upcoming"] is False
def test_extract_final_game(self):
event = _make_espn_event(state="post")
result = self.extractor.extract_game_details(event)
assert result is not None
assert result["is_final"] is True
assert result["is_live"] is False
def test_extract_upcoming_game(self):
event = _make_espn_event(state="pre")
result = self.extractor.extract_game_details(event)
assert result is not None
assert result["is_upcoming"] is True
def test_sport_specific_fields_default_when_pregame(self):
event = _make_espn_event(state="pre")
fields = self.extractor.get_sport_specific_fields(event)
assert "down" in fields
assert "distance" in fields
assert "possession" in fields
assert "is_redzone" in fields
assert fields["is_redzone"] is False
def test_sport_specific_fields_live_with_situation(self):
situation = {
"down": 3,
"distance": 7,
"possession": "KC",
"isRedZone": True,
"homeTimeouts": 2,
"awayTimeouts": 1,
}
event = _make_espn_event(state="in", include_situation=True, situation=situation)
fields = self.extractor.get_sport_specific_fields(event)
assert fields["down"] == 3
assert fields["distance"] == 7
assert fields["is_redzone"] is True
assert fields["home_timeouts"] == 2
assert fields["away_timeouts"] == 1
def test_scoring_event_detected(self):
# situation must be non-empty (truthy) for the live block to execute
situation = {"down": 1, "distance": 10}
event = _make_espn_event(
state="in",
include_situation=True,
situation=situation,
status_detail="touchdown scored",
)
fields = self.extractor.get_sport_specific_fields(event)
assert "touchdown" in fields.get("scoring_event", "").lower()
def test_returns_none_on_empty_event(self):
assert self.extractor.extract_game_details({}) is None
def test_returns_none_when_teams_missing(self):
event = {
"id": "x",
"date": "2024-01-15T20:00:00Z",
"competitions": [
{
"status": {"type": {"state": "in", "shortDetail": "", "detail": "", "name": ""}},
"competitors": [], # no competitors
}
],
}
assert self.extractor.extract_game_details(event) is None
def test_date_z_suffix_parsed(self):
event = _make_espn_event(date_str="2024-01-15T20:00:00Z")
result = self.extractor.extract_game_details(event)
# Should not raise and should return a result
assert result is not None
def test_id_propagated(self):
event = _make_espn_event()
result = self.extractor.extract_game_details(event)
assert result["id"] == "test-game-1"
# ---------------------------------------------------------------------------
# ESPNBaseballExtractor
# ---------------------------------------------------------------------------
class TestESPNBaseballExtractor:
def setup_method(self):
self.extractor = ESPNBaseballExtractor(_make_logger())
def test_extract_live_game(self):
event = _make_espn_event(
state="in", home_abbr="NYY", away_abbr="BOS",
home_score="3", away_score="2"
)
result = self.extractor.extract_game_details(event)
assert result is not None
assert result["home_abbr"] == "NYY"
assert result["is_live"] is True
def test_baseball_sport_fields_defaults(self):
event = _make_espn_event(state="pre")
fields = self.extractor.get_sport_specific_fields(event)
assert "inning" in fields
assert "outs" in fields
assert "bases" in fields
assert "strikes" in fields
assert "balls" in fields
def test_baseball_sport_fields_live(self):
situation = {
"inning": 7,
"outs": 2,
"bases": "110",
"strikes": 2,
"balls": 3,
"pitcher": "Smith",
"batter": "Jones",
}
event = _make_espn_event(state="in", include_situation=True, situation=situation)
fields = self.extractor.get_sport_specific_fields(event)
assert fields["inning"] == 7
assert fields["outs"] == 2
assert fields["strikes"] == 2
assert fields["pitcher"] == "Smith"
def test_returns_none_on_empty(self):
assert self.extractor.extract_game_details({}) is None
# ---------------------------------------------------------------------------
# ESPNHockeyExtractor
# ---------------------------------------------------------------------------
class TestESPNHockeyExtractor:
def setup_method(self):
self.extractor = ESPNHockeyExtractor(_make_logger())
def test_extract_live_game(self):
event = _make_espn_event(
state="in", home_abbr="BOS", away_abbr="TOR",
home_score="2", away_score="1"
)
result = self.extractor.extract_game_details(event)
assert result is not None
assert result["is_live"] is True
def test_hockey_period_text_p1(self):
situation = {"isPowerPlay": False}
event = _make_espn_event(
state="in", include_situation=True, situation=situation, period=1
)
fields = self.extractor.get_sport_specific_fields(event)
assert fields["period_text"] == "P1"
def test_hockey_period_text_p2(self):
situation = {"isPowerPlay": False} # non-empty so the live block executes
event = _make_espn_event(
state="in", include_situation=True, situation=situation, period=2
)
fields = self.extractor.get_sport_specific_fields(event)
assert fields["period_text"] == "P2"
def test_hockey_period_text_p3(self):
situation = {"isPowerPlay": False}
event = _make_espn_event(
state="in", include_situation=True, situation=situation, period=3
)
fields = self.extractor.get_sport_specific_fields(event)
assert fields["period_text"] == "P3"
def test_hockey_period_text_ot(self):
situation = {"isPowerPlay": False}
event = _make_espn_event(
state="in", include_situation=True, situation=situation, period=4
)
fields = self.extractor.get_sport_specific_fields(event)
assert fields["period_text"] == "OT1"
def test_hockey_power_play(self):
situation = {"isPowerPlay": True, "homeShots": 12, "awayShots": 8}
event = _make_espn_event(state="in", include_situation=True, situation=situation, period=2)
fields = self.extractor.get_sport_specific_fields(event)
assert fields["power_play"] is True
assert fields["shots_on_goal"]["home"] == 12
assert fields["shots_on_goal"]["away"] == 8
def test_hockey_fields_defaults_pregame(self):
event = _make_espn_event(state="pre")
fields = self.extractor.get_sport_specific_fields(event)
assert "period" in fields
assert "power_play" in fields
assert fields["power_play"] is False
def test_returns_none_on_empty(self):
assert self.extractor.extract_game_details({}) is None
# ---------------------------------------------------------------------------
# SoccerAPIExtractor
# ---------------------------------------------------------------------------
class TestSoccerAPIExtractor:
def setup_method(self):
self.extractor = SoccerAPIExtractor(_make_logger())
def _make_soccer_event(self, is_live: bool = True) -> dict:
return {
"id": "soccer-1",
"home_team": {"abbreviation": "ARS", "name": "Arsenal"},
"away_team": {"abbreviation": "CHE", "name": "Chelsea"},
"home_score": "2",
"away_score": "1",
"status": "LIVE",
"is_live": is_live,
"is_final": not is_live,
"is_upcoming": False,
"half": "1",
"stoppage_time": "2",
"home_yellow_cards": 1,
"away_yellow_cards": 2,
"home_red_cards": 0,
"away_red_cards": 0,
"home_possession": 55,
"away_possession": 45,
}
def test_extract_live_game(self):
event = self._make_soccer_event(is_live=True)
result = self.extractor.extract_game_details(event)
assert result is not None
assert result["home_abbr"] == "ARS"
assert result["away_abbr"] == "CHE"
assert result["is_live"] is True
def test_sport_specific_cards(self):
event = self._make_soccer_event()
fields = self.extractor.get_sport_specific_fields(event)
assert fields["cards"]["home_yellow"] == 1
assert fields["cards"]["away_yellow"] == 2
assert fields["cards"]["home_red"] == 0
def test_sport_specific_possession(self):
event = self._make_soccer_event()
fields = self.extractor.get_sport_specific_fields(event)
assert fields["possession"]["home"] == 55
assert fields["possession"]["away"] == 45
def test_sport_specific_half(self):
event = self._make_soccer_event()
fields = self.extractor.get_sport_specific_fields(event)
assert fields["half"] == "1"
def test_scores_as_strings(self):
event = self._make_soccer_event()
result = self.extractor.extract_game_details(event)
assert result["home_score"] == "2"
assert result["away_score"] == "1"
-299
View File
@@ -1,299 +0,0 @@
"""
Tests for src/background_data_service.py
Covers BackgroundDataService: submit_fetch_request, get_result,
is_request_complete, get_request_status, cancel_request, get_statistics,
_cleanup_completed_requests, shutdown, and get_background_service singleton.
"""
import time
import pytest
from unittest.mock import MagicMock, patch, Mock
from concurrent.futures import Future
from src.background_data_service import (
BackgroundDataService,
FetchStatus,
FetchResult,
FetchRequest,
get_background_service,
shutdown_background_service,
)
import src.background_data_service as bds_module
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture(autouse=True)
def reset_global_service():
"""Ensure each test starts with no global singleton."""
shutdown_background_service()
yield
shutdown_background_service()
@pytest.fixture
def mock_cache_manager():
m = MagicMock()
m.get.return_value = None
m.set.return_value = None
m.generate_sport_cache_key.return_value = "test_key"
return m
@pytest.fixture
def service(mock_cache_manager):
svc = BackgroundDataService(mock_cache_manager, max_workers=2, request_timeout=5)
yield svc
svc.shutdown(wait=False)
# ---------------------------------------------------------------------------
# Initialisation
# ---------------------------------------------------------------------------
class TestInitialisation:
def test_stats_zeroed(self, service):
stats = service.get_statistics()
assert stats["total_requests"] == 0
assert stats["completed_requests"] == 0
assert stats["failed_requests"] == 0
def test_no_active_requests(self, service):
assert len(service.active_requests) == 0
def test_not_shutdown(self, service):
assert service._shutdown is False
# ---------------------------------------------------------------------------
# Cache hit path
# ---------------------------------------------------------------------------
class TestCacheHit:
def test_cache_hit_returns_request_id(self, service, mock_cache_manager):
mock_cache_manager.get.return_value = {"events": [{"id": "1"}]}
req_id = service.submit_fetch_request(
sport="nfl", year=2024,
url="https://example.com/nfl",
cache_key="nfl_key",
)
assert req_id is not None
# Request should be immediately complete due to cache hit
result = service.get_result(req_id)
assert result is not None
assert result.success is True
assert result.cached is True
def test_cache_hit_increments_stat(self, service, mock_cache_manager):
mock_cache_manager.get.return_value = {"events": []}
service.submit_fetch_request(sport="nba", year=2024, url="https://x.com", cache_key="k")
stats = service.get_statistics()
assert stats["cached_hits"] == 1
# ---------------------------------------------------------------------------
# Actual fetch path (mocked HTTP)
# ---------------------------------------------------------------------------
class TestFetchPath:
def _valid_payload(self) -> dict:
return {"events": [{"id": "g1"}, {"id": "g2"}]}
def test_successful_fetch_completes(self, service, mock_cache_manager):
mock_resp = Mock()
mock_resp.json.return_value = self._valid_payload()
mock_resp.raise_for_status.return_value = None
with patch.object(service.session, "get", return_value=mock_resp):
req_id = service.submit_fetch_request(
sport="nfl", year=2024,
url="https://example.com/nfl",
cache_key="nfl_test",
)
# Wait for the background thread
deadline = time.time() + 5
while not service.is_request_complete(req_id) and time.time() < deadline:
time.sleep(0.05)
result = service.get_result(req_id)
assert result is not None
assert result.success is True
assert result.data == self._valid_payload()
def test_failed_fetch_records_error(self, service, mock_cache_manager):
with patch.object(service.session, "get", side_effect=Exception("network error")):
req_id = service.submit_fetch_request(
sport="nba", year=2024,
url="https://example.com/nba",
cache_key="nba_test",
max_retries=0,
)
deadline = time.time() + 5
while not service.is_request_complete(req_id) and time.time() < deadline:
time.sleep(0.05)
result = service.get_result(req_id)
assert result is not None
assert result.success is False
assert result.error is not None
def test_cache_miss_increments_stat(self, service, mock_cache_manager):
mock_resp = Mock()
mock_resp.json.return_value = self._valid_payload()
mock_resp.raise_for_status.return_value = None
with patch.object(service.session, "get", return_value=mock_resp):
service.submit_fetch_request(
sport="nfl", year=2024, url="https://x.com", cache_key="new_key",
)
stats = service.get_statistics()
assert stats["cache_misses"] == 1
def test_callback_called_on_success(self, service, mock_cache_manager):
callback = Mock()
mock_resp = Mock()
mock_resp.json.return_value = self._valid_payload()
mock_resp.raise_for_status.return_value = None
with patch.object(service.session, "get", return_value=mock_resp):
req_id = service.submit_fetch_request(
sport="nfl", year=2024, url="https://x.com",
cache_key="cb_key", callback=callback, max_retries=0,
)
deadline = time.time() + 5
while not service.is_request_complete(req_id) and time.time() < deadline:
time.sleep(0.05)
callback.assert_called_once()
call_arg = callback.call_args[0][0]
assert isinstance(call_arg, FetchResult)
def test_data_cached_after_successful_fetch(self, service, mock_cache_manager):
mock_resp = Mock()
mock_resp.json.return_value = self._valid_payload()
mock_resp.raise_for_status.return_value = None
with patch.object(service.session, "get", return_value=mock_resp):
req_id = service.submit_fetch_request(
sport="nfl", year=2024, url="https://x.com", cache_key="cache_after_key",
)
deadline = time.time() + 5
while not service.is_request_complete(req_id) and time.time() < deadline:
time.sleep(0.05)
mock_cache_manager.set.assert_called()
# ---------------------------------------------------------------------------
# Request status / cancel
# ---------------------------------------------------------------------------
class TestRequestStatusAndCancel:
def test_unknown_request_status_is_none(self, service):
assert service.get_request_status("nonexistent") is None
def test_cancel_active_request(self, service, mock_cache_manager):
# Manually insert an active request
req = FetchRequest(
id="r1", sport="nfl", year=2024,
cache_key="k", url="https://x.com",
)
req.status = FetchStatus.PENDING
service.active_requests["r1"] = req
result = service.cancel_request("r1")
assert result is True
assert "r1" not in service.active_requests
def test_cancel_nonexistent_request(self, service):
assert service.cancel_request("does-not-exist") is False
def test_is_request_complete_false_for_active(self, service, mock_cache_manager):
req = FetchRequest(
id="r2", sport="mlb", year=2024,
cache_key="k2", url="https://x.com",
)
service.active_requests["r2"] = req
assert service.is_request_complete("r2") is False
def test_is_request_complete_true_for_done(self, service):
result = FetchResult(request_id="r3", success=True)
service.completed_requests["r3"] = result
assert service.is_request_complete("r3") is True
def test_get_result_returns_none_for_unknown(self, service):
assert service.get_result("unknown") is None
# ---------------------------------------------------------------------------
# Shutdown
# ---------------------------------------------------------------------------
class TestShutdown:
def test_shutdown_sets_flag(self, service):
service.shutdown(wait=False)
assert service._shutdown is True
def test_submit_after_shutdown_raises(self, service, mock_cache_manager):
service.shutdown(wait=False)
with pytest.raises(RuntimeError, match="shutting down"):
service.submit_fetch_request(
sport="nfl", year=2024, url="https://x.com", cache_key="k"
)
# ---------------------------------------------------------------------------
# Cleanup
# ---------------------------------------------------------------------------
class TestCleanup:
def test_cleanup_removes_old_requests(self, service):
old_result = FetchResult(request_id="old", success=True)
old_result.completed_at = time.time() - 7200 # 2 hours ago
service.completed_requests["old"] = old_result
service._last_completed_requests_cleanup = 0 # force cleanup
removed = service._cleanup_completed_requests(force=True)
assert removed >= 1
assert "old" not in service.completed_requests
def test_cleanup_respects_interval(self, service):
old_result = FetchResult(request_id="r", success=True)
old_result.completed_at = time.time() - 7200
service.completed_requests["r"] = old_result
# Cleanup interval not passed, should skip
service._last_completed_requests_cleanup = time.time()
removed = service._cleanup_completed_requests(force=False)
assert removed == 0
def test_size_limit_enforcement(self, service):
service._max_completed_requests = 3
for i in range(5):
result = FetchResult(request_id=str(i), success=True)
result.completed_at = time.time() - (5 - i) * 100 # oldest first
service.completed_requests[str(i)] = result
service._last_completed_requests_cleanup = 0
service._cleanup_completed_requests(force=True)
assert len(service.completed_requests) <= 3
# ---------------------------------------------------------------------------
# Singleton get_background_service
# ---------------------------------------------------------------------------
class TestGetBackgroundService:
def test_first_call_requires_cache_manager(self):
with pytest.raises(ValueError, match="cache_manager is required"):
get_background_service()
def test_creates_singleton(self, mock_cache_manager):
svc1 = get_background_service(mock_cache_manager)
svc2 = get_background_service()
assert svc1 is svc2
def test_shutdown_clears_singleton(self, mock_cache_manager):
get_background_service(mock_cache_manager)
shutdown_background_service()
with pytest.raises(ValueError):
get_background_service()
+1 -14
View File
@@ -279,23 +279,10 @@ class TestDiskCache:
"""Test getting expired cache entry."""
cache = DiskCache(cache_dir=str(tmp_path))
cache.set("test_key", {"data": "value"})
# Get with max_age=0 to force expiration
result = cache.get("test_key", max_age=0)
assert result is None
def test_get_max_age_none_never_expires(self, tmp_path):
"""max_age=None must return persisted records regardless of age.
Regression: the age comparison raised TypeError for max_age=None,
which was swallowed and treated as a miss silently breaking
long-lived state (plugin health/metrics) read across processes.
"""
cache = DiskCache(cache_dir=str(tmp_path))
cache.set("test_key", {"data": "value", "timestamp": 0}) # epoch → very old
result = cache.get("test_key", max_age=None)
assert result is not None
assert result["data"] == "value"
def test_get_nonexistent(self, tmp_path):
"""Test getting non-existent key."""
-209
View File
@@ -1,209 +0,0 @@
"""
Tests for src/base_classes/data_sources.py
Covers ESPNDataSource, MLBAPIDataSource, SoccerAPIDataSource.
All HTTP calls are mocked to avoid network access.
"""
import logging
from datetime import datetime, date
from unittest.mock import MagicMock, patch, Mock
import pytest
import requests
from src.base_classes.data_sources import ESPNDataSource, MLBAPIDataSource, SoccerAPIDataSource
def _make_logger() -> logging.Logger:
return logging.getLogger("test_data_sources")
def _mock_response(json_data: dict, status_code: int = 200):
resp = Mock(spec=requests.Response)
resp.status_code = status_code
resp.json.return_value = json_data
resp.raise_for_status = Mock()
if status_code >= 400:
resp.raise_for_status.side_effect = requests.HTTPError(response=resp)
return resp
# ---------------------------------------------------------------------------
# ESPNDataSource
# ---------------------------------------------------------------------------
class TestESPNDataSource:
def setup_method(self):
self.source = ESPNDataSource(_make_logger())
def test_get_headers(self):
headers = self.source.get_headers()
assert headers["Accept"] == "application/json"
assert "LEDMatrix" in headers["User-Agent"]
def test_fetch_live_games_returns_live_events(self):
live_event = {
"competitions": [{"status": {"type": {"state": "in"}}}]
}
non_live_event = {
"competitions": [{"status": {"type": {"state": "pre"}}}]
}
payload = {"events": [live_event, non_live_event]}
with patch.object(self.source.session, "get", return_value=_mock_response(payload)):
result = self.source.fetch_live_games("football", "nfl")
assert len(result) == 1
assert result[0] is live_event
def test_fetch_live_games_empty_when_none_live(self):
payload = {"events": [
{"competitions": [{"status": {"type": {"state": "post"}}}]}
]}
with patch.object(self.source.session, "get", return_value=_mock_response(payload)):
result = self.source.fetch_live_games("football", "nfl")
assert result == []
def test_fetch_live_games_returns_empty_on_error(self):
with patch.object(self.source.session, "get", side_effect=Exception("network failure")):
result = self.source.fetch_live_games("football", "nfl")
assert result == []
def test_fetch_schedule_returns_all_events(self):
events = [{"id": "1"}, {"id": "2"}]
payload = {"events": events}
start = datetime(2024, 1, 1)
end = datetime(2024, 1, 7)
with patch.object(self.source.session, "get", return_value=_mock_response(payload)):
result = self.source.fetch_schedule("football", "nfl", (start, end))
assert len(result) == 2
def test_fetch_schedule_returns_empty_on_error(self):
with patch.object(self.source.session, "get", side_effect=Exception("timeout")):
result = self.source.fetch_schedule("football", "nfl", (datetime.now(), datetime.now()))
assert result == []
def test_fetch_standings_success(self):
payload = {"standings": []}
with patch.object(self.source.session, "get", return_value=_mock_response(payload)):
result = self.source.fetch_standings("football", "nfl")
assert result == payload
def test_fetch_standings_returns_empty_on_error(self):
with patch.object(self.source.session, "get", side_effect=Exception("error")):
result = self.source.fetch_standings("football", "nfl")
assert result == {}
def test_base_url_set_correctly(self):
assert "espn.com" in self.source.base_url
# ---------------------------------------------------------------------------
# MLBAPIDataSource
# ---------------------------------------------------------------------------
class TestMLBAPIDataSource:
def setup_method(self):
self.source = MLBAPIDataSource(_make_logger())
def test_fetch_live_games_filters_live(self):
live_game = {"status": {"abstractGameState": "Live"}}
final_game = {"status": {"abstractGameState": "Final"}}
payload = {"dates": [{"games": [live_game, final_game]}]}
with patch.object(self.source.session, "get", return_value=_mock_response(payload)):
result = self.source.fetch_live_games("baseball", "mlb")
assert len(result) == 1
assert result[0] is live_game
def test_fetch_live_games_empty_dates(self):
payload = {"dates": []}
with patch.object(self.source.session, "get", return_value=_mock_response(payload)):
result = self.source.fetch_live_games("baseball", "mlb")
assert result == []
def test_fetch_live_games_returns_empty_on_error(self):
with patch.object(self.source.session, "get", side_effect=Exception("err")):
result = self.source.fetch_live_games("baseball", "mlb")
assert result == []
def test_fetch_schedule_aggregates_all_dates(self):
payload = {
"dates": [
{"games": [{"id": "1"}, {"id": "2"}]},
{"games": [{"id": "3"}]},
]
}
with patch.object(self.source.session, "get", return_value=_mock_response(payload)):
result = self.source.fetch_schedule("baseball", "mlb", (datetime.now(), datetime.now()))
assert len(result) == 3
def test_fetch_schedule_returns_empty_on_error(self):
with patch.object(self.source.session, "get", side_effect=Exception("err")):
result = self.source.fetch_schedule("baseball", "mlb", (datetime.now(), datetime.now()))
assert result == []
def test_fetch_standings_success(self):
payload = {"records": []}
with patch.object(self.source.session, "get", return_value=_mock_response(payload)):
result = self.source.fetch_standings("baseball", "mlb")
assert result == payload
def test_fetch_standings_returns_empty_on_error(self):
with patch.object(self.source.session, "get", side_effect=Exception("err")):
result = self.source.fetch_standings("baseball", "mlb")
assert result == {}
# ---------------------------------------------------------------------------
# SoccerAPIDataSource
# ---------------------------------------------------------------------------
class TestSoccerAPIDataSource:
def setup_method(self):
self.source = SoccerAPIDataSource(_make_logger(), api_key="test-key-123")
def test_headers_include_api_key(self):
headers = self.source.get_headers()
assert headers["X-Auth-Token"] == "test-key-123"
def test_headers_without_api_key(self):
source = SoccerAPIDataSource(_make_logger())
headers = source.get_headers()
assert "X-Auth-Token" not in headers
def test_fetch_live_games_success(self):
payload = {"matches": [{"id": "m1"}, {"id": "m2"}]}
with patch.object(self.source.session, "get", return_value=_mock_response(payload)):
result = self.source.fetch_live_games("soccer", "eng.1")
assert len(result) == 2
def test_fetch_live_games_returns_empty_on_error(self):
with patch.object(self.source.session, "get", side_effect=Exception("err")):
result = self.source.fetch_live_games("soccer", "eng.1")
assert result == []
def test_fetch_schedule_success(self):
payload = {"matches": [{"id": "m1"}]}
with patch.object(self.source.session, "get", return_value=_mock_response(payload)):
result = self.source.fetch_schedule("soccer", "eng.1", (datetime.now(), datetime.now()))
assert len(result) == 1
def test_fetch_schedule_returns_empty_on_error(self):
with patch.object(self.source.session, "get", side_effect=Exception("err")):
result = self.source.fetch_schedule("soccer", "eng.1", (datetime.now(), datetime.now()))
assert result == []
def test_fetch_standings_success(self):
payload = {"standings": []}
with patch.object(self.source.session, "get", return_value=_mock_response(payload)):
result = self.source.fetch_standings("soccer", "PL")
assert result == payload
def test_fetch_standings_returns_empty_on_error(self):
with patch.object(self.source.session, "get", side_effect=Exception("err")):
result = self.source.fetch_standings("soccer", "PL")
assert result == {}
+10 -170
View File
@@ -167,151 +167,6 @@ class TestDisplayControllerLivePriority:
assert controller.current_display_mode == "test_plugin_live"
assert controller.force_change is True
def test_live_priority_resume_continues_rotation(self, test_display_controller):
"""Regression: when live priority ends, rotation resumes where it was
interrupted, not after the live plugin's mode.
Without the fix, _apply_live_priority left current_mode_index pointing at
the live plugin's slot, so the next rotation step skipped every mode
between the interrupted position and the live plugin (e.g. elections,
which sits just before a flights plugin in the order)."""
controller = test_display_controller
controller.available_modes = [
"weather", "forecast", "almanac", "election_ticker", "flight_live"
]
# Rotation is about to show the 3rd mode (index 2).
controller.current_mode_index = 2
controller.current_display_mode = "almanac"
controller._live_resume_index = None
# Live priority (e.g. planes overhead) preempts -> flight_live (index 4).
controller._apply_live_priority("flight_live")
assert controller.current_display_mode == "flight_live"
assert controller.current_mode_index == 4
assert controller._live_resume_index == 2 # saved rotation position
# Re-checks while the hold continues must not move the saved position.
controller._apply_live_priority("flight_live")
assert controller._live_resume_index == 2
# Live priority ends -> resume at the saved index (almanac), so the next
# rotation step lands on election_ticker (index 3) rather than skipping it.
controller._apply_live_priority(None)
assert controller.current_mode_index == 2
assert controller.current_display_mode == "almanac"
assert controller._live_resume_index is None
def test_live_priority_no_resume_when_idle(self, test_display_controller):
"""No saved position + no live content is a no-op (normal rotation)."""
controller = test_display_controller
controller.available_modes = ["a", "b", "c"]
controller.current_mode_index = 1
controller.current_display_mode = "b"
controller._live_resume_index = None
controller._apply_live_priority(None)
assert controller.current_mode_index == 1
assert controller.current_display_mode == "b"
# --- Round-robin between multiple simultaneous live games --------------
@staticmethod
def _live_plugin(live_modes):
"""A mock plugin that is live and reports the given live mode names."""
p = MagicMock()
p.has_live_priority = MagicMock(return_value=True)
p.has_live_content = MagicMock(return_value=True)
p.get_live_modes = MagicMock(return_value=list(live_modes))
return p
def test_collect_live_modes_dedupes_multi_mode_plugin(self, test_display_controller):
"""A sports plugin registered under several mode keys (one per league)
contributes each live mode once, in registration order; plugins with no
live content are skipped."""
controller = test_display_controller
baseball = self._live_plugin(["baseball_live"])
soccer = self._live_plugin(["soccer_fifa.world_live"])
idle = MagicMock()
idle.has_live_priority = MagicMock(return_value=True)
idle.has_live_content = MagicMock(return_value=False)
controller.plugin_modes = {
"baseball_live": baseball,
"baseball_recent": baseball,
"soccer_fifa.world_live": soccer,
"soccer_usa.1_live": soccer,
"soccer_recent": soccer,
"clock": idle,
}
assert controller._collect_live_modes() == [
"baseball_live", "soccer_fifa.world_live"
]
def test_round_robin_alternates_between_simultaneous_live_games(self, test_display_controller):
"""Regression: with two games live at once, the live-priority pick
round-robins each dwell instead of pinning to the first plugin in
registration order (the bug where a baseball game hid a live World Cup
match)."""
controller = test_display_controller
baseball = self._live_plugin(["baseball_live"])
soccer = self._live_plugin(["soccer_fifa.world_live"])
controller.plugin_modes = {
"baseball_live": baseball,
"soccer_fifa.world_live": soccer,
}
# First entry into live priority from an ambient mode -> first live game.
controller.current_display_mode = "clock"
assert controller._check_live_priority(advance=True) == "baseball_live"
# The controller switches to it; the next dwell advances to the other.
controller.current_display_mode = "baseball_live"
assert controller._check_live_priority(advance=True) == "soccer_fifa.world_live"
# And wraps back again.
controller.current_display_mode = "soccer_fifa.world_live"
assert controller._check_live_priority(advance=True) == "baseball_live"
def test_single_live_game_holds_without_flipping(self, test_display_controller):
"""One live game: advancing returns the same mode, so the hold is stable."""
controller = test_display_controller
controller.plugin_modes = {"baseball_live": self._live_plugin(["baseball_live"])}
controller.current_display_mode = "baseball_live"
assert controller._check_live_priority(advance=True) == "baseball_live"
def test_non_advancing_peek_does_not_rotate(self, test_display_controller):
"""The default (advance=False) peek used by the Vegas coordinator must
not spin the cursor: it returns the live mode already on screen."""
controller = test_display_controller
controller.plugin_modes = {
"baseball_live": self._live_plugin(["baseball_live"]),
"soccer_fifa.world_live": self._live_plugin(["soccer_fifa.world_live"]),
}
controller.current_display_mode = "soccer_fifa.world_live"
assert controller._check_live_priority() == "soccer_fifa.world_live"
assert controller._check_live_priority() == "soccer_fifa.world_live"
# From an ambient mode the peek reports the first live game (truthy).
controller.current_display_mode = "clock"
assert controller._check_live_priority() == "baseball_live"
def test_no_live_content_returns_none(self, test_display_controller):
controller = test_display_controller
idle = MagicMock()
idle.has_live_priority = MagicMock(return_value=True)
idle.has_live_content = MagicMock(return_value=False)
controller.plugin_modes = {"clock": idle}
controller.current_display_mode = "clock"
assert controller._check_live_priority(advance=True) is None
def test_fallback_to_mode_name_when_get_live_modes_unhelpful(self, test_display_controller):
"""A live plugin whose get_live_modes returns nothing registered falls
back to its own '_live' mode name (legacy behavior preserved)."""
controller = test_display_controller
legacy = MagicMock()
legacy.has_live_priority = MagicMock(return_value=True)
legacy.has_live_content = MagicMock(return_value=True)
legacy.get_live_modes = MagicMock(return_value=["unregistered_mode"])
controller.plugin_modes = {"hockey_live": legacy}
controller.current_display_mode = "clock"
assert controller._check_live_priority(advance=True) == "hockey_live"
class TestDisplayControllerDynamicDuration:
"""Test dynamic duration handling."""
@@ -374,33 +229,18 @@ class TestDisplayControllerSchedule:
def test_inactive_hours(self, test_display_controller):
"""Test inactive hours check."""
controller = test_display_controller
# Inject schedule directly into self.config (what _check_schedule actually reads)
# and reset the minute gate so the cached result from any prior call is cleared.
controller.config['schedule'] = {
"enabled": True,
"start_time": "09:00",
"end_time": "17:00",
}
controller._schedule_checked_minute = None
controller._tz = None
with patch('src.display_controller.datetime') as mock_datetime:
mock_datetime.now.return_value.strftime.return_value.lower.return_value = "monday"
mock_datetime.now.return_value.time.return_value = datetime.strptime("20:00", "%H:%M").time()
mock_datetime.strptime = datetime.strptime
controller._check_schedule()
assert controller.is_display_active is False
class TestPluginHealthWiring:
"""Phase 1: DisplayController activates the dormant plugin health/metrics
subsystem by wiring real tracker/monitor instances onto the plugin manager."""
def test_health_tracker_and_resource_monitor_wired(self, test_display_controller):
from src.plugin_system.plugin_health import PluginHealthTracker
from src.plugin_system.resource_monitor import PluginResourceMonitor
pm = test_display_controller.plugin_manager
assert isinstance(pm.health_tracker, PluginHealthTracker)
assert isinstance(pm.resource_monitor, PluginResourceMonitor)
schedule_config = {
"schedule": {
"enabled": True,
"start_time": "09:00",
"end_time": "17:00"
}
}
with patch.object(controller.config_service, 'get_config', return_value=schedule_config):
controller._check_schedule()
assert controller.is_display_active is False
@@ -1,322 +0,0 @@
"""
Tests for the three display_controller.py optimizations:
Opt #1 — inspect.signature() caching per plugin_id
Opt #2 — pre-cached config values (_normal_brightness, _scroll_speed)
Opt #3 — schedule minute-gate (_check_schedule, _check_dim_schedule)
"""
import pytest
import time
from datetime import datetime
from unittest.mock import MagicMock, patch, call
# ---------------------------------------------------------------------------
# Shared fixture
# ---------------------------------------------------------------------------
@pytest.fixture
def controller(test_display_controller):
"""Return a ready DisplayController from the existing suite fixture."""
return test_display_controller
# ---------------------------------------------------------------------------
# Opt #1 — signature cache
# ---------------------------------------------------------------------------
class TestSignatureCache:
"""inspect.signature() should be called at most once per plugin_id."""
class _PluginWithMode:
"""Real class whose display() accepts display_mode — inspectable by signature."""
plugin_id = "mode_plugin"
def display(self, display_mode=None, force_clear=False):
return True
class _PluginNoMode:
"""Real class whose display() does NOT accept display_mode."""
plugin_id = "no_mode_plugin"
def display(self, force_clear=False):
return True
def test_cache_starts_empty(self, controller):
assert controller._plugin_accepts_display_mode == {}
def test_signature_computed_and_cached(self, controller):
"""After the first cache population, the dict holds a bool and stays unchanged
if queried again without explicitly deleting the key."""
import inspect as _inspect
plugin = self._PluginNoMode()
key = "sig_test"
if key not in controller._plugin_accepts_display_mode:
controller._plugin_accepts_display_mode[key] = (
"display_mode" in _inspect.signature(plugin.display).parameters
)
original = controller._plugin_accepts_display_mode[key]
# Accessing cache again should not change the value
second = controller._plugin_accepts_display_mode[key]
assert second == original
def test_cache_stores_false_for_no_display_mode(self, controller):
"""Plugin whose display() doesn't accept display_mode → cached False."""
import inspect as _inspect
plugin = self._PluginNoMode()
controller._plugin_accepts_display_mode["no_mode_plugin"] = (
"display_mode" in _inspect.signature(plugin.display).parameters
)
assert controller._plugin_accepts_display_mode["no_mode_plugin"] is False
def test_cache_stores_true_for_display_mode(self, controller):
"""Plugin whose display() accepts display_mode → cached True."""
import inspect as _inspect
plugin = self._PluginWithMode()
controller._plugin_accepts_display_mode["mode_plugin"] = (
"display_mode" in _inspect.signature(plugin.display).parameters
)
assert controller._plugin_accepts_display_mode["mode_plugin"] is True
def test_cache_cleared_on_plugin_reload(self, controller):
"""Populating plugin_modes for an id that's already cached must clear the entry."""
plugin = MagicMock()
controller._plugin_accepts_display_mode["reload_plugin"] = False
# Simulate the plugin_modes population code path (as in __init__)
plugin_id = "reload_plugin"
controller.plugin_modes["reload_plugin"] = plugin
if hasattr(controller, "_plugin_accepts_display_mode"):
controller._plugin_accepts_display_mode.pop(plugin_id, None)
assert "reload_plugin" not in controller._plugin_accepts_display_mode
# ---------------------------------------------------------------------------
# Opt #2 — cached config values
# ---------------------------------------------------------------------------
class TestCachedConfigValues:
"""_normal_brightness and _scroll_speed are populated from config at init."""
def test_normal_brightness_cached(self, controller):
"""_normal_brightness must equal what the config says."""
expected = (
controller.config
.get("display", {})
.get("hardware", {})
.get("brightness", 90)
)
assert controller._normal_brightness == expected
def test_scroll_speed_cached(self, controller):
"""_scroll_speed must equal what the config says."""
expected = (
controller.config
.get("display", {})
.get("vegas_scroll", {})
.get("scroll_speed", 75)
)
assert controller._scroll_speed == expected
def test_current_brightness_uses_cached_value(self, controller):
"""current_brightness is initialised from _normal_brightness."""
assert controller.current_brightness == controller._normal_brightness
def test_cached_target_brightness_init(self, controller):
"""_cached_target_brightness starts equal to _normal_brightness."""
assert controller._cached_target_brightness == controller._normal_brightness
def test_normal_brightness_default_is_90(self, controller):
"""If config has no brightness key the default is 90."""
controller.config = {}
controller._normal_brightness = (
controller.config.get("display", {})
.get("hardware", {})
.get("brightness", 90)
)
assert controller._normal_brightness == 90
# ---------------------------------------------------------------------------
# Opt #3 — schedule minute-gate
# ---------------------------------------------------------------------------
class TestScheduleMinuteGate:
"""_check_schedule and _check_dim_schedule skip re-evaluation within the same minute."""
# ── _check_schedule ──────────────────────────────────────────────────────
def test_schedule_checked_minute_starts_none(self, controller):
assert controller._schedule_checked_minute is None
def test_first_call_sets_checked_minute(self, controller):
"""After the first real evaluation the minute key is stored."""
controller.config["schedule"] = {
"enabled": True,
"start_time": "00:00",
"end_time": "23:59",
}
controller._schedule_checked_minute = None
controller._tz = None
controller._check_schedule()
assert controller._schedule_checked_minute is not None
def test_second_call_same_minute_does_not_re_evaluate(self, controller):
"""A second call with the same (hour, minute) returns without changing state."""
controller.config["schedule"] = {
"enabled": True,
"start_time": "00:00",
"end_time": "23:59",
}
controller._tz = None
controller._schedule_checked_minute = None
# First call — evaluates and marks as active (whole-day window)
controller._check_schedule()
assert controller.is_display_active is True
first_minute_key = controller._schedule_checked_minute
# Force is_display_active to False so we can tell if it gets re-evaluated
controller.is_display_active = False
# Second call within the same minute — gate fires, is_display_active unchanged
controller._schedule_checked_minute = first_minute_key # same minute
controller._check_schedule()
assert controller.is_display_active is False, (
"Second call in same minute should return immediately without re-evaluation"
)
def test_new_minute_forces_re_evaluation(self, controller):
"""A different (hour, minute) key causes a full re-evaluation."""
controller.config["schedule"] = {
"enabled": True,
"start_time": "00:00",
"end_time": "23:59",
}
controller._tz = None
# Plant a stale minute key from yesterday
controller._schedule_checked_minute = (-1, -1)
controller.is_display_active = False # wrong value to be corrected
controller._check_schedule()
assert controller.is_display_active is True, (
"A new minute key should trigger re-evaluation and correct is_display_active"
)
def test_gate_skipped_when_schedule_disabled(self, controller):
"""When schedule.enabled=False the method returns before reaching the gate."""
controller.config["schedule"] = {"enabled": False}
controller._schedule_checked_minute = None
controller._tz = None
controller._check_schedule()
# The early-return path doesn't set the minute key
assert controller._schedule_checked_minute is None
# ── _check_dim_schedule ──────────────────────────────────────────────────
def test_dim_checked_minute_starts_none(self, controller):
assert controller._dim_checked_minute is None
def test_first_dim_call_sets_checked_minute(self, controller):
"""First call with dim schedule enabled stores the minute key."""
controller.config["dim_schedule"] = {
"enabled": True,
"start_time": "22:00",
"end_time": "06:00",
}
controller.is_display_active = True
controller._dim_checked_minute = None
controller._tz = None
controller._check_dim_schedule()
assert controller._dim_checked_minute is not None
def test_dim_second_call_returns_cached_brightness(self, controller):
"""Second call with same minute returns _cached_target_brightness immediately."""
controller.config["dim_schedule"] = {
"enabled": True,
"start_time": "22:00",
"end_time": "06:00",
}
controller.is_display_active = True
controller._dim_checked_minute = None
controller._tz = None
# First call stores the result
first_result = controller._check_dim_schedule()
assert controller._cached_target_brightness == first_result
minute_key = controller._dim_checked_minute
# Corrupt cached value to something recognisable
controller._cached_target_brightness = 42
# Second call in same minute — must return the cached 42
controller._dim_checked_minute = minute_key
second_result = controller._check_dim_schedule()
assert second_result == 42, (
"Same-minute call must return cached brightness, not re-compute"
)
def test_dim_gate_skipped_when_display_off(self, controller):
"""When display is off the method exits before the minute gate."""
controller.config["dim_schedule"] = {"enabled": True, "start_time": "22:00", "end_time": "06:00"}
controller.is_display_active = False
controller._dim_checked_minute = None
controller._tz = None
controller._check_dim_schedule()
# Early-exit path does not set the minute key
assert controller._dim_checked_minute is None
def test_dim_cached_target_brightness_updated_after_full_evaluation(self, controller):
"""After a full evaluation _cached_target_brightness reflects the result."""
controller.config["dim_schedule"] = {
"enabled": True,
"start_time": "22:00",
"end_time": "06:00",
}
controller.is_display_active = True
controller._dim_checked_minute = None # force full re-evaluation
controller._tz = None
result = controller._check_dim_schedule()
assert controller._cached_target_brightness == result
# ── timezone lazy init ───────────────────────────────────────────────────
def test_tz_starts_none(self, controller):
assert controller._tz is None
def test_tz_lazily_initialised_on_first_schedule_check(self, controller):
"""_tz is None until _check_schedule or _check_dim_schedule is called."""
controller.config["schedule"] = {
"enabled": True,
"start_time": "00:00",
"end_time": "23:59",
}
controller._tz = None
controller._schedule_checked_minute = None
controller._check_schedule()
assert controller._tz is not None
def test_tz_shared_between_schedule_and_dim(self, controller):
"""Both methods use the same cached _tz instance."""
controller.config["schedule"] = {"enabled": True, "start_time": "00:00", "end_time": "23:59"}
controller.config["dim_schedule"] = {"enabled": True, "start_time": "22:00", "end_time": "06:00"}
controller.is_display_active = True
controller._tz = None
controller._schedule_checked_minute = None
controller._dim_checked_minute = None
controller._check_schedule()
tz_after_schedule = controller._tz
controller._check_dim_schedule()
assert controller._tz is tz_after_schedule, (
"_check_dim_schedule should reuse the _tz set by _check_schedule"
)
@@ -1,255 +0,0 @@
"""Tests for live plugin enable/disable hot-reload in DisplayController.
Enabling or disabling a plugin in config used to require a full display
restart because the plugin list and available_modes were built once at init.
These tests cover the reconcile path that loads/unloads plugins and rebuilds
the dispatch maps on the main thread when the enabled set changes.
"""
from unittest.mock import MagicMock
def _make_plugin(modes):
plugin = MagicMock()
plugin.modes = list(modes)
return plugin
def _wire_plugin_manager(controller, plugins, discovered=None):
"""Point the controller's mock plugin_manager at a set of fake plugins.
`plugins` maps plugin_id -> mock instance (with a .modes list).
"""
pm = controller.plugin_manager
pm.discover_plugins.return_value = list(discovered if discovered is not None else plugins.keys())
pm.load_plugin.return_value = True
pm.unload_plugin.return_value = True
pm.plugin_manifests = {}
pm.get_plugin.side_effect = lambda pid: plugins.get(pid)
return pm
def _set_config(controller, cfg):
controller.config_service.get_config = lambda: cfg
class TestPluginEnableDisableHotReload:
def test_enable_plugin_live(self, test_display_controller):
controller = test_display_controller
assert controller.available_modes == []
plugin = _make_plugin(["foo"])
_wire_plugin_manager(controller, {"foo": plugin})
_set_config(controller, {"foo": {"enabled": True}})
controller._reconcile_enabled_plugins()
assert "foo" in controller.plugin_display_modes
assert "foo" in controller.available_modes
assert controller.plugin_modes["foo"] is plugin
assert controller.mode_to_plugin_id["foo"] == "foo"
controller.plugin_manager.load_plugin.assert_any_call("foo")
def test_disable_plugin_live(self, test_display_controller):
controller = test_display_controller
plugin = _make_plugin(["live", "recent"])
_wire_plugin_manager(controller, {"sports": plugin}, discovered=["sports"])
# Enable, then disable.
_set_config(controller, {"sports": {"enabled": True}})
controller._reconcile_enabled_plugins()
assert "sports" in controller.plugin_display_modes
assert "live" in controller.available_modes and "recent" in controller.available_modes
assert "sports" in controller._plugin_config_callbacks
_set_config(controller, {"sports": {"enabled": False}})
controller._reconcile_enabled_plugins()
assert "sports" not in controller.plugin_display_modes
assert "live" not in controller.available_modes
assert "recent" not in controller.available_modes
assert "live" not in controller.plugin_modes
assert "recent" not in controller.mode_to_plugin_id
controller.plugin_manager.unload_plugin.assert_any_call("sports")
assert "sports" not in controller._plugin_config_callbacks
def test_disable_clamps_current_mode_index(self, test_display_controller):
controller = test_display_controller
p1 = _make_plugin(["a"])
p2 = _make_plugin(["b"])
_wire_plugin_manager(controller, {"p1": p1, "p2": p2}, discovered=["p1", "p2"])
_set_config(controller, {"p1": {"enabled": True}, "p2": {"enabled": True}})
controller._reconcile_enabled_plugins()
# Add order across multiple plugins is set-driven (as at init), so
# compare membership, not order.
assert set(controller.available_modes) == {"a", "b"}
# Pretend we're currently showing p2's mode.
controller.current_mode_index = controller.available_modes.index("b")
controller.current_display_mode = "b"
_set_config(controller, {"p1": {"enabled": True}, "p2": {"enabled": False}})
controller._reconcile_enabled_plugins()
assert controller.available_modes == ["a"]
# Index must be back in range and the display mode no longer the removed one.
assert 0 <= controller.current_mode_index < len(controller.available_modes)
assert controller.current_display_mode == "a"
def test_enable_keeps_current_mode(self, test_display_controller):
controller = test_display_controller
p1 = _make_plugin(["a"])
p2 = _make_plugin(["b"])
_wire_plugin_manager(controller, {"p1": p1, "p2": p2}, discovered=["p1", "p2"])
_set_config(controller, {"p1": {"enabled": True}})
controller._reconcile_enabled_plugins()
controller.current_mode_index = 0
controller.current_display_mode = "a"
# Enabling p2 should not disturb the currently-showing mode.
_set_config(controller, {"p1": {"enabled": True}, "p2": {"enabled": True}})
controller._reconcile_enabled_plugins()
assert "b" in controller.available_modes
assert controller.current_display_mode == "a"
assert controller.available_modes[controller.current_mode_index] == "a"
def test_noop_when_enabled_set_unchanged(self, test_display_controller):
controller = test_display_controller
plugin = _make_plugin(["foo"])
_wire_plugin_manager(controller, {"foo": plugin}, discovered=["foo"])
_set_config(controller, {"foo": {"enabled": True}})
controller._reconcile_enabled_plugins()
load_calls = controller.plugin_manager.load_plugin.call_count
unload_calls = controller.plugin_manager.unload_plugin.call_count
# Reconcile again with no change — must not load/unload anything.
controller._reconcile_enabled_plugins()
assert controller.plugin_manager.load_plugin.call_count == load_calls
assert controller.plugin_manager.unload_plugin.call_count == unload_calls
def test_reconcile_ignores_non_dict_config_value(self, test_display_controller, caplog):
"""A malformed config value (e.g. a stray string where a plugin's
section should be a dict) must be treated as disabled, not crash
the reconcile with AttributeError, and should be logged so it's
visible to whoever has to debug the malformed config."""
controller = test_display_controller
plugin = _make_plugin(["foo"])
_wire_plugin_manager(controller, {"foo": plugin}, discovered=["foo"])
_set_config(controller, {"foo": "not-a-dict"})
with caplog.at_level("WARNING"):
controller._reconcile_enabled_plugins() # must not raise
assert "foo" not in controller.plugin_display_modes
assert "foo" not in controller.available_modes
assert any("foo" in r.message and "not a dict" in r.message for r in caplog.records)
def test_disable_keeps_callback_when_unsubscribe_fails(self, test_display_controller):
"""If config_service.unsubscribe() raises, _unregister_plugin must
keep the callback in _plugin_config_callbacks rather than losing the
only reference to it (it still tears down the plugin itself)."""
controller = test_display_controller
plugin = _make_plugin(["live"])
_wire_plugin_manager(controller, {"sports": plugin}, discovered=["sports"])
_set_config(controller, {"sports": {"enabled": True}})
controller._reconcile_enabled_plugins()
assert "sports" in controller._plugin_config_callbacks
controller.config_service.unsubscribe = MagicMock(side_effect=RuntimeError("boom"))
_set_config(controller, {"sports": {"enabled": False}})
controller._reconcile_enabled_plugins()
assert "sports" not in controller.plugin_display_modes
assert "sports" in controller._plugin_config_callbacks
class TestReconcileReturnValue:
"""_reconcile_enabled_plugins() returns True/False so the caller (run()'s
loop) only clears _pending_plugin_reconcile on success, keeping a
retryable failure's request alive instead of silently dropping it."""
def test_returns_true_on_success(self, test_display_controller):
controller = test_display_controller
plugin = _make_plugin(["foo"])
_wire_plugin_manager(controller, {"foo": plugin}, discovered=["foo"])
_set_config(controller, {"foo": {"enabled": True}})
assert controller._reconcile_enabled_plugins() is True
def test_returns_true_for_noop(self, test_display_controller):
controller = test_display_controller
_wire_plugin_manager(controller, {}, discovered=[])
_set_config(controller, {})
assert controller._reconcile_enabled_plugins() is True
def test_returns_false_on_discovery_failure(self, test_display_controller):
controller = test_display_controller
controller.plugin_manager.discover_plugins.side_effect = RuntimeError("boom")
_set_config(controller, {})
assert controller._reconcile_enabled_plugins() is False
def test_returns_true_when_no_plugin_manager(self, test_display_controller):
controller = test_display_controller
controller.plugin_manager = None
assert controller._reconcile_enabled_plugins() is True
class TestRunWithNoModesEnabled:
"""Before hot-reload, an empty available_modes at startup was permanent
-- the display never came back without a restart. Now that a plugin can
be enabled live from the web UI, run() must idle rather than exit."""
def test_idles_instead_of_exiting(self, test_display_controller):
controller = test_display_controller
assert controller.available_modes == []
sleep_calls = []
def fake_sleep(duration, tick_interval=1.0):
sleep_calls.append(duration)
if len(sleep_calls) >= 3:
# Stand in for the process being torn down; run() catches
# this via its broad except + finally, same as any other
# unexpected error during the loop.
raise RuntimeError("stop-test-loop")
controller._sleep_with_plugin_updates = fake_sleep
controller.run()
# Old behavior returned before ever reaching the loop body, so
# _sleep_with_plugin_updates would never have been called. The idle
# tick is short (not a long sleep) so a plugin enabled via the web
# UI while idle is picked up about as promptly as it would be once
# modes exist and the loop is iterating per-frame.
assert sleep_calls == [1, 1, 1]
class TestEnabledSetChanged:
def test_detects_toggle(self, test_display_controller):
c = test_display_controller
assert c._enabled_set_changed({"a": {"enabled": True}}, {"a": {"enabled": False}}) is True
def test_no_change(self, test_display_controller):
c = test_display_controller
cfg = {"a": {"enabled": True}, "b": {"enabled": False}}
assert c._enabled_set_changed(cfg, dict(cfg)) is False
def test_new_enabled_section(self, test_display_controller):
c = test_display_controller
assert c._enabled_set_changed(
{"a": {"enabled": True}},
{"a": {"enabled": True}, "b": {"enabled": True}},
) is True
def test_ignores_non_enabled_value_edits(self, test_display_controller):
c = test_display_controller
assert c._enabled_set_changed(
{"a": {"enabled": True, "duration": 30}},
{"a": {"enabled": True, "duration": 45}},
) is False
+2 -105
View File
@@ -109,114 +109,11 @@ class TestDisplayManagerDrawing:
class TestDisplayManagerResourceManagement:
"""Test resource management."""
def test_cleanup(self, test_config, mock_rgb_matrix):
"""Test cleanup operation."""
with patch.dict('os.environ', {'EMULATOR': 'false'}):
dm = DisplayManager(test_config)
dm.cleanup()
dm.matrix.Clear.assert_called()
class TestDisplayManagerDoubleSided:
"""Double-sided mode: render once at logical size, tile across the chain."""
def _config(self, **double_sided):
"""Build a config (physical 128x32) with the given double_sided block."""
return {
'display': {
'hardware': {
'rows': 32, 'cols': 64, 'chain_length': 2, 'parallel': 1,
'hardware_mapping': 'adafruit-hat-pwm', 'brightness': 90,
},
'runtime': {'gpio_slowdown': 2},
'double_sided': double_sided,
},
'timezone': 'UTC',
'plugin_system': {'plugins_directory': 'plugins'},
}
def _captured_physical(self, mock_rgb_matrix):
"""Return the image handed to the canvas on the last update_display()."""
canvas = mock_rgb_matrix['matrix_instance'].CreateFrameCanvas.return_value
return canvas.SetImage.call_args[0][0]
def test_horizontal_reports_logical_dimensions(self, mock_rgb_matrix):
"""Plugins see the per-screen size, not the full physical chain."""
DisplayManager._instance = None
with patch.dict('os.environ', {'EMULATOR': 'false'}):
dm = DisplayManager(self._config(enabled=True, copies=2, axis='horizontal'),
suppress_test_pattern=True)
# Physical chain is 128x32; two side-by-side copies -> logical 64x32.
assert dm.matrix.width == 64
assert dm.matrix.height == 32
assert (dm.width, dm.height) == (64, 32)
assert dm.image.size == (64, 32)
def test_horizontal_tiles_image_across_chain(self, mock_rgb_matrix):
"""The logical screen is duplicated left/right into a full-chain frame."""
from PIL import Image
DisplayManager._instance = None
with patch.dict('os.environ', {'EMULATOR': 'false'}):
dm = DisplayManager(self._config(enabled=True, copies=2, axis='horizontal'),
suppress_test_pattern=True)
logical = Image.new('RGB', (64, 32), (0, 0, 0))
logical.putpixel((5, 5), (255, 0, 0))
dm.image = logical
dm.update_display()
physical = self._captured_physical(mock_rgb_matrix)
assert physical.size == (128, 32)
assert physical.getpixel((5, 5)) == (255, 0, 0)
assert physical.getpixel((69, 5)) == (255, 0, 0) # copy shifted +64
def test_vertical_axis_tiles_stacked(self, mock_rgb_matrix):
"""Vertical axis stacks copies (for panels on parallel outputs)."""
from PIL import Image
DisplayManager._instance = None
with patch.dict('os.environ', {'EMULATOR': 'false'}):
dm = DisplayManager(self._config(enabled=True, copies=2, axis='vertical'),
suppress_test_pattern=True)
# 128x32 split vertically -> logical 128x16.
assert (dm.matrix.width, dm.matrix.height) == (128, 16)
logical = Image.new('RGB', (128, 16), (0, 0, 0))
logical.putpixel((10, 3), (0, 255, 0))
dm.image = logical
dm.update_display()
physical = self._captured_physical(mock_rgb_matrix)
assert physical.size == (128, 32)
assert physical.getpixel((10, 3)) == (0, 255, 0)
assert physical.getpixel((10, 19)) == (0, 255, 0) # copy shifted +16
def test_indivisible_dimension_disables_mode(self, mock_rgb_matrix):
"""A physical size that doesn't divide evenly falls back to single."""
DisplayManager._instance = None
with patch.dict('os.environ', {'EMULATOR': 'false'}):
dm = DisplayManager(self._config(enabled=True, copies=3, axis='horizontal'),
suppress_test_pattern=True)
assert dm._double_sided is None # 128 % 3 != 0
assert dm.matrix.width == 128
assert dm.image.size == (128, 32)
def test_disabled_blits_logical_image_unchanged(self, mock_rgb_matrix):
"""With the feature off, the rendered image is sent through untouched."""
from PIL import Image
DisplayManager._instance = None
with patch.dict('os.environ', {'EMULATOR': 'false'}):
dm = DisplayManager(self._config(enabled=False), suppress_test_pattern=True)
assert dm._double_sided is None
img = Image.new('RGB', (128, 32))
dm.image = img
dm.update_display()
assert self._captured_physical(mock_rgb_matrix) is img
def test_brightness_write_forwards_through_proxy(self, mock_rgb_matrix):
"""Setting brightness via the proxy reaches the real matrix."""
DisplayManager._instance = None
with patch.dict('os.environ', {'EMULATOR': 'false'}):
dm = DisplayManager(self._config(enabled=True, copies=2, axis='horizontal'),
suppress_test_pattern=True)
assert dm.set_brightness(70) is True
assert mock_rgb_matrix['matrix_instance'].brightness == 70
-317
View File
@@ -1,317 +0,0 @@
"""
Tests for src/common/game_helper.py
Covers GameHelper: extract_game_details, filter_*, sort_games_by_time,
process_games, get_game_summary, and all private helpers.
"""
import logging
import pytest
from datetime import datetime, timezone, timedelta
from src.common.game_helper import GameHelper
def _make_logger() -> logging.Logger:
return logging.getLogger("test_game_helper")
def _make_espn_event(
state: str = "in",
home_abbr: str = "LAL",
away_abbr: str = "BOS",
home_score: str = "105",
away_score: str = "98",
date_str: str = "2024-01-15T20:00:00Z",
period: int = 4,
status_name: str = "STATUS_IN_PROGRESS",
home_record: str = "30-10",
away_record: str = "25-15",
event_id: str = "game-1",
) -> dict:
return {
"id": event_id,
"date": date_str,
"competitions": [
{
"status": {
"type": {
"state": state,
"shortDetail": "Q4 2:30",
"name": status_name,
},
"period": period,
"displayClock": "2:30",
},
"competitors": [
{
"homeAway": "home",
"id": "h1",
"team": {"abbreviation": home_abbr, "displayName": f"{home_abbr} Team"},
"score": home_score,
"records": [{"summary": home_record}],
},
{
"homeAway": "away",
"id": "a1",
"team": {"abbreviation": away_abbr, "displayName": f"{away_abbr} Team"},
"score": away_score,
"records": [{"summary": away_record}],
},
],
}
],
}
@pytest.fixture
def helper():
return GameHelper(timezone_str="UTC", logger=_make_logger())
# ---------------------------------------------------------------------------
# extract_game_details
# ---------------------------------------------------------------------------
class TestExtractGameDetails:
def test_live_game(self, helper):
event = _make_espn_event(state="in")
result = helper.extract_game_details(event)
assert result is not None
assert result["is_live"] is True
assert result["is_final"] is False
assert result["is_upcoming"] is False
def test_final_game(self, helper):
event = _make_espn_event(state="post")
result = helper.extract_game_details(event)
assert result["is_final"] is True
def test_upcoming_game(self, helper):
event = _make_espn_event(state="pre")
result = helper.extract_game_details(event)
assert result["is_upcoming"] is True
def test_halftime_detection(self, helper):
event = _make_espn_event(state="halftime", status_name="STATUS_HALFTIME")
result = helper.extract_game_details(event)
assert result["is_halftime"] is True
def test_basic_fields_present(self, helper):
event = _make_espn_event()
result = helper.extract_game_details(event)
for key in ("id", "home_abbr", "away_abbr", "home_score", "away_score",
"home_record", "away_record", "start_time_utc"):
assert key in result
def test_team_abbreviations(self, helper):
event = _make_espn_event(home_abbr="MIA", away_abbr="PHX")
result = helper.extract_game_details(event)
assert result["home_abbr"] == "MIA"
assert result["away_abbr"] == "PHX"
def test_scores_as_strings(self, helper):
event = _make_espn_event(home_score="110", away_score="99")
result = helper.extract_game_details(event)
assert result["home_score"] == "110"
assert result["away_score"] == "99"
def test_returns_none_on_empty(self, helper):
assert helper.extract_game_details({}) is None
assert helper.extract_game_details(None) is None
def test_returns_none_when_no_competitors(self, helper):
event = _make_espn_event()
event["competitions"][0]["competitors"] = []
assert helper.extract_game_details(event) is None
def test_date_z_suffix_parsed(self, helper):
event = _make_espn_event(date_str="2024-06-01T19:30:00Z")
result = helper.extract_game_details(event)
assert result["start_time_utc"] is not None
assert result["start_time_utc"].tzinfo is not None
def test_zero_zero_record_suppressed(self, helper):
event = _make_espn_event(home_record="0-0", away_record="0-0-0")
result = helper.extract_game_details(event)
assert result["home_record"] == ""
assert result["away_record"] == ""
def test_basketball_sport_fields(self, helper):
event = _make_espn_event(period=3)
result = helper.extract_game_details(event, sport="basketball")
assert result["period_text"] == "Q3"
assert "clock" in result
def test_basketball_overtime_period(self, helper):
event = _make_espn_event(period=5)
result = helper.extract_game_details(event, sport="basketball")
assert result["period_text"] == "OT1"
def test_football_sport_fields(self, helper):
event = _make_espn_event(period=2)
result = helper.extract_game_details(event, sport="football")
assert result["period_text"] == "Q2"
def test_hockey_sport_fields_period_1(self, helper):
event = _make_espn_event(period=1)
result = helper.extract_game_details(event, sport="hockey")
assert result["period_text"] == "P1"
def test_hockey_sport_fields_ot(self, helper):
event = _make_espn_event(period=4)
result = helper.extract_game_details(event, sport="hockey")
assert result["period_text"] == "OT1"
def test_baseball_sport_fields(self, helper):
event = _make_espn_event(period=7)
result = helper.extract_game_details(event, sport="baseball")
assert result["period_text"] == "INN 7"
# ---------------------------------------------------------------------------
# Filter methods
# ---------------------------------------------------------------------------
class TestFilterMethods:
def _make_games(self):
now = datetime.now(timezone.utc)
return [
{"is_live": True, "is_final": False, "is_upcoming": False, "home_abbr": "LAL", "away_abbr": "BOS", "start_time_utc": now},
{"is_live": False, "is_final": True, "is_upcoming": False, "home_abbr": "MIA", "away_abbr": "PHX", "start_time_utc": now - timedelta(hours=3)},
{"is_live": False, "is_final": False, "is_upcoming": True, "home_abbr": "DAL", "away_abbr": "CHI", "start_time_utc": now + timedelta(hours=2)},
]
def test_filter_live_games(self, helper):
games = self._make_games()
result = helper.filter_live_games(games)
assert len(result) == 1
assert result[0]["home_abbr"] == "LAL"
def test_filter_final_games(self, helper):
games = self._make_games()
result = helper.filter_final_games(games)
assert len(result) == 1
assert result[0]["home_abbr"] == "MIA"
def test_filter_upcoming_games(self, helper):
games = self._make_games()
result = helper.filter_upcoming_games(games)
assert len(result) == 1
assert result[0]["home_abbr"] == "DAL"
def test_filter_favorite_teams_match(self, helper):
games = self._make_games()
result = helper.filter_favorite_teams(games, ["LAL"])
assert len(result) == 1
assert result[0]["home_abbr"] == "LAL"
def test_filter_favorite_teams_empty_list_returns_all(self, helper):
games = self._make_games()
result = helper.filter_favorite_teams(games, [])
assert len(result) == 3
def test_filter_favorite_teams_away_match(self, helper):
games = self._make_games()
result = helper.filter_favorite_teams(games, ["BOS"])
assert len(result) == 1
def test_filter_recent_games_within_window(self, helper):
now = datetime.now(timezone.utc)
games = [
{"start_time_utc": now - timedelta(days=2), "is_final": True},
{"start_time_utc": now - timedelta(days=10), "is_final": True},
]
result = helper.filter_recent_games(games, days_back=7)
assert len(result) == 1
def test_filter_recent_games_all_within(self, helper):
now = datetime.now(timezone.utc)
games = [
{"start_time_utc": now - timedelta(days=1)},
{"start_time_utc": now - timedelta(days=3)},
]
result = helper.filter_recent_games(games, days_back=7)
assert len(result) == 2
def test_sort_games_ascending(self, helper):
now = datetime.now(timezone.utc)
games = [
{"start_time_utc": now + timedelta(hours=2), "id": "late"},
{"start_time_utc": now + timedelta(hours=1), "id": "early"},
]
result = helper.sort_games_by_time(games)
assert result[0]["id"] == "early"
def test_sort_games_descending(self, helper):
now = datetime.now(timezone.utc)
games = [
{"start_time_utc": now + timedelta(hours=1), "id": "early"},
{"start_time_utc": now + timedelta(hours=2), "id": "late"},
]
result = helper.sort_games_by_time(games, reverse=True)
assert result[0]["id"] == "late"
# ---------------------------------------------------------------------------
# process_games
# ---------------------------------------------------------------------------
class TestProcessGames:
def test_processes_valid_events(self, helper):
events = [
_make_espn_event(event_id="1"),
_make_espn_event(event_id="2"),
]
result = helper.process_games(events)
assert len(result) == 2
def test_skips_invalid_events(self, helper):
events = [
_make_espn_event(event_id="1"),
{}, # invalid
]
result = helper.process_games(events)
assert len(result) == 1
def test_empty_events(self, helper):
assert helper.process_games([]) == []
# ---------------------------------------------------------------------------
# get_game_summary
# ---------------------------------------------------------------------------
class TestGetGameSummary:
def test_live_summary(self, helper):
game = {
"home_abbr": "LAL", "away_abbr": "BOS",
"home_score": "105", "away_score": "98",
"status_text": "Q4 2:30",
"is_live": True, "is_final": False,
}
summary = helper.get_game_summary(game)
assert "BOS" in summary
assert "LAL" in summary
assert "98" in summary
assert "105" in summary
def test_final_summary(self, helper):
game = {
"home_abbr": "LAL", "away_abbr": "BOS",
"home_score": "110", "away_score": "102",
"status_text": "Final",
"is_live": False, "is_final": True,
}
summary = helper.get_game_summary(game)
assert "Final" in summary
def test_upcoming_summary(self, helper):
game = {
"home_abbr": "LAL", "away_abbr": "BOS",
"home_score": "0", "away_score": "0",
"status_text": "7:30 PM",
"is_live": False, "is_final": False,
}
summary = helper.get_game_summary(game)
assert "7:30 PM" in summary
-307
View File
@@ -1,307 +0,0 @@
"""
Tests for src/plugin_system/health_monitor.py
Covers PluginHealthMonitor: get_plugin_health_status, get_plugin_health_metrics,
get_all_plugin_health, _get_recovery_suggestions, start/stop_monitoring,
register_health_check.
"""
import pytest
from unittest.mock import MagicMock, patch
from datetime import datetime
from src.plugin_system.health_monitor import (
PluginHealthMonitor,
HealthStatus,
HealthMetrics,
)
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
def _make_health_tracker(
summary: dict | None = None,
all_summaries: dict | None = None,
):
"""Return a mock PluginHealthTracker."""
tracker = MagicMock()
tracker.get_health_summary.return_value = summary
tracker.get_all_health_summaries.return_value = all_summaries or {}
return tracker
def _healthy_summary() -> dict:
return {
"success_rate": 100.0,
"circuit_state": "closed",
"consecutive_failures": 0,
"total_failures": 0,
"total_successes": 50,
"last_success_time": datetime.now().isoformat(),
"last_error": None,
}
def _degraded_summary() -> dict:
return {
"success_rate": 40.0, # 60% error rate
"circuit_state": "closed",
"consecutive_failures": 3,
"total_failures": 6,
"total_successes": 4,
"last_success_time": None,
"last_error": "timeout occurred",
}
def _unhealthy_summary() -> dict:
return {
"success_rate": 10.0, # 90% error rate
"circuit_state": "open",
"consecutive_failures": 10,
"total_failures": 9,
"total_successes": 1,
"last_success_time": None,
"last_error": "ImportError: missing module",
}
@pytest.fixture
def monitor():
tracker = _make_health_tracker(_healthy_summary())
return PluginHealthMonitor(health_tracker=tracker)
# ---------------------------------------------------------------------------
# get_plugin_health_status
# ---------------------------------------------------------------------------
class TestGetPluginHealthStatus:
def test_healthy_status(self):
tracker = _make_health_tracker(_healthy_summary())
monitor = PluginHealthMonitor(tracker)
status = monitor.get_plugin_health_status("plugin_a")
assert status == HealthStatus.HEALTHY
def test_degraded_status(self):
tracker = _make_health_tracker(_degraded_summary())
monitor = PluginHealthMonitor(tracker, degraded_threshold=0.5, unhealthy_threshold=0.8)
status = monitor.get_plugin_health_status("plugin_b")
assert status == HealthStatus.DEGRADED
def test_unhealthy_status(self):
tracker = _make_health_tracker(_unhealthy_summary())
monitor = PluginHealthMonitor(tracker, unhealthy_threshold=0.8)
status = monitor.get_plugin_health_status("plugin_c")
assert status == HealthStatus.UNHEALTHY
def test_open_circuit_breaker_is_unhealthy(self):
summary = _healthy_summary()
summary["circuit_state"] = "open"
tracker = _make_health_tracker(summary)
monitor = PluginHealthMonitor(tracker)
status = monitor.get_plugin_health_status("plugin_d")
assert status == HealthStatus.UNHEALTHY
def test_unknown_when_no_tracker(self):
monitor = PluginHealthMonitor(health_tracker=None)
status = monitor.get_plugin_health_status("plugin_e")
assert status == HealthStatus.UNKNOWN
def test_unknown_when_no_summary(self):
tracker = _make_health_tracker(None)
monitor = PluginHealthMonitor(tracker)
status = monitor.get_plugin_health_status("plugin_f")
assert status == HealthStatus.UNKNOWN
# ---------------------------------------------------------------------------
# get_plugin_health_metrics
# ---------------------------------------------------------------------------
class TestGetPluginHealthMetrics:
def test_healthy_metrics(self):
tracker = _make_health_tracker(_healthy_summary())
monitor = PluginHealthMonitor(tracker)
metrics = monitor.get_plugin_health_metrics("plugin_a")
assert isinstance(metrics, HealthMetrics)
assert metrics.status == HealthStatus.HEALTHY
assert metrics.success_rate == pytest.approx(1.0)
assert metrics.error_rate == pytest.approx(0.0)
def test_degraded_metrics(self):
tracker = _make_health_tracker(_degraded_summary())
monitor = PluginHealthMonitor(tracker, degraded_threshold=0.5, unhealthy_threshold=0.8)
metrics = monitor.get_plugin_health_metrics("plugin_b")
assert metrics.status == HealthStatus.DEGRADED
assert metrics.consecutive_failures == 3
def test_unhealthy_metrics(self):
tracker = _make_health_tracker(_unhealthy_summary())
monitor = PluginHealthMonitor(tracker, unhealthy_threshold=0.8)
metrics = monitor.get_plugin_health_metrics("plugin_c")
assert metrics.status == HealthStatus.UNHEALTHY
assert metrics.circuit_breaker_state == "open"
assert metrics.last_error is not None
def test_metrics_without_tracker(self):
monitor = PluginHealthMonitor(health_tracker=None)
metrics = monitor.get_plugin_health_metrics("plugin_d")
assert metrics.status == HealthStatus.UNKNOWN
assert metrics.plugin_id == "plugin_d"
def test_metrics_without_summary(self):
tracker = _make_health_tracker(None)
monitor = PluginHealthMonitor(tracker)
metrics = monitor.get_plugin_health_metrics("plugin_e")
assert metrics.status == HealthStatus.UNKNOWN
def test_last_successful_update_parsed(self):
summary = _healthy_summary()
summary["last_success_time"] = "2024-06-01T12:00:00"
tracker = _make_health_tracker(summary)
monitor = PluginHealthMonitor(tracker)
metrics = monitor.get_plugin_health_metrics("plugin_a")
assert metrics.last_successful_update is not None
assert isinstance(metrics.last_successful_update, datetime)
def test_invalid_last_success_time_handled(self):
summary = _healthy_summary()
summary["last_success_time"] = "not-a-date"
tracker = _make_health_tracker(summary)
monitor = PluginHealthMonitor(tracker)
# Should not raise
metrics = monitor.get_plugin_health_metrics("plugin_a")
assert metrics.last_successful_update is None
def test_total_successes_failures(self):
tracker = _make_health_tracker(_degraded_summary())
monitor = PluginHealthMonitor(tracker, degraded_threshold=0.5, unhealthy_threshold=0.8)
metrics = monitor.get_plugin_health_metrics("plugin_b")
assert metrics.total_failures == 6
assert metrics.total_successes == 4
# ---------------------------------------------------------------------------
# get_all_plugin_health
# ---------------------------------------------------------------------------
class TestGetAllPluginHealth:
def test_returns_empty_without_tracker(self):
monitor = PluginHealthMonitor(health_tracker=None)
result = monitor.get_all_plugin_health()
assert result == {}
def test_returns_metrics_for_each_plugin(self):
all_summaries = {
"plugin_a": _healthy_summary(),
"plugin_b": _degraded_summary(),
}
tracker = MagicMock()
tracker.get_all_health_summaries.return_value = all_summaries
tracker.get_health_summary.side_effect = lambda pid: all_summaries.get(pid)
monitor = PluginHealthMonitor(tracker, degraded_threshold=0.5, unhealthy_threshold=0.8)
result = monitor.get_all_plugin_health()
assert "plugin_a" in result
assert "plugin_b" in result
assert isinstance(result["plugin_a"], HealthMetrics)
def test_returns_empty_when_no_summaries(self):
tracker = _make_health_tracker(all_summaries={})
monitor = PluginHealthMonitor(tracker)
result = monitor.get_all_plugin_health()
assert result == {}
# ---------------------------------------------------------------------------
# _get_recovery_suggestions
# ---------------------------------------------------------------------------
class TestGetRecoverySuggestions:
def test_healthy_plugin_suggestion(self):
tracker = _make_health_tracker(_healthy_summary())
monitor = PluginHealthMonitor(tracker)
suggestions = monitor._get_recovery_suggestions("p", _healthy_summary(), HealthStatus.HEALTHY)
assert any("healthy" in s.lower() for s in suggestions)
def test_unhealthy_suggestions(self):
tracker = _make_health_tracker(_unhealthy_summary())
monitor = PluginHealthMonitor(tracker, unhealthy_threshold=0.8)
suggestions = monitor._get_recovery_suggestions("p", _unhealthy_summary(), HealthStatus.UNHEALTHY)
assert len(suggestions) > 0
assert any("unhealthy" in s.lower() for s in suggestions)
def test_open_circuit_breaker_suggestion(self):
summary = _unhealthy_summary()
summary["circuit_state"] = "open"
tracker = _make_health_tracker(summary)
monitor = PluginHealthMonitor(tracker, unhealthy_threshold=0.8)
suggestions = monitor._get_recovery_suggestions("p", summary, HealthStatus.UNHEALTHY)
assert any("circuit" in s.lower() for s in suggestions)
def test_timeout_error_suggestion(self):
summary = _degraded_summary()
summary["last_error"] = "connection timeout occurred"
tracker = _make_health_tracker(summary)
monitor = PluginHealthMonitor(tracker, degraded_threshold=0.5, unhealthy_threshold=0.8)
suggestions = monitor._get_recovery_suggestions("p", summary, HealthStatus.DEGRADED)
assert any("timeout" in s.lower() for s in suggestions)
def test_import_error_suggestion(self):
summary = _unhealthy_summary()
summary["last_error"] = "ImportError: missing module"
tracker = _make_health_tracker(summary)
monitor = PluginHealthMonitor(tracker, unhealthy_threshold=0.8)
suggestions = monitor._get_recovery_suggestions("p", summary, HealthStatus.UNHEALTHY)
assert any("dependencies" in s.lower() or "import" in s.lower() or "missing" in s.lower()
for s in suggestions)
def test_permission_error_suggestion(self):
summary = _unhealthy_summary()
summary["last_error"] = "permission denied to access resource"
tracker = _make_health_tracker(summary)
monitor = PluginHealthMonitor(tracker, unhealthy_threshold=0.8)
suggestions = monitor._get_recovery_suggestions("p", summary, HealthStatus.UNHEALTHY)
assert any("permission" in s.lower() for s in suggestions)
def test_degraded_suggestions_include_error_rate(self):
tracker = _make_health_tracker(_degraded_summary())
monitor = PluginHealthMonitor(tracker, degraded_threshold=0.5, unhealthy_threshold=0.8)
suggestions = monitor._get_recovery_suggestions("p", _degraded_summary(), HealthStatus.DEGRADED)
assert any("%" in s for s in suggestions)
# ---------------------------------------------------------------------------
# start / stop monitoring
# ---------------------------------------------------------------------------
class TestMonitorLifecycle:
def test_start_monitoring(self, monitor):
monitor.start_monitoring()
try:
assert monitor._monitor_thread is not None
assert monitor._monitor_thread.is_alive()
finally:
monitor.stop_monitoring()
def test_stop_monitoring(self, monitor):
monitor.start_monitoring()
monitor.stop_monitoring()
# Thread should no longer be alive
assert not monitor._monitor_thread.is_alive()
def test_double_start_no_duplicate_threads(self, monitor):
monitor.start_monitoring()
try:
thread1 = monitor._monitor_thread
monitor.start_monitoring() # should be idempotent
assert monitor._monitor_thread is thread1
finally:
monitor.stop_monitoring()
def test_register_health_check(self, monitor):
callback = MagicMock()
monitor.register_health_check(callback)
assert callback in monitor._health_check_callbacks
-129
View File
@@ -1,129 +0,0 @@
"""
Tests for src/logo_downloader.py
Focuses on the pure/static methods that don't require network calls:
normalize_abbreviation, get_logo_filename_variations, get_logo_directory,
ensure_logo_directory, and the download_missing_logo function path
(with HTTP mocked).
"""
import os
import pytest
from pathlib import Path
from unittest.mock import patch, Mock, MagicMock
from src.logo_downloader import LogoDownloader
# ---------------------------------------------------------------------------
# normalize_abbreviation
# ---------------------------------------------------------------------------
class TestNormalizeAbbreviation:
def test_basic_lowercase(self):
result = LogoDownloader.normalize_abbreviation("lal")
assert result == "LAL"
def test_uppercases(self):
result = LogoDownloader.normalize_abbreviation("bos")
assert result == "BOS"
def test_ampersand_replaced(self):
result = LogoDownloader.normalize_abbreviation("TA&M")
assert "&" not in result
assert "AND" in result
def test_forward_slash_replaced(self):
result = LogoDownloader.normalize_abbreviation("A/B")
assert "/" not in result
def test_empty_returns_empty(self):
result = LogoDownloader.normalize_abbreviation("")
assert result == ""
# ---------------------------------------------------------------------------
# get_logo_filename_variations
# ---------------------------------------------------------------------------
class TestGetLogoFilenameVariations:
def test_returns_list(self):
result = LogoDownloader.get_logo_filename_variations("LAL")
assert isinstance(result, list)
assert len(result) > 0
def test_includes_png(self):
result = LogoDownloader.get_logo_filename_variations("KC")
filenames = " ".join(result)
assert ".png" in filenames
def test_includes_original(self):
result = LogoDownloader.get_logo_filename_variations("LAL")
assert any("LAL" in f for f in result)
def test_ampersand_variation(self):
result = LogoDownloader.get_logo_filename_variations("TA&M")
# Should produce at least the normalized version
assert len(result) > 0
def test_empty_string_no_crash(self):
result = LogoDownloader.get_logo_filename_variations("")
assert isinstance(result, list)
# ---------------------------------------------------------------------------
# get_logo_directory
# ---------------------------------------------------------------------------
class TestGetLogoDirectory:
def test_known_sport_returns_string(self):
downloader = LogoDownloader()
result = downloader.get_logo_directory("nfl")
assert isinstance(result, str)
assert len(result) > 0
def test_known_sport_nba(self):
downloader = LogoDownloader()
result = downloader.get_logo_directory("nba")
assert "nba" in result.lower() or "sports" in result.lower()
def test_unknown_sport_returns_string(self):
downloader = LogoDownloader()
result = downloader.get_logo_directory("unknown_sport_xyz")
assert isinstance(result, str)
# ---------------------------------------------------------------------------
# ensure_logo_directory
# ---------------------------------------------------------------------------
class TestEnsureLogoDirectory:
def test_creates_writable_directory(self, tmp_path):
downloader = LogoDownloader()
test_dir = str(tmp_path / "logos" / "nfl")
result = downloader.ensure_logo_directory(test_dir)
assert result is True
assert Path(test_dir).is_dir()
def test_existing_writable_directory(self, tmp_path):
downloader = LogoDownloader()
test_dir = str(tmp_path)
result = downloader.ensure_logo_directory(test_dir)
assert result is True
def test_returns_false_when_write_test_fails(self, tmp_path):
"""Simulate a directory that exists but raises PermissionError on write."""
downloader = LogoDownloader()
test_dir = str(tmp_path / "logos")
import builtins
original_open = builtins.open
def mock_open(path, *args, **kwargs):
if ".write_test" in str(path):
raise PermissionError("no write access")
return original_open(path, *args, **kwargs)
with patch("builtins.open", side_effect=mock_open):
result = downloader.ensure_logo_directory(test_dir)
assert result is False
-83
View File
@@ -1,83 +0,0 @@
"""
Tests for src.common.permission_utils's URL-credential redaction.
Covers the fix for a CodeQL clear-text-logging-of-secrets alert:
install_requirements_file() must never let a private index URL's embedded
user:pass@ credentials reach logs or its returned CompletedProcess, since
pip can echo that URL back verbatim in its own stderr/stdout on failure.
"""
from pathlib import Path
from unittest.mock import MagicMock, patch
from src.common.permission_utils import _redact_url_credentials, install_requirements_file
class TestRedactUrlCredentials:
def test_redacts_embedded_basic_auth(self):
text = "Could not fetch URL https://alice:s3cr3t@pypi.example.com/simple/: 403"
redacted = _redact_url_credentials(text)
assert "s3cr3t" not in redacted
assert "alice" not in redacted
assert "https://***:***@pypi.example.com/simple/" in redacted
def test_leaves_credential_free_text_unchanged(self):
text = "ERROR: Could not find a version that satisfies the requirement foo==1.0"
assert _redact_url_credentials(text) == text
def test_handles_none_and_empty(self):
assert _redact_url_credentials(None) == ""
assert _redact_url_credentials("") == ""
def test_does_not_touch_denied_check_phrases(self):
"""The fixed phrases install_requirements_file greps for must survive
redaction untouched -- they don't overlap with URL syntax, but this
pins that assumption so a regex change can't silently break it."""
text = "sudo: a password is required"
assert _redact_url_credentials(text) == text
class TestInstallRequirementsFileRedaction:
@patch('src.common.permission_utils.subprocess.run')
def test_wrapper_path_redacts_stderr_and_stdout(self, mock_run, tmp_path):
"""safe_pip_install.sh exists in this repo, so install_requirements_file
takes the sudo-wrapper branch; a failing result must come back
with any embedded index-URL credentials already redacted."""
req_file = tmp_path / "requirements.txt"
req_file.write_text("requests\n")
mock_run.return_value = MagicMock(
returncode=1,
stdout="Looking in indexes: https://bob:hunter2@pypi.internal/simple\n",
stderr="ERROR https://bob:hunter2@pypi.internal/simple/foo: 401",
)
result = install_requirements_file(req_file, timeout=5)
assert "hunter2" not in result.stdout
assert "hunter2" not in result.stderr
assert "https://***:***@pypi.internal" in result.stdout
assert "https://***:***@pypi.internal" in result.stderr
@patch('src.common.permission_utils.subprocess.run')
@patch('src.common.permission_utils.Path.exists', return_value=False)
def test_no_wrapper_fallback_path_redacts_stderr_and_stdout(self, mock_exists, mock_run, tmp_path):
"""No safe_pip_install.sh wrapper -> falls straight to the
sys.executable pip fallback (the second subprocess.run call site);
its result must come back redacted too, independent of the wrapper
branch's own redaction above."""
req_file = tmp_path / "requirements.txt"
req_file.write_text("requests\n")
mock_run.return_value = MagicMock(
returncode=1,
stdout="Looking in indexes: https://carol:swordfish@pypi.internal/simple\n",
stderr="ERROR https://carol:swordfish@pypi.internal/simple/foo: 401",
)
result = install_requirements_file(req_file, timeout=5)
assert "swordfish" not in result.stdout
assert "swordfish" not in result.stderr
assert "https://***:***@pypi.internal" in result.stdout
assert "https://***:***@pypi.internal" in result.stderr
-93
View File
@@ -1,93 +0,0 @@
"""
Tests for src/plugin_system/plugin_health.py
Focus on the additive ``set_degraded`` mechanism used by the warn-only schema
validation path: it must surface a degraded reason without touching the circuit
breaker or causing the plugin to be skipped.
"""
from unittest.mock import MagicMock
from src.plugin_system.plugin_health import PluginHealthTracker, CircuitState
def _cache():
cache = MagicMock()
cache.get.return_value = None
return cache
def test_set_degraded_marks_and_surfaces_reason():
tracker = PluginHealthTracker(_cache())
tracker.set_degraded("p", "bad config")
summary = tracker.get_health_summary("p")
assert summary["degraded"] is True
assert summary["degraded_reason"] == "bad config"
def test_set_degraded_none_clears():
tracker = PluginHealthTracker(_cache())
tracker.set_degraded("p", "bad config")
tracker.set_degraded("p", None)
summary = tracker.get_health_summary("p")
assert summary["degraded"] is False
assert summary["degraded_reason"] is None
def test_set_degraded_does_not_affect_circuit_breaker():
tracker = PluginHealthTracker(_cache())
tracker.set_degraded("p", "bad config")
summary = tracker.get_health_summary("p")
# Degraded is a *separate* signal from circuit health: the plugin is not
# counted as failing, the circuit stays closed, and it is not skipped.
assert summary["circuit_state"] == CircuitState.CLOSED.value
assert summary["consecutive_failures"] == 0
assert summary["is_healthy"] is True
assert tracker.should_skip_plugin("p") is False
def test_set_degraded_skips_redundant_cache_write():
cache = _cache()
tracker = PluginHealthTracker(cache)
tracker.set_degraded("p", "x")
writes_after_first = cache.set.call_count
assert writes_after_first >= 1
tracker.set_degraded("p", "x") # unchanged → no extra write
assert cache.set.call_count == writes_after_first
def test_default_summary_has_degraded_fields():
tracker = PluginHealthTracker(_cache())
summary = tracker.get_health_summary("never-seen")
assert summary["degraded"] is False
assert summary["degraded_reason"] is None
def test_force_reload_refreshes_stale_in_memory_snapshot():
"""A long-lived reader (e.g. the web process) must not be pinned to the
first snapshot: force_reload re-reads persisted state and bypasses the
cache manager's memory tier so cross-process updates are visible."""
cache = _cache()
tracker = PluginHealthTracker(cache)
# First read snapshots an empty (healthy) state into the in-memory copy.
assert tracker.get_health_summary("p")["consecutive_failures"] == 0
# The display service later persists a failing/open state.
cache.get.return_value = {
"consecutive_failures": 5,
"circuit_state": "open",
"total_failures": 5,
"total_successes": 0,
}
# A plain read is still pinned to the stale snapshot...
assert tracker.get_health_summary("p")["consecutive_failures"] == 0
# ...but force_reload observes the new persisted state.
fresh = tracker.get_health_summary("p", force_reload=True)
assert fresh["consecutive_failures"] == 5
assert fresh["circuit_state"] == "open"
# and it asked the cache to bypass the in-memory tier (memory_ttl=0).
assert any(c.kwargs.get("memory_ttl") == 0 for c in cache.get.call_args_list)
+6 -128
View File
@@ -4,8 +4,6 @@ Tests for PluginLoader.
Tests plugin directory discovery, module loading, and class instantiation.
"""
import subprocess
import pytest
from unittest.mock import MagicMock, patch
from src.plugin_system.plugin_loader import PluginLoader
@@ -193,7 +191,7 @@ class TestPluginLoader:
mock_subprocess.return_value = MagicMock(returncode=0)
result = plugin_loader.install_dependencies(plugin_dir, "test_plugin", plugins_dir=tmp_plugins_dir)
result = plugin_loader.install_dependencies(plugin_dir, "test_plugin")
assert result is True
mock_subprocess.assert_called_once()
@@ -204,7 +202,7 @@ class TestPluginLoader:
plugin_dir = tmp_plugins_dir / "test_plugin"
plugin_dir.mkdir()
result = plugin_loader.install_dependencies(plugin_dir, "test_plugin", plugins_dir=tmp_plugins_dir)
result = plugin_loader.install_dependencies(plugin_dir, "test_plugin")
assert result is True
mock_subprocess.assert_not_called()
@@ -216,129 +214,9 @@ class TestPluginLoader:
plugin_dir.mkdir()
requirements_file = plugin_dir / "requirements.txt"
requirements_file.write_text("package1==1.0.0\n")
mock_subprocess.return_value = MagicMock(returncode=1)
result = plugin_loader.install_dependencies(plugin_dir, "test_plugin", plugins_dir=tmp_plugins_dir)
result = plugin_loader.install_dependencies(plugin_dir, "test_plugin")
assert result is False
@patch('src.plugin_system.plugin_loader.requirements_are_satisfied', return_value=False)
@patch('subprocess.run')
def test_install_dependencies_retries_with_ignore_installed_on_apt_conflict(
self, mock_subprocess, mock_satisfied, plugin_loader, tmp_plugins_dir
):
"""An apt-managed package with no pip RECORD file triggers a retry with
--ignore-installed rather than silently assuming the old version satisfies
the requirement. requirements_are_satisfied() is mocked False here because
this scenario is exactly the case where the installed (apt) version does
NOT satisfy the pin that's why pip attempts a reinstall in the first place."""
plugin_dir = tmp_plugins_dir / "test_plugin"
plugin_dir.mkdir()
requirements_file = plugin_dir / "requirements.txt"
requirements_file.write_text("requests>=2.33.0,<3.0.0\n")
first_attempt = MagicMock(
returncode=1,
stderr="ERROR: Cannot uninstall requests 2.32.3\nuninstall-no-record-file"
)
retry_attempt = MagicMock(returncode=0, stderr="")
mock_subprocess.side_effect = [first_attempt, retry_attempt]
result = plugin_loader.install_dependencies(plugin_dir, "test_plugin", plugins_dir=tmp_plugins_dir)
assert result is True
assert mock_subprocess.call_count == 2
retry_cmd = mock_subprocess.call_args_list[1][0][0]
assert "--ignore-installed" in retry_cmd
@patch('src.plugin_system.plugin_loader.requirements_are_satisfied', return_value=False)
@patch('subprocess.run')
def test_install_dependencies_apt_conflict_retry_also_fails(
self, mock_subprocess, mock_satisfied, plugin_loader, tmp_plugins_dir
):
"""Still tolerates the failure (returns True) if the --ignore-installed
retry itself fails, matching the prior soft-fallback behavior."""
plugin_dir = tmp_plugins_dir / "test_plugin"
plugin_dir.mkdir()
requirements_file = plugin_dir / "requirements.txt"
requirements_file.write_text("requests>=2.33.0,<3.0.0\n")
first_attempt = MagicMock(
returncode=1,
stderr="ERROR: Cannot uninstall requests 2.32.3\nuninstall-no-record-file"
)
retry_attempt = MagicMock(returncode=1, stderr="some other pip error")
mock_subprocess.side_effect = [first_attempt, retry_attempt]
result = plugin_loader.install_dependencies(plugin_dir, "test_plugin", plugins_dir=tmp_plugins_dir)
assert result is True
assert mock_subprocess.call_count == 2
@patch('src.plugin_system.plugin_loader.requirements_are_satisfied', return_value=False)
@patch('subprocess.run')
def test_install_dependencies_apt_conflict_retry_times_out(
self, mock_subprocess, mock_satisfied, plugin_loader, tmp_plugins_dir
):
"""A retry timeout must be tolerated the same way as a retry failure
(return True), not propagate to the outer TimeoutExpired handler and
return False."""
plugin_dir = tmp_plugins_dir / "test_plugin"
plugin_dir.mkdir()
requirements_file = plugin_dir / "requirements.txt"
requirements_file.write_text("requests>=2.33.0,<3.0.0\n")
first_attempt = MagicMock(
returncode=1,
stderr="ERROR: Cannot uninstall requests 2.32.3\nuninstall-no-record-file"
)
mock_subprocess.side_effect = [
first_attempt,
subprocess.TimeoutExpired(cmd="pip", timeout=300),
]
result = plugin_loader.install_dependencies(plugin_dir, "test_plugin", plugins_dir=tmp_plugins_dir)
assert result is True
assert mock_subprocess.call_count == 2
@patch('subprocess.run')
def test_install_dependencies_already_satisfied_skips_pip(self, mock_subprocess, plugin_loader, tmp_plugins_dir):
"""A requirement already satisfied in the current environment shouldn't invoke pip."""
plugin_dir = tmp_plugins_dir / "test_plugin"
plugin_dir.mkdir()
requirements_file = plugin_dir / "requirements.txt"
requirements_file.write_text("pytest>=1.0\n")
result = plugin_loader.install_dependencies(plugin_dir, "test_plugin", plugins_dir=tmp_plugins_dir)
assert result is True
mock_subprocess.assert_not_called()
def test_install_dependencies_requires_plugins_dir(self, plugin_loader, tmp_plugins_dir):
"""plugins_dir is a required argument, not an optional trust-me flag --
calling without it must fail loudly (TypeError) rather than silently
falling back to trusting plugin_dir unchecked."""
plugin_dir = tmp_plugins_dir / "test_plugin"
plugin_dir.mkdir()
with pytest.raises(TypeError):
plugin_loader.install_dependencies(plugin_dir, "test_plugin")
@patch('subprocess.run')
def test_install_dependencies_rejects_path_outside_plugins_dir(
self, mock_subprocess, plugin_loader, tmp_path, tmp_plugins_dir
):
"""A plugin_dir that doesn't actually live inside plugins_dir (e.g. a
manifest-derived id crafted to traverse elsewhere) must be rejected
rather than read from -- this is the path-injection containment
check CodeQL flagged as missing."""
outside_dir = tmp_path / "outside"
outside_dir.mkdir()
(outside_dir / "requirements.txt").write_text("requests>=2.0\n")
result = plugin_loader.install_dependencies(outside_dir, "evil_plugin", plugins_dir=tmp_plugins_dir)
assert result is False
mock_subprocess.assert_not_called()
-79
View File
@@ -1,79 +0,0 @@
"""
Tests for PluginManager._validate_config_schema_soft (Phase 1, warn-only schema
validation).
Contract:
- A schema violation logs a warning and marks the plugin degraded in the health
tracker, but never raises and never changes load pass/fail behaviour.
- A valid config (or no schema) clears any stale degraded flag.
- The method is safe when no health tracker is wired.
"""
import tempfile
from pathlib import Path
from unittest.mock import MagicMock
import pytest
from src.plugin_system.plugin_manager import PluginManager
@pytest.fixture
def pm():
with tempfile.TemporaryDirectory() as tmp:
manager = PluginManager(plugins_dir=str(Path(tmp) / "plugins"))
manager.schema_manager = MagicMock()
yield manager
def test_invalid_config_marks_degraded_without_raising(pm):
pm.health_tracker = MagicMock()
pm.schema_manager.load_schema.return_value = {"type": "object"}
pm.schema_manager.validate_config_against_schema.return_value = (
False,
["Missing required field: 'api_key'"],
)
pm._validate_config_schema_soft("youtube-stats", {})
pm.health_tracker.set_degraded.assert_called_once()
plugin_id, reason = pm.health_tracker.set_degraded.call_args[0]
assert plugin_id == "youtube-stats"
assert "api_key" in reason
def test_valid_config_clears_degraded(pm):
pm.health_tracker = MagicMock()
pm.schema_manager.load_schema.return_value = {"type": "object"}
pm.schema_manager.validate_config_against_schema.return_value = (True, [])
pm._validate_config_schema_soft("p", {"api_key": "x"})
pm.health_tracker.set_degraded.assert_called_once_with("p", None)
def test_no_schema_clears_degraded(pm):
pm.health_tracker = MagicMock()
pm.schema_manager.load_schema.return_value = None
pm._validate_config_schema_soft("p", {})
pm.health_tracker.set_degraded.assert_called_once_with("p", None)
def test_validation_exception_is_swallowed(pm):
pm.health_tracker = MagicMock()
pm.schema_manager.load_schema.return_value = {"type": "object"}
pm.schema_manager.validate_config_against_schema.side_effect = RuntimeError("boom")
# Must not raise — the validation machinery failing must never break loading.
pm._validate_config_schema_soft("p", {})
def test_safe_without_health_tracker(pm):
pm.health_tracker = None
pm.schema_manager.load_schema.return_value = {"type": "object"}
pm.schema_manager.validate_config_against_schema.return_value = (False, ["err"])
# Must not raise even though there is no tracker to record against.
pm._validate_config_schema_soft("p", {})
+3 -53
View File
@@ -3,7 +3,6 @@ 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.plugin_system.resource_monitor import PluginResourceMonitor
class TestPluginManager:
"""Test PluginManager functionality."""
@@ -75,67 +74,18 @@ class TestPluginManager:
# No manifest in pm.plugin_manifests
result = pm.load_plugin("non_existent_plugin")
assert result is False
assert pm.state_manager.get_state("non_existent_plugin") == PluginState.ERROR
def test_run_scheduled_updates_calls_update_with_resource_monitor(
self, mock_config_manager, mock_display_manager, mock_cache_manager
):
"""Regression test: run_scheduled_updates() must actually call a
plugin's update() when self.resource_monitor is set (as it is in
every real deployment -- display_controller.py and web_interface/
app.py both assign a real PluginResourceMonitor after construction).
Previously, the resource_monitor branch wrapped the call in a
function stored as a *class* attribute on a dynamically-built type
(`type('obj', (object,), {'update': monitored_update})()`), which
the descriptor protocol turns into a bound method on access --
silently passing the synthetic instance as an implicit first
argument to monitored_update(), which takes none. Every plugin's
scheduled update failed with "monitored_update() takes 0 positional
arguments but 1 was given" and was silently swallowed into a
circuit-breaker retry loop that never succeeded, so plugin data
(scores, odds, etc.) never refreshed.
"""
with patch('src.plugin_system.plugin_manager.ensure_directory_permissions'):
pm = PluginManager(
plugins_dir="plugins",
config_manager=mock_config_manager,
display_manager=mock_display_manager,
cache_manager=mock_cache_manager
)
plugin_instance = MagicMock()
plugin_instance.enabled = True
plugin_instance.update = MagicMock()
pm.plugins["test_plugin"] = plugin_instance
pm.plugin_manifests["test_plugin"] = {"update_interval": 10}
pm.state_manager.set_state("test_plugin", PluginState.ENABLED)
# Plain MagicMock, not the mock_cache_manager fixture: this test
# is about run_scheduled_updates() actually invoking update()
# through the resource-monitor wrapper, not about
# PluginResourceMonitor's own cache-backed metrics persistence
# (which calls cache_manager.get(..., memory_ttl=...) --
# a kwarg the fixture's mock_get() doesn't accept).
pm.resource_monitor = PluginResourceMonitor(MagicMock())
pm.run_scheduled_updates(current_time=time.time())
plugin_instance.update.assert_called_once()
assert "test_plugin" in pm.plugin_last_update
assert pm.state_manager.get_state("test_plugin") == PluginState.ENABLED
class TestPluginLoader:
"""Test PluginLoader functionality."""
def test_dependency_check(self):
"""Test dependency checking logic."""
# Covered by test_plugin_loader.py's install_dependencies tests,
# which exercise requirements_has_real_deps/requirements_are_satisfied
# and the pip subprocess fallback.
# This would test _check_dependencies_installed and _install_plugin_dependencies
# which requires mocking subprocess calls and file operations
class TestPluginExecutor:
-129
View File
@@ -1,129 +0,0 @@
"""
Tests for src/plugin_system/resource_monitor.py
Focus areas:
- Execution-time metrics are captured regardless of psutil availability.
- CPU sampling is non-blocking (regression guard for the previous
``cpu_percent(interval=0.1)`` call that blocked 100 ms per monitored call).
- Resource limits are enforced.
"""
import time
import pytest
from unittest.mock import MagicMock
from src.plugin_system.resource_monitor import (
PluginResourceMonitor,
ResourceLimits,
ResourceLimitExceeded,
PSUTIL_AVAILABLE,
)
def _cache():
cache = MagicMock()
cache.get.return_value = None
return cache
class TestExecutionTimeMetrics:
def test_monitor_call_returns_value_and_records_call(self):
mon = PluginResourceMonitor(_cache(), enable_monitoring=False)
result = mon.monitor_call("p", lambda: 42)
assert result == 42
metrics = mon.get_metrics("p")
assert metrics.call_count == 1
assert metrics.total_execution_time >= 0.0
def test_avg_and_max_execution_time(self):
mon = PluginResourceMonitor(_cache(), enable_monitoring=False)
mon.monitor_call("p", lambda: time.sleep(0.01))
mon.monitor_call("p", lambda: None)
summary = mon.get_metrics_summary("p")
assert summary["call_count"] == 2
assert summary["max_execution_time"] >= summary["avg_execution_time"] >= 0.0
def test_exception_propagates_but_is_still_timed(self):
mon = PluginResourceMonitor(_cache(), enable_monitoring=False)
def boom():
raise ValueError("nope")
with pytest.raises(ValueError):
mon.monitor_call("p", boom)
# Execution time is still recorded even when the call raised.
assert mon.get_metrics("p").execution_time >= 0.0
class TestNonBlockingCpu:
def test_cpu_sampling_is_fast_when_disabled(self):
mon = PluginResourceMonitor(_cache(), enable_monitoring=False)
start = time.time()
for _ in range(50):
mon._get_process_cpu_percent()
# The old implementation blocked ~0.1s/call (~5s for 50). Non-blocking
# must complete near-instantly.
assert time.time() - start < 0.5
assert mon._get_process_cpu_percent() == 0.0
@pytest.mark.skipif(not PSUTIL_AVAILABLE, reason="psutil not installed")
def test_cpu_sampling_is_fast_with_psutil(self):
mon = PluginResourceMonitor(_cache(), enable_monitoring=True)
assert mon._process is not None
start = time.time()
for _ in range(30):
mon._get_process_cpu_percent()
# 30 blocking 0.1s samples would be ~3s; non-blocking must be well under.
assert time.time() - start < 0.5
def test_monitor_call_does_not_block_on_cpu_sampling(self):
mon = PluginResourceMonitor(_cache()) # enable depends on psutil
start = time.time()
for _ in range(25):
mon.monitor_call("p", lambda: None)
# 25 * 0.1s = 2.5s under the old blocking bug; must be far faster now.
assert time.time() - start < 1.0
class TestResourceLimits:
def test_execution_time_limit_raises(self):
mon = PluginResourceMonitor(_cache(), enable_monitoring=False)
mon.set_limits("p", ResourceLimits(max_execution_time=0.001))
with pytest.raises(ResourceLimitExceeded):
mon.monitor_call("p", lambda: time.sleep(0.02))
def test_reset_metrics_clears_counts(self):
cache = _cache()
mon = PluginResourceMonitor(cache, enable_monitoring=False)
mon.monitor_call("p", lambda: None)
assert mon.get_metrics("p").call_count == 1
mon.reset_metrics("p")
assert mon.get_metrics("p").call_count == 0
class TestForceReload:
def test_force_reload_refreshes_stale_snapshot(self):
"""A read-only consumer must see the writer process's latest persisted
metrics rather than a pinned first snapshot."""
cache = MagicMock()
persisted = {"value": None} # only the metrics key returns data
def cache_get(key, max_age=None, memory_ttl=None):
return persisted["value"] if key.startswith("plugin_metrics:") else None
cache.get.side_effect = cache_get
mon = PluginResourceMonitor(cache, enable_monitoring=False)
# First read snapshots empty metrics.
assert mon.get_metrics_summary("p")["call_count"] == 0
# The display service later persists real metrics.
persisted["value"] = {"call_count": 7, "total_execution_time": 1.4}
# Plain read stays stale...
assert mon.get_metrics_summary("p")["call_count"] == 0
# ...force_reload picks up the persisted values and bypasses memory.
fresh = mon.get_metrics_summary("p", force_reload=True)
assert fresh["call_count"] == 7
assert any(c.kwargs.get("memory_ttl") == 0 for c in cache.get.call_args_list)
-317
View File
@@ -1,317 +0,0 @@
"""
Tests for src/common/scroll_helper.py
Covers ScrollHelper: create_scrolling_image, update_scroll_position,
get_visible_portion, calculate_dynamic_duration, set_* methods,
reset_scroll, clear_cache, get_scroll_info.
"""
import pytest
import time
from unittest.mock import patch
from PIL import Image
from src.common.scroll_helper import ScrollHelper
DISPLAY_W = 64
DISPLAY_H = 32
@pytest.fixture
def helper():
return ScrollHelper(display_width=DISPLAY_W, display_height=DISPLAY_H)
def _make_image(width: int = 64, height: int = 32, color=(255, 0, 0)) -> Image.Image:
img = Image.new("RGB", (width, height), color)
return img
# ---------------------------------------------------------------------------
# __init__ / initial state
# ---------------------------------------------------------------------------
class TestScrollHelperInit:
def test_initial_scroll_position(self, helper):
assert helper.scroll_position == 0.0
def test_initial_scroll_complete_false(self, helper):
assert helper.scroll_complete is False
def test_display_dimensions(self, helper):
assert helper.display_width == DISPLAY_W
assert helper.display_height == DISPLAY_H
# ---------------------------------------------------------------------------
# create_scrolling_image
# ---------------------------------------------------------------------------
class TestCreateScrollingImage:
def test_empty_content_returns_blank_image(self, helper):
result = helper.create_scrolling_image([])
assert isinstance(result, Image.Image)
assert helper.total_scroll_width == 0
def test_single_item_creates_image(self, helper):
img = _make_image(width=100)
result = helper.create_scrolling_image([img])
assert isinstance(result, Image.Image)
assert result.width > DISPLAY_W # includes leading gap
def test_multiple_items_wider_image(self, helper):
items = [_make_image(width=50), _make_image(width=50)]
result = helper.create_scrolling_image(items)
# Should be wider than two items alone
assert result.width > 100
def test_scroll_position_reset(self, helper):
helper.scroll_position = 500.0
helper.create_scrolling_image([_make_image()])
assert helper.scroll_position == 0.0
def test_cached_array_set(self, helper):
helper.create_scrolling_image([_make_image()])
assert helper.cached_array is not None
def test_scroll_complete_reset(self, helper):
helper.scroll_complete = True
helper.create_scrolling_image([_make_image()])
assert helper.scroll_complete is False
def test_total_scroll_width_matches_image(self, helper):
img = _make_image(width=200)
result = helper.create_scrolling_image([img])
assert helper.total_scroll_width == result.width
# ---------------------------------------------------------------------------
# set_scrolling_image
# ---------------------------------------------------------------------------
class TestSetScrollingImage:
def test_sets_cached_image(self, helper):
img = _make_image(width=200)
helper.set_scrolling_image(img)
assert helper.cached_image is img
def test_sets_cached_array(self, helper):
img = _make_image(width=200)
helper.set_scrolling_image(img)
assert helper.cached_array is not None
def test_scroll_width_matches_image(self, helper):
img = _make_image(width=300)
helper.set_scrolling_image(img)
assert helper.total_scroll_width == 300
def test_none_clears_cache(self, helper):
helper.set_scrolling_image(_make_image())
helper.set_scrolling_image(None)
assert helper.cached_image is None
# ---------------------------------------------------------------------------
# update_scroll_position (time-based mode)
# ---------------------------------------------------------------------------
class TestUpdateScrollPosition:
def test_position_advances_over_time(self, helper):
helper.create_scrolling_image([_make_image(width=200)])
helper.scroll_speed = 100.0 # 100 px/s
helper.last_update_time = time.time() - 0.1 # pretend 100ms elapsed
initial = helper.scroll_position
helper.update_scroll_position()
assert helper.scroll_position > initial
def test_no_advance_without_image(self, helper):
helper.update_scroll_position() # no image, should not crash
assert helper.scroll_position == 0.0
def test_zero_width_content_stays_zero(self, helper):
helper.create_scrolling_image([]) # empty → width 0
helper.update_scroll_position()
assert helper.scroll_position == 0.0
def test_scroll_complete_clamped(self, helper):
helper.create_scrolling_image([_make_image(width=100)])
# Force position past the end
helper.scroll_position = helper.total_scroll_width + 50
helper.total_distance_scrolled = helper.total_scroll_width + 50
helper.update_scroll_position()
assert helper.scroll_complete is True
assert helper.scroll_position <= helper.total_scroll_width
# ---------------------------------------------------------------------------
# get_visible_portion
# ---------------------------------------------------------------------------
class TestGetVisiblePortion:
def test_returns_none_without_image(self, helper):
assert helper.get_visible_portion() is None
def test_returns_image_sized_to_display(self, helper):
helper.create_scrolling_image([_make_image(width=200)])
visible = helper.get_visible_portion()
assert visible is not None
assert visible.width == DISPLAY_W
assert visible.height == DISPLAY_H
def test_different_positions_give_different_images(self, helper):
helper.create_scrolling_image([_make_image(width=300)])
img1 = helper.get_visible_portion()
helper.scroll_position = 50
img2 = helper.get_visible_portion()
# Images should differ (colour from scrolled content)
# Just verify both are valid PIL images with correct size
assert img1.width == img2.width == DISPLAY_W
# ---------------------------------------------------------------------------
# reset_scroll / clear_cache
# ---------------------------------------------------------------------------
class TestResetAndClear:
def test_reset_restores_position(self, helper):
helper.create_scrolling_image([_make_image(width=200)])
helper.scroll_position = 100.0
helper.reset_scroll()
assert helper.scroll_position == 0.0
def test_reset_clears_complete_flag(self, helper):
helper.scroll_complete = True
helper.reset_scroll()
assert helper.scroll_complete is False
def test_reset_alias(self, helper):
helper.scroll_position = 50.0
helper.reset()
assert helper.scroll_position == 0.0
def test_clear_cache(self, helper):
helper.create_scrolling_image([_make_image()])
helper.clear_cache()
assert helper.cached_image is None
assert helper.cached_array is None
assert helper.total_scroll_width == 0
# ---------------------------------------------------------------------------
# calculate_dynamic_duration
# ---------------------------------------------------------------------------
class TestCalculateDynamicDuration:
def test_returns_min_when_disabled(self, helper):
helper.dynamic_duration_enabled = False
helper.min_duration = 30
result = helper.calculate_dynamic_duration()
assert result == 30
def test_returns_min_when_no_content(self, helper):
helper.total_scroll_width = 0
helper.min_duration = 30
result = helper.calculate_dynamic_duration()
assert result == 30
def test_respects_min_duration(self, helper):
helper.create_scrolling_image([_make_image(width=50)])
helper.min_duration = 60
helper.max_duration = 300
helper.scroll_speed = 500.0 # very fast → very short time
result = helper.calculate_dynamic_duration()
assert result >= 60
def test_respects_max_duration(self, helper):
helper.create_scrolling_image([_make_image(width=5000)])
helper.min_duration = 10
helper.max_duration = 60
helper.scroll_speed = 1.0 # very slow → very long time
result = helper.calculate_dynamic_duration()
assert result <= 60
def test_time_based_calculation(self, helper):
helper.create_scrolling_image([_make_image(width=200)])
helper.scroll_speed = 100.0
helper.min_duration = 1
helper.max_duration = 600
helper.frame_based_scrolling = False
result = helper.calculate_dynamic_duration()
assert isinstance(result, int)
assert result > 0
# ---------------------------------------------------------------------------
# set_* configuration methods
# ---------------------------------------------------------------------------
class TestSetMethods:
def test_set_scroll_speed_time_based(self, helper):
helper.frame_based_scrolling = False
helper.set_scroll_speed(50.0)
assert helper.scroll_speed == 50.0
def test_set_scroll_speed_clamped_low(self, helper):
helper.frame_based_scrolling = False
helper.set_scroll_speed(0.0)
assert helper.scroll_speed >= 1.0
def test_set_scroll_speed_clamped_high(self, helper):
helper.frame_based_scrolling = False
helper.set_scroll_speed(10000.0)
assert helper.scroll_speed <= 500.0
def test_set_scroll_delay(self, helper):
helper.set_scroll_delay(0.05)
assert helper.scroll_delay == 0.05
def test_set_scroll_delay_clamped(self, helper):
helper.set_scroll_delay(0.0001)
assert helper.scroll_delay >= 0.001
def test_set_target_fps(self, helper):
helper.set_target_fps(60.0)
assert helper.target_fps == 60.0
def test_set_target_fps_clamped(self, helper):
helper.set_target_fps(1000.0)
assert helper.target_fps <= 200.0
def test_set_sub_pixel_scrolling(self, helper):
helper.set_sub_pixel_scrolling(True)
assert helper.sub_pixel_scrolling is True
helper.set_sub_pixel_scrolling(False)
assert helper.sub_pixel_scrolling is False
def test_set_frame_based_scrolling(self, helper):
helper.set_frame_based_scrolling(True)
assert helper.frame_based_scrolling is True
def test_set_dynamic_duration_settings(self, helper):
helper.set_dynamic_duration_settings(enabled=True, min_duration=20, max_duration=120, buffer=0.2)
assert helper.dynamic_duration_enabled is True
assert helper.min_duration == 20
assert helper.max_duration == 120
assert helper.duration_buffer == pytest.approx(0.2)
# ---------------------------------------------------------------------------
# get_scroll_info
# ---------------------------------------------------------------------------
class TestGetScrollInfo:
def test_returns_dict(self, helper):
info = helper.get_scroll_info()
assert isinstance(info, dict)
def test_required_keys(self, helper):
info = helper.get_scroll_info()
for key in ("scroll_position", "total_distance_scrolled", "scroll_speed",
"scroll_complete", "dynamic_duration"):
assert key in info
def test_scroll_position_reflected(self, helper):
helper.scroll_position = 42.0
info = helper.get_scroll_info()
assert info["scroll_position"] == 42.0
+20 -124
View File
@@ -43,115 +43,6 @@ class TestUninstallTombstone(unittest.TestCase):
self.assertNotIn("foo", self.sm._uninstall_tombstones)
class TestPersistentUninstallRegistry(unittest.TestCase):
"""Regression tests for the persistent uninstall registry that stops a
core `git pull` update from resurrecting built-in plugins the user
removed (plugins committed under plugin-repos/)."""
def setUp(self):
self._tmp = TemporaryDirectory()
self.addCleanup(self._tmp.cleanup)
self.plugins_dir = Path(self._tmp.name) / "plugin-repos"
self.plugins_dir.mkdir()
self.registry_path = Path(self._tmp.name) / "config" / "uninstalled_plugins.json"
self.sm = PluginStoreManager(
plugins_dir=str(self.plugins_dir),
uninstalled_registry_path=str(self.registry_path),
)
def _make_plugin_dir(self, plugin_id):
"""Simulate a built-in plugin restored on disk (e.g. by git pull)."""
d = self.plugins_dir / plugin_id
d.mkdir(parents=True)
(d / "manifest.json").write_text('{"id": "%s"}' % plugin_id)
return d
def test_unrecorded_plugin_is_not_uninstalled(self):
self.assertFalse(self.sm.is_plugin_uninstalled("web-ui-info"))
self.assertEqual(self.sm.get_uninstalled_plugins(), set())
def test_record_persists_across_instances(self):
self.sm.record_uninstalled_plugin("web-ui-info")
self.assertTrue(self.registry_path.exists())
# A fresh manager (simulating a service restart after update) still sees it.
fresh = PluginStoreManager(
plugins_dir=str(self.plugins_dir),
uninstalled_registry_path=str(self.registry_path),
)
self.assertTrue(fresh.is_plugin_uninstalled("web-ui-info"))
def test_forget_clears_record(self):
self.sm.record_uninstalled_plugin("web-ui-info")
self.sm.forget_uninstalled_plugin("web-ui-info")
self.assertFalse(self.sm.is_plugin_uninstalled("web-ui-info"))
def test_purge_removes_resurrected_plugin(self):
# The bug: user removed web-ui-info, then a git pull restored its
# committed files. Recorded uninstall + purge must re-remove it.
self._make_plugin_dir("web-ui-info")
self.sm.record_uninstalled_plugin("web-ui-info")
self.assertTrue((self.plugins_dir / "web-ui-info").exists())
removed = self.sm.purge_uninstalled_plugins()
self.assertEqual(removed, ["web-ui-info"])
self.assertFalse((self.plugins_dir / "web-ui-info").exists())
# Record is kept so the purge stays idempotent across future updates.
self.assertTrue(self.sm.is_plugin_uninstalled("web-ui-info"))
def test_purge_leaves_non_uninstalled_plugins_alone(self):
self._make_plugin_dir("baseball-scoreboard") # present, not recorded
self._make_plugin_dir("web-ui-info")
self.sm.record_uninstalled_plugin("web-ui-info")
self.sm.purge_uninstalled_plugins()
self.assertTrue((self.plugins_dir / "baseball-scoreboard").exists())
self.assertFalse((self.plugins_dir / "web-ui-info").exists())
def test_purge_noop_when_plugin_absent(self):
# Recorded but never restored on disk — nothing to remove.
self.sm.record_uninstalled_plugin("web-ui-info")
self.assertEqual(self.sm.purge_uninstalled_plugins(), [])
def test_corrupt_registry_is_ignored(self):
self.registry_path.parent.mkdir(parents=True, exist_ok=True)
self.registry_path.write_text("{ not valid json")
self.assertEqual(self.sm.get_uninstalled_plugins(), set())
self.assertFalse(self.sm.is_plugin_uninstalled("web-ui-info"))
def _write_raw_registry(self, value):
self.registry_path.parent.mkdir(parents=True, exist_ok=True)
import json as _json
self.registry_path.write_text(_json.dumps(value))
def test_empty_id_does_not_wipe_plugins_root(self):
# An empty id resolves to plugins_dir itself; purge must never delete it.
self._make_plugin_dir("baseball-scoreboard")
self._write_raw_registry([""])
removed = self.sm.purge_uninstalled_plugins()
self.assertEqual(removed, [])
self.assertTrue(self.plugins_dir.exists())
self.assertTrue((self.plugins_dir / "baseball-scoreboard").exists())
# Invalid id is filtered out entirely.
self.assertEqual(self.sm.get_uninstalled_plugins(), set())
def test_traversal_ids_are_ignored(self):
for bad in ["..", "../evil", "a/b", "."]:
with self.subTest(bad=bad):
self.assertFalse(self.sm._is_valid_plugin_id(bad))
self._write_raw_registry(["../evil", "..", "web-ui-info"])
# Only the safe id survives the read.
self.assertEqual(self.sm.get_uninstalled_plugins(), {"web-ui-info"})
def test_record_rejects_invalid_id(self):
self.sm.record_uninstalled_plugin("")
self.sm.record_uninstalled_plugin("../escape")
self.assertEqual(self.sm.get_uninstalled_plugins(), set())
class TestGitInfoCache(unittest.TestCase):
def setUp(self):
self._tmp = TemporaryDirectory()
@@ -167,15 +58,19 @@ class TestGitInfoCache(unittest.TestCase):
(self.plugin_path / ".git" / "HEAD").write_text("ref: refs/heads/main\n")
def _fake_subprocess_run(self, *args, **kwargs):
# _get_local_git_info now reads branch and remote_url directly from
# .git/HEAD and .git/config (no subprocess) and uses a single
# ``git log --format=%H%n%cI`` call that returns SHA on line 1 and
# ISO date on line 2. Adjust the fake accordingly.
# Return different dummy values depending on which git subcommand
# was invoked so the code paths that parse output all succeed.
cmd = args[0]
result = MagicMock()
result.returncode = 0
if "log" in cmd:
result.stdout = "abcdef1234567890\n2026-04-08T12:00:00+00:00\n"
if "rev-parse" in cmd and "HEAD" in cmd and "--abbrev-ref" not in cmd:
result.stdout = "abcdef1234567890\n"
elif "--abbrev-ref" in cmd:
result.stdout = "main\n"
elif "config" in cmd:
result.stdout = "https://example.com/repo.git\n"
elif "log" in cmd:
result.stdout = "2026-04-08T12:00:00+00:00\n"
else:
result.stdout = ""
return result
@@ -189,8 +84,7 @@ class TestGitInfoCache(unittest.TestCase):
self.assertIsNotNone(first)
self.assertEqual(first["short_sha"], "abcdef1")
calls_after_first = mock_run.call_count
# Production code now uses a single ``git log`` call.
self.assertEqual(calls_after_first, 1)
self.assertEqual(calls_after_first, 4)
# Second call with unchanged HEAD: zero new subprocess calls.
second = self.sm._get_local_git_info(self.plugin_path)
@@ -211,8 +105,7 @@ class TestGitInfoCache(unittest.TestCase):
os.utime(head, (new_time, new_time))
self.sm._get_local_git_info(self.plugin_path)
# One new ``git log`` call after cache invalidation.
self.assertEqual(mock_run.call_count, calls_after_first + 1)
self.assertEqual(mock_run.call_count, calls_after_first + 4)
def test_no_git_directory_returns_none(self):
non_git = self.plugins_dir / "no_git"
@@ -299,11 +192,14 @@ class TestGitInfoCache(unittest.TestCase):
result = MagicMock()
result.returncode = 0
cmd = args[0]
# Production code now uses a single ``git log --format=%H%n%cI``.
# Branch and remote_url are read directly from .git/HEAD/.git/config.
if "log" in cmd:
sha = branch_file.read_text().strip()
result.stdout = f"{sha}\n2026-04-08T12:00:00+00:00\n"
if "rev-parse" in cmd and "--abbrev-ref" not in cmd:
result.stdout = branch_file.read_text().strip() + "\n"
elif "--abbrev-ref" in cmd:
result.stdout = "main\n"
elif "config" in cmd:
result.stdout = "https://example.com/repo.git\n"
elif "log" in cmd:
result.stdout = "2026-04-08T12:00:00+00:00\n"
else:
result.stdout = ""
return result
-329
View File
@@ -1,329 +0,0 @@
"""
Tests for src/common/utils.py
Covers all pure utility functions: normalize_team_abbreviation, format_time,
format_date, get_timezone, validate_dimensions, parse_team_abbreviation,
format_score, format_period, is_live_game, is_final_game, is_upcoming_game,
sanitize_filename, truncate_text, parse_boolean.
"""
import pytest
from datetime import datetime, timezone
import pytz
from src.common.utils import (
normalize_team_abbreviation,
format_time,
format_date,
get_timezone,
validate_dimensions,
parse_team_abbreviation,
format_score,
format_period,
is_live_game,
is_final_game,
is_upcoming_game,
sanitize_filename,
truncate_text,
parse_boolean,
)
# ---------------------------------------------------------------------------
# normalize_team_abbreviation
# ---------------------------------------------------------------------------
class TestNormalizeTeamAbbreviation:
def test_basic_uppercase(self):
assert normalize_team_abbreviation("lal") == "LAL"
def test_strips_spaces(self):
assert normalize_team_abbreviation(" KC ") == "KC"
def test_replaces_ampersand(self):
assert normalize_team_abbreviation("TA&M") == "TAANDM"
def test_removes_internal_spaces(self):
assert normalize_team_abbreviation("A B") == "AB"
def test_removes_hyphens(self):
assert normalize_team_abbreviation("A-B") == "AB"
def test_empty_string_returns_empty(self):
assert normalize_team_abbreviation("") == ""
def test_none_returns_empty(self):
assert normalize_team_abbreviation(None) == ""
# ---------------------------------------------------------------------------
# format_time / format_date
# ---------------------------------------------------------------------------
class TestFormatTime:
def _utc_dt(self, hour=20, minute=30):
return datetime(2024, 1, 15, hour, minute, 0, tzinfo=timezone.utc)
def test_formats_utc_to_utc(self):
dt = self._utc_dt(20, 30)
result = format_time(dt, timezone_str="UTC")
# 20:30 UTC → "8:30PM" (leading zero stripped)
assert "8:30PM" in result or "8:30 PM" in result or result != ""
def test_naive_datetime_treated_as_utc(self):
dt = datetime(2024, 1, 15, 12, 0, 0) # naive
result = format_time(dt, timezone_str="UTC")
assert result != ""
def test_invalid_timezone_returns_empty(self):
dt = self._utc_dt()
result = format_time(dt, timezone_str="Invalid/TZ")
assert result == ""
def test_eastern_timezone(self):
dt = self._utc_dt(20, 0) # 8 PM UTC = 3 PM ET
result = format_time(dt, timezone_str="America/New_York")
assert result != ""
class TestFormatDate:
def test_formats_date(self):
dt = datetime(2024, 6, 15, 18, 0, 0, tzinfo=timezone.utc)
result = format_date(dt, timezone_str="UTC")
assert "June" in result or "15" in result
def test_naive_datetime(self):
dt = datetime(2024, 3, 10, 12, 0, 0)
result = format_date(dt, timezone_str="UTC")
assert result != ""
def test_invalid_timezone_returns_empty(self):
dt = datetime(2024, 6, 15, 18, 0, 0, tzinfo=timezone.utc)
result = format_date(dt, timezone_str="BadZone/Here")
assert result == ""
# ---------------------------------------------------------------------------
# get_timezone
# ---------------------------------------------------------------------------
class TestGetTimezone:
def test_valid_timezone(self):
tz = get_timezone("America/New_York")
assert tz is not None
def test_utc(self):
tz = get_timezone("UTC")
assert tz is pytz.utc or str(tz) == "UTC"
def test_invalid_returns_utc(self):
tz = get_timezone("Not/ATimezone")
assert tz is pytz.utc
# ---------------------------------------------------------------------------
# validate_dimensions
# ---------------------------------------------------------------------------
class TestValidateDimensions:
def test_valid(self):
assert validate_dimensions(64, 32) is True
def test_zero_width(self):
assert validate_dimensions(0, 32) is False
def test_zero_height(self):
assert validate_dimensions(64, 0) is False
def test_negative(self):
assert validate_dimensions(-1, 32) is False
def test_too_large(self):
assert validate_dimensions(1001, 32) is False
def test_max_valid(self):
assert validate_dimensions(1000, 1000) is True
def test_non_integer(self):
assert validate_dimensions("64", 32) is False # type: ignore[arg-type]
# ---------------------------------------------------------------------------
# parse_team_abbreviation
# ---------------------------------------------------------------------------
class TestParseTeamAbbreviation:
def test_empty_string(self):
assert parse_team_abbreviation("") == ""
def test_none_returns_empty(self):
assert parse_team_abbreviation(None) == ""
def test_extracts_uppercase(self):
result = parse_team_abbreviation("LAL")
assert result == "LAL"
def test_fallback_first_three(self):
# text without recognisable 2-4 char uppercase block
result = parse_team_abbreviation("ab")
assert len(result) <= 3
# ---------------------------------------------------------------------------
# format_score
# ---------------------------------------------------------------------------
class TestFormatScore:
def test_format_score(self):
assert format_score(14, 7) == "7-14"
def test_format_score_strings(self):
assert format_score("21", "14") == "14-21"
def test_zero_zero(self):
assert format_score(0, 0) == "0-0"
# ---------------------------------------------------------------------------
# format_period
# ---------------------------------------------------------------------------
class TestFormatPeriod:
def test_basketball_q1(self):
assert format_period(1, "basketball") == "Q1"
def test_basketball_q4(self):
assert format_period(4, "basketball") == "Q4"
def test_basketball_ot1(self):
assert format_period(5, "basketball") == "OT1"
def test_basketball_ot2(self):
assert format_period(6, "basketball") == "OT2"
def test_football_q1(self):
assert format_period(1, "football") == "Q1"
def test_football_ot(self):
assert format_period(5, "football") == "OT1"
def test_hockey_p1(self):
assert format_period(1, "hockey") == "P1"
def test_hockey_p3(self):
assert format_period(3, "hockey") == "P3"
def test_hockey_ot(self):
assert format_period(4, "hockey") == "OT1"
def test_baseball_inning(self):
assert format_period(7, "baseball") == "INN 7"
def test_unknown_sport(self):
result = format_period(2, "unknown")
assert "2" in result
# ---------------------------------------------------------------------------
# is_live_game / is_final_game / is_upcoming_game
# ---------------------------------------------------------------------------
class TestGameStatusHelpers:
def test_is_live_game_true(self):
assert is_live_game("In Progress") is True
assert is_live_game("halftime") is True
assert is_live_game("overtime") is True
def test_is_live_game_false(self):
assert is_live_game("Final") is False
assert is_live_game("Scheduled") is False
def test_is_final_game_true(self):
assert is_final_game("Final") is True
assert is_final_game("COMPLETED") is True
def test_is_final_game_false(self):
assert is_final_game("In Progress") is False
def test_is_upcoming_game_true(self):
assert is_upcoming_game("Scheduled") is True
assert is_upcoming_game("upcoming") is True
def test_is_upcoming_game_false(self):
assert is_upcoming_game("Final") is False
assert is_upcoming_game("In Progress") is False
# ---------------------------------------------------------------------------
# sanitize_filename
# ---------------------------------------------------------------------------
class TestSanitizeFilename:
def test_removes_invalid_chars(self):
result = sanitize_filename('file<>:"/\\|?*.txt')
assert "<" not in result
assert ">" not in result
assert ":" not in result
def test_collapses_underscores(self):
result = sanitize_filename("file___name")
assert "__" not in result
def test_strips_leading_trailing(self):
result = sanitize_filename("_file_")
assert not result.startswith("_")
assert not result.endswith("_")
def test_normal_filename_unchanged(self):
result = sanitize_filename("my_logo")
assert result == "my_logo"
# ---------------------------------------------------------------------------
# truncate_text
# ---------------------------------------------------------------------------
class TestTruncateText:
def test_no_truncation_needed(self):
assert truncate_text("hello", 10) == "hello"
def test_truncation_adds_suffix(self):
result = truncate_text("hello world", 8)
assert result.endswith("...")
assert len(result) == 8
def test_exact_length(self):
assert truncate_text("hello", 5) == "hello"
def test_custom_suffix(self):
result = truncate_text("hello world", 8, suffix="~")
assert result.endswith("~")
# ---------------------------------------------------------------------------
# parse_boolean
# ---------------------------------------------------------------------------
class TestParseBoolean:
def test_true_bool(self):
assert parse_boolean(True) is True
def test_false_bool(self):
assert parse_boolean(False) is False
def test_int_1(self):
assert parse_boolean(1) is True
def test_int_0(self):
assert parse_boolean(0) is False
def test_string_true(self):
for val in ("true", "True", "TRUE", "1", "yes", "on", "enabled"):
assert parse_boolean(val) is True, f"Expected True for {val!r}"
def test_string_false(self):
for val in ("false", "False", "0", "no", "off", "disabled"):
assert parse_boolean(val) is False, f"Expected False for {val!r}"
def test_none_returns_false(self):
assert parse_boolean(None) is False # type: ignore[arg-type]

Some files were not shown because too many files have changed in this diff Show More