"""插件加载器。 通过 ``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.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`` 入口函数,校验返回清单 ID 一致性后返回。 参数: manifest: 渠道清单,提供插件入口模块路径与声明 ID。 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) 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 = self._parseManifestForInstall(os.path.join(target_plugin_dir, "manifest.json")) 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 def _parseManifestForInstall(self, manifest_path: str) -> ChannelManifest: """解析 ``manifest.json`` 为 ``ChannelManifest``(安装阶段轻量解析)。 复用 ``PluginLifecycleManager._parse_manifest`` 的同款字段映射逻辑, 但独立实现以避免与 ``PluginLifecycleManager`` 形成循环依赖。仅解析 ``ChannelManifest`` 字段,不解析 ``PluginManifest`` 扩展点。 参数: manifest_path: ``manifest.json`` 文件路径。 返回: 解析后的渠道清单。 抛出: ValidationError: JSON 解析失败 / 必填字段缺失 / 字段值非法。 """ from yuxi.channels.contract.dtos.capability import ChannelCapabilities from yuxi.channels.contract.dtos.channel import ChannelType from yuxi.channels.contract.dtos.config import ConfigField from yuxi.channels.contract.dtos.plugin import EnvVar from yuxi.channels.contract.plugin.manifest import ( AcquireMethod, CredentialStrategy, CredentialType, FailurePolicy, PluginDependency, ResourceQuota, ) 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): raise ValidationError( field="manifest.json", message=f"清单根节点必须为对象: {manifest_path}", ) required_fields = [ "id", "name", "version", "channel_type", "entry_module", "capabilities", "config_schema", "provides", "lifecycle", "compatibility", "failure_policy", ] for field_name in required_fields: if field_name not in data: raise ValidationError( field=field_name, message=f"清单缺少必填字段: {field_name}", ) try: channel_type = ChannelType(data["channel_type"]) except ValueError as e: raise ValidationError( field="channel_type", message=f"未知的渠道类型: {data['channel_type']}", ) from e try: failure_policy = FailurePolicy(data["failure_policy"]) except ValueError as e: raise ValidationError( field="failure_policy", message=f"未知的失败策略: {data['failure_policy']}", ) from e caps_data = data.get("capabilities") or {} capabilities = ChannelCapabilities( rich_message=caps_data.get("rich_message", False), streaming=caps_data.get("streaming", False), typing_indicator=caps_data.get("typing_indicator", False), message_edit=caps_data.get("message_edit", False), message_recall=caps_data.get("message_recall", False), supports_reaction=caps_data.get("supports_reaction", False), supports_pin=caps_data.get("supports_pin", False), supports_card_update=caps_data.get("supports_card_update", False), supports_card_update_streaming=caps_data.get("supports_card_update_streaming", False), supports_image_inbound=caps_data.get("supports_image_inbound", False), supports_video_inbound=caps_data.get("supports_video_inbound", False), supports_image_outbound=caps_data.get("supports_image_outbound", False), supports_video_outbound=caps_data.get("supports_video_outbound", False), mention=caps_data.get("mention", False), command=caps_data.get("command", False), directory=caps_data.get("directory", False), doctor=caps_data.get("doctor", False), whitelist=caps_data.get("whitelist", False), wizard=caps_data.get("wizard", False), tools=caps_data.get("tools", False), status=caps_data.get("status", False), probeable=caps_data.get("probeable", False), identity_resolver=caps_data.get("identity_resolver", False), lifecycle=caps_data.get("lifecycle", False), supports_qr_login=caps_data.get("supports_qr_login", False), agent_collaboration=caps_data.get("agent_collaboration", False), ) config_schema = tuple( ConfigField( key=cf["key"], type=cf["type"], required=cf.get("required", False), default=cf.get("default"), hot_reloadable=cf.get("hot_reloadable", True), constraints=cf.get("constraints"), ) for cf in data.get("config_schema", []) ) depends = tuple( PluginDependency( plugin_id=dep["plugin_id"], version_range=dep["version_range"], ) for dep in data.get("depends", []) ) quota_data = data.get("resource_quota") resource_quota = ( ResourceQuota( max_cpu=quota_data.get("max_cpu"), max_memory=quota_data.get("max_memory"), max_connections=quota_data.get("max_connections"), max_calls_per_sec=quota_data.get("max_calls_per_sec"), ) if quota_data else None ) env_vars = tuple( EnvVar( name=ev["name"], description=ev["description"], required=ev.get("required", True), default=ev.get("default"), sensitive=ev.get("sensitive", False), ) for ev in data.get("env_vars", []) ) cs_data = data.get("credential_strategy") credential_strategy = ( CredentialStrategy( type=CredentialType(cs_data["type"]), acquire_method=AcquireMethod(cs_data["acquire_method"]), required_fields=tuple(cs_data.get("required_fields", [])), optional_fields=tuple(cs_data.get("optional_fields", [])), supports_rotation=cs_data.get("supports_rotation", False), supports_revocation=cs_data.get("supports_revocation", False), ) if cs_data else None ) return ChannelManifest( id=data["id"], name=data["name"], version=data["version"], channel_type=channel_type, provides=tuple(data["provides"]), entry_module=data["entry_module"], capabilities=capabilities, config_schema=config_schema, depends=depends, lifecycle=tuple(data["lifecycle"]), compatibility=data["compatibility"], failure_policy=failure_policy, resource_quota=resource_quota, accessible_ports=tuple(data.get("accessible_ports", [])), injectable_pipelines=tuple(data.get("injectable_pipelines", [])), skills=tuple(data.get("skills", [])), env_vars=env_vars, critical=bool(data.get("critical", False)), requires_dm_pairing=bool(data.get("requires_dm_pairing", True)), requires_outbound_delivery=bool(data.get("requires_outbound_delivery", True)), credential_strategy=credential_strategy, max_message_length=int(data.get("max_message_length", 4096)), supports_credential_cloning=bool(data.get("supports_credential_cloning", False)), )