Files
LEDMatrix/src/plugin_system/plugin_loader.py
Chuck 713539e491 fix(web-ui): fix quick actions not firing, add toast feedback, suppress install handler warning (#346)
* fix(web-ui): fix quick actions not firing, add toast feedback, suppress install handler warning

- base.html: add htmx:afterSettle listener to set data-loaded on tab
  containers after HTMX swaps their content, preventing the overview
  partial from being re-fetched (and handlers lost) on every tab switch
- base.html: call htmx.process() in loadOverviewDirect/loadPluginsDirect
  fallbacks so buttons get HTMX handlers even if HTMX finished its
  initial body scan before the fallback fetch completed
- overview.html + index.html (11 buttons): replace event.detail.xhr.responseJSON
  (undefined in HTMX 1.9.x) with JSON.parse(event.detail.xhr.responseText)
  so quick action toast notifications actually fire
- plugins_manager.js: add guarded htmx:afterSettle listener that only calls
  attachInstallButtonHandler when #install-plugin-from-url is in the DOM,
  eliminating the spurious console warning on non-plugin tab loads

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(web-ui): ensure quick-action toasts always fire even on xhr/parse failure

Replace silent catch(e){} in all 11 hx-on:htmx:after-request handlers with a
pattern that sets default message/status before the try block and calls
showNotification(m,s) unconditionally after it, so a fallback toast is shown
whenever xhr is absent or responseText is not valid JSON.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(web-ui): show error toast on non-JSON 4xx/5xx quick-action responses

In the catch block of all 11 hx-on:htmx:after-request handlers, check
xhr.status >= 400 and downgrade s to 'error' so a failed action that
returns an HTML error page (or other non-JSON body) surfaces as an error
toast instead of the optimistic 'success'/'info' default.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(web-ui): guard setTimeout fallback for attachInstallButtonHandler

The 500ms fallback setTimeout was calling attachInstallButtonHandler()
unconditionally even when the plugins partial wasn't in the DOM, causing
a spurious console.warn on every page load. Add the same element-existence
check already present on the htmx:afterSettle listener.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Fix backup API 404s, hardware status 500, and HTMX loading race

- Add all backup API routes to api_v3.py: preview, list, export,
  validate, restore (with plugin reinstall), download, delete
- Fix PermissionError on /hardware/status: return graceful 200 instead
  of 500 when the status file is owned by a different user; also fix
  root cause by writing the file world-readable (0o644) in display_manager
- Fix HTMX race: dispatch htmx:ready window event from HTMX onload
  callback; loadTabContent now waits for that event instead of
  immediately falling back to direct fetch (eliminating the
  "HTMX not available" console warning on initial load)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Cancel HTMX fallback timers when htmx:ready fires

The 5-second setTimeout fallbacks for plugins and overview were firing
before the htmx:ready event arrived, logging spurious warnings. Each
timer now self-cancels via htmx:ready so the fallback only triggers
when HTMX genuinely fails to load.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Address review feedback: error leaks, ok:false, htmx:ready coverage

- Backup endpoints: replace raw str(e) in user-facing responses with a
  generic message; full exception still logged via exc_info=True
- hardware/status: change ok:null to ok:false for PermissionError and
  json.JSONDecodeError so the UI's hw.ok===false check triggers correctly
- base.html: dispatch htmx:ready from the fallback load path so any
  deferred listeners fire on CDN-fallback loads too
- loadTabContent: also listen for htmx-load-failed so overview/wifi/plugins
  fall back to direct fetch when HTMX is completely unavailable

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Treat system-managed pip packages as satisfied for dependency marker

When a plugin's requirements.txt includes a package installed via the
system package manager (dnf/apt), pip fails with 'uninstall-no-record-file'
because it can't replace the system-tracked copy. The package is present
and functional, but the missing marker caused the install to be retried
on every service restart.

Detect this specific error pattern: if the only pip failure is
uninstall-no-record-file, write the .dependencies_installed marker and
log a warning instead of returning False, suppressing the repeated warning.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Fix uninstall-no-record-file detection condition

The previous check used a string replacement that left 'error:' in the
remaining text, causing the condition to always evaluate false. Simplify
to a direct substring check: if 'uninstall-no-record-file' appears in pip
stderr the affected package is installed at the system level and we write
the marker, suppressing the repeated warning on every restart.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Resolve CodeQL security findings in backup API

Path traversal (CWE-22):
- backup_download: switch from send_file(user-tainted-path) to
  send_from_directory(_BACKUP_EXPORT_DIR, filename); Flask uses
  werkzeug safe_join internally which CodeQL recognises as a sanitizer
- backup_delete: enumerate the export directory and match by name so
  entry.unlink() operates on a filesystem-derived Path rather than one
  constructed from user input; _safe_backup_path still guards first

Information exposure through exceptions (CWE-209):
- backup_validate: err_msg from validate_backup() can embed exception
  strings containing temp-file paths; log the detail, return a generic
  'Invalid or corrupted backup file' to the client
- Other backup endpoints: already fixed (str(e) -> generic message);
  CodeQL alerts will clear on next scan

plugin_loader.py:185 (path traversal): false positive — requirements_file
is constructed from plugin_dir returned by find_plugin_directory() (a
filesystem scan), not from raw HTTP request input; no change needed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Fix pre-existing information exposure in version and action endpoints

- get_system_version (alert #218): replaced str(e) with generic message;
  exception still logged via logger.error(exc_info=True)
- execute_system_action (alert #216): removed str(e) and full
  traceback.format_exc() from the HTTP response — the full stack trace
  was being sent directly to clients; replaced with generic message and
  proper logger.error call

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Fix remaining GitHub CodeQL security alerts

- py/stack-trace-exposure: Remove str(e) and traceback.format_exc() from
  all HTTP responses across api_v3.py, pages_v3.py, and app.py; replace
  with generic messages and logger.error(exc_info=True)
- py/reflective-xss: Escape partial_name via markupsafe.escape in the
  load_partial 404 response
- py/path-injection: Add regex validation of plugin_id before filesystem
  use in _load_plugin_config_partial
- py/incomplete-url-substring-sanitization: Replace 'github.com' in
  substring checks with urlparse hostname comparison in store_manager.py
- py/clear-text-logging-sensitive-data: Remove football-scoreboard debug
  prints and sensitive request-body prints from update endpoint
- js/bad-tag-filter: Replace script-only regex in BaseWidget.sanitizeValue
  with DOM-based textContent stripping that removes all HTML
- js/incomplete-sanitization: Fix escapeAttr to properly encode &, ", ',
  <, > using HTML entities instead of backslash escaping
- js/prototype-pollution-utility: Add __proto__/constructor/prototype
  key guards to deepMerge function in plugins_manager.js
- app.py error handlers: Always return generic messages; remove debug-mode
  branches that could expose tracebacks in production

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Fix three remaining CodeQL path-injection and info-exposure alerts

- plugin_loader.py: resolve plugin_dir with strict=True and validate
  marker_path with relative_to() before any filesystem writes, giving
  CodeQL the positive sanitization pattern it requires (py/path-injection)
- api_v3.py _safe_backup_path: replace substring negative checks with a
  strict positive regex (^[a-zA-Z0-9][a-zA-Z0-9._-]{0,200}\.zip$) that
  CodeQL recognises as sanitising the user-supplied filename
  (py/path-injection)
- api_v3.py backup_validate: whitelist known-safe manifest fields before
  returning JSON, preventing any exception strings captured inside
  validate_backup() from reaching the HTTP response (py/stack-trace-exposure)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Resolve 29 open CodeQL security alerts across 5 files

py/flask-debug (#214):
- debug_web_manual.py: read debug mode from LEDMATRIX_FLASK_DEBUG env var
  instead of hardcoded True

py/stack-trace-exposure (#216, #218):
- api_v3.py execute_system_action: remove subprocess stdout/stderr from
  HTTP responses; log via logger instead
- api_v3.py get_git_version: validate output matches safe ref format
  (^[a-zA-Z0-9._-]+$) before including in response
- api_v3.py: remove all remaining traceback.format_exc() dead variables
  and print() debug calls (replaced with logger.debug/warning)

py/reflective-xss (#207, #208, #209, #210, #211, #212):
- api_v3.py: remove plugin_id from all error/success response messages
  (uninstall, install, update, health, not-found responses)
- pages_v3.py load_partial: return static "Partial not found" message
  instead of echoing partial_name
- pages_v3.py _load_starlark_config_partial: add app_id regex validation,
  use static error messages instead of f-strings with app_id

py/path-injection (#187–#206):
- pages_v3.py _load_plugin_config_partial: resolve plugins_base and
  validate _plugin_dir with relative_to() before all file operations;
  same for assets metadata directory
- pages_v3.py _load_starlark_config_partial: resolve starlark_base and
  validate schema_file/config_file paths with relative_to()
- plugin_loader.py _find_plugin_directory: resolve plugins_dir and
  validate strategy-2 candidates with relative_to()
- plugin_loader.py install_dependencies: resolve plugin_dir first, then
  construct requirements_file and marker_path from resolved base
- plugin_loader.py load_module: resolve plugin_dir with strict=True and
  validate entry_file with relative_to() before exec_module

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Fix 15 remaining CodeQL path-injection and stack-trace-exposure alerts

Switch from resolve()+relative_to() to os.path.basename() reassignment,
which CodeQL recognizes as a path sanitizer that breaks the taint chain.
Also remove exception objects from backup_manager validate_backup return
strings to eliminate the stack-trace-exposure taint source.

Fixes alerts #227, #233, #234, #235, #237, #238, #239, #240, #241,
#242, #243, #244, #245, #246, #247.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Fix broken logger format string and leaked exception in config save error

- pages_v3.py: plain string was used instead of %-style substitution,
  so every manifest-read failure logged the literal "{plugin_id}"
- api_v3.py save_main_config: exception message was still leaking
  through the error response; replace with generic message (consistent
  with the rest of the CodeQL sweep in this PR)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 09:29:53 -04:00

596 lines
23 KiB
Python

"""
Plugin Loader
Handles plugin module imports, dependency installation, and class instantiation.
Extracted from PluginManager to improve separation of concerns.
"""
import json
import importlib
import importlib.util
import os
import sys
import subprocess
import threading
from pathlib import Path
from typing import Dict, Any, Optional, Tuple, Type
import logging
from src.exceptions import PluginError
from src.logging_config import get_logger
from src.common.permission_utils import (
ensure_file_permissions,
get_plugin_file_mode
)
class PluginLoader:
"""Handles plugin module loading and class instantiation."""
def __init__(self, logger: Optional[logging.Logger] = None) -> None:
"""
Initialize the plugin loader.
Args:
logger: Optional logger instance
"""
self.logger = logger or get_logger(__name__)
self._loaded_modules: Dict[str, Any] = {}
self._plugin_module_registry: Dict[str, set] = {} # Maps plugin_id to set of module names
# Lock to serialize module loading when plugins share module names
# (e.g., scroll_display.py, game_renderer.py across sport plugins).
# During exec_module, bare-name sub-modules temporarily appear in
# sys.modules; the lock prevents concurrent plugins from seeing each
# other's entries. After exec_module, _namespace_plugin_modules
# moves those bare names to namespaced keys (e.g.
# _plg_basketball_scoreboard_scroll_display) so they never collide.
self._module_load_lock = threading.Lock()
def find_plugin_directory(
self,
plugin_id: str,
plugins_dir: Path,
plugin_directories: Optional[Dict[str, Path]] = None
) -> Optional[Path]:
"""
Find the plugin directory for a given plugin ID.
Tries multiple strategies:
1. Use plugin_directories mapping if available
2. Direct path matching
3. Case-insensitive directory matching
4. Manifest-based search
Args:
plugin_id: Plugin identifier
plugins_dir: Base plugins directory
plugin_directories: Optional mapping of plugin_id to directory
Returns:
Path to plugin directory or None if not found
"""
# Sanitize plugin_id — os.path.basename is a CodeQL-recognized path sanitizer
plugin_id = os.path.basename(plugin_id or '')
if not plugin_id:
return None
# Strategy 1: Use mapping from discovery
if plugin_directories and plugin_id in plugin_directories:
plugin_dir = plugin_directories[plugin_id]
if plugin_dir.exists():
self.logger.debug("Using plugin directory from discovery mapping: %s", plugin_dir)
return plugin_dir
# Strategy 2: Direct paths — resolve and validate they stay within plugins_dir
plugins_dir_resolved = plugins_dir.resolve()
for _candidate_name in (plugin_id, f"ledmatrix-{plugin_id}"):
_candidate = (plugins_dir_resolved / _candidate_name).resolve()
try:
_candidate.relative_to(plugins_dir_resolved)
except ValueError:
continue
if _candidate.exists():
return _candidate
# Strategy 3: Case-insensitive search
normalized_id = plugin_id.lower()
for item in plugins_dir.iterdir():
if not item.is_dir():
continue
item_name = item.name
if item_name.lower() == normalized_id:
return item
if item_name.lower() == f"ledmatrix-{plugin_id}".lower():
return item
# Strategy 4: Manifest-based search
self.logger.debug("Directory name search failed for %s, searching by manifest...", plugin_id)
for item in plugins_dir.iterdir():
if not item.is_dir():
continue
# Skip if already checked
if item.name.lower() == normalized_id or item.name.lower() == f"ledmatrix-{plugin_id}".lower():
continue
manifest_path = item / "manifest.json"
if manifest_path.exists():
try:
with open(manifest_path, 'r', encoding='utf-8') as f:
item_manifest = json.load(f)
item_manifest_id = item_manifest.get('id')
if item_manifest_id == plugin_id:
self.logger.info(
"Found plugin %s in directory %s (manifest ID matches)",
plugin_id,
item.name
)
return item
except (json.JSONDecodeError, Exception) as e:
self.logger.debug("Skipping %s due to manifest error: %s", item.name, e)
continue
return None
def install_dependencies(
self,
plugin_dir: Path,
plugin_id: str,
timeout: int = 300
) -> bool:
"""
Install plugin dependencies from requirements.txt.
Args:
plugin_dir: Plugin directory path
plugin_id: Plugin identifier
timeout: Installation timeout in seconds
Returns:
True if dependencies installed or not needed, False on error
"""
plugin_id = os.path.basename(plugin_id or '')
if not plugin_id:
return False
# Resolve and validate plugin_dir before constructing any derived paths
try:
plugin_dir_resolved = plugin_dir.resolve(strict=True)
except OSError:
self.logger.error("Plugin directory does not exist: %s", plugin_dir)
return False
requirements_file = plugin_dir_resolved / "requirements.txt"
if not requirements_file.exists():
return True # No dependencies needed
marker_path = plugin_dir_resolved / ".dependencies_installed"
# Check if already installed
if marker_path.exists():
self.logger.debug("Dependencies already installed for %s", plugin_id)
return True
try:
self.logger.info("Installing dependencies for plugin %s...", plugin_id)
result = subprocess.run(
[sys.executable, "-m", "pip", "install", "--break-system-packages", "-r", str(requirements_file)],
capture_output=True,
text=True,
timeout=timeout,
check=False
)
if result.returncode == 0:
# Mark as installed
marker_path.touch()
# Set proper file permissions after creating marker
ensure_file_permissions(marker_path, get_plugin_file_mode())
self.logger.info("Dependencies installed successfully for %s", plugin_id)
return True
else:
stderr = result.stderr or ""
# uninstall-no-record-file means the package is already present at the
# system level (e.g. installed via dnf/apt without a pip RECORD file).
# pip can't replace it, but it IS installed — write the marker so we
# don't retry on every restart.
if "uninstall-no-record-file" in stderr:
self.logger.warning(
"Dependencies for %s include system-managed packages (no pip RECORD). "
"Assuming they are satisfied: %s",
plugin_id, stderr.strip()
)
marker_path.touch()
ensure_file_permissions(marker_path, get_plugin_file_mode())
return True
self.logger.warning(
"Dependency installation returned non-zero exit code for %s: %s",
plugin_id,
stderr
)
return False
except subprocess.TimeoutExpired:
self.logger.error("Dependency installation timed out for %s", plugin_id)
return False
except FileNotFoundError:
self.logger.warning("pip not found. Skipping dependency installation for %s", plugin_id)
return True
except (BrokenPipeError, OSError) as e:
# Handle broken pipe errors (errno 32) which can occur during pip downloads
# Often caused by network interruptions or output buffer issues
if isinstance(e, OSError) and e.errno == 32:
self.logger.error(
"Broken pipe error during dependency installation for %s. "
"This usually indicates a network interruption or pip output buffer issue. "
"Try installing again or check your network connection.", plugin_id
)
else:
self.logger.error("OS error during dependency installation for %s: %s", plugin_id, e)
return False
except Exception as e:
self.logger.error("Unexpected error installing dependencies for %s: %s", plugin_id, e, exc_info=True)
return False
@staticmethod
def _iter_plugin_bare_modules(
plugin_dir: Path, before_keys: set
) -> list:
"""Return bare-name modules from plugin_dir added after before_keys.
Returns a list of (mod_name, module) tuples for modules that:
- Were added to sys.modules after before_keys snapshot
- Have bare names (no dots)
- Have a ``__file__`` inside plugin_dir
"""
resolved_dir = plugin_dir.resolve()
result = []
for key in set(sys.modules.keys()) - before_keys:
if "." in key:
continue
mod = sys.modules.get(key)
if mod is None:
continue
mod_file = getattr(mod, "__file__", None)
if not mod_file:
continue
try:
if Path(mod_file).resolve().is_relative_to(resolved_dir):
result.append((key, mod))
except (ValueError, TypeError):
continue
return result
def _evict_stale_bare_modules(self, plugin_dir: Path) -> dict:
"""Temporarily remove bare-name sys.modules entries from other plugins.
Before exec_module, scan the current plugin directory for .py files.
For each, if sys.modules has a bare-name entry whose ``__file__`` lives
in a *different* directory, remove it so Python's import system will
load the current plugin's version instead of reusing the stale cache.
Returns:
Dict mapping evicted module names to their module objects
(for restoration on error).
"""
resolved_dir = plugin_dir.resolve()
evicted: dict = {}
for py_file in plugin_dir.glob("*.py"):
mod_name = py_file.stem
if mod_name.startswith("_"):
continue
existing = sys.modules.get(mod_name)
if existing is None:
continue
existing_file = getattr(existing, "__file__", None)
if not existing_file:
continue
try:
if not Path(existing_file).resolve().is_relative_to(resolved_dir):
evicted[mod_name] = sys.modules.pop(mod_name)
self.logger.debug(
"Evicted stale module '%s' (from %s) before loading plugin in %s",
mod_name, existing_file, plugin_dir,
)
except (ValueError, TypeError):
continue
return evicted
def _namespace_plugin_modules(
self, plugin_id: str, plugin_dir: Path, before_keys: set
) -> None:
"""
Move bare-name plugin modules to namespaced keys in sys.modules.
After exec_module loads a plugin's entry point, Python will have added
the plugin's local modules (scroll_display, game_renderer, …) to
sys.modules under their bare names. This method renames them to
``_plg_<plugin_id>_<module>`` so they cannot collide with identically-
named modules from other plugins.
The plugin code keeps working because ``from scroll_display import X``
binds ``X`` to the class *object*, not to the sys.modules entry.
Args:
plugin_id: Plugin identifier
plugin_dir: Plugin directory path
before_keys: Snapshot of sys.modules keys taken *before* exec_module
"""
safe_id = plugin_id.replace("-", "_")
namespaced_names: set = set()
for mod_name, mod in self._iter_plugin_bare_modules(plugin_dir, before_keys):
namespaced = f"_plg_{safe_id}_{mod_name}"
sys.modules[namespaced] = mod
# Remove the bare sys.modules entry. The module object stays
# alive via the namespaced key and all existing Python-level
# bindings (``from scroll_display import X`` already bound X
# to the class object). Leaving bare entries would cause the
# NEXT plugin's exec_module to find the cached entry and reuse
# it instead of loading its own version.
sys.modules.pop(mod_name, None)
namespaced_names.add(namespaced)
self.logger.debug(
"Namespace-isolated module '%s' -> '%s' for plugin %s",
mod_name, namespaced, plugin_id,
)
# Track for cleanup during unload
self._plugin_module_registry[plugin_id] = namespaced_names
if namespaced_names:
self.logger.info(
"Namespace-isolated %d module(s) for plugin %s",
len(namespaced_names), plugin_id,
)
def unregister_plugin_modules(self, plugin_id: str) -> None:
"""Remove namespaced sub-modules and cached module for a plugin from sys.modules.
Called by PluginManager during unload to clean up all module entries
that were created when the plugin was loaded.
"""
for ns_name in self._plugin_module_registry.pop(plugin_id, set()):
sys.modules.pop(ns_name, None)
self._loaded_modules.pop(plugin_id, None)
def load_module(
self,
plugin_id: str,
plugin_dir: Path,
entry_point: str
) -> Optional[Any]:
"""
Load a plugin module from file.
Module loading is serialized via _module_load_lock because plugins are
loaded in parallel (ThreadPoolExecutor) and multiple sport plugins
share identically-named local modules (scroll_display.py,
game_renderer.py, sports.py, etc.).
After loading, bare-name modules from the plugin directory are moved
to namespaced keys in sys.modules (e.g. ``_plg_basketball_scoreboard_scroll_display``)
so they cannot collide with other plugins.
Args:
plugin_id: Plugin identifier
plugin_dir: Plugin directory path
entry_point: Entry point filename (e.g., 'manager.py')
Returns:
Loaded module or None on error
"""
plugin_id = os.path.basename(plugin_id or '')
if not plugin_id:
raise PluginError("Invalid plugin ID")
try:
plugin_dir_resolved = plugin_dir.resolve(strict=True)
except OSError:
raise PluginError("Plugin directory not found", plugin_id=plugin_id)
entry_file = (plugin_dir_resolved / entry_point).resolve()
try:
entry_file.relative_to(plugin_dir_resolved)
except ValueError:
raise PluginError("Invalid entry point path", plugin_id=plugin_id)
if not entry_file.exists():
error_msg = f"Entry point file not found for plugin {plugin_id}"
self.logger.error(error_msg)
raise PluginError(error_msg, plugin_id=plugin_id, context={'entry_file': str(entry_file)})
with self._module_load_lock:
# Add plugin directory to sys.path if not already there
plugin_dir_str = str(plugin_dir)
if plugin_dir_str not in sys.path:
sys.path.insert(0, plugin_dir_str)
self.logger.debug("Added plugin directory to sys.path: %s", plugin_dir_str)
# Import the plugin module
module_name = f"plugin_{plugin_id.replace('-', '_')}"
# Check if already loaded
if module_name in sys.modules:
self.logger.debug("Module %s already loaded, reusing", module_name)
return sys.modules[module_name]
spec = importlib.util.spec_from_file_location(module_name, entry_file)
if spec is None or spec.loader is None:
error_msg = f"Could not create module spec for {entry_file}"
self.logger.error(error_msg)
raise PluginError(error_msg, plugin_id=plugin_id, context={'entry_file': str(entry_file)})
module = importlib.util.module_from_spec(spec)
sys.modules[module_name] = module
# Snapshot AFTER inserting the main module so that
# _namespace_plugin_modules and error cleanup only target
# sub-modules, not the main module entry itself.
before_keys = set(sys.modules.keys())
# Evict stale bare-name modules from other plugin directories
# so Python's import system loads fresh copies from this plugin.
evicted = self._evict_stale_bare_modules(plugin_dir)
try:
spec.loader.exec_module(module)
# Move bare-name plugin modules to namespaced keys so they
# cannot collide with identically-named modules from other plugins
self._namespace_plugin_modules(plugin_id, plugin_dir, before_keys)
except Exception:
# Restore evicted modules so other plugins are unaffected
for evicted_name, evicted_mod in evicted.items():
if evicted_name not in sys.modules:
sys.modules[evicted_name] = evicted_mod
# Clean up the partially-initialized main module and any
# bare-name sub-modules that were added during exec_module
# so they don't leak into subsequent plugin loads.
sys.modules.pop(module_name, None)
for key, _ in self._iter_plugin_bare_modules(plugin_dir, before_keys):
sys.modules.pop(key, None)
raise
self._loaded_modules[plugin_id] = module
self.logger.debug("Loaded module %s for plugin %s", module_name, plugin_id)
return module
def get_plugin_class(
self,
plugin_id: str,
module: Any,
class_name: str
) -> Type[Any]:
"""
Get the plugin class from a loaded module.
Args:
plugin_id: Plugin identifier
module: Loaded module
class_name: Name of the plugin class
Returns:
Plugin class
Raises:
PluginError: If class not found
"""
if not hasattr(module, class_name):
error_msg = f"Class {class_name} not found in module for plugin {plugin_id}"
self.logger.error(error_msg)
raise PluginError(
error_msg,
plugin_id=plugin_id,
context={'class_name': class_name, 'module': module.__name__}
)
plugin_class = getattr(module, class_name)
# Verify it's a class
if not isinstance(plugin_class, type):
error_msg = f"{class_name} is not a class in module for plugin {plugin_id}"
self.logger.error(error_msg)
raise PluginError(error_msg, plugin_id=plugin_id, context={'class_name': class_name})
return plugin_class
def instantiate_plugin(
self,
plugin_id: str,
plugin_class: Type[Any],
config: Dict[str, Any],
display_manager: Any,
cache_manager: Any,
plugin_manager: Any
) -> Any:
"""
Instantiate a plugin class.
Args:
plugin_id: Plugin identifier
plugin_class: Plugin class to instantiate
config: Plugin configuration
display_manager: Display manager instance
cache_manager: Cache manager instance
plugin_manager: Plugin manager instance
Returns:
Plugin instance
Raises:
PluginError: If instantiation fails
"""
try:
plugin_instance = plugin_class(
plugin_id=plugin_id,
config=config,
display_manager=display_manager,
cache_manager=cache_manager,
plugin_manager=plugin_manager
)
self.logger.debug("Instantiated plugin %s", plugin_id)
return plugin_instance
except Exception as e:
error_msg = f"Failed to instantiate plugin {plugin_id}: {e}"
self.logger.error(error_msg, exc_info=True)
raise PluginError(error_msg, plugin_id=plugin_id) from e
def load_plugin(
self,
plugin_id: str,
manifest: Dict[str, Any],
plugin_dir: Path,
config: Dict[str, Any],
display_manager: Any,
cache_manager: Any,
plugin_manager: Any,
install_deps: bool = True
) -> Tuple[Any, Any]:
"""
Complete plugin loading process.
Args:
plugin_id: Plugin identifier
manifest: Plugin manifest
plugin_dir: Plugin directory path
config: Plugin configuration
display_manager: Display manager instance
cache_manager: Cache manager instance
plugin_manager: Plugin manager instance
install_deps: Whether to install dependencies
Returns:
Tuple of (plugin_instance, module)
Raises:
PluginError: If loading fails
"""
# Install dependencies if needed
if install_deps:
self.install_dependencies(plugin_dir, plugin_id)
# Load module
entry_point = manifest.get('entry_point', 'manager.py')
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
class_name = manifest.get('class_name')
if not class_name:
raise PluginError(f"No class_name in manifest for plugin {plugin_id}", plugin_id=plugin_id)
plugin_class = self.get_plugin_class(plugin_id, module, class_name)
# Instantiate plugin
plugin_instance = self.instantiate_plugin(
plugin_id,
plugin_class,
config,
display_manager,
cache_manager,
plugin_manager
)
return (plugin_instance, module)