ForcePilot/backend/package/yuxi/channels/application/lifecycle/plugin_loader.py
Kris 742299cb07 chore: 批量清理报告相关代码并完成多项功能迭代
本次提交包含多维度代码优化与功能增强:
1.  移除报告模块冗余导入与枚举,清理报表相关代码
2.  新增扫码登录支持方法与飞书适配器适配
3.  完善异常日志与健康检查信息
4.  扩展目录、配对管理、能力查询等接口
5.  优化出站管道与事务提交后钩子逻辑
6.  修复飞书消息解析与响应空值问题
7.  重构配置更新与服务账号创建逻辑
8.  统一传输错误分类契约与错误基类扩展
2026-07-06 20:49:35 +08:00

346 lines
14 KiB
Python
Raw 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.

"""插件加载器。
通过 ``importlib`` 动态导入插件模块,调用 ``CHANNEL_ENTRY`` 入口函数,返回
``PluginManifest``。插件通过 ``CHANNEL_ENTRY`` 契约注册,不隐式全局注入
FR-32
异常处理保留原始错误上下文INV-7使用 ``raise ... from e`` 保留 Python
异常链,同时将原始异常封装为 ``Error`` 作为结构化 ``cause``
- 模块导入失败 → ``DependencyError``
- ``CHANNEL_ENTRY`` 缺失或不可调用 → ``ValidationError``
- ``CHANNEL_ENTRY`` 执行失败 → ``InternalError``
- 清单 ID 不一致 → ``ValidationError``
"""
from __future__ import annotations
import importlib
import json
import os
import shutil
from yuxi.channels.application.lifecycle.manifest_loader import load_manifest_from_dir
from yuxi.channels.contract.errors import (
ConflictError,
DependencyError,
InternalError,
NotFoundError,
ValidationError,
)
from yuxi.channels.contract.errors.base import Error
from yuxi.channels.contract.plugin.entry import CHANNEL_ENTRY, PluginHost
from yuxi.channels.contract.plugin.manifest import (
ChannelManifest,
PluginManifest,
)
from yuxi.channels.contract.ports.driven.logger_port import LoggerPort
__all__ = ["PluginLoader"]
class PluginLoader:
"""插件加载器,``importlib`` 动态导入 + ``CHANNEL_ENTRY`` 调用。
FR-32。插件通过 ``CHANNEL_ENTRY`` 注册,不隐式全局注入。
加载流程:
1. ``importlib.import_module`` 导入 ``manifest.entry_module`` 指定的
插件模块。
2. 从模块获取 ``CHANNEL_ENTRY`` 入口函数,校验存在性与可调用性。
3. 调用入口函数,传入 ``PluginHost``,获取 ``PluginManifest``。
4. 校验返回清单的 ID 与声明清单的 ID 一致。
异常处理保留原始错误上下文INV-7使用 ``raise ... from e`` 保留
Python 异常链,同时将原始异常封装为 ``Error`` 作为结构化 ``cause``
(与 ``adapters`` 层既有模式一致):
- ``ImportError`` → ``DependencyError``(依赖模块导入失败)
- ``CHANNEL_ENTRY`` 缺失或不可调用 → ``ValidationError``
- ``CHANNEL_ENTRY`` 执行失败 → ``InternalError``
- 清单 ID 不一致 → ``ValidationError``
"""
def __init__(self, logger: LoggerPort, plugin_dir: str | None = None) -> None:
"""初始化插件加载器。
参数:
logger: 日志被驱动端口,用于记录加载过程中的调试与成功日志。
plugin_dir: 插件目录路径P1 PLG-INSTALL/UNINSTALL 文件级操作
注入),用于 ``installFromSource`` 复制插件文件与 ``uninstall``
删除插件文件。为 ``None`` 时仅可执行 ``load``,文件级安装/卸载
抛 ``ValidationError``(未配置插件目录)。
"""
self._logger = logger
self._plugin_dir = plugin_dir
async def load(
self,
manifest: ChannelManifest,
host: PluginHost,
) -> PluginManifest:
"""加载插件,返回 ``PluginManifest``。
通过 ``importlib`` 动态导入 ``manifest.entry_module`` 指定的模块,
调用其 ``CHANNEL_ENTRY`` 入口函数(传入 discover 阶段解析的 manifest
供插件复用),校验返回清单 ID 一致性后返回。
参数:
manifest: 渠道清单,提供插件入口模块路径与声明 ID同时作为
``CHANNEL_ENTRY`` 第二入参供插件复用F-03 单真相源)。
host: 插件宿主,传入 ``CHANNEL_ENTRY`` 供插件获取端口与注册扩展点。
返回:
插件清单,包含渠道清单与扩展点声明。
抛出:
DependencyError: 插件模块导入失败(保留原始 ``ImportError`` 链)。
ValidationError: ``CHANNEL_ENTRY`` 缺失或不可调用,或清单 ID 不一致。
InternalError: ``CHANNEL_ENTRY`` 执行失败(保留原始异常链)。
"""
await self._logger.debug(
f"加载插件模块: entry_module={manifest.entry_module}",
plugin_id=manifest.id,
)
try:
module = importlib.import_module(manifest.entry_module)
except ImportError as e:
raise DependencyError(
manifest.entry_module,
Error(str(e)),
) from e
entry_fn: CHANNEL_ENTRY | None = getattr(module, "CHANNEL_ENTRY", None)
if entry_fn is None or not callable(entry_fn):
raise ValidationError(
"CHANNEL_ENTRY",
f"插件入口函数未找到或不可调用: {manifest.entry_module}",
)
try:
plugin_manifest = entry_fn(host, manifest)
except Exception as e:
raise InternalError(Error(str(e))) from e
if plugin_manifest.manifest.id != manifest.id:
raise ValidationError(
"manifest.id",
f"清单 ID 不匹配: 声明={manifest.id}, 实际={plugin_manifest.manifest.id}",
)
await self._logger.info(
f"插件加载成功: plugin_id={manifest.id}",
plugin_id=manifest.id,
entry_module=manifest.entry_module,
)
return plugin_manifest
async def installFromSource(
self,
source_type: str,
source: str,
version: str | None,
force: bool,
plugin_dir: str | None = None,
) -> PluginManifest:
"""从来源安装插件文件到插件目录,返回 ``PluginManifest``PLG-INSTALL
按 ``source_type`` 分派安装策略:
- ``path``: 从本地路径复制插件目录到 ``plugin_dir`` 下的同名子目录。
- ``url`` / ``registry``: 暂不支持,抛 ``NotImplementedError``
(保留扩展点)。
复制完成后解析 ``manifest.json`` 构造 ``ChannelManifest``(不含
``adapters`` 等扩展点,需后续 ``load`` 调用 ``CHANNEL_ENTRY`` 获取完整
``PluginManifest``)。安装元数据(``installed_at`` / ``install_source``
/ ``install_version``)写入返回的 ``PluginManifest``。
参数:
source_type: 来源类型(``path`` / ``url`` / ``registry``)。
source: 来源路径或 URL``path`` 时为本地插件目录绝对路径)。
version: 期望安装的版本号(可选,记录到 ``install_version``)。
force: 是否强制覆盖已存在插件目录。``False`` 时目标目录已存在
抛 ``ConflictError``。
plugin_dir: 覆盖默认插件目录(可选,默认使用 ``__init__`` 注入的
``self._plugin_dir``)。
返回:
安装后的 ``PluginManifest``,含 ``installed_at`` / ``install_source``
/ ``install_version`` 元数据,但 ``adapters`` 等扩展点字段为空元组
(需后续 ``load`` 调用 ``CHANNEL_ENTRY`` 填充)。
抛出:
ValidationError: ``source_type`` 非法 / ``source`` 为空 / 插件目录
未配置 / 本地来源路径不存在 / ``manifest.json`` 缺失或格式非法。
ConflictError: 目标插件目录已存在且 ``force=False``。
DependencyError: 文件系统操作失败(复制 / 删除)。
"""
target_dir = plugin_dir if plugin_dir is not None else self._plugin_dir
if not target_dir:
raise ValidationError(
"plugin_dir",
"plugin_dir is not configured for PluginLoader, cannot perform installFromSource",
)
if not source:
raise ValidationError("source", "source must not be empty")
if source_type not in ("path", "url", "registry"):
raise ValidationError(
"source_type",
f"unsupported source_type: {source_type}",
)
if source_type in ("url", "registry"):
raise NotImplementedError(
f"installFromSource source_type={source_type}",
)
# source_type == "path":本地目录复制安装
if not os.path.exists(source) or not os.path.isdir(source):
raise ValidationError(
"source",
f"source path does not exist or is not a directory: {source}",
)
source_manifest_path = os.path.join(source, "manifest.json")
if not os.path.exists(source_manifest_path):
raise ValidationError(
"manifest.json",
f"source manifest.json not found: {source_manifest_path}",
)
plugin_id = self._readPluginIdFromManifest(source_manifest_path)
target_plugin_dir = os.path.join(target_dir, plugin_id)
if os.path.exists(target_plugin_dir):
if not force:
await self._logger.warn(
f"插件目录已存在,未启用 force 覆盖: {target_plugin_dir}",
plugin_id=plugin_id,
target_plugin_dir=target_plugin_dir,
)
raise ConflictError(resource="plugin_dir")
shutil.rmtree(target_plugin_dir)
os.makedirs(target_dir, exist_ok=True)
try:
shutil.copytree(source, target_plugin_dir)
except OSError as e:
raise DependencyError(
"plugin_install",
Error(str(e)),
) from e
manifest = load_manifest_from_dir(target_plugin_dir)
from datetime import UTC, datetime
plugin_manifest = PluginManifest(
manifest=manifest,
installed_at=datetime.now(UTC),
install_source=source,
install_version=version,
)
await self._logger.info(
f"插件安装成功: plugin_id={plugin_id}",
plugin_id=plugin_id,
source_type=source_type,
source=source,
)
return plugin_manifest
async def uninstall(
self,
plugin_id: str,
plugin_dir: str | None = None,
) -> tuple[str, ...]:
"""删除插件目录文件返回删除的文件列表PLG-UNINSTALL-FILE
定位 ``plugin_dir/{plugin_id}`` 目录,遍历其下所有文件(相对
``plugin_id`` 目录的相对路径),删除整个目录后返回文件列表供审计
追溯。
参数:
plugin_id: 插件 ID同时为插件目录名
plugin_dir: 覆盖默认插件目录(可选,默认使用 ``__init__`` 注入的
``self._plugin_dir``)。
返回:
已删除的文件相对路径元组(相对 ``plugin_id`` 目录),按字典序
排序。
抛出:
ValidationError: 插件目录未配置。
NotFoundError: 插件目录不存在。
DependencyError: 文件系统删除失败。
"""
target_dir = plugin_dir if plugin_dir is not None else self._plugin_dir
if not target_dir:
raise ValidationError(
"plugin_dir",
"plugin_dir is not configured for PluginLoader, cannot perform uninstall",
)
plugin_path = os.path.join(target_dir, plugin_id)
if not os.path.exists(plugin_path):
raise NotFoundError(
resource="plugin_dir",
id=plugin_id,
)
if not os.path.isdir(plugin_path):
raise ValidationError(
"plugin_dir",
f"plugin path is not a directory: {plugin_path}",
)
removed_files: list[str] = []
for root, _dirs, files in os.walk(plugin_path):
for filename in files:
full_path = os.path.join(root, filename)
rel_path = os.path.relpath(full_path, plugin_path)
removed_files.append(rel_path)
removed_files.sort()
try:
shutil.rmtree(plugin_path)
except OSError as e:
raise DependencyError(
"plugin_uninstall",
Error(str(e)),
) from e
await self._logger.info(
f"插件卸载成功: plugin_id={plugin_id}",
plugin_id=plugin_id,
files_removed=len(removed_files),
)
return tuple(removed_files)
@staticmethod
def _readPluginIdFromManifest(manifest_path: str) -> str:
"""读取 ``manifest.json`` 顶层 ``id`` 字段,供安装目标目录命名。
参数:
manifest_path: ``manifest.json`` 文件绝对路径。
返回:
插件 ID 字符串。
抛出:
ValidationError: JSON 解析失败 / 根节点非对象 / 缺少 ``id`` 字段。
"""
try:
with open(manifest_path, encoding="utf-8") as f:
data = json.load(f)
except (OSError, json.JSONDecodeError) as e:
raise ValidationError(
field="manifest.json",
message=f"清单文件解析失败: {manifest_path}: {e}",
) from e
if not isinstance(data, dict) or "id" not in data:
raise ValidationError(
field="manifest.json",
message=f"清单缺少 id 字段: {manifest_path}",
)
plugin_id = data["id"]
if not isinstance(plugin_id, str) or not plugin_id:
raise ValidationError(
field="manifest.json",
message=f"清单 id 字段必须为非空字符串: {manifest_path}",
)
return plugin_id