EU-Utility/core/plugin_manager_optimized.py

416 lines
14 KiB
Python

"""
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 plugins.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,
}