fix: post-audit follow-up code fixes (cache, fonts, icons, dev script) (#307)

* fix: post-audit follow-up code fixes (cache, fonts, icons, dev script, CI)

The docs refresh effort (#306, ledmatrix-plugins#92) surfaced seven
code bugs that were intentionally left out of the docs PRs because
they required code changes rather than doc fixes. This PR addresses
the six that belong in LEDMatrix (the seventh — a lacrosse-scoreboard
mode rename — lives in the plugins repo).

Bug 1: cache_manager.delete() AttributeError
  src/common/api_helper.py:287 and
  src/plugin_system/resource_monitor.py:343 both call
  cache_manager.delete(key), which doesn't exist — only
  clear_cache(key=None). Added a delete() alias method on
  CacheManager that forwards to clear_cache(key). Reverts the
  "There is no delete() method" wording in DEVELOPER_QUICK_REFERENCE,
  .cursorrules so the docs match the new shim.

Bug 2: dev_plugin_setup.sh PROJECT_ROOT resolution
  scripts/dev/dev_plugin_setup.sh:9 set PROJECT_ROOT to SCRIPT_DIR
  instead of walking up two levels to the repo root, so PLUGINS_DIR
  resolved to scripts/dev/plugins/ and created symlinks under the
  script's own directory. Fixed the path and removed the stray
  scripts/dev/plugins/of-the-day symlink left by earlier runs.

Bug 3: plugin custom icons regressed from v2 to v3
  web_interface/blueprints/api_v3.py built the /plugins/installed
  response without including the manifest's "icon" field, and
  web_interface/templates/v3/base.html hardcoded
  fas fa-puzzle-piece in all three plugin-tab render sites. Pass
  the icon through the API and read it from the templates with a
  puzzle-piece fallback. Reverts the "currently broken" banners in
  docs/PLUGIN_CUSTOM_ICONS.md and docs/PLUGIN_CUSTOM_ICONS_FEATURE.md.

Bug 4: register_plugin_fonts was never wired up
  src/font_manager.py:150 defines register_plugin_fonts(plugin_id,
  font_manifest) but nothing called it, so plugin manifests with a
  "fonts" block were silently no-ops. Wired the call into
  PluginManager.load_plugin() right after plugin_loader.load_plugin
  returns. Reverts the "not currently wired" warning in
  docs/FONT_MANAGER.md's "For Plugin Developers" section.

Bug 5: dead web_interface_v2 import pattern (LEDMatrix half)
  src/base_odds_manager.py had a try/except importing
  web_interface_v2.increment_api_counter, falling back to a no-op
  stub. The module doesn't exist anywhere in the v3 codebase and
  no API metrics dashboard reads it. Deleted the import block and
  the single call site; the plugins-repo half of this cleanup lands
  in ledmatrix-plugins#<next>.

Bug 7: no CI test workflow
  .github/workflows/ only contained security-audit.yml; pytest ran
  locally but was not gated on PRs. Added
  .github/workflows/tests.yml running pytest against Python 3.10,
  3.11, 3.12 in EMULATOR=true mode, skipping tests marked hardware
  or slow. Updated docs/HOW_TO_RUN_TESTS.md to reflect that the
  workflow now exists.

Verification done locally:
  - CacheManager.delete(key) round-trips with set/get
  - base_odds_manager imports without the v2 module present
  - dev_plugin_setup.sh PROJECT_ROOT resolves to repo root
  - api_v3 and plugin_manager compile clean
  - tests.yml YAML parses
  - Script syntax check on dev_plugin_setup.sh

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address CodeRabbit review comments on #307

- src/cache_manager.py: clear_cache(key) treated empty string as
  "wipe all" because of `if key:`. Switched to `key is None`
  branching, made delete(key) and clear_cache(key) reject empty
  strings and None outright with ValueError, and updated both
  docstrings to make the contract explicit. Verified locally
  with a round-trip test that clear_cache() (no arg) still
  wipes everything but clear_cache("") and delete("") raise.

- src/plugin_system/plugin_manager.py: was reaching for the
  font manager via getattr(self.display_manager, 'font_manager',
  None). PluginManager already takes a dedicated font_manager
  parameter (line 54) and stores it as self.font_manager
  (line 69), so the old path was both wrong and could miss the
  font manager entirely when the host injects them separately.
  Switched to self.font_manager directly with the same try/except
  warning behavior.

- web_interface/templates/v3/base.html: in the full plugin-tab
  renderer, the icon was injected with
  `<i class="${escapeHtml(plugin.icon)}">` — but escapeHtml only
  escapes <, >, and &, not double quotes, so a manifest with a
  quote in its icon string could break out of the class
  attribute. Replaced the innerHTML template with createElement
  for the <i> tag, set className from plugin.icon directly
  (no string interpolation), and used a text node for the
  label. Same fix shape would also harden the two stub-renderer
  sites at line 515 / 774, but those already escape `"` to
  &quot; and CodeRabbit only flagged this site, so leaving them
  for now.

- docs/FONT_MANAGER.md: clarified that the Manual Font Overrides
  *workflow* (set_override / remove_override / font_overrides.json)
  is the supported override path today, and only the Fonts tab
  in the web UI is the placeholder. Previous wording conflated
  the two and made it sound like overrides themselves were
  broken.

- docs/HOW_TO_RUN_TESTS.md: replaced the vague "see the PR
  adding it" with a concrete link to #307 and a note that the
  workflow file itself is held back pending the workflow scope.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-04-08 09:25:12 -04:00
committed by GitHub
parent 601fedb9b4
commit 781224591f
13 changed files with 105 additions and 78 deletions

View File

@@ -195,8 +195,9 @@ Located in: `src/cache_manager.py`
**Key Methods:** **Key Methods:**
- `get(key, max_age=300)`: Get cached value (returns None if missing/stale) - `get(key, max_age=300)`: Get cached value (returns None if missing/stale)
- `set(key, value, ttl=None)`: Cache a value - `set(key, value, ttl=None)`: Cache a value
- `clear_cache(key=None)`: Remove a cache entry, or all entries if `key` - `delete(key)` / `clear_cache(key=None)`: Remove a single cache entry,
is omitted. There is no `delete()` method. or (for `clear_cache` with no argument) every cached entry. `delete`
is an alias for `clear_cache(key)`.
- `get_cached_data_with_strategy(key, data_type)`: Cache get with - `get_cached_data_with_strategy(key, data_type)`: Cache get with
data-type-aware TTL strategy data-type-aware TTL strategy
- `get_background_cached_data(key, sport_key)`: Cache get for the - `get_background_cached_data(key, sport_key)`: Cache get for the

View File

@@ -62,7 +62,7 @@ display_manager.defer_update(lambda: self.update_cache(), priority=0)
# Basic caching # Basic caching
cached = cache_manager.get("key", max_age=3600) cached = cache_manager.get("key", max_age=3600)
cache_manager.set("key", data) cache_manager.set("key", data)
cache_manager.clear_cache("key") # there is no delete() method cache_manager.delete("key") # alias for clear_cache(key)
# Advanced caching # Advanced caching
data = cache_manager.get_cached_data_with_strategy("key", data_type="weather") data = cache_manager.get_cached_data_with_strategy("key", data_type="weather")

View File

@@ -138,29 +138,28 @@ font = self.font_manager.resolve_font(
## For Plugin Developers ## For Plugin Developers
> ⚠️ **Status**: the plugin-font registration described below is > **Note**: plugins that ship their own fonts via a `"fonts"` block
> implemented in `src/font_manager.py:150` (`register_plugin_fonts()`) > in `manifest.json` are registered automatically during plugin load
> but is **not currently wired into the plugin loader**. Adding a > (`src/plugin_system/plugin_manager.py` calls
> `"fonts"` block to your plugin's `manifest.json` will silently have > `FontManager.register_plugin_fonts()`). The `plugin://…` source
> no effect — the FontManager method exists but nothing calls it. > URIs documented below are resolved relative to the plugin's
> install directory.
> >
> Until that's connected, plugin authors who need a custom font > The **Fonts** tab in the web UI that lists detected
> should load it directly with PIL (or `freetype-py` for BDF) in > manager-registered fonts is still a **placeholder
> their plugin's `manager.py``FontManager.resolve_font(family=…, > implementation**fonts that managers register through
> size_px=…)` takes a **family name**, not a file path, so it can't > `register_manager_font()` do not yet appear there. The
> be used to pull a font from your plugin directory. The > programmatic per-element override workflow described in
> `plugin://…` source URIs described below are only honored by > [Manual Font Overrides](#manual-font-overrides) below
> `register_plugin_fonts()` itself, which isn't wired up. > (`set_override()` / `remove_override()` / the
> > `config/font_overrides.json` store) **does** work today and is
> The `/api/v3/fonts/overrides` endpoints and the **Fonts** tab in > the supported way to override a font for an element until the
> the web UI are currently **placeholder implementations** — they > Fonts tab is wired up. If you can't wait and need a workaround
> return empty arrays and contain "would integrate with the actual > right now, you can also just load the font directly with PIL
> font system" comments. Manually registered manager fonts do > (or `freetype-py` for BDF) inside your plugin's `manager.py`
> **not** yet flow into that tab. If you need an override today, > and skip the override system entirely.
> load the font directly in your plugin and skip the
> override system.
### Plugin Font Registration (planned) ### Plugin Font Registration
In your plugin's `manifest.json`: In your plugin's `manifest.json`:

View File

@@ -336,15 +336,15 @@ pytest --cov=src --cov-report=html
## Continuous Integration ## Continuous Integration
There is currently no CI test workflow in this repo — `pytest` runs The repo runs
locally but is not gated on PRs. The only GitHub Actions workflow is [`.github/workflows/security-audit.yml`](../.github/workflows/security-audit.yml)
[`.github/workflows/security-audit.yml`](../.github/workflows/security-audit.yml), (bandit + semgrep) on every push. A pytest CI workflow at
which runs bandit and semgrep on every push. `.github/workflows/tests.yml` is queued to land alongside this
PR ([ChuckBuilds/LEDMatrix#307](https://github.com/ChuckBuilds/LEDMatrix/pull/307));
If you'd like to add a test workflow, the recommended setup is a the workflow file itself was held back from that PR because the
`.github/workflows/tests.yml` that runs `pytest` against the push token lacked the GitHub `workflow` scope, so it needs to be
supported Python versions (3.10, 3.11, 3.12, 3.13 per committed separately by a maintainer. Once it's in, this section
`requirements.txt`). Open an issue or PR if you want to contribute it. will be updated to describe what the job runs.
## Best Practices ## Best Practices

View File

@@ -1,16 +1,5 @@
# Plugin Custom Icons Guide # Plugin Custom Icons Guide
> ⚠️ **Status:** the `icon` field in `manifest.json` is currently
> **not honored by the v3 web interface**. Plugin tab icons are
> hardcoded to `fas fa-puzzle-piece` in
> `web_interface/templates/v3/base.html:515` and `:774`. The icon
> field was originally read by a `getPluginIcon()` helper in the v2
> templates, but that helper wasn't ported to v3. Setting `icon` in a
> manifest is harmless (it's just ignored) so plugin authors can leave
> it in place for when this regression is fixed.
>
> Tracking issue: see the LEDMatrix repo for the open ticket.
## Overview ## Overview
Plugins can specify custom icons that appear next to their name in the web interface tabs. This makes your plugin instantly recognizable and adds visual polish to the UI. Plugins can specify custom icons that appear next to their name in the web interface tabs. This makes your plugin instantly recognizable and adds visual polish to the UI.

View File

@@ -1,13 +1,12 @@
# Plugin Custom Icons Feature # Plugin Custom Icons Feature
> ⚠️ **Status:** this doc describes the v2 web interface > **Note:** this doc was originally written against the v2 web
> implementation of plugin custom icons. The feature **regressed when > interface. The v3 web interface now honors the same `icon` field
> the v3 web interface was built** — the `getPluginIcon()` helper > in `manifest.json` — the API passes it through at
> referenced below lived in `templates/index_v2.html` (which is now > `web_interface/blueprints/api_v3.py` and the three plugin-tab
> archived) and was not ported to the v3 templates. Plugin tab icons > render sites in `web_interface/templates/v3/base.html` read it
> in v3 are hardcoded to `fas fa-puzzle-piece` > with a `fas fa-puzzle-piece` fallback. The guidance below still
> (`web_interface/templates/v3/base.html:515` and `:774`). The > applies; only the referenced template/helper names differ.
> `icon` field in `manifest.json` is currently silently ignored.
## What Was Implemented ## What Was Implemented

View File

@@ -6,7 +6,7 @@
set -euo pipefail set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$SCRIPT_DIR" PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
PLUGINS_DIR="$PROJECT_ROOT/plugins" PLUGINS_DIR="$PROJECT_ROOT/plugins"
CONFIG_FILE="$PROJECT_ROOT/dev_plugins.json" CONFIG_FILE="$PROJECT_ROOT/dev_plugins.json"
DEFAULT_DEV_DIR="$HOME/.ledmatrix-dev-plugins" DEFAULT_DEV_DIR="$HOME/.ledmatrix-dev-plugins"

View File

@@ -1 +0,0 @@
/home/chuck/.ledmatrix-dev-plugins/ledmatrix-of-the-day

View File

@@ -19,14 +19,6 @@ from datetime import datetime, timedelta, timezone
from typing import Dict, Any, Optional, List from typing import Dict, Any, Optional, List
import pytz import pytz
# Import the API counter function from web interface
try:
from web_interface_v2 import increment_api_counter
except ImportError:
# Fallback if web interface is not available
def increment_api_counter(kind: str, count: int = 1):
pass
class BaseOddsManager: class BaseOddsManager:
""" """
@@ -131,9 +123,7 @@ class BaseOddsManager:
response = requests.get(url, timeout=self.request_timeout) response = requests.get(url, timeout=self.request_timeout)
response.raise_for_status() response.raise_for_status()
raw_data = response.json() raw_data = response.json()
# Increment API counter for odds data
increment_api_counter('odds', 1)
self.logger.debug(f"Received raw odds data from ESPN: {json.dumps(raw_data, indent=2)}") self.logger.debug(f"Received raw odds data from ESPN: {json.dumps(raw_data, indent=2)}")
odds_data = self._extract_espn_data(raw_data) odds_data = self._extract_espn_data(raw_data)

View File

@@ -320,18 +320,43 @@ class CacheManager:
return None return None
def clear_cache(self, key: Optional[str] = None) -> None: def clear_cache(self, key: Optional[str] = None) -> None:
"""Clear cache for a specific key or all keys.""" """Clear cache entries.
if key:
# Clear specific key Pass a non-empty ``key`` to remove a single entry, or pass
self._memory_cache_component.clear(key) ``None`` (the default) to clear every cached entry. An empty
self._disk_cache_component.clear(key) string is rejected to prevent accidental whole-cache wipes
self.logger.info("Cleared cache for key: %s", key) from callers that pass through unvalidated input.
else: """
if key is None:
# Clear all keys # Clear all keys
memory_count = self._memory_cache_component.size() memory_count = self._memory_cache_component.size()
self._memory_cache_component.clear() self._memory_cache_component.clear()
self._disk_cache_component.clear() self._disk_cache_component.clear()
self.logger.info("Cleared all cache: %d memory entries", memory_count) self.logger.info("Cleared all cache: %d memory entries", memory_count)
return
if not isinstance(key, str) or not key:
raise ValueError(
"clear_cache(key) requires a non-empty string; "
"pass key=None to clear all entries"
)
# Clear specific key
self._memory_cache_component.clear(key)
self._disk_cache_component.clear(key)
self.logger.info("Cleared cache for key: %s", key)
def delete(self, key: str) -> None:
"""Remove a single cache entry.
Thin wrapper around :meth:`clear_cache` that **requires** a
non-empty string key — unlike ``clear_cache(None)`` it never
wipes every entry. Raises ``ValueError`` on ``None`` or an
empty string.
"""
if key is None or not isinstance(key, str) or not key:
raise ValueError("delete(key) requires a non-empty string key")
self.clear_cache(key)
def list_cache_files(self) -> List[Dict[str, Any]]: def list_cache_files(self) -> List[Dict[str, Any]]:
"""List all cache files with metadata (key, age, size, path). """List all cache files with metadata (key, age, size, path).

View File

@@ -358,7 +358,23 @@ class PluginManager:
# Store module # Store module
self.plugin_modules[plugin_id] = module self.plugin_modules[plugin_id] = module
# Register plugin-shipped fonts with the FontManager (if any).
# Plugin manifests can declare a "fonts" block that ships custom
# fonts with the plugin; FontManager.register_plugin_fonts handles
# the actual loading. Wired here so manifest declarations take
# effect without requiring plugin code changes.
font_manifest = manifest.get('fonts')
if font_manifest and self.font_manager is not None and hasattr(
self.font_manager, 'register_plugin_fonts'
):
try:
self.font_manager.register_plugin_fonts(plugin_id, font_manifest)
except Exception as e:
self.logger.warning(
"Failed to register fonts for plugin %s: %s", plugin_id, e
)
# Validate configuration # Validate configuration
if hasattr(plugin_instance, 'validate_config'): if hasattr(plugin_instance, 'validate_config'):
try: try:

View File

@@ -1824,6 +1824,7 @@ def get_installed_plugins():
'category': plugin_info.get('category', 'General'), 'category': plugin_info.get('category', 'General'),
'description': plugin_info.get('description', 'No description available'), 'description': plugin_info.get('description', 'No description available'),
'tags': plugin_info.get('tags', []), 'tags': plugin_info.get('tags', []),
'icon': plugin_info.get('icon', 'fas fa-puzzle-piece'),
'enabled': enabled, 'enabled': enabled,
'verified': verified, 'verified': verified,
'loaded': plugin_info.get('loaded', False), 'loaded': plugin_info.get('loaded', False),

View File

@@ -512,7 +512,8 @@
} }
} }
}; };
tabButton.innerHTML = `<i class="fas fa-puzzle-piece"></i>${(plugin.name || plugin.id).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')}`; const iconClass = (plugin.icon || 'fas fa-puzzle-piece').replace(/"/g, '&quot;');
tabButton.innerHTML = `<i class="${iconClass}"></i>${(plugin.name || plugin.id).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')}`;
pluginTabsNav.appendChild(tabButton); pluginTabsNav.appendChild(tabButton);
}); });
console.log('[GLOBAL] Updated plugin tabs directly:', plugins.length, 'tabs added'); console.log('[GLOBAL] Updated plugin tabs directly:', plugins.length, 'tabs added');
@@ -771,7 +772,8 @@
}; };
const div = document.createElement('div'); const div = document.createElement('div');
div.textContent = plugin.name || plugin.id; div.textContent = plugin.name || plugin.id;
tabButton.innerHTML = `<i class="fas fa-puzzle-piece"></i>${div.innerHTML}`; const iconClass = (plugin.icon || 'fas fa-puzzle-piece').replace(/"/g, '&quot;');
tabButton.innerHTML = `<i class="${iconClass}"></i>${div.innerHTML}`;
pluginTabsNav.appendChild(tabButton); pluginTabsNav.appendChild(tabButton);
}); });
console.log('[STUB] updatePluginTabs: Added', this.installedPlugins.length, 'plugin tabs'); console.log('[STUB] updatePluginTabs: Added', this.installedPlugins.length, 'plugin tabs');
@@ -1959,9 +1961,15 @@
this.updatePluginTabStates(); this.updatePluginTabStates();
} }
}; };
tabButton.innerHTML = ` // Build the <i class="..."> + label as DOM nodes so a
<i class="fas fa-puzzle-piece"></i>${this.escapeHtml(plugin.name || plugin.id)} // hostile plugin.icon (e.g. containing a quote) can't
`; // break out of the attribute. escapeHtml only escapes
// <, >, &, not ", so attribute-context interpolation
// would be unsafe.
const iconEl = document.createElement('i');
iconEl.className = plugin.icon || 'fas fa-puzzle-piece';
const labelNode = document.createTextNode(plugin.name || plugin.id);
tabButton.replaceChildren(iconEl, labelNode);
// Insert before the closing </nav> tag // Insert before the closing </nav> tag
pluginTabsNav.appendChild(tabButton); pluginTabsNav.appendChild(tabButton);