Files
2026-04-07 14:11:45 +08:00

306 lines
10 KiB
Python

# ///
# 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()