mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-05 23:05:10 +00:00
fix(plugins): store and plugin-manager bugs; tidy src/plugin_system (#635)
* fix(store): don't read a ZIP-installed plugin's remote from the LEDMatrix repo update_plugin looked up remote.origin.url with `git -C <plugin> config --local` for plugins that are not git checkouts. Under plugin-repos/ git walks up to the enclosing LEDMatrix repository, so the lookup returned LEDMatrix's own URL and a plugin missing from the registry was "reinstalled" from the LEDMatrix repo. Only ask git when the plugin directory has its own .git, the test _get_local_git_info already uses. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(schema): report each missing required field once, by name validate_config_against_schema ran its own required-fields loop after Draft7Validator.iter_errors, which already yields one `required` error per missing field, so every missing top-level field was listed twice. The validator's copy also printed the schema's whole `required` list ("Missing required property '['api_key', 'city']'") instead of the field. Drop the loop and take the field name from the error itself. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(store): stop mangling repository URLs that contain ".git" install_from_url and fetch_registry_from_url cleaned URLs with `rstrip('/').replace('.git', '')`, which removes ".git" anywhere: https://github.com/user/my.github.io became .../myhub.io, so installing or browsing that repository asked GitHub for one that does not exist. Add src/plugin_system/repo_urls.py with one anchored normalize_repo_url(), same_repo() for comparisons, github_owner_repo() and github_api_headers(), and use them for the five copies of the owner/repo parsing and GitHub headers in the store and for saved repositories. GitHub URLs are now recognised by urlparse().hostname everywhere: _get_latest_commit_info used a substring test, and _install_from_monorepo_api parsed any host. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(store): install a repository whose only branch is not main/master _install_via_git returned None both when every clone failed and when the last-resort clone of the repository's default branch succeeded. _install_plugin_impl papered over it with `and not plugin_path.exists()`; install_from_url did not, so a repository whose only branch is e.g. `develop` was cloned, then treated as a failure, then "downloaded" from main/master archives that do not exist. After a default-branch clone, return the branch the clone checked out (read from .git/HEAD), so None means failure and nothing else, and give both callers the same `branch_used is None` fallback. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(plugins): judge the memory limit on each call's own growth monitor_call stores `metrics.memory_mb = max(previous, growth)`, and _check_limits compared that high-water mark with max_memory_mb. It never decreases, so once one update() grew the process past the limit every later call raised ResourceLimitExceeded and the circuit breaker kept reopening. Pass the call's own RSS growth to _check_limits; keep the high-water mark for reporting and document what it measures. Remove ResourceMetrics.update_average_execution_time: nothing called it, and it overwrote the running total with the average. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(plugins): reload_plugin re-reads the manifest from the discovered directory reload_plugin read `plugins_dir / plugin_id / "manifest.json"`, ignoring the discovery map and the plugin_dirs rules. For a plugin whose directory name differs from its manifest id the path did not exist, the re-read was skipped without a word, and the reload kept the stale manifest. Resolve the directory with find_plugin_directory, as load_plugin does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(plugins): drop the always-null last_display from plugin state info PluginStateManager reported `last_display` from `_last_display`, which nothing ever wrote, so it was null for every plugin. Recording it in PluginExecutor.execute_display would not help: get_state_info's only reader is the web process, whose PluginManager never calls display(). Remove the field, its dict and get_last_display() (no caller in core, the web UI or the plugin monorepo). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(store): share the rollback and requirements helpers, drop dead code - install_plugin and _reinstall_with_rollback set aside, discard and restore the old copy through _set_aside/_discard_backup/_restore_backup instead of two copies of the same blocks. - The loader and the store run the same pre-pip checks through contained_plugin_dir() and requirements_to_install() in plugin_loader. They still invoke pip differently (sys.executable -m pip vs. the sudo wrapper). `except (BrokenPipeError, OSError)` + `isinstance(e, OSError)` becomes `except OSError` checking errno.EPIPE. - load_module never returns None, so load_plugin's check is gone and the docstring says what it raises. - Remove the always-true JSONSCHEMA_AVAILABLE, the inline re-imports of re and permission_utils, the fake status_result object nobody reads, hasattr(git_error, 'cmd'), a redundant "merge conflict" test and `import traceback` (exc_info=True does it). - Correct comments: install_from_url names the directory for the caller's id when given (not always the manifest id), _get_local_git_info saves one git subprocess (not four), _enrich calls two helpers, search_plugins documents all its arguments, _find_plugin_path states its behaviour instead of a TODO, and history narration is gone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(plugins): tidy base_plugin, correct plugin_manager/state comments - base_plugin: drop the unused `import logging`; get_display_duration runs the instance value and the config value through one _positive_seconds() helper instead of two copies of the coercion; the 'static'/'none'/fallback branches of get_vegas_display_mode, which all returned FIXED_SEGMENT, are one; fix the mis-indented validate_config example; say that get_supported_vegas_modes/get_vegas_segment_width are not consulted by core (kept, plugins override them). - schema_manager: import expand_style_elements normally rather than swallowing an ImportError of a core module. - plugin_manager: the plugins directory is the configured one (plugin-repos/ by default), not plugins/; get_config() returns the live dict, not a copy, so the interval cache comments say what it saves. - state_manager: config_version and the file version are not used to detect corruption; say what they are. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(plugins): stop writing data/plugin_operations.json PluginOperationQueue wrote its finished-operation history to data/plugin_operations.json after every operation, and read it back only into its own in-memory list, which only get_operation_history() exposes -- and nothing calls that. The operation-history endpoint reads OperationHistory (data/operation_history.json). No code in src/, web_interface/, scripts/ or test/ reads the file. Drop the history_file/lazy_load parameters and the load/save code; the bounded in-memory history stays. web_interface/app.py and the integration test stop passing the removed arguments. An existing data/plugin_operations.json is left in place (data/* is gitignored). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(changelog): plugin-system Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+4
-4
@@ -81,10 +81,10 @@ assets/stocks/crypto_icons/
|
|||||||
|
|
||||||
# Plugin operation state written at runtime.
|
# Plugin operation state written at runtime.
|
||||||
#
|
#
|
||||||
# web_interface/app.py writes data/plugin_operations.json, data/plugin_state.json
|
# web_interface/app.py writes data/plugin_state.json and data/operation_history.json
|
||||||
# and data/operation_history.json as the web interface runs, into a directory that
|
# (older releases also data/plugin_operations.json) as the web interface runs, into
|
||||||
# ships tracked (data/.gitkeep) and was otherwise unignored. So every rig that ever
|
# a directory that ships tracked (data/.gitkeep). Unignored, every rig that ever
|
||||||
# opened the web UI -- and every test run that constructs the app -- left three
|
# opened the web UI -- and every test run that constructs the app -- would leave
|
||||||
# untracked files behind and a permanently dirty `git status`. Same reasoning as
|
# untracked files behind and a permanently dirty `git status`. Same reasoning as
|
||||||
# the logo rule above: a checkout that is always dirty is a checkout nobody reads.
|
# the logo rule above: a checkout that is always dirty is a checkout nobody reads.
|
||||||
data/*
|
data/*
|
||||||
|
|||||||
@@ -33,6 +33,15 @@ accepts both, but the store flags the old spelling as deprecated
|
|||||||
- `src/common/README.md` covers every module.
|
- `src/common/README.md` covers every module.
|
||||||
- Stale setup, service and troubleshooting claims are corrected.
|
- Stale setup, service and troubleshooting claims are corrected.
|
||||||
|
|
||||||
|
- Plugin store and plugin manager fixes:
|
||||||
|
- Updating a plugin that was installed from a ZIP no longer tries to reinstall it from the LEDMatrix repository's own URL.
|
||||||
|
- Repository URLs with `.git` in the middle are no longer mangled. The URL helpers now live in `src/plugin_system/repo_urls.py`.
|
||||||
|
- Installing from a URL works when the repository's only branch isn't `main` or `master`.
|
||||||
|
- A missing required config field is reported once, by name.
|
||||||
|
- A plugin that went over `max_memory_mb` once is no longer refused on every call after that.
|
||||||
|
- `reload_plugin` reads the manifest from the plugin's discovered directory.
|
||||||
|
- Removed: `last_display` from plugin state info and `get_last_display()` (nothing recorded them); `PluginOperationQueue`'s `history_file` and `lazy_load` arguments; and `data/plugin_operations.json`, which nothing read.
|
||||||
|
|
||||||
- The web service (`ledmatrix-web`) logs through `src.logging_config` like the
|
- The web service (`ledmatrix-web`) logs through `src.logging_config` like the
|
||||||
display service, so `journalctl -p err -u ledmatrix-web` works. Successful
|
display service, so `journalctl -p err -u ledmatrix-web` works. Successful
|
||||||
GET/HEAD/OPTIONS requests (the UI's polling) are logged at DEBUG instead of
|
GET/HEAD/OPTIONS requests (the UI's polling) are logged at DEBUG instead of
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ Stability: Stable - maintains backward compatibility
|
|||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import Dict, Any, Optional, List
|
from typing import Dict, Any, Optional, List
|
||||||
import logging
|
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
from src.logging_config import get_logger
|
from src.logging_config import get_logger
|
||||||
@@ -511,104 +510,53 @@ class BasePlugin(ABC):
|
|||||||
"""
|
"""
|
||||||
Get the display duration for this plugin instance.
|
Get the display duration for this plugin instance.
|
||||||
|
|
||||||
Automatically detects duration from:
|
Uses, in order, the first positive number among:
|
||||||
1. self.display_duration instance variable (if exists)
|
1. ``self.display_duration`` (a common pattern in scoreboard plugins)
|
||||||
2. self.config.get("display_duration", 15.0) (fallback)
|
2. ``self.config["display_duration"]``
|
||||||
|
3. 15.0
|
||||||
|
|
||||||
Can be overridden by plugins to provide dynamic durations based
|
Numeric strings count as numbers. Can be overridden by plugins to
|
||||||
on content (e.g., longer duration for more complex displays).
|
provide dynamic durations based on content (e.g., longer duration for
|
||||||
|
more complex displays).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Duration in seconds to display this plugin's content
|
Duration in seconds to display this plugin's content
|
||||||
"""
|
"""
|
||||||
# Check for instance variable first (common pattern in scoreboard plugins)
|
|
||||||
if hasattr(self, 'display_duration'):
|
|
||||||
try:
|
try:
|
||||||
duration = getattr(self, 'display_duration')
|
duration = getattr(self, 'display_duration', None)
|
||||||
# Handle None case
|
|
||||||
if duration is None:
|
|
||||||
pass # Fall through to config
|
|
||||||
# Try to convert to float if it's a number or numeric string.
|
|
||||||
# bool is excluded: it's an int subclass, and True would
|
|
||||||
# otherwise read as a 1-second duration.
|
|
||||||
elif isinstance(duration, (int, float)) and not isinstance(duration, bool):
|
|
||||||
if duration > 0:
|
|
||||||
return float(duration)
|
|
||||||
else:
|
|
||||||
self.logger.debug(
|
|
||||||
"display_duration instance variable is non-positive (%s), using config fallback",
|
|
||||||
duration
|
|
||||||
)
|
|
||||||
# Try converting string representations of numbers
|
|
||||||
elif isinstance(duration, str):
|
|
||||||
try:
|
|
||||||
duration_float = float(duration)
|
|
||||||
if duration_float > 0:
|
|
||||||
return duration_float
|
|
||||||
else:
|
|
||||||
self.logger.debug(
|
|
||||||
"display_duration string value is non-positive (%s), using config fallback",
|
|
||||||
duration
|
|
||||||
)
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
self.logger.warning(
|
|
||||||
"display_duration instance variable has invalid string value '%s', using config fallback",
|
|
||||||
duration
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
self.logger.warning(
|
|
||||||
"display_duration instance variable has unexpected type %s (value: %s), using config fallback",
|
|
||||||
type(duration).__name__, duration
|
|
||||||
)
|
|
||||||
except (TypeError, ValueError, AttributeError) as e:
|
except (TypeError, ValueError, AttributeError) as e:
|
||||||
|
# A plugin may define display_duration as a property that raises.
|
||||||
self.logger.warning(
|
self.logger.warning(
|
||||||
"Error reading display_duration instance variable: %s, using config fallback",
|
"Error reading display_duration instance variable: %s, using config fallback", e)
|
||||||
e
|
duration = None
|
||||||
)
|
if duration is not None:
|
||||||
|
seconds = self._positive_seconds(duration, "display_duration instance variable")
|
||||||
|
if seconds is not None:
|
||||||
|
return seconds
|
||||||
|
|
||||||
# Fall back to config
|
seconds = self._positive_seconds(
|
||||||
config_duration = self.config.get("display_duration", 15.0)
|
self.config.get("display_duration", 15.0), "config display_duration")
|
||||||
|
return seconds if seconds is not None else 15.0
|
||||||
|
|
||||||
|
def _positive_seconds(self, value: Any, source: str) -> Optional[float]:
|
||||||
|
"""``value`` as a positive float, or None (with a log line) if it is not one.
|
||||||
|
|
||||||
|
bool is rejected although it is an int subclass: True would otherwise
|
||||||
|
read as a 1-second duration.
|
||||||
|
"""
|
||||||
|
if isinstance(value, bool) or not isinstance(value, (int, float, str)):
|
||||||
|
self.logger.warning("%s has unexpected type %s (value: %s), ignoring it",
|
||||||
|
source, type(value).__name__, value)
|
||||||
|
return None
|
||||||
try:
|
try:
|
||||||
# Ensure config value is also a valid float (bool excluded — an
|
seconds = float(value)
|
||||||
# int subclass that would otherwise read True as 1 second)
|
|
||||||
if isinstance(config_duration, (int, float)) and not isinstance(config_duration, bool):
|
|
||||||
if config_duration > 0:
|
|
||||||
return float(config_duration)
|
|
||||||
else:
|
|
||||||
self.logger.debug(
|
|
||||||
"Config display_duration is non-positive (%s), using default 15.0",
|
|
||||||
config_duration
|
|
||||||
)
|
|
||||||
return 15.0
|
|
||||||
elif isinstance(config_duration, str):
|
|
||||||
try:
|
|
||||||
duration_float = float(config_duration)
|
|
||||||
if duration_float > 0:
|
|
||||||
return duration_float
|
|
||||||
else:
|
|
||||||
self.logger.debug(
|
|
||||||
"Config display_duration string is non-positive (%s), using default 15.0",
|
|
||||||
config_duration
|
|
||||||
)
|
|
||||||
return 15.0
|
|
||||||
except ValueError:
|
except ValueError:
|
||||||
self.logger.warning(
|
self.logger.warning("%s has invalid value %r, ignoring it", source, value)
|
||||||
"Config display_duration has invalid string value '%s', using default 15.0",
|
return None
|
||||||
config_duration
|
if seconds > 0:
|
||||||
)
|
return seconds
|
||||||
return 15.0
|
self.logger.debug("%s is non-positive (%s), ignoring it", source, value)
|
||||||
else:
|
return None
|
||||||
self.logger.warning(
|
|
||||||
"Config display_duration has unexpected type %s (value: %s), using default 15.0",
|
|
||||||
type(config_duration).__name__, config_duration
|
|
||||||
)
|
|
||||||
except (ValueError, TypeError) as e:
|
|
||||||
self.logger.warning(
|
|
||||||
"Error processing config display_duration: %s, using default 15.0",
|
|
||||||
e
|
|
||||||
)
|
|
||||||
|
|
||||||
return 15.0
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------
|
# ---------------------------------------------------------------------
|
||||||
# Dynamic duration support hooks
|
# Dynamic duration support hooks
|
||||||
@@ -926,25 +874,20 @@ class BasePlugin(ABC):
|
|||||||
config_mode, self.plugin_id
|
config_mode, self.plugin_id
|
||||||
)
|
)
|
||||||
|
|
||||||
# Fall back to mapping legacy content_type
|
# Fall back to mapping legacy content_type. 'none' (excluded from
|
||||||
content_type = self.get_vegas_content_type()
|
# Vegas) also maps to FIXED_SEGMENT: exclusion is decided by checking
|
||||||
if content_type == 'multi':
|
# get_vegas_content_type() separately.
|
||||||
|
if self.get_vegas_content_type() == 'multi':
|
||||||
return VegasDisplayMode.SCROLL
|
return VegasDisplayMode.SCROLL
|
||||||
elif content_type == 'static':
|
|
||||||
return VegasDisplayMode.FIXED_SEGMENT
|
|
||||||
elif content_type == 'none':
|
|
||||||
# 'none' means excluded - return FIXED_SEGMENT as default
|
|
||||||
# The exclusion is handled by checking get_vegas_content_type() separately
|
|
||||||
return VegasDisplayMode.FIXED_SEGMENT
|
|
||||||
|
|
||||||
return VegasDisplayMode.FIXED_SEGMENT
|
return VegasDisplayMode.FIXED_SEGMENT
|
||||||
|
|
||||||
def get_supported_vegas_modes(self) -> List[VegasDisplayMode]:
|
def get_supported_vegas_modes(self) -> List[VegasDisplayMode]:
|
||||||
"""
|
"""
|
||||||
Return list of Vegas display modes this plugin supports.
|
Return list of Vegas display modes this plugin supports.
|
||||||
|
|
||||||
Used by the web UI to show available mode options for user configuration.
|
Not currently consulted by core: neither Vegas mode nor the web UI
|
||||||
Override to customize which modes are available for this plugin.
|
calls it. It is kept, and plugins override it, as the declared set of
|
||||||
|
modes a future mode picker would offer.
|
||||||
|
|
||||||
By default:
|
By default:
|
||||||
- 'multi' content type plugins support SCROLL and FIXED_SEGMENT
|
- 'multi' content type plugins support SCROLL and FIXED_SEGMENT
|
||||||
@@ -972,6 +915,10 @@ class BasePlugin(ABC):
|
|||||||
"""
|
"""
|
||||||
Get the preferred width for this plugin in Vegas FIXED_SEGMENT mode.
|
Get the preferred width for this plugin in Vegas FIXED_SEGMENT mode.
|
||||||
|
|
||||||
|
Not currently consulted by core: Vegas mode sizes a card from the
|
||||||
|
``vegas_width_pct`` / ``vegas_scroll.render_width_pct`` settings
|
||||||
|
(see get_vegas_render_width()). Kept because plugins override it.
|
||||||
|
|
||||||
Returns the number of panels this plugin should occupy when displayed
|
Returns the number of panels this plugin should occupy when displayed
|
||||||
as a fixed segment. The actual pixel width is calculated as:
|
as a fixed segment. The actual pixel width is calculated as:
|
||||||
width = panels * single_panel_width
|
width = panels * single_panel_width
|
||||||
|
|||||||
@@ -9,8 +9,6 @@ import threading
|
|||||||
import queue
|
import queue
|
||||||
from typing import Dict, Optional, List, Callable, Any
|
from typing import Dict, Optional, List, Callable, Any
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
|
||||||
import json
|
|
||||||
|
|
||||||
from src.plugin_system.operation_types import (
|
from src.plugin_system.operation_types import (
|
||||||
PluginOperation, OperationType, OperationStatus
|
PluginOperation, OperationType, OperationStatus
|
||||||
@@ -28,28 +26,22 @@ class PluginOperationQueue:
|
|||||||
- Prevents concurrent operations on same plugin
|
- Prevents concurrent operations on same plugin
|
||||||
- Operation status tracking
|
- Operation status tracking
|
||||||
- Operation cancellation
|
- Operation cancellation
|
||||||
- Operation history
|
- In-memory history of finished operations
|
||||||
|
|
||||||
|
The history is not persisted. The web UI's operation history comes from
|
||||||
|
OperationHistory (operation_history.py), which has its own file; a copy
|
||||||
|
written here was never read back by anything.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(self, max_history: int = 100):
|
||||||
self,
|
|
||||||
history_file: Optional[str] = None,
|
|
||||||
max_history: int = 100,
|
|
||||||
lazy_load: bool = False
|
|
||||||
):
|
|
||||||
"""
|
"""
|
||||||
Initialize operation queue.
|
Initialize operation queue.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
history_file: Optional path to file for persisting operation history
|
|
||||||
max_history: Maximum number of operations to keep in history
|
max_history: Maximum number of operations to keep in history
|
||||||
lazy_load: If True, defer loading history file until first access
|
|
||||||
"""
|
"""
|
||||||
self.logger = get_logger(__name__)
|
self.logger = get_logger(__name__)
|
||||||
self.history_file = Path(history_file) if history_file else None
|
|
||||||
self.max_history = max_history
|
self.max_history = max_history
|
||||||
self._lazy_load = lazy_load
|
|
||||||
self._history_loaded = False
|
|
||||||
|
|
||||||
# Operation tracking
|
# Operation tracking
|
||||||
self._operations: Dict[str, PluginOperation] = {}
|
self._operations: Dict[str, PluginOperation] = {}
|
||||||
@@ -62,20 +54,8 @@ class PluginOperationQueue:
|
|||||||
self._worker_thread: Optional[threading.Thread] = None
|
self._worker_thread: Optional[threading.Thread] = None
|
||||||
self._stop_event = threading.Event()
|
self._stop_event = threading.Event()
|
||||||
|
|
||||||
# Load history from file if it exists (unless lazy loading)
|
|
||||||
if not self._lazy_load and self.history_file and self.history_file.exists():
|
|
||||||
self._load_history()
|
|
||||||
self._history_loaded = True
|
|
||||||
|
|
||||||
# Start worker thread
|
|
||||||
self._start_worker()
|
self._start_worker()
|
||||||
|
|
||||||
def _ensure_loaded(self) -> None:
|
|
||||||
"""Ensure history is loaded (for lazy loading)."""
|
|
||||||
if not self._history_loaded and self.history_file and self.history_file.exists():
|
|
||||||
self._load_history()
|
|
||||||
self._history_loaded = True
|
|
||||||
|
|
||||||
def enqueue_operation(
|
def enqueue_operation(
|
||||||
self,
|
self,
|
||||||
operation_type: OperationType,
|
operation_type: OperationType,
|
||||||
@@ -139,7 +119,6 @@ class PluginOperationQueue:
|
|||||||
Returns:
|
Returns:
|
||||||
PluginOperation if found, None otherwise
|
PluginOperation if found, None otherwise
|
||||||
"""
|
"""
|
||||||
self._ensure_loaded()
|
|
||||||
with self._lock:
|
with self._lock:
|
||||||
return self._operations.get(operation_id)
|
return self._operations.get(operation_id)
|
||||||
|
|
||||||
@@ -184,7 +163,6 @@ class PluginOperationQueue:
|
|||||||
Returns:
|
Returns:
|
||||||
List of operations, sorted by creation time (newest first)
|
List of operations, sorted by creation time (newest first)
|
||||||
"""
|
"""
|
||||||
self._ensure_loaded()
|
|
||||||
with self._lock:
|
with self._lock:
|
||||||
# Sort by creation time (newest first)
|
# Sort by creation time (newest first)
|
||||||
history = sorted(
|
history = sorted(
|
||||||
@@ -314,12 +292,8 @@ class PluginOperationQueue:
|
|||||||
if self._active_operations[operation.plugin_id].operation_id == operation.operation_id:
|
if self._active_operations[operation.plugin_id].operation_id == operation.operation_id:
|
||||||
del self._active_operations[operation.plugin_id]
|
del self._active_operations[operation.plugin_id]
|
||||||
|
|
||||||
# Add to history
|
|
||||||
self._add_to_history(operation)
|
self._add_to_history(operation)
|
||||||
|
|
||||||
# Save history to file
|
|
||||||
self._save_history()
|
|
||||||
|
|
||||||
def _add_to_history(self, operation: PluginOperation) -> None:
|
def _add_to_history(self, operation: PluginOperation) -> None:
|
||||||
"""Add operation to history, maintaining max_history limit."""
|
"""Add operation to history, maintaining max_history limit."""
|
||||||
self._operation_history.append(operation)
|
self._operation_history.append(operation)
|
||||||
@@ -330,46 +304,6 @@ class PluginOperationQueue:
|
|||||||
self._operation_history.sort(key=lambda op: op.created_at)
|
self._operation_history.sort(key=lambda op: op.created_at)
|
||||||
self._operation_history = self._operation_history[-self.max_history:]
|
self._operation_history = self._operation_history[-self.max_history:]
|
||||||
|
|
||||||
def _save_history(self) -> None:
|
|
||||||
"""Save operation history to file."""
|
|
||||||
if not self.history_file:
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
with self._lock:
|
|
||||||
# Convert operations to dicts
|
|
||||||
history_data = [op.to_dict() for op in self._operation_history]
|
|
||||||
|
|
||||||
# Ensure directory exists
|
|
||||||
self.history_file.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
# Write to file
|
|
||||||
with open(self.history_file, 'w') as f:
|
|
||||||
json.dump(history_data, f, indent=2)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.warning(f"Error saving operation history: {e}")
|
|
||||||
|
|
||||||
def _load_history(self) -> None:
|
|
||||||
"""Load operation history from file."""
|
|
||||||
if not self.history_file or not self.history_file.exists():
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
with open(self.history_file, 'r') as f:
|
|
||||||
history_data = json.load(f)
|
|
||||||
|
|
||||||
with self._lock:
|
|
||||||
self._operation_history = [
|
|
||||||
PluginOperation.from_dict(op_data)
|
|
||||||
for op_data in history_data
|
|
||||||
]
|
|
||||||
|
|
||||||
self.logger.info(f"Loaded {len(self._operation_history)} operations from history")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.warning(f"Error loading operation history: {e}")
|
|
||||||
|
|
||||||
def shutdown(self) -> None:
|
def shutdown(self) -> None:
|
||||||
"""Shutdown the operation queue and worker thread."""
|
"""Shutdown the operation queue and worker thread."""
|
||||||
self.logger.info("Shutting down plugin operation queue")
|
self.logger.info("Shutting down plugin operation queue")
|
||||||
@@ -378,6 +312,3 @@ class PluginOperationQueue:
|
|||||||
if self._worker_thread and self._worker_thread.is_alive():
|
if self._worker_thread and self._worker_thread.is_alive():
|
||||||
self._worker_thread.join(timeout=5.0)
|
self._worker_thread.join(timeout=5.0)
|
||||||
|
|
||||||
# Save history one last time
|
|
||||||
self._save_history()
|
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ Handles plugin module imports, dependency installation, and class instantiation.
|
|||||||
Extracted from PluginManager to improve separation of concerns.
|
Extracted from PluginManager to improve separation of concerns.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import errno
|
||||||
import importlib
|
import importlib
|
||||||
import importlib.metadata
|
import importlib.metadata
|
||||||
import importlib.util
|
import importlib.util
|
||||||
@@ -184,6 +185,46 @@ def find_trusted_subdir(trusted_dir: str, name: str) -> Optional[str]:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def contained_plugin_dir(plugin_dir: Path, plugins_dir: Path) -> Optional[str]:
|
||||||
|
"""``plugin_dir`` rebuilt from an entry enumerated under ``plugins_dir``.
|
||||||
|
|
||||||
|
Returns None when ``plugin_dir`` is not a subdirectory of ``plugins_dir``.
|
||||||
|
Callers derive ``plugin_dir`` from a manifest-declared id, so the path is
|
||||||
|
rebuilt from :func:`find_trusted_subdir`'s answer rather than trusted: a
|
||||||
|
name that came out of ``os.scandir()`` on the trusted root carries no
|
||||||
|
taint, which is a real containment guarantee (and one CodeQL's
|
||||||
|
path-injection query can follow), not a string sanitiser.
|
||||||
|
"""
|
||||||
|
plugin_dir_real = os.path.realpath(str(plugin_dir))
|
||||||
|
plugins_dir_real = os.path.realpath(str(plugins_dir))
|
||||||
|
matched_name = find_trusted_subdir(plugins_dir_real, os.path.basename(plugin_dir_real))
|
||||||
|
if matched_name is None:
|
||||||
|
return None
|
||||||
|
return os.path.join(plugins_dir_real, matched_name)
|
||||||
|
|
||||||
|
|
||||||
|
def requirements_to_install(plugin_dir: str, logger: logging.Logger,
|
||||||
|
label: str) -> Optional[str]:
|
||||||
|
"""The plugin's requirements.txt if pip has work to do, else None.
|
||||||
|
|
||||||
|
None when there is no requirements.txt, when it lists nothing (plugins
|
||||||
|
whose dependencies ship with core often keep an all-comments file), or
|
||||||
|
when every requirement is already installed. Shared by the loader and the
|
||||||
|
store so both skip pip for the same reasons; they differ only in how they
|
||||||
|
run it.
|
||||||
|
"""
|
||||||
|
requirements_file = os.path.join(plugin_dir, "requirements.txt")
|
||||||
|
if not os.path.isfile(requirements_file):
|
||||||
|
return None
|
||||||
|
if not requirements_has_real_deps(requirements_file):
|
||||||
|
logger.debug("requirements.txt for %s has no real dependencies, skipping pip", label)
|
||||||
|
return None
|
||||||
|
if requirements_are_satisfied(requirements_file):
|
||||||
|
logger.debug("Dependencies for %s already satisfied, skipping pip", label)
|
||||||
|
return None
|
||||||
|
return requirements_file
|
||||||
|
|
||||||
|
|
||||||
class PluginLoader:
|
class PluginLoader:
|
||||||
"""Handles plugin module loading and class instantiation."""
|
"""Handles plugin module loading and class instantiation."""
|
||||||
|
|
||||||
@@ -273,43 +314,15 @@ class PluginLoader:
|
|||||||
if not plugin_id:
|
if not plugin_id:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Resolve to a canonical absolute path (normalises .. and symlinks)
|
safe_plugin_dir = contained_plugin_dir(plugin_dir, plugins_dir)
|
||||||
plugin_dir_real = os.path.realpath(str(plugin_dir))
|
if safe_plugin_dir is None:
|
||||||
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(
|
self.logger.error(
|
||||||
"Plugin directory for %s not found inside plugins dir", plugin_id
|
"Plugin directory for %s not found inside plugins dir", plugin_id
|
||||||
)
|
)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
safe_plugin_dir = os.path.join(plugins_dir_real, matched_name)
|
requirements_file = requirements_to_install(safe_plugin_dir, self.logger, plugin_id)
|
||||||
requirements_file = os.path.join(safe_plugin_dir, "requirements.txt")
|
if requirements_file is None:
|
||||||
|
|
||||||
if not os.path.isfile(requirements_file):
|
|
||||||
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
|
|
||||||
)
|
|
||||||
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
|
return True
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -348,8 +361,8 @@ class PluginLoader:
|
|||||||
# below).
|
# below).
|
||||||
try:
|
try:
|
||||||
# sys.executable is this process's own interpreter (not
|
# sys.executable is this process's own interpreter (not
|
||||||
# attacker-influenced), and requirements_file is a path
|
# attacker-influenced), and requirements_file is rebuilt
|
||||||
# built internally by find_plugin_directory, never raw
|
# by contained_plugin_dir() from a trusted listing, never raw
|
||||||
# external input.
|
# external input.
|
||||||
retry_result = subprocess.run( # nosec B603 - no shell invoked (list-form argv) # nosemgrep
|
retry_result = subprocess.run( # nosec B603 - no shell invoked (list-form argv) # nosemgrep
|
||||||
[sys.executable, "-m", "pip", "install", "--break-system-packages",
|
[sys.executable, "-m", "pip", "install", "--break-system-packages",
|
||||||
@@ -384,10 +397,10 @@ class PluginLoader:
|
|||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
self.logger.warning("pip not found. Skipping dependency installation for %s", plugin_id)
|
self.logger.warning("pip not found. Skipping dependency installation for %s", plugin_id)
|
||||||
return True
|
return True
|
||||||
except (BrokenPipeError, OSError) as e:
|
except OSError as e:
|
||||||
# Handle broken pipe errors (errno 32) which can occur during pip downloads
|
# A broken pipe (EPIPE) happens when pip's output pipe closes
|
||||||
# Often caused by network interruptions or output buffer issues
|
# mid-download, usually a network interruption.
|
||||||
if isinstance(e, OSError) and e.errno == 32:
|
if e.errno == errno.EPIPE:
|
||||||
self.logger.error(
|
self.logger.error(
|
||||||
"Broken pipe error during dependency installation for %s. "
|
"Broken pipe error during dependency installation for %s. "
|
||||||
"This usually indicates a network interruption or pip output buffer issue. "
|
"This usually indicates a network interruption or pip output buffer issue. "
|
||||||
@@ -528,7 +541,7 @@ class PluginLoader:
|
|||||||
plugin_id: str,
|
plugin_id: str,
|
||||||
plugin_dir: Path,
|
plugin_dir: Path,
|
||||||
entry_point: str
|
entry_point: str
|
||||||
) -> Optional[Any]:
|
) -> Any:
|
||||||
"""
|
"""
|
||||||
Load a plugin module from file.
|
Load a plugin module from file.
|
||||||
|
|
||||||
@@ -547,7 +560,12 @@ class PluginLoader:
|
|||||||
entry_point: Entry point filename (e.g., 'manager.py')
|
entry_point: Entry point filename (e.g., 'manager.py')
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Loaded module or None on error
|
The loaded module
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
PluginError: If the plugin id, directory or entry point is
|
||||||
|
invalid. Whatever the module raises while executing
|
||||||
|
propagates unchanged.
|
||||||
"""
|
"""
|
||||||
plugin_id = os.path.basename(plugin_id or '')
|
plugin_id = os.path.basename(plugin_id or '')
|
||||||
if not plugin_id:
|
if not plugin_id:
|
||||||
@@ -782,8 +800,6 @@ class PluginLoader:
|
|||||||
# Load module
|
# Load module
|
||||||
entry_point = manifest.get('entry_point', 'manager.py')
|
entry_point = manifest.get('entry_point', 'manager.py')
|
||||||
module = self.load_module(plugin_id, plugin_dir, entry_point)
|
module = self.load_module(plugin_id, plugin_dir, entry_point)
|
||||||
if module is None:
|
|
||||||
raise PluginError(f"Failed to load module for plugin {plugin_id}", plugin_id=plugin_id)
|
|
||||||
|
|
||||||
# Get plugin class
|
# Get plugin class
|
||||||
class_name = manifest.get('class_name')
|
class_name = manifest.get('class_name')
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
Plugin Manager
|
Plugin Manager
|
||||||
|
|
||||||
Manages plugin discovery, loading, and lifecycle for the LEDMatrix system.
|
Manages plugin discovery, loading, and lifecycle for the LEDMatrix system.
|
||||||
Handles dynamic plugin loading from the plugins/ directory.
|
Loads plugins from the configured plugins directory
|
||||||
|
(``plugin_system.plugins_directory``, ``plugin-repos/`` by default).
|
||||||
|
|
||||||
API Version: 1.0.0
|
API Version: 1.0.0
|
||||||
"""
|
"""
|
||||||
@@ -40,7 +41,7 @@ class PluginManager:
|
|||||||
Manages plugin discovery, loading, and lifecycle.
|
Manages plugin discovery, loading, and lifecycle.
|
||||||
|
|
||||||
The PluginManager is responsible for:
|
The PluginManager is responsible for:
|
||||||
- Discovering plugins in the plugins/ directory
|
- Discovering plugins in the configured plugins directory
|
||||||
- Loading plugin modules and instantiating plugin classes
|
- Loading plugin modules and instantiating plugin classes
|
||||||
- Managing plugin lifecycle (load, unload, reload)
|
- Managing plugin lifecycle (load, unload, reload)
|
||||||
- Providing access to loaded plugins
|
- Providing access to loaded plugins
|
||||||
@@ -99,10 +100,9 @@ class PluginManager:
|
|||||||
self.plugin_directories: Dict[str, Path] = {}
|
self.plugin_directories: Dict[str, Path] = {}
|
||||||
self.plugin_last_update: Dict[str, float] = {}
|
self.plugin_last_update: Dict[str, float] = {}
|
||||||
|
|
||||||
# Cached data-fetch intervals per plugin_id.
|
# Cached static data-fetch intervals per plugin_id, so the render
|
||||||
# _get_plugin_update_interval falls back to config_manager.get_config()
|
# loop's scheduling tick does not repeat the manifest/config lookup
|
||||||
# (a full dict copy) when the manifest lacks an interval — caching avoids
|
# for every plugin. Cleared on load/unload.
|
||||||
# that copy on every 30-fps tick. Cleared on load/unload.
|
|
||||||
self._update_interval_cache: Dict[str, Optional[float]] = {}
|
self._update_interval_cache: Dict[str, Optional[float]] = {}
|
||||||
|
|
||||||
# Health tracking (optional, set by display_controller if available)
|
# Health tracking (optional, set by display_controller if available)
|
||||||
@@ -110,14 +110,12 @@ class PluginManager:
|
|||||||
self.resource_monitor = None
|
self.resource_monitor = None
|
||||||
|
|
||||||
# --- Asynchronous plugin updates -------------------------------
|
# --- Asynchronous plugin updates -------------------------------
|
||||||
# update() used to run inline in the render loop (execute_update's
|
# Run inline in the render loop, one slow plugin HTTP fetch in
|
||||||
# internal thread.join(timeout=30) blocked it), so one slow plugin
|
# update() freezes scrolling for the whole fetch. Scheduling happens
|
||||||
# HTTP fetch froze scrolling for the whole fetch. Scheduling still
|
# on the render thread (run_scheduled_updates); execution happens on
|
||||||
# happens on the render thread (run_scheduled_updates), but
|
# this single background worker. Per-plugin locks keep a plugin's
|
||||||
# execution moves to this single background worker. Per-plugin
|
# update() and display() mutually exclusive, including across the
|
||||||
# locks keep a plugin's update() and display() mutually exclusive —
|
# post-timeout window.
|
||||||
# today's implicit guarantee, now explicit (and, unlike today,
|
|
||||||
# also held across the post-timeout window).
|
|
||||||
# Kill switch: plugin_system.synchronous_updates: true restores the
|
# Kill switch: plugin_system.synchronous_updates: true restores the
|
||||||
# inline path.
|
# inline path.
|
||||||
self._update_queue: "queue.Queue[Optional[Tuple[str, float]]]" = queue.Queue()
|
self._update_queue: "queue.Queue[Optional[Tuple[str, float]]]" = queue.Queue()
|
||||||
@@ -661,9 +659,15 @@ class PluginManager:
|
|||||||
if not self.unload_plugin(plugin_id):
|
if not self.unload_plugin(plugin_id):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Re-discover to get updated manifest
|
# Re-read the manifest so an edit to it takes effect, from the
|
||||||
manifest_path = self.plugins_dir / plugin_id / "manifest.json"
|
# directory discovery found the plugin in: a directory's name need not
|
||||||
if manifest_path.exists():
|
# be the id its manifest declares.
|
||||||
|
with self._discovery_lock:
|
||||||
|
directories = dict(self.plugin_directories)
|
||||||
|
plugin_dir = self.plugin_loader.find_plugin_directory(
|
||||||
|
plugin_id, self.plugins_dir, directories)
|
||||||
|
manifest_path = plugin_dir / "manifest.json" if plugin_dir is not None else None
|
||||||
|
if manifest_path is not None and manifest_path.exists():
|
||||||
try:
|
try:
|
||||||
with open(manifest_path, 'r', encoding='utf-8') as f:
|
with open(manifest_path, 'r', encoding='utf-8') as f:
|
||||||
manifest = json.load(f)
|
manifest = json.load(f)
|
||||||
@@ -881,11 +885,12 @@ class PluginManager:
|
|||||||
updating, since a scheduler that propagates a plugin bug stops every
|
updating, since a scheduler that propagates a plugin bug stops every
|
||||||
other plugin too.
|
other plugin too.
|
||||||
|
|
||||||
The static result is cached per plugin_id after the first lookup to
|
The static result is cached per plugin_id after the first lookup, so
|
||||||
avoid calling config_manager.get_config() — which returns a full dict
|
the manifest/config resolution is not repeated on every scheduling
|
||||||
copy — on every tick of the 30-fps display loop. The cache is
|
tick of the display loop. A change to ``update_interval`` in
|
||||||
invalidated when a plugin is loaded or unloaded. The dynamic hook is
|
config.json therefore takes effect when the plugin is next loaded or
|
||||||
deliberately *not* cached: caching it would defeat its only purpose.
|
unloaded, which clears the cache. The dynamic hook is deliberately
|
||||||
|
*not* cached: caching it would defeat its only purpose.
|
||||||
"""
|
"""
|
||||||
dynamic = self._dynamic_update_interval(plugin_id, plugin_instance)
|
dynamic = self._dynamic_update_interval(plugin_id, plugin_instance)
|
||||||
if dynamic is not None:
|
if dynamic is not None:
|
||||||
|
|||||||
@@ -41,7 +41,6 @@ class PluginStateManager:
|
|||||||
self._state_transition_counts: Dict[str, int] = {}
|
self._state_transition_counts: Dict[str, int] = {}
|
||||||
self._error_info: Dict[str, Dict[str, Any]] = {}
|
self._error_info: Dict[str, Dict[str, Any]] = {}
|
||||||
self._last_update: Dict[str, datetime] = {}
|
self._last_update: Dict[str, datetime] = {}
|
||||||
self._last_display: Dict[str, datetime] = {}
|
|
||||||
|
|
||||||
def _record_transition(self, plugin_id: str) -> None:
|
def _record_transition(self, plugin_id: str) -> None:
|
||||||
"""Count a state transition. Callers must already hold ``_lock``."""
|
"""Count a state transition. Callers must already hold ``_lock``."""
|
||||||
@@ -182,10 +181,6 @@ class PluginStateManager:
|
|||||||
"""Get timestamp of last update() call."""
|
"""Get timestamp of last update() call."""
|
||||||
return self._last_update.get(plugin_id)
|
return self._last_update.get(plugin_id)
|
||||||
|
|
||||||
def get_last_display(self, plugin_id: str) -> Optional[datetime]:
|
|
||||||
"""Get timestamp of last display() call."""
|
|
||||||
return self._last_display.get(plugin_id)
|
|
||||||
|
|
||||||
def get_state_info(self, plugin_id: str) -> Dict[str, Any]:
|
def get_state_info(self, plugin_id: str) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Get comprehensive state information for a plugin.
|
Get comprehensive state information for a plugin.
|
||||||
@@ -212,7 +207,6 @@ class PluginStateManager:
|
|||||||
'is_error': self.is_error(plugin_id),
|
'is_error': self.is_error(plugin_id),
|
||||||
'can_execute': self.can_execute(plugin_id),
|
'can_execute': self.can_execute(plugin_id),
|
||||||
'last_update': self.get_last_update(plugin_id),
|
'last_update': self.get_last_update(plugin_id),
|
||||||
'last_display': self.get_last_display(plugin_id),
|
|
||||||
'error_info': self.get_error_info(plugin_id),
|
'error_info': self.get_error_info(plugin_id),
|
||||||
'state_history_count': self._state_transition_counts.get(plugin_id, 0)
|
'state_history_count': self._state_transition_counts.get(plugin_id, 0)
|
||||||
}
|
}
|
||||||
@@ -231,5 +225,4 @@ class PluginStateManager:
|
|||||||
self._state_transition_counts.pop(plugin_id, None)
|
self._state_transition_counts.pop(plugin_id, None)
|
||||||
self._error_info.pop(plugin_id, None)
|
self._error_info.pop(plugin_id, None)
|
||||||
self._last_update.pop(plugin_id, None)
|
self._last_update.pop(plugin_id, None)
|
||||||
self._last_display.pop(plugin_id, None)
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
"""
|
||||||
|
Repository URL helpers shared by the plugin store and saved repositories.
|
||||||
|
|
||||||
|
One definition of "the same repository URL", of how a GitHub URL maps to
|
||||||
|
``owner/repo``, and of the headers sent to the GitHub API.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Dict, Optional, Tuple
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
#: Hosts whose URLs name a GitHub repository. Matched against
|
||||||
|
#: ``urlparse(url).hostname``, never by substring: a substring test accepts
|
||||||
|
#: ``https://github.com.example.org/...`` as GitHub.
|
||||||
|
GITHUB_HOSTS = frozenset({'github.com', 'www.github.com'})
|
||||||
|
|
||||||
|
#: Sent on every request the store makes to GitHub.
|
||||||
|
USER_AGENT = 'LEDMatrix-Plugin-Manager/1.0'
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_repo_url(url: str) -> str:
|
||||||
|
"""``url`` without surrounding whitespace, trailing slashes or a trailing ``.git``.
|
||||||
|
|
||||||
|
Only a *trailing* ``.git`` is removed. The unanchored
|
||||||
|
``url.replace('.git', '')`` this replaces turned
|
||||||
|
``https://github.com/user/my.github.io`` into ``.../myhub.io``.
|
||||||
|
Case is preserved; compare with :func:`same_repo`.
|
||||||
|
"""
|
||||||
|
url = url.strip().rstrip('/')
|
||||||
|
if url.endswith('.git'):
|
||||||
|
url = url[:-4]
|
||||||
|
return url
|
||||||
|
|
||||||
|
|
||||||
|
def same_repo(url_a: str, url_b: str) -> bool:
|
||||||
|
"""Whether two URLs name the same repository.
|
||||||
|
|
||||||
|
GitHub owner and repository names are case-insensitive, so
|
||||||
|
``ChuckBuilds/LEDMatrix-Plugins`` and ``chuckbuilds/ledmatrix-plugins``
|
||||||
|
are one repository.
|
||||||
|
"""
|
||||||
|
return normalize_repo_url(url_a).lower() == normalize_repo_url(url_b).lower()
|
||||||
|
|
||||||
|
|
||||||
|
def github_owner_repo(url: str) -> Optional[Tuple[str, str]]:
|
||||||
|
"""``(owner, repo)`` for a github.com repository URL, else None.
|
||||||
|
|
||||||
|
The first two path segments, so a URL that points inside the repository
|
||||||
|
(``.../owner/repo/tree/main/plugins/x``) still names ``owner/repo``.
|
||||||
|
"""
|
||||||
|
parsed = urlparse(normalize_repo_url(url))
|
||||||
|
if parsed.hostname not in GITHUB_HOSTS:
|
||||||
|
return None
|
||||||
|
parts = [part for part in parsed.path.split('/') if part]
|
||||||
|
if len(parts) < 2:
|
||||||
|
return None
|
||||||
|
return parts[0], normalize_repo_url(parts[1])
|
||||||
|
|
||||||
|
|
||||||
|
def github_api_headers(token: Optional[str] = None) -> Dict[str, str]:
|
||||||
|
"""Headers for a GitHub REST API request, authenticated when ``token`` is set.
|
||||||
|
|
||||||
|
An authenticated request gets 5000 requests an hour instead of 60.
|
||||||
|
"""
|
||||||
|
headers = {
|
||||||
|
'Accept': 'application/vnd.github.v3+json',
|
||||||
|
'User-Agent': USER_AGENT,
|
||||||
|
}
|
||||||
|
if token:
|
||||||
|
headers['Authorization'] = f'token {token}'
|
||||||
|
return headers
|
||||||
@@ -33,7 +33,14 @@ class ResourceLimits:
|
|||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class ResourceMetrics:
|
class ResourceMetrics:
|
||||||
"""Resource usage metrics for a plugin."""
|
"""Resource usage metrics for a plugin.
|
||||||
|
|
||||||
|
``memory_mb`` is the largest growth in this *process's* resident memory
|
||||||
|
seen across a single monitored call -- a high-water mark, not current
|
||||||
|
usage, and not the plugin's own footprint (another thread allocating
|
||||||
|
during the call counts too). ``cpu_percent`` is the whole process's CPU
|
||||||
|
use since the previous sample.
|
||||||
|
"""
|
||||||
memory_mb: float = 0.0
|
memory_mb: float = 0.0
|
||||||
cpu_percent: float = 0.0
|
cpu_percent: float = 0.0
|
||||||
execution_time: float = 0.0
|
execution_time: float = 0.0
|
||||||
@@ -43,11 +50,6 @@ class ResourceMetrics:
|
|||||||
min_execution_time: float = float('inf')
|
min_execution_time: float = float('inf')
|
||||||
last_update_time: float = field(default_factory=time.time)
|
last_update_time: float = field(default_factory=time.time)
|
||||||
|
|
||||||
def update_average_execution_time(self):
|
|
||||||
"""Update average execution time."""
|
|
||||||
if self.call_count > 0:
|
|
||||||
self.total_execution_time = self.total_execution_time / self.call_count
|
|
||||||
|
|
||||||
|
|
||||||
#: How often a plugin's metrics are written to the cache, in seconds.
|
#: How often a plugin's metrics are written to the cache, in seconds.
|
||||||
#:
|
#:
|
||||||
@@ -287,6 +289,7 @@ class PluginResourceMonitor:
|
|||||||
|
|
||||||
# Calculate execution time
|
# Calculate execution time
|
||||||
execution_time = time.time() - start_time
|
execution_time = time.time() - start_time
|
||||||
|
memory_growth_mb = 0.0
|
||||||
|
|
||||||
# Update metrics
|
# Update metrics
|
||||||
with self._lock:
|
with self._lock:
|
||||||
@@ -302,17 +305,17 @@ class PluginResourceMonitor:
|
|||||||
|
|
||||||
# Update memory and CPU if monitoring enabled
|
# Update memory and CPU if monitoring enabled
|
||||||
if self.enable_monitoring:
|
if self.enable_monitoring:
|
||||||
end_memory = self._get_process_memory_mb()
|
memory_growth_mb = self._get_process_memory_mb() - start_memory
|
||||||
metrics.memory_mb = max(metrics.memory_mb, end_memory - start_memory)
|
metrics.memory_mb = max(metrics.memory_mb, memory_growth_mb)
|
||||||
# CPU is harder to measure per-call, so we track it separately
|
# CPU is harder to measure per-call, so we track it separately
|
||||||
metrics.cpu_percent = self._get_process_cpu_percent()
|
metrics.cpu_percent = self._get_process_cpu_percent()
|
||||||
|
|
||||||
# Persist metrics, at most once per interval per plugin.
|
# Persist metrics, at most once per interval per plugin.
|
||||||
self._persist_metrics(plugin_id, metrics)
|
self._persist_metrics(plugin_id, metrics)
|
||||||
|
|
||||||
# Check limits
|
|
||||||
if limits:
|
if limits:
|
||||||
self._check_limits(plugin_id, metrics, limits, execution_time)
|
self._check_limits(plugin_id, metrics, limits, execution_time,
|
||||||
|
memory_growth_mb)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
@@ -327,8 +330,16 @@ class PluginResourceMonitor:
|
|||||||
raise
|
raise
|
||||||
|
|
||||||
def _check_limits(self, plugin_id: str, metrics: ResourceMetrics,
|
def _check_limits(self, plugin_id: str, metrics: ResourceMetrics,
|
||||||
limits: ResourceLimits, execution_time: float) -> None:
|
limits: ResourceLimits, execution_time: float,
|
||||||
"""Check if plugin has exceeded resource limits."""
|
memory_growth_mb: float) -> None:
|
||||||
|
"""Raise ResourceLimitExceeded if this call went over a limit.
|
||||||
|
|
||||||
|
Execution time and memory growth are this call's own; CPU is the
|
||||||
|
latest process sample. Judging memory by the stored high-water mark
|
||||||
|
(``metrics.memory_mb``) instead would fail every call after the first
|
||||||
|
expensive one, so the health tracker's circuit breaker would reopen
|
||||||
|
on every recovery probe and the plugin would never update again.
|
||||||
|
"""
|
||||||
warnings = []
|
warnings = []
|
||||||
errors = []
|
errors = []
|
||||||
|
|
||||||
@@ -343,13 +354,13 @@ class PluginResourceMonitor:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Check memory
|
# Check memory
|
||||||
if limits.max_memory_mb and metrics.memory_mb > limits.max_memory_mb:
|
if limits.max_memory_mb and memory_growth_mb > limits.max_memory_mb:
|
||||||
errors.append(
|
errors.append(
|
||||||
f"Memory usage {metrics.memory_mb:.2f}MB exceeds limit {limits.max_memory_mb:.2f}MB"
|
f"Memory growth {memory_growth_mb:.2f}MB exceeds limit {limits.max_memory_mb:.2f}MB"
|
||||||
)
|
)
|
||||||
elif limits.max_memory_mb and metrics.memory_mb > limits.max_memory_mb * limits.warning_threshold:
|
elif limits.max_memory_mb and memory_growth_mb > limits.max_memory_mb * limits.warning_threshold:
|
||||||
warnings.append(
|
warnings.append(
|
||||||
f"Memory usage {metrics.memory_mb:.2f}MB approaching limit {limits.max_memory_mb:.2f}MB"
|
f"Memory growth {memory_growth_mb:.2f}MB approaching limit {limits.max_memory_mb:.2f}MB"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check CPU
|
# Check CPU
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import os
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import List, Dict, Optional
|
from typing import List, Dict, Optional
|
||||||
|
|
||||||
|
from src.plugin_system.repo_urls import normalize_repo_url
|
||||||
|
|
||||||
|
|
||||||
class SavedRepositoriesManager:
|
class SavedRepositoriesManager:
|
||||||
"""Manages saved GitHub repository URLs."""
|
"""Manages saved GitHub repository URLs."""
|
||||||
@@ -71,18 +73,6 @@ class SavedRepositoriesManager:
|
|||||||
pass
|
pass
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _clean_url(repo_url: str) -> str:
|
|
||||||
"""Normalize a repo URL: strip whitespace, trailing slashes, and a
|
|
||||||
trailing ``.git`` suffix ONLY. (The old ``.replace('.git', '')``
|
|
||||||
was an unanchored substring replace that mangled URLs merely
|
|
||||||
containing ``.git``, e.g. ``https://github.com/user/my.github.io``.)
|
|
||||||
"""
|
|
||||||
repo_url = repo_url.strip().rstrip('/')
|
|
||||||
if repo_url.endswith('.git'):
|
|
||||||
repo_url = repo_url[:-4]
|
|
||||||
return repo_url
|
|
||||||
|
|
||||||
def get_all(self) -> List[Dict[str, str]]:
|
def get_all(self) -> List[Dict[str, str]]:
|
||||||
"""Get all saved repositories."""
|
"""Get all saved repositories."""
|
||||||
return self.repositories.copy()
|
return self.repositories.copy()
|
||||||
@@ -98,7 +88,7 @@ class SavedRepositoriesManager:
|
|||||||
Returns:
|
Returns:
|
||||||
True if added successfully
|
True if added successfully
|
||||||
"""
|
"""
|
||||||
repo_url = self._clean_url(repo_url)
|
repo_url = normalize_repo_url(repo_url)
|
||||||
|
|
||||||
# Check if already exists
|
# Check if already exists
|
||||||
for repo in self.repositories:
|
for repo in self.repositories:
|
||||||
@@ -138,7 +128,7 @@ class SavedRepositoriesManager:
|
|||||||
Returns:
|
Returns:
|
||||||
True if removed successfully
|
True if removed successfully
|
||||||
"""
|
"""
|
||||||
repo_url = self._clean_url(repo_url)
|
repo_url = normalize_repo_url(repo_url)
|
||||||
|
|
||||||
previous = self.repositories
|
previous = self.repositories
|
||||||
remaining = [r for r in previous if r.get('url') != repo_url]
|
remaining = [r for r in previous if r.get('url') != repo_url]
|
||||||
@@ -156,7 +146,7 @@ class SavedRepositoriesManager:
|
|||||||
|
|
||||||
def has(self, repo_url: str) -> bool:
|
def has(self, repo_url: str) -> bool:
|
||||||
"""Check if a repository is already saved."""
|
"""Check if a repository is already saved."""
|
||||||
repo_url = self._clean_url(repo_url)
|
repo_url = normalize_repo_url(repo_url)
|
||||||
return any(r.get('url') == repo_url for r in self.repositories)
|
return any(r.get('url') == repo_url for r in self.repositories)
|
||||||
|
|
||||||
def get_registry_repositories(self) -> List[Dict[str, str]]:
|
def get_registry_repositories(self) -> List[Dict[str, str]]:
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import jsonschema
|
|||||||
from jsonschema import Draft7Validator, ValidationError
|
from jsonschema import Draft7Validator, ValidationError
|
||||||
|
|
||||||
from src.core_config_keys import CORE_CONFIG_KEYS
|
from src.core_config_keys import CORE_CONFIG_KEYS
|
||||||
|
from src.element_style import expand_style_elements
|
||||||
|
|
||||||
|
|
||||||
def _renders_as_object(prop: Dict[str, Any]) -> bool:
|
def _renders_as_object(prop: Dict[str, Any]) -> bool:
|
||||||
@@ -452,11 +453,7 @@ class SchemaManager:
|
|||||||
# full per-element style blocks (font/size/color + layout
|
# full per-element style blocks (font/size/color + layout
|
||||||
# offsets) the web-UI config form renders. No-op for schemas
|
# offsets) the web-UI config form renders. No-op for schemas
|
||||||
# without the declaration; never raises.
|
# without the declaration; never raises.
|
||||||
try:
|
|
||||||
from src.element_style import expand_style_elements
|
|
||||||
schema = expand_style_elements(schema)
|
schema = expand_style_elements(schema)
|
||||||
except ImportError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Cache the schema
|
# Cache the schema
|
||||||
self._schema_cache[plugin_id] = schema
|
self._schema_cache[plugin_id] = schema
|
||||||
@@ -643,19 +640,11 @@ class SchemaManager:
|
|||||||
[name for name in CORE_PLUGIN_PROPERTIES if name not in declared]
|
[name for name in CORE_PLUGIN_PROPERTIES if name not in declared]
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create validator with enhanced schema
|
# iter_errors reports every violation, including one ``required``
|
||||||
|
# error per missing field at every depth.
|
||||||
validator = Draft7Validator(enhanced_schema)
|
validator = Draft7Validator(enhanced_schema)
|
||||||
|
|
||||||
# Collect all validation errors
|
|
||||||
for error in validator.iter_errors(config):
|
for error in validator.iter_errors(config):
|
||||||
error_msg = self._format_validation_error(error, plugin_id)
|
errors.append(self._format_validation_error(error, plugin_id))
|
||||||
errors.append(error_msg)
|
|
||||||
|
|
||||||
# Check required fields
|
|
||||||
required_fields = enhanced_schema.get('required', [])
|
|
||||||
for field in required_fields:
|
|
||||||
if field not in config:
|
|
||||||
errors.append(f"Missing required field: '{field}'")
|
|
||||||
|
|
||||||
if errors:
|
if errors:
|
||||||
return False, errors
|
return False, errors
|
||||||
@@ -687,7 +676,15 @@ class SchemaManager:
|
|||||||
field_path = f"'{path}'" if path else "root"
|
field_path = f"'{path}'" if path else "root"
|
||||||
|
|
||||||
if error.validator == 'required':
|
if error.validator == 'required':
|
||||||
missing = error.validator_value
|
# validator_value is the schema's whole ``required`` list; the
|
||||||
|
# error itself is about one field, which jsonschema names only in
|
||||||
|
# its message ("'api_key' is a required property").
|
||||||
|
missing = next(
|
||||||
|
(name for name in error.validator_value
|
||||||
|
if error.message.startswith(f"{name!r} ")),
|
||||||
|
None)
|
||||||
|
if missing is None:
|
||||||
|
return f"Field {field_path}: {error.message}"
|
||||||
return f"Field {field_path}: Missing required property '{missing}'"
|
return f"Field {field_path}: Missing required property '{missing}'"
|
||||||
elif error.validator == 'type':
|
elif error.validator == 'type':
|
||||||
expected = error.validator_value
|
expected = error.validator_value
|
||||||
|
|||||||
@@ -34,7 +34,9 @@ class PluginState:
|
|||||||
version: Optional[str] = None
|
version: Optional[str] = None
|
||||||
installed_at: Optional[datetime] = None
|
installed_at: Optional[datetime] = None
|
||||||
last_updated: Optional[datetime] = None
|
last_updated: Optional[datetime] = None
|
||||||
config_version: int = 1 # For detecting state corruption
|
# Bumped on every update_plugin_state(). Nothing reads it; it stays so
|
||||||
|
# plugin_state.json keeps the shape older releases load with cls(**data).
|
||||||
|
config_version: int = 1
|
||||||
metadata: Dict[str, Any] = None
|
metadata: Dict[str, Any] = None
|
||||||
|
|
||||||
def __post_init__(self):
|
def __post_init__(self):
|
||||||
@@ -100,6 +102,8 @@ class PluginStateManager:
|
|||||||
|
|
||||||
# State storage
|
# State storage
|
||||||
self._states: Dict[str, PluginState] = {}
|
self._states: Dict[str, PluginState] = {}
|
||||||
|
# The file's top-level "version", written back as read. Nothing
|
||||||
|
# checks it yet; it is there for a future format change to branch on.
|
||||||
self._state_version = 1
|
self._state_version = 1
|
||||||
|
|
||||||
# Threading
|
# Threading
|
||||||
@@ -193,7 +197,6 @@ class PluginStateManager:
|
|||||||
current_state.metadata = {}
|
current_state.metadata = {}
|
||||||
current_state.metadata.update(updates['metadata'])
|
current_state.metadata.update(updates['metadata'])
|
||||||
|
|
||||||
# Increment config version
|
|
||||||
current_state.config_version += 1
|
current_state.config_version += 1
|
||||||
|
|
||||||
# Store updated state
|
# Store updated state
|
||||||
|
|||||||
+214
-333
@@ -5,6 +5,7 @@ Handles plugin discovery, installation, updates, and uninstallation
|
|||||||
from both the official registry and custom GitHub repositories.
|
from both the official registry and custom GitHub repositories.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import errno
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import json
|
import json
|
||||||
@@ -22,21 +23,19 @@ from pathlib import Path
|
|||||||
from typing import List, Dict, Optional, Any, Tuple, Set
|
from typing import List, Dict, Optional, Any, Tuple, Set
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from urllib.parse import urlparse
|
from jsonschema import Draft7Validator, ValidationError
|
||||||
|
|
||||||
from src.common.permission_utils import sudo_remove_directory, install_requirements_file
|
from src.common.permission_utils import (
|
||||||
from src.plugin_system.plugin_loader import (
|
ensure_directory_permissions, get_plugin_dir_mode, install_requirements_file,
|
||||||
requirements_has_real_deps, requirements_are_satisfied, find_trusted_subdir
|
sudo_remove_directory,
|
||||||
)
|
)
|
||||||
|
from src.plugin_system.plugin_loader import contained_plugin_dir, requirements_to_install
|
||||||
from src.plugin_system.plugin_dirs import (
|
from src.plugin_system.plugin_dirs import (
|
||||||
BACKUP_MARKER, PluginDirectoryIndex, resolve_plugin_dir, store_search_dirs,
|
BACKUP_MARKER, PluginDirectoryIndex, resolve_plugin_dir, store_search_dirs,
|
||||||
)
|
)
|
||||||
|
from src.plugin_system.repo_urls import (
|
||||||
try:
|
USER_AGENT, github_api_headers, github_owner_repo, normalize_repo_url, same_repo,
|
||||||
from jsonschema import Draft7Validator, ValidationError
|
)
|
||||||
JSONSCHEMA_AVAILABLE = True
|
|
||||||
except ImportError:
|
|
||||||
JSONSCHEMA_AVAILABLE = False
|
|
||||||
|
|
||||||
|
|
||||||
class PluginStoreManager:
|
class PluginStoreManager:
|
||||||
@@ -93,11 +92,11 @@ class PluginStoreManager:
|
|||||||
self.registry_cache_timeout = 900
|
self.registry_cache_timeout = 900
|
||||||
self.commit_info_cache = {} # Cache for latest commit info: {key: (timestamp, data)}
|
self.commit_info_cache = {} # Cache for latest commit info: {key: (timestamp, data)}
|
||||||
# 30 minutes for commit/manifest caches. Plugin Store users browse
|
# 30 minutes for commit/manifest caches. Plugin Store users browse
|
||||||
# the catalog via /plugins/store/list which fetches commit info and
|
# the catalog via /plugins/store/list, which fetches commit info per
|
||||||
# manifest data per plugin. 5-min TTLs meant every fresh browse on
|
# plugin; with a 5-minute TTL nearly every browse on a Pi4 paid for
|
||||||
# a Pi4 paid for ~3 HTTP requests x N plugins (30-60s serial). 30
|
# an HTTP request per plugin again. 30 minutes keeps the cache warm
|
||||||
# minutes keeps the cache warm across a realistic session while
|
# across a realistic session while still picking up upstream updates
|
||||||
# still picking up upstream updates within a reasonable window.
|
# within a reasonable window.
|
||||||
self.commit_cache_timeout = 1800
|
self.commit_cache_timeout = 1800
|
||||||
self.manifest_cache = {} # Cache for GitHub manifest fetches: {key: (timestamp, data)}
|
self.manifest_cache = {} # Cache for GitHub manifest fetches: {key: (timestamp, data)}
|
||||||
self.manifest_cache_timeout = 1800
|
self.manifest_cache_timeout = 1800
|
||||||
@@ -127,9 +126,9 @@ class PluginStoreManager:
|
|||||||
# where ``signature`` is a tuple of (head_mtime, resolved_ref_mtime,
|
# where ``signature`` is a tuple of (head_mtime, resolved_ref_mtime,
|
||||||
# head_contents) so a fast-forward update to the current branch
|
# head_contents) so a fast-forward update to the current branch
|
||||||
# (which touches .git/refs/heads/<branch> but NOT .git/HEAD) still
|
# (which touches .git/refs/heads/<branch> but NOT .git/HEAD) still
|
||||||
# invalidates the cache. Before this cache, every
|
# invalidates the cache. Without it every /plugins/installed request
|
||||||
# /plugins/installed request fired 4 git subprocesses per plugin,
|
# runs a git subprocess per plugin, which adds up on a Pi4 with a
|
||||||
# which pegged the CPU on a Pi4 with a dozen plugins. The cached
|
# dozen plugins. The cached
|
||||||
# ``data`` dict is the same shape returned by ``_get_local_git_info``
|
# ``data`` dict is the same shape returned by ``_get_local_git_info``
|
||||||
# itself (sha / short_sha / branch / optional remote_url, date_iso,
|
# itself (sha / short_sha / branch / optional remote_url, date_iso,
|
||||||
# date) — all string-keyed strings.
|
# date) — all string-keyed strings.
|
||||||
@@ -368,13 +367,7 @@ class PluginStoreManager:
|
|||||||
# Validate token by making a lightweight API call to /user endpoint
|
# Validate token by making a lightweight API call to /user endpoint
|
||||||
try:
|
try:
|
||||||
api_url = "https://api.github.com/user"
|
api_url = "https://api.github.com/user"
|
||||||
headers = {
|
response = requests.get(api_url, headers=github_api_headers(token), timeout=5)
|
||||||
'Accept': 'application/vnd.github.v3+json',
|
|
||||||
'User-Agent': 'LEDMatrix-Plugin-Manager/1.0',
|
|
||||||
'Authorization': f'token {token}'
|
|
||||||
}
|
|
||||||
|
|
||||||
response = requests.get(api_url, headers=headers, timeout=5)
|
|
||||||
|
|
||||||
if response.status_code == 200:
|
if response.status_code == 200:
|
||||||
# Token is valid
|
# Token is valid
|
||||||
@@ -502,9 +495,6 @@ class PluginStoreManager:
|
|||||||
Returns:
|
Returns:
|
||||||
List of validation error messages (empty if valid or schema unavailable)
|
List of validation error messages (empty if valid or schema unavailable)
|
||||||
"""
|
"""
|
||||||
if not JSONSCHEMA_AVAILABLE:
|
|
||||||
return []
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Load manifest schema
|
# Load manifest schema
|
||||||
schema_path = Path(__file__).parent.parent.parent / "schema" / "manifest_schema.json"
|
schema_path = Path(__file__).parent.parent.parent / "schema" / "manifest_schema.json"
|
||||||
@@ -535,48 +525,46 @@ class PluginStoreManager:
|
|||||||
self.logger.debug(f"Error validating manifest schema for {plugin_id}: {e}")
|
self.logger.debug(f"Error validating manifest schema for {plugin_id}: {e}")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
def _get_github_repo_info(self, repo_url: str) -> Dict[str, Any]:
|
_EMPTY_REPO_INFO: Dict[str, Any] = {
|
||||||
"""Fetch GitHub repository information (stars, etc.)"""
|
'stars': 0,
|
||||||
# Extract owner/repo from URL
|
'forks': 0,
|
||||||
try:
|
'open_issues': 0,
|
||||||
# Handle different URL formats
|
'updated_at_iso': '',
|
||||||
_parsed_url = urlparse(repo_url)
|
'last_commit_iso': '',
|
||||||
if _parsed_url.hostname in ('github.com', 'www.github.com'):
|
'last_commit_date': '',
|
||||||
parts = repo_url.strip('/').split('/')
|
'language': '',
|
||||||
if len(parts) >= 2:
|
'license': '',
|
||||||
owner = parts[-2]
|
'default_branch': 'main',
|
||||||
repo = parts[-1]
|
}
|
||||||
if repo.endswith('.git'):
|
|
||||||
repo = repo[:-4]
|
|
||||||
|
|
||||||
|
def _get_github_repo_info(self, repo_url: str) -> Dict[str, Any]:
|
||||||
|
"""GitHub metadata for a repository (stars, default branch, last push).
|
||||||
|
|
||||||
|
Returns zeroed defaults (``_EMPTY_REPO_INFO``) for a non-GitHub URL or
|
||||||
|
when GitHub cannot be asked and nothing is cached.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
owner_repo = github_owner_repo(repo_url)
|
||||||
|
if owner_repo is None:
|
||||||
|
return dict(self._EMPTY_REPO_INFO)
|
||||||
|
owner, repo = owner_repo
|
||||||
cache_key = f"{owner}/{repo}"
|
cache_key = f"{owner}/{repo}"
|
||||||
|
|
||||||
# Check cache first
|
|
||||||
if cache_key in self.github_cache:
|
if cache_key in self.github_cache:
|
||||||
cached_time, cached_data = self.github_cache[cache_key]
|
cached_time, cached_data = self.github_cache[cache_key]
|
||||||
if time.time() - cached_time < self.cache_timeout:
|
if time.time() - cached_time < self.cache_timeout:
|
||||||
return cached_data
|
return cached_data
|
||||||
|
|
||||||
# Fetch from GitHub API
|
|
||||||
api_url = f"https://api.github.com/repos/{owner}/{repo}"
|
api_url = f"https://api.github.com/repos/{owner}/{repo}"
|
||||||
headers = {
|
|
||||||
'Accept': 'application/vnd.github.v3+json',
|
|
||||||
'User-Agent': 'LEDMatrix-Plugin-Manager/1.0'
|
|
||||||
}
|
|
||||||
|
|
||||||
# Add authentication if token is available
|
|
||||||
if self.github_token:
|
|
||||||
headers['Authorization'] = f'token {self.github_token}'
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = requests.get(api_url, headers=headers, timeout=10)
|
response = requests.get(
|
||||||
|
api_url, headers=github_api_headers(self.github_token), timeout=10)
|
||||||
except requests.RequestException as req_err:
|
except requests.RequestException as req_err:
|
||||||
# Network error: prefer a stale cache hit over an
|
# Network error: prefer a stale cache hit over an empty
|
||||||
# empty default so the UI keeps working on a flaky
|
# default so the UI keeps working on a flaky Pi WiFi link.
|
||||||
# Pi WiFi link. Bump the cached entry's timestamp
|
# Bump the cached entry's timestamp into a short backoff
|
||||||
# into a short backoff window so subsequent
|
# window so subsequent requests serve the stale payload
|
||||||
# requests serve the stale payload cheaply instead
|
# cheaply instead of re-hitting the network on every request.
|
||||||
# of re-hitting the network on every request.
|
|
||||||
if cache_key in self.github_cache:
|
if cache_key in self.github_cache:
|
||||||
_, stale = self.github_cache[cache_key]
|
_, stale = self.github_cache[cache_key]
|
||||||
self._record_cache_backoff(self.github_cache, cache_key, self.cache_timeout, stale)
|
self._record_cache_backoff(self.github_cache, cache_key, self.cache_timeout, stale)
|
||||||
@@ -601,18 +589,13 @@ class PluginStoreManager:
|
|||||||
'license': data.get('license', {}).get('name', '') if data.get('license') else '',
|
'license': data.get('license', {}).get('name', '') if data.get('license') else '',
|
||||||
'default_branch': data.get('default_branch', 'main')
|
'default_branch': data.get('default_branch', 'main')
|
||||||
}
|
}
|
||||||
|
|
||||||
# Cache the result
|
|
||||||
self.github_cache[cache_key] = (time.time(), repo_info)
|
self.github_cache[cache_key] = (time.time(), repo_info)
|
||||||
return repo_info
|
return repo_info
|
||||||
elif response.status_code == 403:
|
|
||||||
# Rate limit or authentication issue. If we have a
|
if response.status_code == 403:
|
||||||
# previously-cached value, serve it rather than
|
# Rate limit or authentication issue. A stale star count is
|
||||||
# returning empty defaults — a stale star count is
|
# better than a reset to zero, and the backoff bump stops the
|
||||||
# better than a reset to zero. Apply the same
|
# store hammering the API while rate-limited.
|
||||||
# failure-backoff bump as the network-error path
|
|
||||||
# so we don't hammer the API with repeat requests
|
|
||||||
# while rate-limited.
|
|
||||||
if cache_key in self.github_cache:
|
if cache_key in self.github_cache:
|
||||||
_, stale = self.github_cache[cache_key]
|
_, stale = self.github_cache[cache_key]
|
||||||
self._record_cache_backoff(self.github_cache, cache_key, self.cache_timeout, stale)
|
self._record_cache_backoff(self.github_cache, cache_key, self.cache_timeout, stale)
|
||||||
@@ -638,31 +621,11 @@ class PluginStoreManager:
|
|||||||
self._record_cache_backoff(self.github_cache, cache_key, self.cache_timeout, stale)
|
self._record_cache_backoff(self.github_cache, cache_key, self.cache_timeout, stale)
|
||||||
return stale
|
return stale
|
||||||
|
|
||||||
return {
|
return dict(self._EMPTY_REPO_INFO)
|
||||||
'stars': 0,
|
|
||||||
'forks': 0,
|
|
||||||
'open_issues': 0,
|
|
||||||
'updated_at_iso': '',
|
|
||||||
'last_commit_iso': '',
|
|
||||||
'last_commit_date': '',
|
|
||||||
'language': '',
|
|
||||||
'license': '',
|
|
||||||
'default_branch': 'main'
|
|
||||||
}
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.error(f"Error fetching GitHub repo info for {repo_url}: {e}")
|
self.logger.error(f"Error fetching GitHub repo info for {repo_url}: {e}")
|
||||||
return {
|
return dict(self._EMPTY_REPO_INFO)
|
||||||
'stars': 0,
|
|
||||||
'forks': 0,
|
|
||||||
'open_issues': 0,
|
|
||||||
'updated_at_iso': '',
|
|
||||||
'last_commit_iso': '',
|
|
||||||
'last_commit_date': '',
|
|
||||||
'language': '',
|
|
||||||
'license': '',
|
|
||||||
'default_branch': 'main'
|
|
||||||
}
|
|
||||||
|
|
||||||
def _http_get_with_retries(self, url: str, *, timeout: int = 10, stream: bool = False, headers: Dict[str, str] = None, max_retries: int = 3, backoff_sec: float = 0.75):
|
def _http_get_with_retries(self, url: str, *, timeout: int = 10, stream: bool = False, headers: Dict[str, str] = None, max_retries: int = 3, backoff_sec: float = 0.75):
|
||||||
"""
|
"""
|
||||||
@@ -697,27 +660,17 @@ class PluginStoreManager:
|
|||||||
Registry dict with plugins list, or None if not found/invalid
|
Registry dict with plugins list, or None if not found/invalid
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Clean up URL
|
repo_url = normalize_repo_url(repo_url)
|
||||||
repo_url = repo_url.rstrip('/').replace('.git', '')
|
|
||||||
|
|
||||||
# Try to find plugins.json in common locations
|
# plugins.json or registry.json at the root of main, then master.
|
||||||
# First try root directory
|
|
||||||
registry_urls = []
|
registry_urls = []
|
||||||
|
owner_repo = github_owner_repo(repo_url)
|
||||||
# Extract owner/repo from URL
|
if owner_repo is not None:
|
||||||
_parsed_repo_url = urlparse(repo_url)
|
owner, repo = owner_repo
|
||||||
if _parsed_repo_url.hostname in ('github.com', 'www.github.com'):
|
|
||||||
parts = repo_url.split('/')
|
|
||||||
if len(parts) >= 2:
|
|
||||||
owner = parts[-2]
|
|
||||||
repo = parts[-1]
|
|
||||||
|
|
||||||
# Try common branch names
|
|
||||||
for branch in ['main', 'master']:
|
for branch in ['main', 'master']:
|
||||||
registry_urls.append(f"https://raw.githubusercontent.com/{owner}/{repo}/{branch}/plugins.json")
|
registry_urls.append(f"https://raw.githubusercontent.com/{owner}/{repo}/{branch}/plugins.json")
|
||||||
registry_urls.append(f"https://raw.githubusercontent.com/{owner}/{repo}/{branch}/registry.json")
|
registry_urls.append(f"https://raw.githubusercontent.com/{owner}/{repo}/{branch}/registry.json")
|
||||||
|
|
||||||
# Try each URL
|
|
||||||
for url in registry_urls:
|
for url in registry_urls:
|
||||||
try:
|
try:
|
||||||
response = self._http_get_with_retries(url, timeout=10)
|
response = self._http_get_with_retries(url, timeout=10)
|
||||||
@@ -815,15 +768,20 @@ class PluginStoreManager:
|
|||||||
"""
|
"""
|
||||||
Search for plugins in the registry with enhanced metadata.
|
Search for plugins in the registry with enhanced metadata.
|
||||||
|
|
||||||
GitHub is now treated as the source of truth for live metadata like
|
GitHub supplies live metadata such as stars and last commit
|
||||||
stars and last commit timestamps. The registry provides descriptive
|
timestamps; the registry supplies descriptive information (name,
|
||||||
information (name, description, repo URL, etc.).
|
description, repo URL, etc.).
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
query: Search query string (searches name, description, id)
|
query: Search query string (searches name, description, id, author)
|
||||||
category: Filter by category (e.g., 'sports', 'weather', 'time')
|
category: Filter by category (e.g., 'sports', 'weather', 'time')
|
||||||
tags: Filter by tags (matches any tag in list)
|
tags: Filter by tags (matches any tag in list)
|
||||||
fetch_commit_info: If True (default), fetch commit metadata from GitHub.
|
fetch_commit_info: If True (default), fetch commit metadata from GitHub.
|
||||||
|
include_saved_repos: If True (default), also search the
|
||||||
|
registry-style repositories the user saved.
|
||||||
|
saved_repositories_manager: The SavedRepositoriesManager holding
|
||||||
|
those repositories; without it only the official registry is
|
||||||
|
searched.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of matching plugin metadata enriched with GitHub information
|
List of matching plugin metadata enriched with GitHub information
|
||||||
@@ -877,11 +835,11 @@ class PluginStoreManager:
|
|||||||
def _enrich(plugin: Dict) -> Dict:
|
def _enrich(plugin: Dict) -> Dict:
|
||||||
"""Enrich a single plugin with GitHub metadata.
|
"""Enrich a single plugin with GitHub metadata.
|
||||||
|
|
||||||
Called concurrently from a ThreadPoolExecutor. Each underlying
|
Called concurrently from a ThreadPoolExecutor. Both HTTP helpers
|
||||||
HTTP helper (``_get_github_repo_info`` / ``_get_latest_commit_info``
|
(``_get_github_repo_info`` / ``_get_latest_commit_info``) are
|
||||||
/ ``_fetch_manifest_from_github``) is thread-safe — they use
|
thread-safe -- they use ``requests`` and write their own cache
|
||||||
``requests`` and write their own cache keys on Python dicts,
|
keys on Python dicts, which is atomic under the GIL for
|
||||||
which is atomic under the GIL for single-key assignments.
|
single-key assignments.
|
||||||
"""
|
"""
|
||||||
enhanced_plugin = plugin.copy()
|
enhanced_plugin = plugin.copy()
|
||||||
repo_url = plugin.get('repo', '')
|
repo_url = plugin.get('repo', '')
|
||||||
@@ -912,24 +870,21 @@ class PluginStoreManager:
|
|||||||
# The registry's plugins.json already carries ``description``
|
# The registry's plugins.json already carries ``description``
|
||||||
# (it is generated from each plugin's manifest by
|
# (it is generated from each plugin's manifest by
|
||||||
# ``update_registry.py``), and ``last_updated`` is filled in
|
# ``update_registry.py``), and ``last_updated`` is filled in
|
||||||
# from the commit info above. An earlier implementation
|
# from the commit info above. Fetching manifest.json per
|
||||||
# fetched manifest.json per plugin anyway, which meant one
|
# plugin costs one extra HTTPS round trip per result; on a Pi4
|
||||||
# extra HTTPS round trip per result; on a Pi4 with a flaky
|
# with a flaky WiFi link the tail retries of that one call
|
||||||
# WiFi link the tail retries of that one extra call
|
|
||||||
# (_http_get_with_retries does 3 attempts with exponential
|
# (_http_get_with_retries does 3 attempts with exponential
|
||||||
# backoff) dominated wall time even after parallelization.
|
# backoff) dominate wall time even with the thread pool.
|
||||||
|
|
||||||
return enhanced_plugin
|
return enhanced_plugin
|
||||||
|
|
||||||
# Fan out the per-plugin GitHub enrichment. The previous
|
# Fan out the per-plugin GitHub enrichment. Serially, a Pi4 with ~15
|
||||||
# implementation did this serially, which on a Pi4 with ~15 plugins
|
# plugins and a cold cache makes 30+ HTTP requests in strict sequence
|
||||||
# and a fresh cache meant 30+ HTTP requests in strict sequence (the
|
# (the "connecting to display" hang users reported). With a thread
|
||||||
# "connecting to display" hang reported by users). With a thread
|
|
||||||
# pool, latency is dominated by the slowest request rather than
|
# pool, latency is dominated by the slowest request rather than
|
||||||
# their sum. Workers capped at 10 to stay well under the
|
# their sum. Workers capped at 10 to stay well under the
|
||||||
# unauthenticated GitHub rate limit burst and avoid overwhelming a
|
# unauthenticated GitHub rate limit burst and avoid overwhelming a
|
||||||
# Pi's WiFi link. For a small number of plugins the pool is
|
# Pi's WiFi link.
|
||||||
# essentially free.
|
|
||||||
if not filtered:
|
if not filtered:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
@@ -959,21 +914,11 @@ class PluginStoreManager:
|
|||||||
Manifest data or None if not found
|
Manifest data or None if not found
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Convert repo URL to raw content URL
|
owner_repo = github_owner_repo(repo_url)
|
||||||
# https://github.com/user/repo -> https://raw.githubusercontent.com/user/repo/branch/manifest.json
|
if owner_repo is None:
|
||||||
_parsed_manifest_url = urlparse(repo_url)
|
return None
|
||||||
if _parsed_manifest_url.hostname in ('github.com', 'www.github.com'):
|
owner, repo = owner_repo
|
||||||
# Handle different URL formats
|
|
||||||
repo_url = repo_url.rstrip('/')
|
|
||||||
if repo_url.endswith('.git'):
|
|
||||||
repo_url = repo_url[:-4]
|
|
||||||
|
|
||||||
parts = repo_url.split('/')
|
|
||||||
if len(parts) >= 2:
|
|
||||||
owner = parts[-2]
|
|
||||||
repo = parts[-1]
|
|
||||||
|
|
||||||
# Check cache first
|
|
||||||
cache_key = f"{owner}/{repo}:{branch}:{manifest_path}"
|
cache_key = f"{owner}/{repo}:{branch}:{manifest_path}"
|
||||||
if not force_refresh and cache_key in self.manifest_cache:
|
if not force_refresh and cache_key in self.manifest_cache:
|
||||||
cached_time, cached_data = self.manifest_cache[cache_key]
|
cached_time, cached_data = self.manifest_cache[cache_key]
|
||||||
@@ -981,15 +926,12 @@ class PluginStoreManager:
|
|||||||
return cached_data
|
return cached_data
|
||||||
|
|
||||||
raw_url = f"https://raw.githubusercontent.com/{owner}/{repo}/{branch}/{manifest_path}"
|
raw_url = f"https://raw.githubusercontent.com/{owner}/{repo}/{branch}/{manifest_path}"
|
||||||
|
|
||||||
response = self._http_get_with_retries(raw_url, timeout=10)
|
response = self._http_get_with_retries(raw_url, timeout=10)
|
||||||
if response.status_code == 200:
|
if response.status_code == 200:
|
||||||
result = response.json()
|
result = response.json()
|
||||||
self.manifest_cache[cache_key] = (time.time(), result)
|
self.manifest_cache[cache_key] = (time.time(), result)
|
||||||
return result
|
return result
|
||||||
elif response.status_code == 404:
|
if response.status_code == 404 and branch != "main":
|
||||||
# Try main branch instead
|
|
||||||
if branch != "main":
|
|
||||||
raw_url = f"https://raw.githubusercontent.com/{owner}/{repo}/main/{manifest_path}"
|
raw_url = f"https://raw.githubusercontent.com/{owner}/{repo}/main/{manifest_path}"
|
||||||
response = self._http_get_with_retries(raw_url, timeout=10)
|
response = self._http_get_with_retries(raw_url, timeout=10)
|
||||||
if response.status_code == 200:
|
if response.status_code == 200:
|
||||||
@@ -997,7 +939,8 @@ class PluginStoreManager:
|
|||||||
self.manifest_cache[cache_key] = (time.time(), result)
|
self.manifest_cache[cache_key] = (time.time(), result)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
# Cache negative result
|
# Cache the miss too, so a plugin without a manifest at this path
|
||||||
|
# is not re-fetched on every browse.
|
||||||
self.manifest_cache[cache_key] = (time.time(), None)
|
self.manifest_cache[cache_key] = (time.time(), None)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.debug(f"Could not fetch manifest from GitHub for {repo_url}: {e}")
|
self.logger.debug(f"Could not fetch manifest from GitHub for {repo_url}: {e}")
|
||||||
@@ -1007,21 +950,11 @@ class PluginStoreManager:
|
|||||||
def _get_latest_commit_info(self, repo_url: str, branch: str = "main", force_refresh: bool = False) -> Optional[Dict[str, Any]]:
|
def _get_latest_commit_info(self, repo_url: str, branch: str = "main", force_refresh: bool = False) -> Optional[Dict[str, Any]]:
|
||||||
"""Return metadata about the latest commit on the given branch."""
|
"""Return metadata about the latest commit on the given branch."""
|
||||||
try:
|
try:
|
||||||
if 'github.com' not in repo_url:
|
owner_repo = github_owner_repo(repo_url)
|
||||||
|
if owner_repo is None:
|
||||||
return None
|
return None
|
||||||
|
owner, repo = owner_repo
|
||||||
|
|
||||||
repo_url = repo_url.rstrip('/')
|
|
||||||
if repo_url.endswith('.git'):
|
|
||||||
repo_url = repo_url[:-4]
|
|
||||||
|
|
||||||
parts = repo_url.split('/')
|
|
||||||
if len(parts) < 2:
|
|
||||||
return None
|
|
||||||
|
|
||||||
owner = parts[-2]
|
|
||||||
repo = parts[-1]
|
|
||||||
|
|
||||||
# Check cache first
|
|
||||||
cache_key = f"{owner}/{repo}:{branch}"
|
cache_key = f"{owner}/{repo}:{branch}"
|
||||||
if not force_refresh and cache_key in self.commit_info_cache:
|
if not force_refresh and cache_key in self.commit_info_cache:
|
||||||
cached_time, cached_data = self.commit_info_cache[cache_key]
|
cached_time, cached_data = self.commit_info_cache[cache_key]
|
||||||
@@ -1029,14 +962,7 @@ class PluginStoreManager:
|
|||||||
return cached_data
|
return cached_data
|
||||||
|
|
||||||
branches_to_try = self._distinct_sequence([branch, 'main', 'master'])
|
branches_to_try = self._distinct_sequence([branch, 'main', 'master'])
|
||||||
|
headers = github_api_headers(self.github_token)
|
||||||
headers = {
|
|
||||||
'Accept': 'application/vnd.github.v3+json',
|
|
||||||
'User-Agent': 'LEDMatrix-Plugin-Manager/1.0'
|
|
||||||
}
|
|
||||||
|
|
||||||
if self.github_token:
|
|
||||||
headers['Authorization'] = f'token {self.github_token}'
|
|
||||||
|
|
||||||
last_error = None
|
last_error = None
|
||||||
for branch_name in branches_to_try:
|
for branch_name in branches_to_try:
|
||||||
@@ -1254,46 +1180,57 @@ class PluginStoreManager:
|
|||||||
|
|
||||||
backup_path = plugin_path.with_name(
|
backup_path = plugin_path.with_name(
|
||||||
f"{plugin_path.name}{BACKUP_MARKER}preinstall")
|
f"{plugin_path.name}{BACKUP_MARKER}preinstall")
|
||||||
if backup_path.exists() and not self._safe_remove_directory(backup_path):
|
problem = self._set_aside(plugin_path, backup_path)
|
||||||
# Can't stage a safety net. Better to attempt the install than
|
if problem:
|
||||||
# to refuse outright, which is what callers got before this
|
# Can't stage a safety net. Attempting the install anyway is
|
||||||
# existed.
|
# what callers got before the net existed; refusing would be
|
||||||
|
# a new failure mode for a direct install.
|
||||||
self.logger.warning(
|
self.logger.warning(
|
||||||
"Could not clear stale pre-install backup for %s at %s; "
|
"Installing %s without a rollback net: %s", plugin_id, problem)
|
||||||
"installing without a rollback net", plugin_id, backup_path)
|
|
||||||
return self._install_plugin_impl(plugin_id, branch)
|
|
||||||
|
|
||||||
try:
|
|
||||||
plugin_path.rename(backup_path)
|
|
||||||
except OSError as e:
|
|
||||||
self.logger.warning(
|
|
||||||
"Could not set aside existing install of %s (%s); "
|
|
||||||
"installing without a rollback net", plugin_id, e)
|
|
||||||
return self._install_plugin_impl(plugin_id, branch)
|
return self._install_plugin_impl(plugin_id, branch)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
installed = self._install_plugin_impl(plugin_id, branch)
|
installed = self._install_plugin_impl(plugin_id, branch)
|
||||||
except Exception:
|
except Exception:
|
||||||
self._restore_preinstall_backup(plugin_id, plugin_path, backup_path)
|
self._restore_backup(plugin_id, plugin_path, backup_path, "Install")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
if installed:
|
if installed:
|
||||||
if not self._safe_remove_directory(backup_path):
|
self._discard_backup(plugin_id, backup_path, "install")
|
||||||
self.logger.warning(
|
|
||||||
"Install of %s succeeded but the previous copy at %s "
|
|
||||||
"could not be removed; it will be cleared on the next "
|
|
||||||
"install", plugin_id, backup_path)
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
self._restore_preinstall_backup(plugin_id, plugin_path, backup_path)
|
self._restore_backup(plugin_id, plugin_path, backup_path, "Install")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def _restore_preinstall_backup(
|
def _set_aside(self, plugin_path: Path, backup_path: Path) -> Optional[str]:
|
||||||
self, plugin_id: str, plugin_path: Path, backup_path: Path
|
"""Rename an installed plugin to ``backup_path`` so a failed
|
||||||
|
(re)install can put it back.
|
||||||
|
|
||||||
|
A stale backup left by a crash is cleared first, since it would block
|
||||||
|
the rename. Returns None on success, otherwise why it could not.
|
||||||
|
"""
|
||||||
|
if backup_path.exists() and not self._safe_remove_directory(backup_path):
|
||||||
|
return f"could not clear stale backup at {backup_path}"
|
||||||
|
try:
|
||||||
|
plugin_path.rename(backup_path)
|
||||||
|
except OSError as e:
|
||||||
|
return f"could not set aside {plugin_path}: {e}"
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _discard_backup(self, plugin_id: str, backup_path: Path, action: str) -> None:
|
||||||
|
"""Remove the set-aside copy after a successful (re)install."""
|
||||||
|
if not self._safe_remove_directory(backup_path):
|
||||||
|
self.logger.warning(
|
||||||
|
"%s of %s succeeded but the previous copy at %s could not be "
|
||||||
|
"removed; it will be cleared on the next %s",
|
||||||
|
action.capitalize(), plugin_id, backup_path, action)
|
||||||
|
|
||||||
|
def _restore_backup(
|
||||||
|
self, plugin_id: str, plugin_path: Path, backup_path: Path, action: str
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Put the previous install back after a failed (re)install."""
|
"""Put the set-aside copy back after a failed (re)install."""
|
||||||
self.logger.error(
|
self.logger.error(
|
||||||
"Install of %s failed; restoring the previous version", plugin_id)
|
"%s of %s failed; restoring the previous version", action, plugin_id)
|
||||||
try:
|
try:
|
||||||
if plugin_path.exists():
|
if plugin_path.exists():
|
||||||
# Partial download debris from the failed install.
|
# Partial download debris from the failed install.
|
||||||
@@ -1375,8 +1312,7 @@ class PluginStoreManager:
|
|||||||
return False
|
return False
|
||||||
else:
|
else:
|
||||||
branch_used = self._install_via_git(repo_url, plugin_path, branch_candidates)
|
branch_used = self._install_via_git(repo_url, plugin_path, branch_candidates)
|
||||||
if branch_used is None and not plugin_path.exists():
|
if branch_used is None:
|
||||||
# Git failed entirely; fall back to zip download
|
|
||||||
self.logger.info("Git not available or clone failed, attempting archive download...")
|
self.logger.info("Git not available or clone failed, attempting archive download...")
|
||||||
for candidate in branch_candidates:
|
for candidate in branch_candidates:
|
||||||
download_url = f"{repo_url}/archive/refs/heads/{candidate}.zip"
|
download_url = f"{repo_url}/archive/refs/heads/{candidate}.zip"
|
||||||
@@ -1384,7 +1320,7 @@ class PluginStoreManager:
|
|||||||
branch_used = candidate
|
branch_used = candidate
|
||||||
break
|
break
|
||||||
|
|
||||||
if branch_used is None and not plugin_path.exists():
|
if branch_used is None:
|
||||||
self.logger.error(f"Failed to install plugin {plugin_id} via git or archive download")
|
self.logger.error(f"Failed to install plugin {plugin_id} via git or archive download")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -1523,8 +1459,7 @@ class PluginStoreManager:
|
|||||||
branch_info = f" (branch: {branch})" if branch else ""
|
branch_info = f" (branch: {branch})" if branch else ""
|
||||||
self.logger.info(f"Installing plugin from custom URL: {repo_url}{branch_info}" + (f" (subpath: {plugin_path})" if plugin_path else ""))
|
self.logger.info(f"Installing plugin from custom URL: {repo_url}{branch_info}" + (f" (subpath: {plugin_path})" if plugin_path else ""))
|
||||||
|
|
||||||
# Clean up URL (remove .git suffix if present)
|
repo_url = normalize_repo_url(repo_url)
|
||||||
repo_url = repo_url.rstrip('/').replace('.git', '')
|
|
||||||
|
|
||||||
temp_dir = None
|
temp_dir = None
|
||||||
try:
|
try:
|
||||||
@@ -1549,13 +1484,11 @@ class PluginStoreManager:
|
|||||||
'error': f'Failed to download or extract plugin from monorepo subdirectory: {plugin_path}'
|
'error': f'Failed to download or extract plugin from monorepo subdirectory: {plugin_path}'
|
||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
# Try git clone for direct plugin repos
|
|
||||||
branch_used = self._install_via_git(repo_url, temp_dir, branch_candidates)
|
branch_used = self._install_via_git(repo_url, temp_dir, branch_candidates)
|
||||||
if branch_used:
|
if branch_used is not None:
|
||||||
self.logger.info(f"Cloned via git (branch: {branch_used})")
|
self.logger.info(f"Cloned via git (branch: {branch_used})")
|
||||||
else:
|
else:
|
||||||
# Git failed; try downloading as zip
|
self.logger.info("Git not available or clone failed, attempting archive download...")
|
||||||
branch_used = None
|
|
||||||
for candidate in branch_candidates:
|
for candidate in branch_candidates:
|
||||||
download_url = f"{repo_url}/archive/refs/heads/{candidate}.zip"
|
download_url = f"{repo_url}/archive/refs/heads/{candidate}.zip"
|
||||||
if self._install_via_download(download_url, temp_dir):
|
if self._install_via_download(download_url, temp_dir):
|
||||||
@@ -1634,8 +1567,10 @@ class PluginStoreManager:
|
|||||||
json.dump(manifest, f, indent=2)
|
json.dump(manifest, f, indent=2)
|
||||||
self.logger.info(f"Added missing entry_point field to {plugin_id} manifest (defaulted to manager.py)")
|
self.logger.info(f"Added missing entry_point field to {plugin_id} manifest (defaulted to manager.py)")
|
||||||
|
|
||||||
# Move to plugins directory - use manifest ID as source of truth
|
# The directory is named for the caller's plugin_id when one was
|
||||||
# This ensures directory name always matches manifest ID
|
# given (update_plugin passes the installed id), else for the
|
||||||
|
# manifest's id -- so it can differ from the manifest id, which
|
||||||
|
# discovery tolerates by reading the manifest.
|
||||||
final_path = self.plugins_dir / plugin_id
|
final_path = self.plugins_dir / plugin_id
|
||||||
if final_path.exists():
|
if final_path.exists():
|
||||||
self.logger.warning(f"Plugin {plugin_id} already exists, removing existing copy")
|
self.logger.warning(f"Plugin {plugin_id} already exists, removing existing copy")
|
||||||
@@ -1648,8 +1583,6 @@ class PluginStoreManager:
|
|||||||
shutil.move(str(temp_dir), str(final_path))
|
shutil.move(str(temp_dir), str(final_path))
|
||||||
temp_dir = None # Prevent cleanup since we moved it
|
temp_dir = None # Prevent cleanup since we moved it
|
||||||
|
|
||||||
# Note: plugin_id here is already from manifest (line 749), so directory name matches manifest ID
|
|
||||||
|
|
||||||
# Install dependencies
|
# Install dependencies
|
||||||
self._install_dependencies(final_path)
|
self._install_dependencies(final_path)
|
||||||
|
|
||||||
@@ -1701,7 +1634,6 @@ class PluginStoreManager:
|
|||||||
Class name if found, None otherwise
|
Class name if found, None otherwise
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
import re
|
|
||||||
with open(manager_file, 'r', encoding='utf-8') as f:
|
with open(manager_file, 'r', encoding='utf-8') as f:
|
||||||
content = f.read()
|
content = f.read()
|
||||||
|
|
||||||
@@ -1723,7 +1655,18 @@ class PluginStoreManager:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
def _install_via_git(self, repo_url: str, target_path: Path, branches: Optional[List[str]] = None) -> Optional[str]:
|
def _install_via_git(self, repo_url: str, target_path: Path, branches: Optional[List[str]] = None) -> Optional[str]:
|
||||||
"""Clone a repository into ``target_path``. Returns the branch name on success."""
|
"""Clone a repository into ``target_path``.
|
||||||
|
|
||||||
|
Tries each of ``branches`` (default ``main``, ``master``), then the
|
||||||
|
repository's own default branch, so a repository whose only branch
|
||||||
|
is e.g. ``develop`` still installs.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The branch that was cloned, or None when every clone failed and
|
||||||
|
``target_path`` has been removed. After a default-branch clone
|
||||||
|
this is the branch the clone checked out (``'HEAD'`` if the
|
||||||
|
remote's HEAD is detached), never None.
|
||||||
|
"""
|
||||||
branches_to_try = self._distinct_sequence(branches or [])
|
branches_to_try = self._distinct_sequence(branches or [])
|
||||||
if not branches_to_try:
|
if not branches_to_try:
|
||||||
branches_to_try = ['main', 'master']
|
branches_to_try = ['main', 'master']
|
||||||
@@ -1758,7 +1701,7 @@ class PluginStoreManager:
|
|||||||
timeout=60
|
timeout=60
|
||||||
)
|
)
|
||||||
self.logger.debug(f"Successfully cloned {repo_url} (git default branch) to {target_path}")
|
self.logger.debug(f"Successfully cloned {repo_url} (git default branch) to {target_path}")
|
||||||
return None # Unknown branch name, git default used
|
return self._checked_out_branch(target_path)
|
||||||
except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError) as e:
|
except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError) as e:
|
||||||
last_error = e
|
last_error = e
|
||||||
if target_path.exists():
|
if target_path.exists():
|
||||||
@@ -1767,6 +1710,19 @@ class PluginStoreManager:
|
|||||||
self.logger.error(f"Git clone failed for all attempted branches: {last_error}")
|
self.logger.error(f"Git clone failed for all attempted branches: {last_error}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _checked_out_branch(checkout: Path) -> str:
|
||||||
|
"""The branch a fresh clone has checked out, read from ``.git/HEAD``.
|
||||||
|
|
||||||
|
``'HEAD'`` when HEAD is detached or unreadable.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
head = (checkout / '.git' / 'HEAD').read_text(encoding='utf-8').strip()
|
||||||
|
except OSError:
|
||||||
|
return 'HEAD'
|
||||||
|
prefix = 'ref: refs/heads/'
|
||||||
|
return head[len(prefix):] if head.startswith(prefix) else 'HEAD'
|
||||||
|
|
||||||
def _install_from_monorepo(self, download_url: str, plugin_subpath: str, target_path: Path) -> bool:
|
def _install_from_monorepo(self, download_url: str, plugin_subpath: str, target_path: Path) -> bool:
|
||||||
"""
|
"""
|
||||||
Install a plugin from a monorepo by downloading only the target subdirectory.
|
Install a plugin from a monorepo by downloading only the target subdirectory.
|
||||||
@@ -1815,14 +1771,6 @@ class PluginStoreManager:
|
|||||||
pass
|
pass
|
||||||
return None, None
|
return None, None
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _normalize_repo_url(url: str) -> str:
|
|
||||||
"""Normalize a GitHub repo URL for comparison (strip trailing / and .git)."""
|
|
||||||
url = url.rstrip('/')
|
|
||||||
if url.endswith('.git'):
|
|
||||||
url = url[:-4]
|
|
||||||
return url.lower()
|
|
||||||
|
|
||||||
def _install_from_monorepo_api(self, repo_url: str, branch: str, plugin_subpath: str, target_path: Path) -> bool:
|
def _install_from_monorepo_api(self, repo_url: str, branch: str, plugin_subpath: str, target_path: Path) -> bool:
|
||||||
"""
|
"""
|
||||||
Install a plugin subdirectory using the GitHub Git Trees API.
|
Install a plugin subdirectory using the GitHub Git Trees API.
|
||||||
@@ -1841,25 +1789,15 @@ class PluginStoreManager:
|
|||||||
True if successful, False to trigger ZIP fallback
|
True if successful, False to trigger ZIP fallback
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Parse owner/repo from URL
|
owner_repo = github_owner_repo(repo_url)
|
||||||
clean_url = repo_url.rstrip('/')
|
if owner_repo is None:
|
||||||
if clean_url.endswith('.git'):
|
|
||||||
clean_url = clean_url[:-4]
|
|
||||||
parts = clean_url.split('/')
|
|
||||||
if len(parts) < 2:
|
|
||||||
return False
|
return False
|
||||||
owner, repo = parts[-2], parts[-1]
|
owner, repo = owner_repo
|
||||||
|
|
||||||
# Step 1: Get the recursive tree listing (1 API call)
|
# Step 1: Get the recursive tree listing (1 API call)
|
||||||
api_url = f"https://api.github.com/repos/{owner}/{repo}/git/trees/{branch}?recursive=true"
|
api_url = f"https://api.github.com/repos/{owner}/{repo}/git/trees/{branch}?recursive=true"
|
||||||
headers = {
|
tree_response = self._http_get_with_retries(
|
||||||
'Accept': 'application/vnd.github.v3+json',
|
api_url, timeout=15, headers=github_api_headers(self.github_token))
|
||||||
'User-Agent': 'LEDMatrix-Plugin-Manager/1.0'
|
|
||||||
}
|
|
||||||
if self.github_token:
|
|
||||||
headers['Authorization'] = f'token {self.github_token}'
|
|
||||||
|
|
||||||
tree_response = self._http_get_with_retries(api_url, timeout=15, headers=headers)
|
|
||||||
if tree_response.status_code != 200:
|
if tree_response.status_code != 200:
|
||||||
self.logger.debug(f"Trees API returned {tree_response.status_code} for {owner}/{repo}")
|
self.logger.debug(f"Trees API returned {tree_response.status_code} for {owner}/{repo}")
|
||||||
return False
|
return False
|
||||||
@@ -1892,10 +1830,6 @@ class PluginStoreManager:
|
|||||||
self.logger.info(f"Downloading {len(file_entries)} files for {plugin_subpath} via API")
|
self.logger.info(f"Downloading {len(file_entries)} files for {plugin_subpath} via API")
|
||||||
|
|
||||||
# Step 3: Create target directory and download each file
|
# Step 3: Create target directory and download each file
|
||||||
from src.common.permission_utils import (
|
|
||||||
ensure_directory_permissions,
|
|
||||||
get_plugin_dir_mode
|
|
||||||
)
|
|
||||||
ensure_directory_permissions(target_path.parent, get_plugin_dir_mode())
|
ensure_directory_permissions(target_path.parent, get_plugin_dir_mode())
|
||||||
target_path.mkdir(parents=True, exist_ok=True)
|
target_path.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
@@ -1991,10 +1925,6 @@ class PluginStoreManager:
|
|||||||
|
|
||||||
source_plugin_dir = temp_extract / root_dir / plugin_subpath
|
source_plugin_dir = temp_extract / root_dir / plugin_subpath
|
||||||
|
|
||||||
from src.common.permission_utils import (
|
|
||||||
ensure_directory_permissions,
|
|
||||||
get_plugin_dir_mode
|
|
||||||
)
|
|
||||||
ensure_directory_permissions(target_path.parent, get_plugin_dir_mode())
|
ensure_directory_permissions(target_path.parent, get_plugin_dir_mode())
|
||||||
# Ensure target doesn't exist to prevent shutil.move nesting
|
# Ensure target doesn't exist to prevent shutil.move nesting
|
||||||
if target_path.exists():
|
if target_path.exists():
|
||||||
@@ -2028,7 +1958,7 @@ class PluginStoreManager:
|
|||||||
try:
|
try:
|
||||||
self.logger.info(f"Downloading from: {download_url}")
|
self.logger.info(f"Downloading from: {download_url}")
|
||||||
# Allow redirects (GitHub archive URLs redirect to codeload.github.com)
|
# Allow redirects (GitHub archive URLs redirect to codeload.github.com)
|
||||||
response = self._http_get_with_retries(download_url, timeout=60, stream=True, headers={'User-Agent': 'LEDMatrix-Plugin-Manager/1.0'})
|
response = self._http_get_with_retries(download_url, timeout=60, stream=True, headers={'User-Agent': USER_AGENT})
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
|
|
||||||
# Download to temporary file
|
# Download to temporary file
|
||||||
@@ -2065,10 +1995,6 @@ class PluginStoreManager:
|
|||||||
# Move contents from root_dir to target
|
# Move contents from root_dir to target
|
||||||
source_dir = temp_extract / root_dir
|
source_dir = temp_extract / root_dir
|
||||||
if source_dir.exists():
|
if source_dir.exists():
|
||||||
from src.common.permission_utils import (
|
|
||||||
ensure_directory_permissions,
|
|
||||||
get_plugin_dir_mode
|
|
||||||
)
|
|
||||||
ensure_directory_permissions(target_path.parent, get_plugin_dir_mode())
|
ensure_directory_permissions(target_path.parent, get_plugin_dir_mode())
|
||||||
shutil.move(str(source_dir), str(target_path))
|
shutil.move(str(source_dir), str(target_path))
|
||||||
else:
|
else:
|
||||||
@@ -2094,42 +2020,23 @@ class PluginStoreManager:
|
|||||||
"""
|
"""
|
||||||
Install Python dependencies from requirements.txt.
|
Install Python dependencies from requirements.txt.
|
||||||
|
|
||||||
|
``plugin_path`` is ultimately derived from a plugin-supplied manifest
|
||||||
|
``id``, so it is only used after contained_plugin_dir() has rebuilt it
|
||||||
|
from a listing of ``self.plugins_dir``.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
plugin_path: Path to plugin directory
|
plugin_path: Path to plugin directory
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True if successful or no requirements file
|
True if successful or no requirements file
|
||||||
"""
|
"""
|
||||||
# Reconstruct the plugin path from the trusted self.plugins_dir base +
|
safe_plugin_dir = contained_plugin_dir(plugin_path, self.plugins_dir)
|
||||||
# an entry actually enumerated from it, rather than trusting
|
if safe_plugin_dir is None:
|
||||||
# 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")
|
self.logger.error("Plugin directory not found inside plugins dir for dependency install")
|
||||||
return False
|
return False
|
||||||
safe_plugin_path = Path(os.path.join(plugins_dir_real, matched_name))
|
|
||||||
|
|
||||||
requirements_file = safe_plugin_path / "requirements.txt"
|
requirements_file = requirements_to_install(safe_plugin_dir, self.logger, plugin_path.name)
|
||||||
|
if requirements_file is None:
|
||||||
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
|
return True
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -2141,7 +2048,7 @@ class PluginStoreManager:
|
|||||||
# ledmatrix.service, so pip reports success while the package
|
# ledmatrix.service, so pip reports success while the package
|
||||||
# stays invisible to the running plugin (e.g. missing `astral`
|
# stays invisible to the running plugin (e.g. missing `astral`
|
||||||
# for the weather plugin even though "install" succeeded).
|
# for the weather plugin even though "install" succeeded).
|
||||||
result = install_requirements_file(requirements_file, timeout=300)
|
result = install_requirements_file(Path(requirements_file), timeout=300)
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
self.logger.error(
|
self.logger.error(
|
||||||
f"Error installing dependencies for {plugin_path.name}: {result.stderr}"
|
f"Error installing dependencies for {plugin_path.name}: {result.stderr}"
|
||||||
@@ -2153,10 +2060,10 @@ class PluginStoreManager:
|
|||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
self.logger.error("Dependency installation timed out")
|
self.logger.error("Dependency installation timed out")
|
||||||
return False
|
return False
|
||||||
except (BrokenPipeError, OSError) as e:
|
except OSError as e:
|
||||||
# Handle broken pipe errors (errno 32) which can occur during pip downloads
|
# A broken pipe (EPIPE) happens when pip's output pipe closes
|
||||||
# Often caused by network interruptions or output buffer issues
|
# mid-download, usually a network interruption.
|
||||||
if isinstance(e, OSError) and e.errno == 32:
|
if e.errno == errno.EPIPE:
|
||||||
self.logger.error(
|
self.logger.error(
|
||||||
f"Broken pipe error during dependency installation for {plugin_path.name}. "
|
f"Broken pipe error during dependency installation for {plugin_path.name}. "
|
||||||
f"This usually indicates a network interruption or pip output buffer issue. "
|
f"This usually indicates a network interruption or pip output buffer issue. "
|
||||||
@@ -2166,7 +2073,6 @@ class PluginStoreManager:
|
|||||||
self.logger.error(f"OS error during dependency installation: {e}")
|
self.logger.error(f"OS error during dependency installation: {e}")
|
||||||
return False
|
return False
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Catch any other unexpected errors
|
|
||||||
self.logger.error(f"Unexpected error installing dependencies for {plugin_path.name}: {e}", exc_info=True)
|
self.logger.error(f"Unexpected error installing dependencies for {plugin_path.name}: {e}", exc_info=True)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -2241,9 +2147,9 @@ class PluginStoreManager:
|
|||||||
|
|
||||||
Results are cached keyed on a signature that includes HEAD
|
Results are cached keyed on a signature that includes HEAD
|
||||||
contents plus the mtime of HEAD AND the resolved ref (or
|
contents plus the mtime of HEAD AND the resolved ref (or
|
||||||
packed-refs). Repeated calls skip the four ``git`` subprocesses
|
packed-refs). Repeated calls skip the ``git log`` subprocess when
|
||||||
when nothing has changed, and a ``git pull`` that fast-forwards
|
nothing has changed, and a ``git pull`` that fast-forwards the
|
||||||
the branch correctly invalidates the cache.
|
branch correctly invalidates the cache.
|
||||||
"""
|
"""
|
||||||
git_dir = plugin_path / '.git'
|
git_dir = plugin_path / '.git'
|
||||||
if not git_dir.exists():
|
if not git_dir.exists():
|
||||||
@@ -2412,12 +2318,11 @@ class PluginStoreManager:
|
|||||||
|
|
||||||
No ``ledmatrix-`` prefix and no case folding here, unlike the loader:
|
No ``ledmatrix-`` prefix and no case folding here, unlike the loader:
|
||||||
a store operation may delete what this returns, so it only accepts a
|
a store operation may delete what this returns, so it only accepts a
|
||||||
directory that names the id exactly or declares it. Note that this
|
directory that names the id exactly or declares it. So a registry id
|
||||||
leaves registry ids like `stocks` unresolved when the installed
|
such as `stocks` does not resolve to an installed `ledmatrix-stocks/`
|
||||||
plugin is `ledmatrix-stocks/` declaring `ledmatrix-stocks` (the
|
declaring `ledmatrix-stocks` (the monorepo's leaderboard, music,
|
||||||
monorepo's leaderboard, music, stocks and weather); passing
|
stocks and weather); callers pass the installed id, and
|
||||||
``prefix=True`` would resolve them, but update_plugin()'s reinstall
|
update_plugin() maps it back to the registry id itself.
|
||||||
path has not been checked against that yet.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
plugin_id: Plugin identifier
|
plugin_id: Plugin identifier
|
||||||
@@ -2545,11 +2450,9 @@ class PluginStoreManager:
|
|||||||
|
|
||||||
The old install is renamed aside (not deleted) until the new install
|
The old install is renamed aside (not deleted) until the new install
|
||||||
succeeds, then removed; on ANY install failure the old directory is
|
succeeds, then removed; on ANY install failure the old directory is
|
||||||
restored. This is the difference between a failed update and a
|
restored. Deleting first turns a failed download into a destroyed
|
||||||
destroyed plugin: the previous delete-then-install flow permanently
|
plugin: during the monorepo migration a Pi with broken DNS lost every
|
||||||
removed plugins whenever the download failed mid-update (seen in the
|
old-remote plugin that way, with none able to be re-downloaded.
|
||||||
field during the monorepo migration on a Pi with broken DNS — every
|
|
||||||
old-remote plugin was deleted and none could be re-downloaded).
|
|
||||||
|
|
||||||
The aside name embeds BACKUP_MARKER ('.standalone-backup-') so every
|
The aside name embeds BACKUP_MARKER ('.standalone-backup-') so every
|
||||||
plugin directory lookup (src/plugin_system/plugin_dirs.py) ignores it
|
plugin directory lookup (src/plugin_system/plugin_dirs.py) ignores it
|
||||||
@@ -2564,18 +2467,11 @@ class PluginStoreManager:
|
|||||||
with self._get_reinstall_lock(plugin_id):
|
with self._get_reinstall_lock(plugin_id):
|
||||||
backup_path = plugin_path.with_name(
|
backup_path = plugin_path.with_name(
|
||||||
f"{plugin_path.name}{BACKUP_MARKER}migrating")
|
f"{plugin_path.name}{BACKUP_MARKER}migrating")
|
||||||
# A stale aside from a previous crash would block the rename
|
problem = self._set_aside(plugin_path, backup_path)
|
||||||
if backup_path.exists():
|
if problem:
|
||||||
if not self._safe_remove_directory(backup_path):
|
|
||||||
self.logger.error(
|
self.logger.error(
|
||||||
f"Could not clear stale backup for {plugin_id} at "
|
"Not updating %s: %s; the installed version is left in place",
|
||||||
f"{backup_path}; leaving old install in place")
|
plugin_id, problem)
|
||||||
return False
|
|
||||||
try:
|
|
||||||
plugin_path.rename(backup_path)
|
|
||||||
except OSError as e:
|
|
||||||
self.logger.error(
|
|
||||||
f"Could not set aside old plugin directory for {plugin_id}: {e}")
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -2585,27 +2481,11 @@ class PluginStoreManager:
|
|||||||
installed = False
|
installed = False
|
||||||
|
|
||||||
if installed:
|
if installed:
|
||||||
if not self._safe_remove_directory(backup_path):
|
self._discard_backup(plugin_id, backup_path, "update")
|
||||||
self.logger.warning(
|
|
||||||
f"Update of {plugin_id} succeeded but the old backup "
|
|
||||||
f"at {backup_path} could not be removed; it will be "
|
|
||||||
f"cleared on the next update")
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# Install failed (bad network, registry error...) — put the old
|
# Bad network, registry error...: the user keeps a working plugin.
|
||||||
# version back so the user still has a working plugin.
|
self._restore_backup(plugin_id, plugin_path, backup_path, "Reinstall")
|
||||||
self.logger.error(
|
|
||||||
f"Reinstall of {plugin_id} failed; restoring previous version")
|
|
||||||
try:
|
|
||||||
if plugin_path.exists():
|
|
||||||
# partial download debris from the failed install
|
|
||||||
self._safe_remove_directory(plugin_path)
|
|
||||||
backup_path.rename(plugin_path)
|
|
||||||
self.logger.info(f"Restored previous install of {plugin_id}")
|
|
||||||
except OSError as e:
|
|
||||||
self.logger.error(
|
|
||||||
f"CRITICAL: could not restore {plugin_id} from {backup_path}: {e}. "
|
|
||||||
f"The previous install is preserved there — rename it back manually.")
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def update_plugin(self, plugin_id: str) -> bool:
|
def update_plugin(self, plugin_id: str) -> bool:
|
||||||
@@ -2665,7 +2545,7 @@ class PluginStoreManager:
|
|||||||
# while the registry now points to the monorepo. Detect this and reinstall.
|
# while the registry now points to the monorepo. Detect this and reinstall.
|
||||||
registry_repo = plugin_info_remote.get('repo', '')
|
registry_repo = plugin_info_remote.get('repo', '')
|
||||||
local_remote = git_info.get('remote_url', '')
|
local_remote = git_info.get('remote_url', '')
|
||||||
if local_remote and registry_repo and self._normalize_repo_url(local_remote) != self._normalize_repo_url(registry_repo):
|
if local_remote and registry_repo and not same_repo(local_remote, registry_repo):
|
||||||
self.logger.info(
|
self.logger.info(
|
||||||
f"Plugin {resolved_id} git remote ({local_remote}) differs from registry ({registry_repo}). "
|
f"Plugin {resolved_id} git remote ({local_remote}) differs from registry ({registry_repo}). "
|
||||||
f"Reinstalling from registry to migrate to new source."
|
f"Reinstalling from registry to migrate to new source."
|
||||||
@@ -2814,7 +2694,6 @@ class PluginStoreManager:
|
|||||||
# If status check times out, assume there might be changes and proceed
|
# If status check times out, assume there might be changes and proceed
|
||||||
self.logger.warning(f"Git status check timed out for {plugin_id}, proceeding with update")
|
self.logger.warning(f"Git status check timed out for {plugin_id}, proceeding with update")
|
||||||
has_changes = True
|
has_changes = True
|
||||||
status_result = type('obj', (object,), {'stdout': '', 'stderr': 'Status check timed out'})()
|
|
||||||
|
|
||||||
stash_info = ""
|
stash_info = ""
|
||||||
# Whether the pull can be undone without destroying work.
|
# Whether the pull can be undone without destroying work.
|
||||||
@@ -2898,7 +2777,7 @@ class PluginStoreManager:
|
|||||||
|
|
||||||
except subprocess.CalledProcessError as git_error:
|
except subprocess.CalledProcessError as git_error:
|
||||||
error_output = git_error.stderr or git_error.stdout or "Unknown error"
|
error_output = git_error.stderr or git_error.stdout or "Unknown error"
|
||||||
cmd_str = ' '.join(git_error.cmd) if hasattr(git_error, 'cmd') else 'unknown'
|
cmd_str = ' '.join(git_error.cmd)
|
||||||
self.logger.error(f"Git update failed for {plugin_id}")
|
self.logger.error(f"Git update failed for {plugin_id}")
|
||||||
self.logger.error(f"Command: {cmd_str}")
|
self.logger.error(f"Command: {cmd_str}")
|
||||||
self.logger.error(f"Return code: {git_error.returncode}")
|
self.logger.error(f"Return code: {git_error.returncode}")
|
||||||
@@ -2914,7 +2793,7 @@ class PluginStoreManager:
|
|||||||
self.logger.error(f"Authentication failed for {plugin_id}. Check git credentials or repository permissions.")
|
self.logger.error(f"Authentication failed for {plugin_id}. Check git credentials or repository permissions.")
|
||||||
elif "not found" in error_lower or "does not exist" in error_lower:
|
elif "not found" in error_lower or "does not exist" in error_lower:
|
||||||
self.logger.error(f"Remote branch or repository not found for {plugin_id}. Check repository URL and branch name.")
|
self.logger.error(f"Remote branch or repository not found for {plugin_id}. Check repository URL and branch name.")
|
||||||
elif "merge conflict" in error_lower or "conflict" in error_lower:
|
elif "conflict" in error_lower:
|
||||||
self.logger.error(f"Merge conflict detected for {plugin_id}. Resolve conflicts manually or reinstall plugin.")
|
self.logger.error(f"Merge conflict detected for {plugin_id}. Resolve conflicts manually or reinstall plugin.")
|
||||||
|
|
||||||
return False
|
return False
|
||||||
@@ -2922,12 +2801,15 @@ class PluginStoreManager:
|
|||||||
self.logger.warning(f"Git update timed out for {plugin_id}")
|
self.logger.warning(f"Git update timed out for {plugin_id}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Not a git repository - try to get repo URL from git config if it exists
|
# A plugin with its own .git that _get_local_git_info could not
|
||||||
# (in case .git directory was removed but remote URL is still in config)
|
# read (e.g. no commits yet) may still name a remote to reinstall
|
||||||
|
# from. Without its own .git, `git -C <plugin>` walks up and finds
|
||||||
|
# the enclosing LEDMatrix checkout when plugins live in
|
||||||
|
# plugin-repos/ -- `--local` does not prevent that -- and the
|
||||||
|
# "plugin's" remote would be LEDMatrix itself.
|
||||||
repo_url = None
|
repo_url = None
|
||||||
|
if (plugin_path / '.git').exists():
|
||||||
try:
|
try:
|
||||||
# Use --local to avoid inheriting the parent LEDMatrix repo's git config
|
|
||||||
# when the plugin directory lives inside the main repo (e.g. plugin-repos/).
|
|
||||||
remote_url_result = subprocess.run(
|
remote_url_result = subprocess.run(
|
||||||
['git', '-C', str(plugin_path), 'config', '--local', '--get', 'remote.origin.url'],
|
['git', '-C', str(plugin_path), 'config', '--local', '--get', 'remote.origin.url'],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
@@ -2936,9 +2818,10 @@ class PluginStoreManager:
|
|||||||
check=False
|
check=False
|
||||||
)
|
)
|
||||||
if remote_url_result.returncode == 0:
|
if remote_url_result.returncode == 0:
|
||||||
repo_url = remote_url_result.stdout.strip()
|
repo_url = remote_url_result.stdout.strip() or None
|
||||||
|
if repo_url:
|
||||||
self.logger.info(f"Found git remote URL for {plugin_id}: {repo_url}")
|
self.logger.info(f"Found git remote URL for {plugin_id}: {repo_url}")
|
||||||
except Exception as e:
|
except (OSError, subprocess.SubprocessError) as e:
|
||||||
self.logger.debug(f"Could not get git remote URL: {e}")
|
self.logger.debug(f"Could not get git remote URL: {e}")
|
||||||
|
|
||||||
# Try registry-based update
|
# Try registry-based update
|
||||||
@@ -3027,9 +2910,7 @@ class PluginStoreManager:
|
|||||||
return self._reinstall_with_rollback(registry_id, plugin_path)
|
return self._reinstall_with_rollback(registry_id, plugin_path)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
import traceback
|
self.logger.error(f"Error updating plugin {plugin_id}: {e}", exc_info=True)
|
||||||
self.logger.error(f"Error updating plugin {plugin_id}: {e}")
|
|
||||||
self.logger.debug(traceback.format_exc())
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def list_installed_plugins(self) -> List[str]:
|
def list_installed_plugins(self) -> List[str]:
|
||||||
|
|||||||
@@ -299,3 +299,48 @@ class TestDefaultMerging:
|
|||||||
|
|
||||||
assert merged["enabled"] is False
|
assert merged["enabled"] is False
|
||||||
assert merged["display_duration"] == 60
|
assert merged["display_duration"] == 60
|
||||||
|
|
||||||
|
|
||||||
|
class TestMissingRequiredFields:
|
||||||
|
"""One message per missing field, naming that field.
|
||||||
|
|
||||||
|
A manual ``required`` loop used to run after Draft7Validator, which already
|
||||||
|
reports ``required``, so every missing top-level field was listed twice --
|
||||||
|
and the validator's copy printed the schema's whole ``required`` list as
|
||||||
|
if it were the field name.
|
||||||
|
"""
|
||||||
|
|
||||||
|
SCHEMA = {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"api_key": {"type": "string"},
|
||||||
|
"city": {"type": "string"},
|
||||||
|
"units": {"type": "string"},
|
||||||
|
},
|
||||||
|
"required": ["api_key", "city", "units"],
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_each_missing_field_is_reported_once_by_name(self):
|
||||||
|
ok, errors = SchemaManager().validate_config_against_schema(
|
||||||
|
{"units": "metric"}, self.SCHEMA, "test-plugin")
|
||||||
|
|
||||||
|
assert not ok
|
||||||
|
assert errors == [
|
||||||
|
"Field root: Missing required property 'api_key'",
|
||||||
|
"Field root: Missing required property 'city'",
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_nested_missing_field_names_the_field_and_its_parent(self):
|
||||||
|
schema = {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {"nfl": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {"api_key": {"type": "string"}},
|
||||||
|
"required": ["api_key"],
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
ok, errors = SchemaManager().validate_config_against_schema(
|
||||||
|
{"nfl": {}}, schema, "test-plugin")
|
||||||
|
|
||||||
|
assert not ok
|
||||||
|
assert errors == ["Field 'nfl': Missing required property 'api_key'"]
|
||||||
|
|||||||
@@ -116,10 +116,14 @@ def test_values_of_the_wrong_type_fall_back_to_usable_defaults(bad):
|
|||||||
assert isinstance(getattr(metrics, field_name), (int, float)), \
|
assert isinstance(getattr(metrics, field_name), (int, float)), \
|
||||||
f"{field_name} came back as {getattr(metrics, field_name)!r}"
|
f"{field_name} came back as {getattr(metrics, field_name)!r}"
|
||||||
|
|
||||||
# The real proof: arithmetic on the loaded metrics must not explode.
|
# The real proof: the arithmetic monitor_call and get_metrics_summary do
|
||||||
|
# on the loaded metrics must not explode.
|
||||||
metrics.call_count += 1
|
metrics.call_count += 1
|
||||||
metrics.total_execution_time += 0.5
|
metrics.total_execution_time += 0.5
|
||||||
metrics.update_average_execution_time()
|
metrics.max_execution_time = max(metrics.max_execution_time, 0.5)
|
||||||
|
metrics.min_execution_time = min(metrics.min_execution_time, 0.5)
|
||||||
|
metrics.memory_mb = max(metrics.memory_mb, 1.0)
|
||||||
|
assert metrics.total_execution_time / metrics.call_count >= 0
|
||||||
|
|
||||||
|
|
||||||
def test_a_numeric_string_is_accepted_rather_than_discarded():
|
def test_a_numeric_string_is_accepted_rather_than_discarded():
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
"""reload_plugin re-reads the manifest from the plugin's actual directory.
|
||||||
|
|
||||||
|
It read ``plugins_dir / plugin_id / manifest.json``, but a plugin directory's
|
||||||
|
name need not be the id its manifest declares -- discovery maps ids to
|
||||||
|
directories for exactly that reason. For such a plugin the path did not exist,
|
||||||
|
the re-read was skipped silently, and the reload kept the stale manifest.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.plugin_system.plugin_manager import PluginManager
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def pm_with_renamed_dir(tmp_path):
|
||||||
|
plugins_dir = tmp_path / "plugins"
|
||||||
|
plugin_dir = plugins_dir / "stock-ticker-v2"
|
||||||
|
plugin_dir.mkdir(parents=True)
|
||||||
|
manifest_path = plugin_dir / "manifest.json"
|
||||||
|
manifest_path.write_text(json.dumps({"id": "stocks", "version": "1.0.0"}))
|
||||||
|
pm = PluginManager(plugins_dir=str(plugins_dir))
|
||||||
|
assert pm.discover_plugins() == ["stocks"]
|
||||||
|
return pm, manifest_path
|
||||||
|
|
||||||
|
|
||||||
|
def test_reload_picks_up_an_edited_manifest(pm_with_renamed_dir, monkeypatch):
|
||||||
|
pm, manifest_path = pm_with_renamed_dir
|
||||||
|
manifest_path.write_text(json.dumps({"id": "stocks", "version": "2.0.0"}))
|
||||||
|
loaded = []
|
||||||
|
monkeypatch.setattr(pm, "load_plugin", lambda pid: loaded.append(pid) or True)
|
||||||
|
|
||||||
|
assert pm.reload_plugin("stocks") is True
|
||||||
|
assert pm.plugin_manifests["stocks"]["version"] == "2.0.0"
|
||||||
|
assert loaded == ["stocks"]
|
||||||
@@ -115,5 +115,22 @@ def test_get_state_info_is_a_consistent_snapshot():
|
|||||||
assert not inconsistent, f"observed a torn snapshot: {inconsistent[:1]}"
|
assert not inconsistent, f"observed a torn snapshot: {inconsistent[:1]}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_state_info_reports_only_what_something_records():
|
||||||
|
"""No field that is always null.
|
||||||
|
|
||||||
|
``last_display`` was reported here, but nothing ever recorded a display()
|
||||||
|
call, so it was null for every plugin. Its only reader is the web process,
|
||||||
|
whose PluginManager never calls display(), so recording it in the display
|
||||||
|
process could not have filled it either.
|
||||||
|
"""
|
||||||
|
manager = PluginStateManager()
|
||||||
|
manager.set_state("clock", PluginState.ENABLED)
|
||||||
|
manager.record_update("clock")
|
||||||
|
|
||||||
|
info = manager.get_state_info("clock")
|
||||||
|
assert "last_display" not in info
|
||||||
|
assert info["last_update"] is not None
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
sys.exit(pytest.main([__file__, "-v"]))
|
sys.exit(pytest.main([__file__, "-v"]))
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
"""Repository URL handling shared by the plugin store and saved repositories.
|
||||||
|
|
||||||
|
The store cleaned URLs with ``url.rstrip('/').replace('.git', '')`` in two
|
||||||
|
places, which removes ``.git`` anywhere in the URL:
|
||||||
|
``https://github.com/user/my.github.io`` became ``.../myhub.io``, so installing
|
||||||
|
or browsing such a repository asked GitHub for one that does not exist.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.plugin_system.repo_urls import (
|
||||||
|
github_api_headers, github_owner_repo, normalize_repo_url, same_repo,
|
||||||
|
)
|
||||||
|
from src.plugin_system.store_manager import PluginStoreManager
|
||||||
|
|
||||||
|
PAGES_REPO = "https://github.com/user/my.github.io"
|
||||||
|
|
||||||
|
|
||||||
|
class TestNormalizeRepoUrl:
|
||||||
|
@pytest.mark.parametrize("raw, expected", [
|
||||||
|
(PAGES_REPO, PAGES_REPO),
|
||||||
|
(PAGES_REPO + ".git", PAGES_REPO),
|
||||||
|
("https://github.com/user/repo.git/", "https://github.com/user/repo"),
|
||||||
|
(" https://github.com/user/repo/ ", "https://github.com/user/repo"),
|
||||||
|
])
|
||||||
|
def test_only_a_trailing_dot_git_is_removed(self, raw, expected):
|
||||||
|
assert normalize_repo_url(raw) == expected
|
||||||
|
|
||||||
|
def test_same_repo_ignores_case_and_suffix(self):
|
||||||
|
assert same_repo("https://github.com/Owner/Repo.git",
|
||||||
|
"https://github.com/owner/repo/")
|
||||||
|
assert not same_repo("https://github.com/owner/repo",
|
||||||
|
"https://github.com/owner/other")
|
||||||
|
|
||||||
|
|
||||||
|
class TestGithubOwnerRepo:
|
||||||
|
@pytest.mark.parametrize("url, expected", [
|
||||||
|
(PAGES_REPO + ".git", ("user", "my.github.io")),
|
||||||
|
("https://www.github.com/owner/repo", ("owner", "repo")),
|
||||||
|
("https://github.com/owner/repo/tree/main/plugins/x", ("owner", "repo")),
|
||||||
|
])
|
||||||
|
def test_github_urls(self, url, expected):
|
||||||
|
assert github_owner_repo(url) == expected
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("url", [
|
||||||
|
"https://github.com.example.org/owner/repo",
|
||||||
|
"https://gitlab.com/owner/repo",
|
||||||
|
"https://github.com/owner",
|
||||||
|
"github.com/owner/repo",
|
||||||
|
])
|
||||||
|
def test_anything_else_is_not_a_github_repo(self, url):
|
||||||
|
assert github_owner_repo(url) is None
|
||||||
|
|
||||||
|
def test_headers_carry_the_token_only_when_given(self):
|
||||||
|
assert "Authorization" not in github_api_headers(None)
|
||||||
|
assert github_api_headers("abc")["Authorization"] == "token abc"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def store(tmp_path):
|
||||||
|
return PluginStoreManager(
|
||||||
|
plugins_dir=str(tmp_path / "plugins"),
|
||||||
|
uninstalled_registry_path=str(tmp_path / "uninstalled.json"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_install_from_url_keeps_an_interior_dot_git(store, monkeypatch):
|
||||||
|
cloned_from = []
|
||||||
|
monkeypatch.setattr(store, "_install_via_git",
|
||||||
|
lambda url, *a, **k: cloned_from.append(url))
|
||||||
|
downloaded = []
|
||||||
|
monkeypatch.setattr(store, "_install_via_download",
|
||||||
|
lambda url, *a, **k: downloaded.append(url) or False)
|
||||||
|
|
||||||
|
result = store.install_from_url(PAGES_REPO + ".git")
|
||||||
|
|
||||||
|
assert result["success"] is False
|
||||||
|
assert cloned_from == [PAGES_REPO]
|
||||||
|
assert all(url.startswith(PAGES_REPO + "/archive/") for url in downloaded)
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_registry_from_url_asks_for_the_named_repository(store, monkeypatch):
|
||||||
|
requested = []
|
||||||
|
|
||||||
|
def fake_get(url, **kwargs):
|
||||||
|
requested.append(url)
|
||||||
|
return MagicMock(status_code=404)
|
||||||
|
|
||||||
|
monkeypatch.setattr(store, "_http_get_with_retries", fake_get)
|
||||||
|
|
||||||
|
assert store.fetch_registry_from_url(PAGES_REPO) is None
|
||||||
|
assert requested
|
||||||
|
assert all(url.startswith("https://raw.githubusercontent.com/user/my.github.io/")
|
||||||
|
for url in requested)
|
||||||
@@ -93,6 +93,27 @@ class TestResourceLimits:
|
|||||||
with pytest.raises(ResourceLimitExceeded):
|
with pytest.raises(ResourceLimitExceeded):
|
||||||
mon.monitor_call("p", lambda: time.sleep(0.02))
|
mon.monitor_call("p", lambda: time.sleep(0.02))
|
||||||
|
|
||||||
|
def test_memory_limit_judges_each_call_on_its_own_growth(self):
|
||||||
|
"""One expensive call must not fail every call after it.
|
||||||
|
|
||||||
|
The check used to compare the stored high-water mark, which never
|
||||||
|
decreases, so after one call grew memory past the limit every later
|
||||||
|
call raised too and the plugin never updated again.
|
||||||
|
"""
|
||||||
|
mon = PluginResourceMonitor(_cache(), enable_monitoring=False)
|
||||||
|
mon.enable_monitoring = True # measure without needing psutil
|
||||||
|
readings = iter([100.0, 200.0, # first call grows RSS by 100 MB
|
||||||
|
200.0, 201.0]) # second call grows it by 1 MB
|
||||||
|
mon._get_process_memory_mb = lambda: next(readings)
|
||||||
|
mon._get_process_cpu_percent = lambda: 0.0
|
||||||
|
mon.set_limits("p", ResourceLimits(max_memory_mb=50))
|
||||||
|
|
||||||
|
with pytest.raises(ResourceLimitExceeded):
|
||||||
|
mon.monitor_call("p", lambda: None)
|
||||||
|
assert mon.monitor_call("p", lambda: "ok") == "ok"
|
||||||
|
# The high-water mark is still reported.
|
||||||
|
assert mon.get_metrics("p").memory_mb == 100.0
|
||||||
|
|
||||||
def test_reset_metrics_clears_counts(self):
|
def test_reset_metrics_clears_counts(self):
|
||||||
cache = _cache()
|
cache = _cache()
|
||||||
mon = PluginResourceMonitor(cache, enable_monitoring=False)
|
mon = PluginResourceMonitor(cache, enable_monitoring=False)
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ SavedRepositoriesManager contract.
|
|||||||
Covers: the three accepted on-disk load shapes (bare list, wrapped
|
Covers: the three accepted on-disk load shapes (bare list, wrapped
|
||||||
{"repositories": [...]}, anything else -> []) and that saves always write
|
{"repositories": [...]}, anything else -> []) and that saves always write
|
||||||
the bare-list form; add/remove/has round trips through a fresh manager;
|
the bare-list form; add/remove/has round trips through a fresh manager;
|
||||||
URL normalization post-fix (_clean_url strips only a TRAILING '.git' after
|
URL normalization post-fix (normalize_repo_url strips only a TRAILING '.git' after
|
||||||
trailing slashes — the old unanchored .replace('.git', '') mangled URLs
|
trailing slashes — the old unanchored .replace('.git', '') mangled URLs
|
||||||
like my.github.io); name derivation and registry-vs-single type
|
like my.github.io); name derivation and registry-vs-single type
|
||||||
classification (the ledmatrix-plugins check is lowercased, the
|
classification (the ledmatrix-plugins check is lowercased, the
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
"""A repository whose only branch is neither main nor master still installs.
|
||||||
|
|
||||||
|
_install_via_git tries the candidate branches, then the repository's default
|
||||||
|
branch -- but it returned None both for "every clone failed" and for "the
|
||||||
|
default-branch clone succeeded". install_from_url took the None as failure,
|
||||||
|
fell through to the archive download of main/master (which does not exist),
|
||||||
|
and reported "Failed to clone or download repository" for a repository it had
|
||||||
|
just cloned.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.plugin_system.store_manager import PluginStoreManager
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.skipif(shutil.which("git") is None, reason="git not installed")
|
||||||
|
|
||||||
|
MANIFEST = {
|
||||||
|
"id": "develop-only", "name": "Develop Only", "class_name": "P",
|
||||||
|
"display_modes": ["develop_only"], "version": "1.0.0",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _git(*args, cwd):
|
||||||
|
subprocess.run(
|
||||||
|
["git", "-c", "user.name=t", "-c", "user.email=t@example.invalid", *args],
|
||||||
|
cwd=cwd, check=True, capture_output=True)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def develop_only_repo(tmp_path):
|
||||||
|
repo = tmp_path / "upstream"
|
||||||
|
repo.mkdir()
|
||||||
|
_git("init", "-q", "-b", "develop", cwd=repo)
|
||||||
|
(repo / "manifest.json").write_text(json.dumps(MANIFEST))
|
||||||
|
(repo / "manager.py").write_text("class P: pass\n")
|
||||||
|
_git("add", ".", cwd=repo)
|
||||||
|
_git("commit", "-q", "-m", "init", cwd=repo)
|
||||||
|
return repo.as_uri()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def store(tmp_path, monkeypatch):
|
||||||
|
mgr = PluginStoreManager(
|
||||||
|
plugins_dir=str(tmp_path / "plugins"),
|
||||||
|
uninstalled_registry_path=str(tmp_path / "uninstalled.json"))
|
||||||
|
monkeypatch.setattr(mgr, "_install_dependencies", lambda *a, **k: True)
|
||||||
|
downloads = []
|
||||||
|
monkeypatch.setattr(mgr, "_install_via_download",
|
||||||
|
lambda url, *a, **k: downloads.append(url) or False)
|
||||||
|
mgr.downloads = downloads
|
||||||
|
return mgr
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_default_branch_clone_reports_its_branch(store, develop_only_repo, tmp_path):
|
||||||
|
target = tmp_path / "clone"
|
||||||
|
assert store._install_via_git(develop_only_repo, target, ["main", "master"]) == "develop"
|
||||||
|
assert (target / "manifest.json").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_failed_clone_reports_none(store, tmp_path):
|
||||||
|
missing = (tmp_path / "no-such-repo").as_uri()
|
||||||
|
target = tmp_path / "clone"
|
||||||
|
assert store._install_via_git(missing, target, ["main"]) is None
|
||||||
|
assert not target.exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_install_from_url_installs_a_develop_only_repository(store, develop_only_repo):
|
||||||
|
result = store.install_from_url(develop_only_repo)
|
||||||
|
|
||||||
|
assert result == {"success": True, "plugin_id": "develop-only",
|
||||||
|
"name": "Develop Only", "branch": "develop"}
|
||||||
|
assert (store.plugins_dir / "develop-only" / "manifest.json").exists()
|
||||||
|
assert store.downloads == []
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
"""update_plugin must not borrow the enclosing LEDMatrix checkout's remote.
|
||||||
|
|
||||||
|
Plugins live in ``plugin-repos/`` inside the LEDMatrix git checkout. For a
|
||||||
|
plugin installed from a ZIP (no ``.git`` of its own), ``git -C <plugin>``
|
||||||
|
walks up to the LEDMatrix repository, and ``git config --local --get
|
||||||
|
remote.origin.url`` answers with LEDMatrix's own URL. update_plugin then tried
|
||||||
|
to "reinstall" the plugin from the LEDMatrix repository.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.plugin_system.store_manager import PluginStoreManager
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.skipif(shutil.which("git") is None, reason="git not installed")
|
||||||
|
|
||||||
|
PLUGIN_ID = "zip-installed"
|
||||||
|
PARENT_REMOTE = "https://github.com/example/LEDMatrix"
|
||||||
|
|
||||||
|
|
||||||
|
def _git(*args, cwd):
|
||||||
|
subprocess.run(["git", *args], cwd=cwd, check=True, capture_output=True)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def store_inside_checkout(tmp_path):
|
||||||
|
checkout = tmp_path / "LEDMatrix"
|
||||||
|
checkout.mkdir()
|
||||||
|
_git("init", "-q", cwd=checkout)
|
||||||
|
_git("remote", "add", "origin", PARENT_REMOTE, cwd=checkout)
|
||||||
|
|
||||||
|
plugins_dir = checkout / "plugin-repos"
|
||||||
|
plugin_dir = plugins_dir / PLUGIN_ID
|
||||||
|
plugin_dir.mkdir(parents=True)
|
||||||
|
(plugin_dir / "manifest.json").write_text(json.dumps(
|
||||||
|
{"id": PLUGIN_ID, "name": "Zip", "version": "1.0.0"}))
|
||||||
|
|
||||||
|
store = PluginStoreManager(
|
||||||
|
plugins_dir=str(plugins_dir),
|
||||||
|
uninstalled_registry_path=str(tmp_path / "uninstalled.json"))
|
||||||
|
return store, plugin_dir
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_plugin_without_its_own_git_has_no_remote(store_inside_checkout, monkeypatch):
|
||||||
|
store, plugin_dir = store_inside_checkout
|
||||||
|
# The premise: git itself does report the parent's remote here.
|
||||||
|
parent_view = subprocess.run(
|
||||||
|
["git", "-C", str(plugin_dir), "config", "--local", "--get", "remote.origin.url"],
|
||||||
|
capture_output=True, text=True)
|
||||||
|
assert parent_view.stdout.strip() == PARENT_REMOTE
|
||||||
|
|
||||||
|
monkeypatch.setattr(store, "fetch_registry", lambda *a, **k: {"plugins": []})
|
||||||
|
monkeypatch.setattr(store, "get_plugin_info", lambda *a, **k: None)
|
||||||
|
install_calls = []
|
||||||
|
monkeypatch.setattr(store, "install_from_url",
|
||||||
|
lambda *a, **k: install_calls.append((a, k)) or {"success": True})
|
||||||
|
|
||||||
|
assert store.update_plugin(PLUGIN_ID) is False
|
||||||
|
assert install_calls == []
|
||||||
@@ -21,10 +21,7 @@ class TestPluginOperationsIntegration(unittest.TestCase):
|
|||||||
self.temp_dir = Path(tempfile.mkdtemp())
|
self.temp_dir = Path(tempfile.mkdtemp())
|
||||||
|
|
||||||
# Initialize components
|
# Initialize components
|
||||||
self.operation_queue = PluginOperationQueue(
|
self.operation_queue = PluginOperationQueue(max_history=100)
|
||||||
history_file=str(self.temp_dir / "operations.json"),
|
|
||||||
max_history=100
|
|
||||||
)
|
|
||||||
|
|
||||||
self.state_manager = PluginStateManager(
|
self.state_manager = PluginStateManager(
|
||||||
state_file=str(self.temp_dir / "state.json"),
|
state_file=str(self.temp_dir / "state.json"),
|
||||||
|
|||||||
@@ -144,12 +144,7 @@ schema_manager = SchemaManager(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Initialize operation queue for plugin operations
|
# Initialize operation queue for plugin operations
|
||||||
# Use lazy_load=True to defer file loading until first use (improves startup time)
|
operation_queue = PluginOperationQueue(max_history=500)
|
||||||
operation_queue = PluginOperationQueue(
|
|
||||||
history_file=str(project_root / "data" / "plugin_operations.json"),
|
|
||||||
max_history=500,
|
|
||||||
lazy_load=True
|
|
||||||
)
|
|
||||||
|
|
||||||
# Initialize plugin state manager
|
# Initialize plugin state manager
|
||||||
# Use lazy_load=True to defer file loading until first use (improves startup time)
|
# Use lazy_load=True to defer file loading until first use (improves startup time)
|
||||||
|
|||||||
Reference in New Issue
Block a user