# /// # plugin_loader.py # 描述:热加载插件系统,支持外部插件目录和动态加载 # 功能:监控插件目录变化,自动注册/卸载插件,无需重启 # 支持插件自定义依赖自动安装 # 作者:AI Generated # 创建日期:2026-04-06 # /// import os import sys import time import importlib import inspect import logging import subprocess from typing import Dict, List, Optional, Type, Any from watchdog.observers import Observer from watchdog.events import FileSystemEventHandler, FileSystemEvent from plugins.base import PluginBase, PluginRegistry logging.basicConfig(level=logging.INFO) logger = logging.getLogger("plugin_loader") class DependencyManager: def __init__(self): self._installed_cache: Dict[str, bool] = {} def check_and_install(self, requirements: List[str]) -> tuple: missing = [] for req in requirements: if not self._is_installed(req): missing.append(req) if not missing: return True, [] logger.warning(f"Missing dependencies: {missing}") success, failed = self._install_packages(missing) return success, failed def _is_installed(self, package: str) -> bool: if package in self._installed_cache: return self._installed_cache[package] pkg_name = package.split(">")[0].split("<")[0].split("=")[0].strip() try: importlib.import_module(pkg_name.replace("-", "_")) self._installed_cache[package] = True return True except ImportError: self._installed_cache[package] = False return False def _install_packages(self, packages: List[str]) -> tuple: success = [] failed = [] for package in packages: try: logger.info(f"Installing dependency: {package}") subprocess.check_call( [sys.executable, "-m", "pip", "install", package, "-q"], timeout=60, stderr=subprocess.DEVNULL, stdout=subprocess.DEVNULL ) self._installed_cache[package] = True success.append(package) logger.info(f"Installed: {package}") except Exception as e: logger.error(f"Failed to install {package}: {e}") self._installed_cache[package] = False failed.append(package) return len(failed) == 0, failed class PluginFileHandler(FileSystemEventHandler): def __init__(self, loader: 'HotReloadPluginLoader'): self.loader = loader def on_created(self, event: FileSystemEvent): if not event.is_directory and event.src_path.endswith('.py'): logger.info(f"New plugin file detected: {event.src_path}") time.sleep(0.5) self.loader.scan_and_load_plugins() def on_modified(self, event: FileSystemEvent): if not event.is_directory and event.src_path.endswith('.py'): logger.info(f"Plugin file modified: {event.src_path}") time.sleep(0.5) self.loader.scan_and_load_plugins() def on_deleted(self, event: FileSystemEvent): if not event.is_directory and event.src_path.endswith('.py'): logger.info(f"Plugin file deleted: {event.src_path}") self.loader.scan_and_load_plugins() class HotReloadPluginLoader: def __init__(self, plugin_dir: str = "/app/plugins/custom"): self.plugin_dir = plugin_dir self._loaded_files: Dict[str, float] = {} self._loaded_classes: Dict[str, Type[PluginBase]] = {} self._observer: Optional[Observer] = None self._running = False self._dep_manager = DependencyManager() def start(self): if self._running: return os.makedirs(self.plugin_dir, exist_ok=True) logger.info(f"Starting hot-reload plugin loader on: {self.plugin_dir}") self.scan_and_load_plugins() self._observer = Observer() event_handler = PluginFileHandler(self) self._observer.schedule(event_handler, self.plugin_dir, recursive=False) self._observer.start() self._running = True logger.info("Hot-reload plugin loader started") def stop(self): if self._observer: self._observer.stop() self._observer.join() self._running = False logger.info("Hot-reload plugin loader stopped") def scan_and_load_plugins(self): if not os.path.exists(self.plugin_dir): return current_files = {} for filename in os.listdir(self.plugin_dir): if filename.endswith('.py') and not filename.startswith('_'): filepath = os.path.join(self.plugin_dir, filename) mtime = os.path.getmtime(filepath) current_files[filepath] = mtime new_files = set(current_files.keys()) - set(self._loaded_files.keys()) modified_files = [ f for f in current_files if f in self._loaded_files and current_files[f] > self._loaded_files[f] ] deleted_files = set(self._loaded_files.keys()) - set(current_files.keys()) for filepath in deleted_files: self._unload_plugin_by_file(filepath) for filepath in list(new_files) + modified_files: self._load_plugin_from_file(filepath) self._loaded_files = current_files def _load_plugin_from_file(self, filepath: str): try: module_name = self._get_module_name(filepath) if not self._check_plugin_dependencies(filepath): return spec = importlib.util.spec_from_file_location(module_name, filepath) if not spec or not spec.loader: return module = importlib.util.module_from_spec(spec) sys.modules[module_name] = module spec.loader.exec_module(module) for name, obj in inspect.getmembers(module): if (inspect.isclass(obj) and issubclass(obj, PluginBase) and obj != PluginBase): plugin_instance = obj() self._register_plugin(plugin_instance) self._loaded_classes[plugin_instance.plugin_name] = obj logger.info(f"Loaded plugin: {plugin_instance.plugin_name} v{plugin_instance.plugin_version}") except Exception as e: logger.error(f"Failed to load plugin from {filepath}: {e}") def _check_plugin_dependencies(self, filepath: str) -> bool: try: with open(filepath, 'r') as f: content = f.read() requires = self._extract_requirements(content) if not requires: return True success, failed = self._dep_manager.check_and_install(requires) if not success: logger.error(f"Plugin {filepath} has missing dependencies: {failed}") return False return True except Exception as e: logger.error(f"Failed to check dependencies for {filepath}: {e}") return False def _extract_requirements(self, content: str) -> List[str]: requirements = [] for line in content.split('\n'): line = line.strip() if line.startswith('#') and 'requires:' in line.lower(): req_part = line.split('requires:')[1].strip() requirements.extend([r.strip() for r in req_part.split(',')]) if line.startswith('import ') or line.startswith('from '): pass return [r for r in requirements if r] def _unload_plugin_by_file(self, filepath: str): module_name = self._get_module_name(filepath) to_remove = [] for plugin_name, cls in self._loaded_classes.items(): cls_module = inspect.getmodule(cls) if cls_module and cls_module.__name__ == module_name: PluginRegistry.unregister(plugin_name) logger.info(f"Unloaded plugin: {plugin_name}") to_remove.append(plugin_name) for plugin_name in to_remove: del self._loaded_classes[plugin_name] if module_name in sys.modules: del sys.modules[module_name] if filepath in self._loaded_files: del self._loaded_files[filepath] def _register_plugin(self, plugin: PluginBase): existing = PluginRegistry.get(plugin.plugin_name) if existing: PluginRegistry.unregister(plugin.plugin_name) logger.info(f"Replacing existing plugin: {plugin.plugin_name}") PluginRegistry.register(plugin) logger.info(f"Registered plugin: {plugin.plugin_name}") def _get_module_name(self, filepath: str) -> str: filename = os.path.basename(filepath) module_name = filename[:-3] return f"custom_plugins.{module_name}" def get_loaded_plugins(self) -> List[Dict[str, Any]]: return [p.get_info() for p in PluginRegistry.get_all() if p.plugin_name in self._loaded_classes] def get_dependency_status(self) -> Dict[str, Any]: return { "installed": list(self._dep_manager._installed_cache.keys()), "status": "ok" } def create_sample_plugin() -> str: sample_code = '''# /// # sample_plugin.py # 描述:示例自定义插件 # requires: httpx, pytz # /// from plugins.base import PluginBase, PluginType class SamplePlugin(PluginBase): plugin_name = "sample_plugin" plugin_version = "1.0.0" plugin_type = PluginType.CUSTOM plugin_description = "示例自定义插件" plugin_author = "User" def initialize(self, config) -> bool: self.config = config return True def execute(self, params) -> dict: return { "success": True, "message": "Sample plugin executed!", "params": params } ''' os.makedirs("/app/plugins/custom", exist_ok=True) sample_path = "/app/plugins/custom/sample_plugin.py" if not os.path.exists(sample_path): with open(sample_path, 'w') as f: f.write(sample_code) logger.info(f"Created sample plugin at: {sample_path}") return sample_path hot_plugin_loader = HotReloadPluginLoader()