feat(plugin): add virtual plugin instances

This commit is contained in:
jxxghp
2026-08-22 09:40:38 +08:00
parent 4061868d25
commit 7b9499fc5b
21 changed files with 891 additions and 114 deletions
+165
View File
@@ -3,12 +3,16 @@
from __future__ import annotations
import importlib
import importlib.util
import sys
import threading
import traceback
from collections.abc import Callable
from pathlib import Path
from typing import Any, Optional
from app.schemas.plugin import PluginInstance
PluginImportPreparer = Callable[..., None]
PluginImportScanner = Callable[..., None]
@@ -18,6 +22,8 @@ PluginValidator = Callable[[Any], bool]
class PluginLoader:
"""只负责从运行目录发现插件类,并维护对应模块缓存。"""
_instance_import_lock = threading.RLock()
def __init__(
self,
*,
@@ -96,6 +102,165 @@ class PluginLoader:
)
return plugins
def load_instance(
self,
instance: PluginInstance,
validator: PluginValidator,
) -> list[Any]:
"""在实例专属模块命名空间中重新执行源插件代码并返回适配类。"""
source_dir = self._plugins_root / instance.source_plugin_id.lower()
source_file = source_dir / "__init__.py"
if not source_file.exists():
self._logger.warning(
f"虚拟插件实例 {instance.instance_id} 的源码不存在:{source_dir}"
)
return []
module_name = f"app.plugins.{instance.instance_id.lower()}"
self.clear_modules(instance.instance_id)
try:
self._import_preparer(
plugin_id=instance.source_plugin_id.lower(),
plugin_dir=source_dir,
)
self._import_scanner(
plugin_id=instance.source_plugin_id.lower(),
plugin_dir=source_dir,
)
spec = importlib.util.spec_from_file_location(
module_name,
source_file,
submodule_search_locations=[str(source_dir)],
)
if spec is None or spec.loader is None:
raise ImportError(f"无法创建模块规格:{module_name}")
module = importlib.util.module_from_spec(spec)
self._execute_instance_module(
module=module,
module_name=module_name,
source_module_name=(
f"app.plugins.{instance.source_plugin_id.lower()}"
),
loader=spec.loader,
)
for name, candidate in module.__dict__.items():
if name.startswith("_") or not isinstance(candidate, type):
continue
if not validator(candidate):
continue
self._adapt_instance_class(candidate, instance)
self._logger.debug(
f"{instance.source_plugin_id} 加载虚拟插件实例:{instance.instance_id}"
)
return [candidate]
except Exception as error: # noqa: BLE001
self.clear_modules(instance.instance_id)
self._logger.error(
f"加载虚拟插件实例 {instance.instance_id} 失败:{error} - "
f"{traceback.format_exc()}"
)
return []
def _execute_instance_module(
self,
*,
module: Any,
module_name: str,
source_module_name: str,
loader: Any,
) -> None:
"""执行实例模块,并把旧式自身绝对导入迁移到实例命名空间。"""
source_prefix = f"{source_module_name}."
parent_module = sys.modules.get("app.plugins")
source_attribute = source_module_name.rsplit(".", 1)[-1]
missing = object()
with self._instance_import_lock:
source_snapshot = {
name: loaded_module
for name, loaded_module in list(sys.modules.items())
if name == source_module_name or name.startswith(source_prefix)
}
parent_snapshot = (
getattr(parent_module, source_attribute, missing)
if parent_module
else missing
)
for name in source_snapshot:
sys.modules.pop(name, None)
sys.modules[module_name] = module
# 兼容旧插件在包内仍写 app.plugins.<source> 的绝对导入。
sys.modules[source_module_name] = module
if parent_module:
setattr(parent_module, source_attribute, module)
captured: dict[str, Any] = {}
try:
loader.exec_module(module)
captured = {
name: loaded_module
for name, loaded_module in list(sys.modules.items())
if name == source_module_name or name.startswith(source_prefix)
}
finally:
for name in list(sys.modules):
if name == source_module_name or name.startswith(source_prefix):
sys.modules.pop(name, None)
sys.modules.update(source_snapshot)
if parent_module:
if parent_snapshot is missing:
try:
delattr(parent_module, source_attribute)
except AttributeError:
pass
else:
setattr(parent_module, source_attribute, parent_snapshot)
for source_name, loaded_module in captured.items():
suffix = source_name[len(source_module_name):]
instance_name = f"{module_name}{suffix}"
sys.modules[instance_name] = loaded_module
self._retarget_module_identity(
loaded_module,
source_name,
instance_name,
)
@staticmethod
def _retarget_module_identity(
module: Any,
source_name: str,
instance_name: str,
) -> None:
"""修正被旧绝对路径加载对象的模块身份,避免事件与诊断键冲突。"""
if getattr(module, "__name__", None) == source_name:
module.__name__ = instance_name
package_name = getattr(module, "__package__", None)
if isinstance(package_name, str) and package_name.startswith(source_name):
module.__package__ = instance_name + package_name[len(source_name):]
spec = getattr(module, "__spec__", None)
if spec and getattr(spec, "name", None) == source_name:
spec.name = instance_name
for value in vars(module).values():
if getattr(value, "__module__", None) == source_name:
try:
value.__module__ = instance_name
except (AttributeError, TypeError):
continue
@staticmethod
def _adapt_instance_class(candidate: Any, instance: PluginInstance) -> None:
"""只改运行身份与展示元数据,不改源码、限定名和联邦产物。"""
candidate.__name__ = instance.instance_id
candidate.plugin_instance_id = instance.instance_id
candidate.plugin_source_id = instance.source_plugin_id
candidate.is_clone = True
candidate.plugin_config_prefix = f"{instance.instance_id.lower()}_"
if instance.plugin_name:
candidate.plugin_name = instance.plugin_name
if instance.plugin_desc:
candidate.plugin_desc = instance.plugin_desc
if instance.plugin_icon:
candidate.plugin_icon = instance.plugin_icon
def clear_modules(self, plugin_id: Optional[str] = None) -> list[str]:
"""清除指定插件或全部插件的 Python 模块缓存。"""
prefix = (