""" EU-Utility - Optimized Plugin Manager Performance improvements: 1. Lazy plugin loading 2. Cached plugin metadata 3. Parallel plugin initialization 4. Efficient config I/O 5. Reduced file system operations """ import os import sys import json import importlib import importlib.util import threading from pathlib import Path from typing import Dict, List, Type, Optional, Any from concurrent.futures import ThreadPoolExecutor, as_completed from functools import lru_cache from core.base_plugin import BasePlugin class PluginMetadata: """Cached plugin metadata to avoid repeated file reads.""" __slots__ = ['plugin_id', 'name', 'version', 'description', 'module_name', 'file_path', 'plugin_class'] def __init__(self, plugin_id: str, plugin_class: Type[BasePlugin], module_name: str, file_path: Path): self.plugin_id = plugin_id self.name = plugin_class.name self.version = plugin_class.version self.description = plugin_class.description self.module_name = module_name self.file_path = file_path self.plugin_class = plugin_class class PluginManager: """ Optimized Plugin Manager with lazy loading and caching. Features: - Cached plugin metadata - Lazy plugin instantiation - Parallel initialization - Efficient config I/O """ PLUGIN_DIRS = [ "plugins", # Built-in plugins "user_plugins", # User-installed plugins ] def __init__(self, overlay_window): self.overlay = overlay_window self.plugins: Dict[str, BasePlugin] = {} self.plugin_metadata: Dict[str, PluginMetadata] = {} self._metadata_lock = threading.RLock() self._plugins_lock = threading.RLock() # Config self._config: Dict[str, Any] = {} self._config_path = Path("config/plugins.json") self._config_lock = threading.RLock() self._config_dirty = False self._load_config() # Add plugin dirs to path for plugin_dir in self.PLUGIN_DIRS: path = Path(plugin_dir).absolute() if path.exists() and str(path) not in sys.path: sys.path.insert(0, str(path)) # Thread pool for parallel init self._executor = ThreadPoolExecutor(max_workers=4) def _load_config(self) -> None: """Load plugin configuration (cached).""" with self._config_lock: if self._config_path.exists(): try: with open(self._config_path, 'r') as f: self._config = json.load(f) return except (json.JSONDecodeError, IOError) as e: print(f"[PluginManager] Config load error: {e}") # Default: no plugins enabled self._config = {"enabled": [], "settings": {}} def save_config(self, force: bool = False) -> None: """Save plugin configuration (batched).""" with self._config_lock: if not force and not self._config_dirty: return try: self._config_path.parent.mkdir(parents=True, exist_ok=True) with open(self._config_path, 'w') as f: json.dump(self._config, f, indent=2) self._config_dirty = False except Exception as e: print(f"[PluginManager] Config save error: {e}") def _mark_config_dirty(self): """Mark config as needing save.""" with self._config_lock: self._config_dirty = True def is_plugin_enabled(self, plugin_id: str) -> bool: """Check if a plugin is enabled.""" with self._config_lock: return plugin_id in self._config.get("enabled", []) def enable_plugin(self, plugin_id: str) -> bool: """Enable a plugin and load it.""" with self._config_lock: if plugin_id in self._config.get("enabled", []): return True if "enabled" not in self._config: self._config["enabled"] = [] self._config["enabled"].append(plugin_id) self._mark_config_dirty() # Load the plugin metadata = self.plugin_metadata.get(plugin_id) if metadata: return self._load_plugin_internal(metadata) return False def disable_plugin(self, plugin_id: str) -> bool: """Disable a plugin and unload it.""" with self._config_lock: enabled = self._config.get("enabled", []) if plugin_id in enabled: enabled.remove(plugin_id) self._mark_config_dirty() # Unload if loaded with self._plugins_lock: if plugin_id in self.plugins: self._unload_plugin_internal(plugin_id) return True return False def discover_plugins(self) -> List[PluginMetadata]: """ Discover all available plugins with caching. Only reads filesystem once per session. """ with self._metadata_lock: if self.plugin_metadata: return list(self.plugin_metadata.values()) discovered = [] for plugin_dir in self.PLUGIN_DIRS: base_path = Path(plugin_dir) if not base_path.exists(): continue # Find plugin folders try: items = list(base_path.iterdir()) except OSError: continue for item in items: if not item.is_dir(): continue if item.name.startswith("__"): continue if item.name == "__pycache__": continue plugin_file = item / "plugin.py" if not plugin_file.exists(): continue try: metadata = self._load_plugin_metadata(item, plugin_file, plugin_dir) if metadata: discovered.append(metadata) except Exception as e: print(f"[PluginManager] Failed to discover {item.name}: {e}") continue # Cache metadata with self._metadata_lock: for metadata in discovered: self.plugin_metadata[metadata.plugin_id] = metadata return discovered def _load_plugin_metadata(self, item: Path, plugin_file: Path, plugin_dir: str) -> Optional[PluginMetadata]: """Load metadata for a plugin without instantiating it.""" module_name = f"{plugin_dir}.{item.name}.plugin" # Check if already loaded if module_name in sys.modules: module = sys.modules[module_name] else: spec = importlib.util.spec_from_file_location( module_name, plugin_file ) if not spec or not spec.loader: return None module = importlib.util.module_from_spec(spec) sys.modules[module_name] = module spec.loader.exec_module(module) # Find plugin class for attr_name in dir(module): attr = getattr(module, attr_name) if (isinstance(attr, type) and issubclass(attr, BasePlugin) and attr != BasePlugin and not attr.__name__.startswith("Base")): plugin_id = f"{module_name}.{attr.__name__}" return PluginMetadata( plugin_id=plugin_id, plugin_class=attr, module_name=module_name, file_path=plugin_file ) return None def load_plugin(self, plugin_class: Type[BasePlugin]) -> bool: """Load a plugin by class.""" plugin_id = f"{plugin_class.__module__}.{plugin_class.__name__}" metadata = PluginMetadata( plugin_id=plugin_id, plugin_class=plugin_class, module_name=plugin_class.__module__, file_path=Path(".") ) with self._metadata_lock: self.plugin_metadata[plugin_id] = metadata return self._load_plugin_internal(metadata) def _load_plugin_internal(self, metadata: PluginMetadata) -> bool: """Internal method to load a plugin instance.""" plugin_id = metadata.plugin_id # Check if already loaded with self._plugins_lock: if plugin_id in self.plugins: return True # Get plugin config with self._config_lock: plugin_config = self._config.get("settings", {}).get(plugin_id, {}) # Create instance try: instance = metadata.plugin_class(self.overlay, plugin_config) except Exception as e: print(f"[PluginManager] Failed to create {metadata.name}: {e}") return False # Initialize with error handling try: instance.initialize() except Exception as e: print(f"[PluginManager] Failed to initialize {metadata.name}: {e}") import traceback traceback.print_exc() return False # Store with self._plugins_lock: self.plugins[plugin_id] = instance print(f"[PluginManager] ✓ Loaded: {instance.name} v{instance.version}") return True def load_all_plugins(self, parallel: bool = True) -> None: """ Load all enabled plugins. Args: parallel: Whether to initialize plugins in parallel """ # Discover all plugins first discovered = self.discover_plugins() # Filter to enabled plugins enabled_metadata = [ m for m in discovered if self.is_plugin_enabled(m.plugin_id) ] if not enabled_metadata: print("[PluginManager] No plugins enabled") return if parallel and len(enabled_metadata) > 1: self._load_plugins_parallel(enabled_metadata) else: for metadata in enabled_metadata: self._load_plugin_internal(metadata) def _load_plugins_parallel(self, metadata_list: List[PluginMetadata]): """Load plugins in parallel using thread pool.""" futures = {} for metadata in metadata_list: future = self._executor.submit(self._load_plugin_internal, metadata) futures[future] = metadata # Wait for completion for future in as_completed(futures): metadata = futures[future] try: future.result() except Exception as e: print(f"[PluginManager] Error loading {metadata.name}: {e}") def get_all_discovered_plugins(self) -> Dict[str, Type[BasePlugin]]: """Get all discovered plugin classes (including disabled).""" with self._metadata_lock: return { id: m.plugin_class for id, m in self.plugin_metadata.items() } def get_plugin(self, plugin_id: str) -> Optional[BasePlugin]: """Get a loaded plugin by ID.""" with self._plugins_lock: return self.plugins.get(plugin_id) def get_plugin_ui(self, plugin_id: str): """Get UI widget for a plugin.""" plugin = self.get_plugin(plugin_id) if plugin: try: return plugin.get_ui() except Exception as e: print(f"[PluginManager] Error getting UI for {plugin_id}: {e}") return None def get_all_plugins(self) -> Dict[str, BasePlugin]: """Get all loaded plugins.""" with self._plugins_lock: return self.plugins.copy() def _unload_plugin_internal(self, plugin_id: str) -> None: """Internal method to unload a plugin.""" try: self.plugins[plugin_id].shutdown() except Exception as e: print(f"[PluginManager] Error shutting down {plugin_id}: {e}") del self.plugins[plugin_id] def unload_plugin(self, plugin_id: str) -> None: """Shutdown and unload a plugin.""" with self._plugins_lock: if plugin_id in self.plugins: self._unload_plugin_internal(plugin_id) def shutdown_all(self) -> None: """Shutdown all plugins.""" with self._plugins_lock: for plugin_id in list(self.plugins.keys()): self._unload_plugin_internal(plugin_id) # Save config self.save_config(force=True) # Shutdown executor self._executor.shutdown(wait=True) def trigger_hotkey(self, hotkey: str) -> bool: """Trigger plugin by hotkey. Returns True if handled.""" with self._plugins_lock: plugins = list(self.plugins.items()) for plugin_id, plugin in plugins: if plugin.hotkey == hotkey and plugin.enabled: try: plugin.on_hotkey() return True except Exception as e: print(f"[PluginManager] Error triggering hotkey for {plugin_id}: {e}") return False def get_stats(self) -> Dict[str, Any]: """Get plugin manager statistics.""" with self._metadata_lock: discovered_count = len(self.plugin_metadata) with self._plugins_lock: loaded_count = len(self.plugins) with self._config_lock: enabled_count = len(self._config.get("enabled", [])) return { 'discovered': discovered_count, 'loaded': loaded_count, 'enabled': enabled_count, }