Files
DDSRem 2de976f6b3 Merge pull request #7050 from DDSRem-Dev/codex-comment-migration
docs: 统一全项目中文注释和结构化契约说明
2026-10-17 19:26:29 +08:00

553 lines
22 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
插件源码发现、导入和模块缓存清理。
"""
from __future__ import annotations
import importlib
import importlib.util
import sys
import threading
import traceback
import typing
from collections.abc import Callable, Mapping
from pathlib import Path
from typing import Any, Optional
from app.domain.plugin import check_plugin_runtime_compatibility
from app.foundation.environment import is_free_threaded_runtime
from app.runtime.execution import plugin_package_lock
from app.runtime.extensions.plugin.gil import (
GilFallbackRecorder,
attribute_gil_fallback,
ignore_gil_fallback,
)
from app.runtime.settings import get_runtime_setting
from app.schemas.plugin import PluginInstance, PluginRuntimeStatus
PluginImportPreparer = Callable[..., None]
PluginImportScanner = Callable[..., None]
PluginValidator = Callable[[Any], bool]
# 按物理插件 ID 读取安装时提交的运行时声明;未建立声明的存量安装返回空映射
PluginRuntimeDeclarationReader = Callable[[str], Mapping[str, bool]]
def _undeclared_runtime(_: str) -> Mapping[str, bool]:
"""
默认声明读取端口:无声明即保持历史插件可加载。
"""
return {}
PluginRuntimeStatusWriter = Callable[[str, PluginRuntimeStatus], None]
# 插件模块卸载后释放第三方类型缓存
# 插件重载或卸载只把模块移出 sys.modules;若旧插件类仍被进程级类型缓存引用,
# 整份旧模块(函数、类、模块全局)会一直留在内存中,每重载一次多留一份。
# 类型参数嵌套深度上限,防止病态注解导致递归过深
_MAX_TYPE_DEPTH = 8
def release_plugin_type_caches(module_prefix: str) -> None:
"""
释放缓存中引用指定插件模块前缀下类型的条目。
- Pydantic 泛型模型缓存:值是弱引用,但键以强引用保存参数化用到的类,而参数化
结果又引用这些类,两者互相保活永不释放;顶层参数化还会在泛型基类模块留下全局
引用。只删除键里含有该插件类型的条目及其全局引用,其余模型的参数化结果与类身份
保持不变。
- ``typing`` 下标缓存:``Optional[X]`` 等注解按参数强引用缓存(每个函数 128 条),
只影响注解构造性能,整体清空。
:param module_prefix: 插件模块前缀,例如 ``app.plugins.demo``;为 ``app.plugins`` 时覆盖全部插件
"""
_purge_pydantic_generic_cache(module_prefix)
# typing 私有清理入口,与标准库测试清理类型缓存的方式一致;版本不提供时跳过
for cleanup in getattr(typing, "_cleanups", ()):
cleanup()
def _purge_pydantic_generic_cache(module_prefix: str) -> None:
"""
删除 Pydantic 泛型缓存中键引用了插件类型的条目,以及它们留下的模块全局引用。
"""
try:
from pydantic._internal._generics import _GENERIC_TYPES_CACHE
except ImportError:
# 私有实现随 Pydantic 版本变化,缺失时不影响插件卸载
return
for key in list(_GENERIC_TYPES_CACHE.keys()):
if not _references_module(key, module_prefix, 0):
continue
model = _GENERIC_TYPES_CACHE.pop(key, None)
if model is not None:
_drop_pickle_reference(model)
def _drop_pickle_reference(model: type) -> None:
"""
移除 Pydantic 为支持 pickle 写入泛型基类所在模块的全局引用。
在模块顶层参数化泛型模型(如 ``RootModel[list[Payload]]``)时,Pydantic 会把结果以
``RootModel[list[Payload]]``(重名时追加下划线)登记到泛型基类所在模块的全局命名空间,
该引用与缓存无关、永不删除。只移除以模型名开头且确实指向该模型的登记项。
"""
module = sys.modules.get(model.__module__)
if module is None:
return
namespace = vars(module)
for name, value in list(namespace.items()):
if value is model and name.startswith(model.__name__):
namespace.pop(name, None)
def _references_module(value: Any, module_prefix: str, depth: int) -> bool:
"""
判断缓存键(类型、元组或参数化注解)是否引用了指定模块前缀下定义的类型。
"""
if depth > _MAX_TYPE_DEPTH:
return False
if isinstance(value, type):
module = value.__dict__.get("__module__")
return isinstance(module, str) and (
module == module_prefix or module.startswith(f"{module_prefix}.")
)
if isinstance(value, tuple):
return any(_references_module(item, module_prefix, depth + 1) for item in value)
origin = typing.get_origin(value)
if origin is None:
return False
return _references_module(origin, module_prefix, depth + 1) or any(
_references_module(arg, module_prefix, depth + 1)
for arg in typing.get_args(value)
)
class PluginLoader:
"""
只负责从运行目录发现插件类,并维护对应模块缓存。
"""
_instance_import_lock = threading.RLock()
def __init__(
self,
*,
plugins_root: Path,
import_preparer: PluginImportPreparer,
import_scanner: PluginImportScanner,
log: Any,
runtime_status_writer: Optional[PluginRuntimeStatusWriter] = None,
runtime_declaration: PluginRuntimeDeclarationReader = _undeclared_runtime,
gil_fallback_recorder: GilFallbackRecorder = ignore_gil_fallback,
) -> None:
"""
保存插件目录、导入前置能力、状态回写端口、声明读取端口、GIL 归因端口和日志端口。
"""
self._plugins_root = plugins_root
self._import_preparer = import_preparer
self._import_scanner = import_scanner
self._runtime_status_writer = runtime_status_writer
self._runtime_declaration = runtime_declaration
self._gil_fallback_recorder = gil_fallback_recorder
self._logger = log
def load(
self,
plugin_id: Optional[str],
installed_plugins: list[str],
validator: PluginValidator,
) -> list[Any]:
"""
只导入指定插件或已安装插件,并返回通过契约检查的插件类。
"""
if not self._plugins_root.exists():
self._logger.warning(f"插件目录不存在:{self._plugins_root}")
return []
targets = (
[plugin_id.lower()]
if plugin_id
else [item.lower() for item in installed_plugins]
)
# 运行目录名统一小写,而卡片按调用方传入的原始 ID 读状态,回写前要还原大小写
installed_ids = {
item.lower(): item
for item in ([plugin_id] if plugin_id else installed_plugins)
if item
}
if not targets:
self._logger.debug("没有需要加载的插件")
return []
plugins: list[Any] = []
loaded_classes: set[str] = set()
for plugin_dir in self._plugins_root.iterdir():
if not plugin_dir.is_dir() or plugin_dir.name.startswith("_"):
continue
if plugin_dir.name not in targets:
self._logger.debug(
f"跳过插件目录:{plugin_dir.name}(不在加载列表中)"
)
continue
with plugin_package_lock(plugin_dir.name):
self._load_plugin_directory(
plugin_dir=plugin_dir,
plugin_id=installed_ids.get(
plugin_dir.name,
plugin_dir.name,
),
validator=validator,
loaded_classes=loaded_classes,
plugins=plugins,
)
return plugins
def _load_plugin_directory(
self,
*,
plugin_dir: Path,
plugin_id: str,
validator: PluginValidator,
loaded_classes: set[str],
plugins: list[Any],
) -> None:
"""
在持有包锁期间验证并导入一个完整的插件目录。
"""
if not (plugin_dir / "__init__.py").exists():
self._logger.debug(
f"跳过插件目录:{plugin_dir.name}(缺少__init__.py)"
)
return
if not self._is_runtime_compatible(plugin_dir.name):
self._logger.warning(
f"跳过插件 {plugin_dir.name}:声明与当前运行时不兼容"
)
self._mark_incompatible_runtime(plugin_id)
return
try:
module_name = f"app.plugins.{plugin_dir.name}"
self._logger.debug(f"正在导入插件模块:{module_name}")
self._import_preparer(
plugin_id=plugin_dir.name,
plugin_dir=plugin_dir,
)
self._import_scanner(
plugin_id=plugin_dir.name,
plugin_dir=plugin_dir,
)
# 插件模块顶层导入原生扩展最常见,GIL 回退多发生在这一步;
# 全量启动时逐个模块观察,才能把回退归因到具体插件
with attribute_gil_fallback(
plugin_id,
self._gil_fallback_recorder,
):
module = importlib.import_module(module_name)
for name, candidate in module.__dict__.items():
if name.startswith("_") or not isinstance(candidate, type):
continue
if name in loaded_classes or not validator(candidate):
continue
loaded_classes.add(name)
plugins.append(candidate)
self._logger.debug(f"找到符合条件的插件类:{name}")
break
except Exception as error:
self._logger.error(
f"加载插件 {plugin_dir.name} 失败:{str(error)} - "
f"{traceback.format_exc()}"
)
def load_instance(
self,
instance: PluginInstance,
validator: PluginValidator,
) -> list[Any]:
"""
在实例专属模块命名空间中重新执行源插件代码并返回适配类。
"""
with plugin_package_lock(instance.source_plugin_id):
return self._load_instance_locked(instance, validator)
def _load_instance_locked(
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 []
if not self._is_runtime_compatible(instance.source_plugin_id):
self._logger.warning(
f"跳过虚拟插件实例 {instance.instance_id}:声明与当前运行时不兼容"
)
self._mark_incompatible_runtime(instance.instance_id)
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 is_runtime_compatible(self, plugin_id: str) -> bool:
"""
按插件 ID 判断运行目录载荷是否兼容当前运行时。
公开可调用:生命周期在装载结果为空时要区分"加载失败"和"运行时不兼容",
两者在卡片上是完全不同的提示。
:param plugin_id: 物理插件 ID,大小写不敏感
"""
return self._is_runtime_compatible(plugin_id)
def _mark_incompatible_runtime(self, plugin_id: str) -> None:
"""
把运行时不兼容记成插件卡片可见的状态。
按 ID 定向装载时生命周期的空结果分支也会落同一个状态;这里补的是全量装载
(``plugin_id`` 为空)那条路径——它不逐个遍历目标,缺了这一笔,用户看到的
就是一张既不运行也不解释的占位卡片。
"""
if not self._runtime_status_writer:
return
self._runtime_status_writer(
plugin_id,
PluginRuntimeStatus.INCOMPATIBLE_RUNTIME,
)
def _is_runtime_compatible(self, plugin_id: str) -> bool:
"""
按安装时提交的声明快照执行运行时兼容门禁,缺失声明时保持兼容。
判据来自 ``PluginIdentity.declared_metadata`` 里随载荷一起提交的 package
运行时声明,而不是运行目录中的文件:插件目录里同名的 ``package.json`` 是
模块联邦组件的 npm manifest,既不承载代际位,也不会被安装流程写入。
:param plugin_id: 物理插件 ID,大小写不敏感
"""
declaration = self._runtime_declaration(plugin_id.lower())
version_flag = get_runtime_setting("VERSION_FLAG")
if version_flag and declaration.get(version_flag) is False:
return False
compatible, _ = check_plugin_runtime_compatibility(
declaration,
free_threaded=is_free_threaded_runtime(),
)
return compatible
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:
continue
try:
value.__module__ = instance_name
except (AttributeError, TypeError):
continue
if isinstance(value, type):
PluginLoader._retarget_member_identity(
value, source_name, instance_name
)
@staticmethod
def _retarget_member_identity(
owner: type,
source_name: str,
instance_name: str,
) -> None:
"""
把类体内定义的函数一并改到实例模块名下。
事件处理器的注册键取自函数所在模块名,而类体内的函数是类的属性、不是模块
的属性,只遍历模块顶层碰不到它们。漏改会让实例与源插件本体注册到同一个
handler 键上:后注册的覆盖先注册的,实例收不到事件,停本体会把实例一起停掉。
"""
for member in vars(owner).values():
target = getattr(member, "__func__", member)
if getattr(target, "__module__", None) != source_name:
continue
try:
target.__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 = (
f"app.plugins.{plugin_id.lower()}"
if plugin_id
else "app.plugins"
)
removed = [
module_name
for module_name in list(sys.modules)
if module_name == prefix or module_name.startswith(f"{prefix}.")
]
for module_name in removed:
sys.modules.pop(module_name, None)
self._logger.debug(f"已清除插件模块缓存:{module_name}")
if removed:
# 只移出 sys.modules 不足以释放旧模块:第三方类型缓存仍强引用旧插件类
release_plugin_type_caches(prefix)
importlib.invalidate_caches()
self._logger.debug("已清除查找器的缓存")
if plugin_id:
if removed:
self._logger.info(
f"插件 {plugin_id} 共清除 {len(removed)} 个模块缓存:{removed}"
)
else:
self._logger.debug(f"插件 {plugin_id} 没有找到需要清除的模块缓存")
return removed