mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-10 09:06:36 +00:00
* 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>
340 lines
11 KiB
Python
340 lines
11 KiB
Python
"""
|
|
Centralized plugin state management.
|
|
|
|
Provides a single source of truth for plugin state (installed, enabled, version, etc.)
|
|
with persistence.
|
|
"""
|
|
|
|
import json
|
|
import threading
|
|
from typing import Dict, Any, Optional
|
|
from pathlib import Path
|
|
from datetime import datetime
|
|
from dataclasses import dataclass, asdict
|
|
from enum import Enum
|
|
|
|
from src.logging_config import get_logger
|
|
|
|
|
|
class PluginStateStatus(Enum):
|
|
"""Status of a plugin."""
|
|
INSTALLED = "installed"
|
|
ENABLED = "enabled"
|
|
DISABLED = "disabled"
|
|
ERROR = "error"
|
|
UNKNOWN = "unknown"
|
|
|
|
|
|
@dataclass
|
|
class PluginState:
|
|
"""Represents the state of a plugin."""
|
|
plugin_id: str
|
|
status: PluginStateStatus
|
|
enabled: bool
|
|
version: Optional[str] = None
|
|
installed_at: Optional[datetime] = None
|
|
last_updated: Optional[datetime] = None
|
|
# 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
|
|
|
|
def __post_init__(self):
|
|
if self.metadata is None:
|
|
self.metadata = {}
|
|
|
|
def to_dict(self) -> Dict[str, Any]:
|
|
"""Convert state to dictionary for serialization."""
|
|
result = asdict(self)
|
|
# Convert enum to string
|
|
result['status'] = self.status.value
|
|
# Convert datetime to ISO string
|
|
if result.get('installed_at'):
|
|
result['installed_at'] = self.installed_at.isoformat()
|
|
if result.get('last_updated'):
|
|
result['last_updated'] = self.last_updated.isoformat()
|
|
return result
|
|
|
|
@classmethod
|
|
def from_dict(cls, data: Dict[str, Any]) -> 'PluginState':
|
|
"""Create state from dictionary."""
|
|
# Parse enum
|
|
if isinstance(data.get('status'), str):
|
|
data['status'] = PluginStateStatus(data['status'])
|
|
|
|
# Parse datetime
|
|
if data.get('installed_at') and isinstance(data['installed_at'], str):
|
|
data['installed_at'] = datetime.fromisoformat(data['installed_at'])
|
|
if data.get('last_updated') and isinstance(data['last_updated'], str):
|
|
data['last_updated'] = datetime.fromisoformat(data['last_updated'])
|
|
|
|
return cls(**data)
|
|
|
|
|
|
class PluginStateManager:
|
|
"""
|
|
Centralized plugin state manager.
|
|
|
|
Provides:
|
|
- Single source of truth for plugin state
|
|
- State persistence
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
state_file: Optional[str] = None,
|
|
auto_save: bool = True,
|
|
lazy_load: bool = False
|
|
):
|
|
"""
|
|
Initialize state manager.
|
|
|
|
Args:
|
|
state_file: Path to file for persisting state
|
|
auto_save: Whether to automatically save state on changes
|
|
lazy_load: If True, defer loading state file until first access
|
|
"""
|
|
self.logger = get_logger(__name__)
|
|
self.state_file = Path(state_file) if state_file else None
|
|
self.auto_save = auto_save
|
|
self._lazy_load = lazy_load
|
|
self._state_loaded = False
|
|
|
|
# State storage
|
|
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
|
|
|
|
# Threading
|
|
self._lock = threading.RLock()
|
|
|
|
# Load state from file if it exists (unless lazy loading)
|
|
if not self._lazy_load and self.state_file and self.state_file.exists():
|
|
self._load_state()
|
|
self._state_loaded = True
|
|
|
|
def _ensure_loaded(self) -> None:
|
|
"""Ensure state is loaded (for lazy loading)."""
|
|
if not self._state_loaded and self.state_file and self.state_file.exists():
|
|
self._load_state()
|
|
self._state_loaded = True
|
|
|
|
def get_plugin_state(self, plugin_id: str) -> Optional[PluginState]:
|
|
"""
|
|
Get state for a plugin.
|
|
|
|
Args:
|
|
plugin_id: Plugin identifier
|
|
|
|
Returns:
|
|
PluginState if found, None otherwise
|
|
"""
|
|
self._ensure_loaded()
|
|
with self._lock:
|
|
return self._states.get(plugin_id)
|
|
|
|
def get_all_states(self) -> Dict[str, PluginState]:
|
|
"""
|
|
Get all plugin states.
|
|
|
|
Returns:
|
|
Dictionary mapping plugin_id to PluginState
|
|
"""
|
|
self._ensure_loaded()
|
|
with self._lock:
|
|
return self._states.copy()
|
|
|
|
def update_plugin_state(
|
|
self,
|
|
plugin_id: str,
|
|
updates: Dict[str, Any]
|
|
) -> bool:
|
|
"""
|
|
Update plugin state.
|
|
|
|
Args:
|
|
plugin_id: Plugin identifier
|
|
updates: Dictionary of state updates
|
|
|
|
Returns:
|
|
True if update successful
|
|
"""
|
|
self._ensure_loaded()
|
|
with self._lock:
|
|
# Get current state or create new
|
|
current_state = self._states.get(plugin_id)
|
|
if not current_state:
|
|
current_state = PluginState(
|
|
plugin_id=plugin_id,
|
|
status=PluginStateStatus.UNKNOWN,
|
|
enabled=False
|
|
)
|
|
|
|
# Apply updates
|
|
if 'status' in updates:
|
|
if isinstance(updates['status'], str):
|
|
current_state.status = PluginStateStatus(updates['status'])
|
|
else:
|
|
current_state.status = updates['status']
|
|
|
|
if 'enabled' in updates:
|
|
current_state.enabled = bool(updates['enabled'])
|
|
|
|
if 'version' in updates:
|
|
current_state.version = updates['version']
|
|
|
|
if 'installed_at' in updates:
|
|
current_state.installed_at = updates['installed_at']
|
|
|
|
if 'last_updated' in updates:
|
|
current_state.last_updated = updates['last_updated']
|
|
else:
|
|
current_state.last_updated = datetime.now()
|
|
|
|
if 'metadata' in updates:
|
|
if current_state.metadata is None:
|
|
current_state.metadata = {}
|
|
current_state.metadata.update(updates['metadata'])
|
|
|
|
current_state.config_version += 1
|
|
|
|
# Store updated state
|
|
self._states[plugin_id] = current_state
|
|
|
|
# Auto-save if enabled
|
|
if self.auto_save:
|
|
self._save_state()
|
|
|
|
return True
|
|
|
|
def set_plugin_enabled(self, plugin_id: str, enabled: bool) -> bool:
|
|
"""
|
|
Set plugin enabled/disabled state.
|
|
|
|
Args:
|
|
plugin_id: Plugin identifier
|
|
enabled: Whether plugin is enabled
|
|
|
|
Returns:
|
|
True if update successful
|
|
"""
|
|
status = PluginStateStatus.ENABLED if enabled else PluginStateStatus.DISABLED
|
|
return self.update_plugin_state(
|
|
plugin_id,
|
|
{
|
|
'enabled': enabled,
|
|
'status': status
|
|
}
|
|
)
|
|
|
|
def set_plugin_installed(
|
|
self,
|
|
plugin_id: str,
|
|
version: Optional[str] = None,
|
|
installed_at: Optional[datetime] = None
|
|
) -> bool:
|
|
"""
|
|
Mark plugin as installed.
|
|
|
|
Args:
|
|
plugin_id: Plugin identifier
|
|
version: Plugin version
|
|
installed_at: Installation timestamp
|
|
|
|
Returns:
|
|
True if update successful
|
|
"""
|
|
return self.update_plugin_state(
|
|
plugin_id,
|
|
{
|
|
'status': PluginStateStatus.INSTALLED,
|
|
'version': version,
|
|
'installed_at': installed_at or datetime.now()
|
|
}
|
|
)
|
|
|
|
def remove_plugin_state(self, plugin_id: str) -> bool:
|
|
"""
|
|
Remove plugin state (e.g., after uninstall).
|
|
|
|
Args:
|
|
plugin_id: Plugin identifier
|
|
|
|
Returns:
|
|
True if removal successful
|
|
"""
|
|
self._ensure_loaded()
|
|
with self._lock:
|
|
if plugin_id in self._states:
|
|
del self._states[plugin_id]
|
|
|
|
# Auto-save if enabled
|
|
if self.auto_save:
|
|
self._save_state()
|
|
|
|
return True
|
|
|
|
return False
|
|
|
|
def _save_state(self) -> None:
|
|
"""Save state to file."""
|
|
if not self.state_file:
|
|
return
|
|
|
|
try:
|
|
with self._lock:
|
|
# Convert states to dicts
|
|
states_data = {
|
|
plugin_id: state.to_dict()
|
|
for plugin_id, state in self._states.items()
|
|
}
|
|
|
|
state_data = {
|
|
'version': self._state_version,
|
|
'states': states_data,
|
|
'last_updated': datetime.now().isoformat()
|
|
}
|
|
|
|
# Ensure directory exists with proper permissions
|
|
from src.common.permission_utils import (
|
|
ensure_directory_permissions,
|
|
get_config_dir_mode
|
|
)
|
|
ensure_directory_permissions(self.state_file.parent, get_config_dir_mode())
|
|
|
|
# Write to file
|
|
with open(self.state_file, 'w') as f:
|
|
json.dump(state_data, f, indent=2)
|
|
|
|
except Exception as e:
|
|
self.logger.error(f"Error saving plugin state: {e}", exc_info=True)
|
|
|
|
def _load_state(self) -> None:
|
|
"""Load state from file."""
|
|
if not self.state_file or not self.state_file.exists():
|
|
return
|
|
|
|
try:
|
|
with open(self.state_file, 'r') as f:
|
|
state_data = json.load(f)
|
|
|
|
with self._lock:
|
|
# Load state version
|
|
self._state_version = state_data.get('version', 1)
|
|
|
|
# Load states
|
|
states_data = state_data.get('states', {})
|
|
for plugin_id, state_dict in states_data.items():
|
|
try:
|
|
self._states[plugin_id] = PluginState.from_dict(state_dict)
|
|
except Exception as e:
|
|
self.logger.warning(
|
|
f"Error loading state for plugin {plugin_id}: {e}"
|
|
)
|
|
|
|
self.logger.info(f"Loaded {len(self._states)} plugin states from file")
|
|
|
|
except Exception as e:
|
|
self.logger.error(f"Error loading plugin state: {e}", exc_info=True)
|