306 lines
10 KiB
Python
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()
|