ForcePilot/backend/package/yuxi/channels/application/lifecycle/plugin_loader.py

346 lines
14 KiB
Python
Raw Normal View History

"""插件加载器。
通过 ``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