From 61aa3cd6491c60a71fc7332ccc95f49c0649dd90 Mon Sep 17 00:00:00 2001 From: Aqr-K <95741669+Aqr-K@users.noreply.github.com> Date: Tue, 15 Sep 2026 07:01:00 +0800 Subject: [PATCH] =?UTF-8?q?refactor(sdk):=20=E6=8F=92=E4=BB=B6=E5=A5=91?= =?UTF-8?q?=E7=BA=A6=E5=9F=BA=E7=B1=BB=E8=BF=81=E5=85=A5=20SDK=20=E5=B9=B6?= =?UTF-8?q?=E7=94=B1=20Compat=20=E6=89=BF=E6=8E=A5=E6=97=A7=E5=8C=85?= =?UTF-8?q?=E6=A0=B9=E7=AC=A6=E5=8F=B7=20(#6701)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/db/oper/systemconfig.py | 4 +- app/plugins/__init__.py | 399 +---------------- app/runtime/compat/imports.py | 38 +- app/runtime/compat/manifest.py | 23 +- app/runtime/events.py | 12 +- app/sdk/plugin/__init__.py | 30 ++ app/sdk/plugin/base.py | 407 ++++++++++++++++++ app/sdk/{plugins.py => plugin/manager.py} | 1 - app/startup/initializers/modules.py | 2 +- docker/update.sh | 2 + docs/adr/0008-plugin-contract-base-in-sdk.md | 60 +++ docs/architecture-overview.md | 8 +- docs/refactor/optimization-checklist.md | 8 +- docs/rules/05-architecture.md | 11 +- scripts/architecture/service_locator.py | 4 +- .../architecture/dependency-baseline.json | 42 +- .../fixtures/architecture/mypy-baseline.json | 13 +- .../fixtures/architecture/ruff-baseline.json | 3 - .../runtime-contract-baseline.json | 35 +- tests/test_legacy_import_compat.py | 87 +++- tests/test_plugin_compat_governance.py | 24 +- tests/test_plugin_database_hooks.py | 2 +- tests/test_plugin_manager_async_methods.py | 2 +- tests/test_plugin_sdk.py | 46 +- tests/test_runtime_manager_identity.py | 4 +- tests/test_service_locator_gate.py | 2 +- 26 files changed, 805 insertions(+), 464 deletions(-) create mode 100644 app/sdk/plugin/__init__.py create mode 100644 app/sdk/plugin/base.py rename app/sdk/{plugins.py => plugin/manager.py} (99%) create mode 100644 docs/adr/0008-plugin-contract-base-in-sdk.md diff --git a/app/db/oper/systemconfig.py b/app/db/oper/systemconfig.py index a96c9b8ec..12234bb9f 100644 --- a/app/db/oper/systemconfig.py +++ b/app/db/oper/systemconfig.py @@ -19,10 +19,10 @@ class SystemConfigOper(DbOper, metaclass=Singleton): 系统配置管理 """ - def __init__(self): + def __init__(self) -> None: """初始化空快照,数据库加载由启动组合根显式执行。""" super().__init__() - self.__SYSTEMCONF = {} + self.__SYSTEMCONF: dict[str, Any] = {} self._snapshot_lock = threading.RLock() self._write_lock = threading.RLock() self._loaded = False diff --git a/app/plugins/__init__.py b/app/plugins/__init__.py index 4721c247b..b50d0e642 100644 --- a/app/plugins/__init__.py +++ b/app/plugins/__init__.py @@ -1,398 +1 @@ -from abc import ABCMeta, abstractmethod -from pathlib import Path -from typing import Any, List, Dict, Tuple, Optional, Type, Union - -from app.chain import ChainBase -from app.core.config import settings -from app.core.event import EventManager -from app.db.oper.plugindata import PluginDataOper -from app.db.oper.plugininstance import PluginInstanceOper -from app.db.oper.systemconfig import SystemConfigOper -from app.db.plugin.container import PluginDatabaseHandle -from app.db.plugin.registry import get_database as get_plugin_database -from app.helper.message import MessageHelper -from app.schemas import Notification, NotificationType, MessageChannel - - -class PluginChian(ChainBase): - """ - 插件处理链 - """ - pass - - -class _PluginBase(metaclass=ABCMeta): - """ - 插件模块基类,通过继续该类实现插件功能 - 除内置属性外,还有以下方法可以扩展或调用: - - stop_service() 停止插件服务 - - get_config() 获取配置信息 - - update_config() 更新配置信息 - - init_plugin() 生效配置信息 - - get_data_path() 获取插件数据保存目录 - """ - # 插件名称 - plugin_name: Optional[str] = "" - # 插件描述 - plugin_desc: Optional[str] = "" - # 插件顺序 - plugin_order: Optional[int] = 9999 - # 是否为插件分身 - is_clone: bool = False - - def __init__(self): - # 插件数据 - self.plugindata = PluginDataOper() - # 插件实例配置,仅供 get_config()/update_config() 使用,不属于插件公开面 - self._plugininstance = PluginInstanceOper() - # 处理链 - self.chain = PluginChian() - # 系统配置 - self.systemconfig = SystemConfigOper() - # 系统消息 - self.systemmessage = MessageHelper() - # 事件管理器 - self.eventmanager = EventManager() - - @abstractmethod - def init_plugin(self, config: dict = None): - """ - 生效配置信息 - :param config: 配置信息字典 - """ - pass - - def get_name(self) -> str: - """ - 获取插件名称 - :return: 插件名称 - """ - return self.plugin_name - - @abstractmethod - def get_state(self) -> bool: - """ - 获取插件运行状态 - """ - pass - - @staticmethod - def get_command() -> List[Dict[str, Any]]: - """ - 注册插件远程命令 - [{ - "cmd": "/xx", - "event": EventType.xx, - "desc": "名称", - "category": "分类,需要注册到Wechat时必须有分类", - "data": {} - }] - """ - pass - - @staticmethod - def get_render_mode() -> Tuple[str, Optional[str]]: - """ - 获取插件渲染模式 - :return: 1、渲染模式,支持:vue/vuetify,默认vuetify;2、vue模式下编译后文件的相对路径,默认为`dist/asserts`,vuetify模式下为None - """ - return "vuetify", None - - @abstractmethod - def get_api(self) -> List[Dict[str, Any]]: - """ - 注册插件API - [{ - "path": "/xx", - "endpoint": self.xxx, - "methods": ["GET", "POST"], - "auth: "apikey", # 鉴权类型:apikey/bear - "summary": "API名称", - "description": "API说明" - }] - """ - pass - - @abstractmethod - def get_form(self) -> Tuple[Optional[List[dict]], Dict[str, Any]]: - """ - 拼装插件配置页面,插件配置页面使用Vuetify组件拼装,参考:https://vuetifyjs.com/ - :return: 1、页面配置(vuetify模式)或 None(vue模式);2、默认数据结构 - """ - pass - - @abstractmethod - def get_page(self) -> Optional[List[dict]]: - """ - 拼装插件详情页面,需要返回页面配置,同时附带数据 - 插件详情页面使用Vuetify组件拼装,参考:https://vuetifyjs.com/ - :return: 页面配置(vuetify模式)或 None(vue模式) - """ - pass - - def get_service(self) -> List[Dict[str, Any]]: - """ - 注册插件公共服务 - [{ - "id": "服务ID", - "name": "服务名称", - "trigger": "触发器:cron/interval/date/CronTrigger.from_crontab()", - "func": self.xxx, - "kwargs": {} # 定时器参数 - }] - """ - pass - - def get_dashboard(self, key: str, **kwargs) -> Optional[Tuple[Dict[str, Any], Dict[str, Any], Optional[List[dict]]]]: - """ - 获取插件仪表盘页面,需要返回:1、仪表板col配置字典;2、全局配置(布局、自动刷新等);3、仪表板页面元素配置含数据json(vuetify)或 None(vue模式) - 1、col配置参考: - { - "cols": 12, "md": 6 - } - 2、全局配置参考: - { - "refresh": 10, // 自动刷新时间,单位秒 - "border": True, // 是否显示边框,默认True,为False时取消组件边框和边距,由插件自行控制 - "title": "组件标题", // 组件标题,如有将显示该标题,否则显示插件名称 - "subtitle": "组件子标题", // 组件子标题,缺省时不展示子标题 - } - 3、vuetify模式页面配置使用Vuetify组件拼装,参考:https://vuetifyjs.com/;vue模式为None - - kwargs参数可获取的值:1、user_agent:浏览器UA - - :param key: 仪表盘key,根据指定的key返回相应的仪表盘数据,缺省时返回一个固定的仪表盘数据(兼容旧版) - """ - pass - - def get_dashboard_meta(self) -> Optional[List[Dict[str, str]]]: - """ - 获取插件仪表盘元信息 - 返回示例: - [{ - "key": "dashboard1", // 仪表盘的key,在当前插件范围唯一 - "name": "仪表盘1" // 仪表盘的名称 - }, { - "key": "dashboard2", - "name": "仪表盘2" - }] - """ - pass - - def get_auth_providers(self) -> List[Dict[str, Any]]: - """ - 声明插件提供的登录认证入口。 - - 返回示例: - [{ - "id": "oidc", - "name": "OIDC 登录", - "icon": "mdi-openid", - "component": "AuthPage", - "enabled": True - }] - """ - pass - - def get_module(self) -> Dict[str, Any]: - """ - 获取插件模块声明,用于胁持系统模块实现(方法名:方法实现) - { - "id1": self.xxx1, - "id2": self.xxx2, - } - """ - pass - - def get_media_source(self) -> List[Dict[str, Any]]: - """ - 注册插件提供的媒体数据源。 - - 返回的每项至少包含 ``name``、``media_source`` 和 ``media_types``;实际的 - 搜索、识别、图片和 NFO 刮削实现通过 ``get_module`` 暴露对应方法。 - """ - pass - - def get_actions(self) -> List[Dict[str, Any]]: - """ - 获取插件工作流动作 - [{ - "id": "动作ID", - "name": "动作名称", - "func": self.xxx, - "kwargs": {} # 需要附加传递的参数 - }] - - 对实现函数的要求: - 1、函数的第一个参数固定为 ActionContent 实例,如需要传递额外参数,在kwargs中定义 - 2、函数的返回:执行状态 True / False,更新后的 ActionContent 实例 - """ - pass - - def get_agent_tools(self) -> List[Type]: - """ - 获取插件智能体工具 - 返回工具类列表,每个工具类必须继承自 MoviePilotTool - [ToolClass1, ToolClass2, ...] - - 对工具类的要求: - 1、工具类必须继承自 app.agent.tools.base.MoviePilotTool - 2、工具类需要实现 run 方法(异步方法) - 3、工具类需要定义 name 和 description 属性 - 4、工具类可以定义 args_schema 来指定输入参数模型 - """ - pass - - def get_database_models(self) -> Optional[List[Type]]: - """ - 声明插件自有数据库中的模型 - [ModelClass1, ModelClass2, ...] - - 对模型类的要求: - 1、模型类必须继承 app.sdk.database.plugin_declarative_base() 产出的基类 - 2、同一插件的模型应继承同一个基类,它们的表在插件启动时一并建立 - 3、同时声明了迁移目录时本声明被忽略,改由 alembic 建表 - """ - pass - - def get_database_migrations(self) -> Optional[Union[str, Path]]: - """ - 声明插件自有数据库的 Alembic 迁移脚本目录 - 目录须符合 Alembic script_location 布局,且必须是绝对路径,否则插件启动时报错 - (相对路径按宿主进程的当前工作目录解析,插件无法预期它指向哪里, - 建议用 Path(__file__).parent / "migrations" 之类的写法取得) - - 声明后插件启动时执行 alembic upgrade head,不再按 get_database_models() 建表。 - PostgreSQL 下迁移脚本的 env.py 必须从 context.config.attributes["connection"] - 取用宿主传入的连接,否则迁移会落在 public schema 而不是本插件的 schema。 - """ - pass - - @abstractmethod - def stop_service(self): - """ - 停止插件 - """ - pass - - def update_config(self, config: dict, plugin_id: Optional[str] = None) -> bool: - """ - 更新配置信息 - :param config: 配置信息字典 - :param plugin_id: 插件ID - """ - if not plugin_id: - plugin_id = self.__class__.__name__ - return self._plugininstance.save_config_data( - instance_id=plugin_id, - source_plugin_id=plugin_id, - config_data=config, - ) - - def get_config(self, plugin_id: Optional[str] = None) -> Any: - """ - 获取配置信息 - :param plugin_id: 插件ID - """ - if not plugin_id: - plugin_id = self.__class__.__name__ - return self._plugininstance.get_config_data(plugin_id) - - def get_data_path(self, plugin_id: Optional[str] = None) -> Path: - """ - 获取插件数据保存目录 - """ - if not plugin_id: - plugin_id = self.__class__.__name__ - data_path = settings.PLUGIN_DATA_PATH / f"{plugin_id}" - if not data_path.exists(): - data_path.mkdir(parents=True) - return data_path - - def get_database(self, plugin_id: Optional[str] = None) -> PluginDatabaseHandle: - """ - 获取插件自有数据库句柄,用于取会话读写插件自有表 - 句柄不存在时按需建立,不要求先声明模型 - PostgreSQL 下句柄的会话与连接在每个事务开始时把 search_path 限定到本插件 schema, - 未限定的原生 SQL 因此同样解析到插件自己的表,找不到的表名直接报错而非落到宿主表 - :param plugin_id: 插件ID - """ - if not plugin_id: - plugin_id = self.__class__.__name__ - return get_plugin_database(plugin_id) - - def save_data(self, key: str, value: Any, plugin_id: Optional[str] = None): - """ - 保存插件数据 - :param key: 数据key - :param value: 数据值 - :param plugin_id: 插件ID - """ - if not plugin_id: - plugin_id = self.__class__.__name__ - self.plugindata.save(plugin_id, key, value) - - async def async_save_data( - self, key: str, value: Any, plugin_id: Optional[str] = None - ) -> None: - """ - 异步保存插件数据 - - :param key: 数据键 - :param value: 数据值 - :param plugin_id: 插件ID - """ - if not plugin_id: - plugin_id = self.__class__.__name__ - await self.plugindata.async_save(plugin_id, key, value) - - def get_data(self, key: Optional[str] = None, plugin_id: Optional[str] = None) -> Any: - """ - 获取插件数据 - :param key: 数据key - :param plugin_id: plugin_id - """ - if not plugin_id: - plugin_id = self.__class__.__name__ - return self.plugindata.get_data(plugin_id, key) - - async def async_get_data( - self, key: Optional[str] = None, plugin_id: Optional[str] = None - ) -> Any: - """ - 异步获取插件数据 - - :param key: 数据键 - :param plugin_id: 插件ID - :return: 指定键的数据值或插件的全部数据 - """ - if not plugin_id: - plugin_id = self.__class__.__name__ - return await self.plugindata.async_get_data(plugin_id, key) - - def del_data(self, key: str, plugin_id: Optional[str] = None) -> Any: - """ - 删除插件数据 - :param key: 数据key - :param plugin_id: plugin_id - """ - if not plugin_id: - plugin_id = self.__class__.__name__ - return self.plugindata.del_data(plugin_id, key) - - def post_message(self, channel: MessageChannel = None, mtype: NotificationType = None, title: Optional[str] = None, - text: Optional[str] = None, image: Optional[str] = None, link: Optional[str] = None, - userid: Optional[str] = None, username: Optional[str] = None, - **kwargs): - """ - 发送消息 - """ - if not link: - link = settings.MP_DOMAIN(f"#/plugins?tab=installed&id={self.__class__.__name__}") - self.chain.post_message(Notification( - channel=channel, mtype=mtype, title=title, text=text, - image=image, link=link, userid=userid, username=username, **kwargs - )) - - def close(self): - pass +"""插件安装命名空间;契约基类由 app.sdk.plugin 拥有,旧包根符号由 Compat 惰性解析。""" diff --git a/app/runtime/compat/imports.py b/app/runtime/compat/imports.py index 7954d62e6..1ea3dc63c 100644 --- a/app/runtime/compat/imports.py +++ b/app/runtime/compat/imports.py @@ -5,7 +5,7 @@ import importlib.util import sys import threading from types import ModuleType -from typing import Dict +from typing import Dict, List, Tuple from app.runtime.compat.diagnostics import record_legacy_import from app.runtime.compat.manifest import ( @@ -15,6 +15,7 @@ from app.runtime.compat.manifest import ( SYMBOL_ALIASES, VIRTUAL_PACKAGES, ModuleAlias, + SymbolAlias, ) @@ -116,6 +117,29 @@ class VirtualLegacyPackageLoader(importlib.abc.Loader): record_legacy_import(self.package_name) +def detect_shadowed_exports( + module: ModuleType, + exports: Dict[str, SymbolAlias], +) -> List[Tuple[str, str]]: + """返回物理模块用第二份实现遮蔽的兼容符号及其 canonical 路径。 + + 模块级 ``__getattr__`` 只在属性查找失败时触发,物理模块自带同名定义时兼容叠加 + 完全不会被调用。重新导入同一个 canonical 对象是无害的,只有身份不同才是遮蔽。 + + :param module: 已执行完毕的物理模块 + :param exports: 该模块在兼容清单中登记的符号 + :return: ``(符号名, canonical 路径)`` 列表,无遮蔽时为空 + """ + shadowed = [] + for name, symbol in exports.items(): + if name not in module.__dict__: + continue + target = importlib.import_module(symbol.target_module) + if module.__dict__[name] is not getattr(target, symbol.target_name): + shadowed.append((name, symbol.replacement)) + return shadowed + + class LegacySymbolOverlayLoader(importlib.abc.Loader): """在标准物理模块执行后叠加旧符号的惰性解析,不修改 canonical 源码。""" @@ -161,6 +185,18 @@ class LegacySymbolOverlayLoader(importlib.abc.Loader): executor(module) exports = SYMBOL_ALIASES[self.module_name] + shadowed = detect_shadowed_exports(module, exports) + if shadowed: + detail = ";".join( + f"{self.module_name}.{name} 覆盖了 {replacement}" + for name, replacement in shadowed + ) + raise ImportError( + f"模块 {self.module_name} 自带第二份实现遮蔽了兼容符号:{detail}。" + f"通常是把旧版本的同名目录挂载或复制到了运行目录," + f"请让该包只保留不含实现的 __init__.py", + name=self.module_name, + ) previous_getattr = module.__dict__.get("__getattr__") previous_dir = module.__dict__.get("__dir__") had_all = "__all__" in module.__dict__ diff --git a/app/runtime/compat/manifest.py b/app/runtime/compat/manifest.py index b1354ab72..bff0815d8 100644 --- a/app/runtime/compat/manifest.py +++ b/app/runtime/compat/manifest.py @@ -442,13 +442,13 @@ MODULE_ALIASES: Dict[str, ModuleAlias] = { ), "app.core.module": ModuleAlias( target="app.runtime.extensions.module.manager", - replacement="app.sdk.plugins", + replacement="app.sdk.plugin.manager", introduced="v3.0.0", owner="runtime", ), "app.core.plugin": ModuleAlias( target="app.runtime.extensions.plugin.manager", - replacement="app.sdk.plugins", + replacement="app.sdk.plugin.manager", introduced="v3.0.0", owner="runtime", ), @@ -646,6 +646,12 @@ MODULE_ALIASES: Dict[str, ModuleAlias] = { introduced="v3.0.0", owner="agent", ), + "app.sdk.plugins": ModuleAlias( + target="app.sdk.plugin.manager", + replacement="app.sdk.plugin.manager", + introduced="v3.0.0", + owner="sdk", + ), } # 需要合成包级符号的旧路径单独登记;它们不是 canonical 模块别名。 @@ -1042,4 +1048,17 @@ SYMBOL_ALIASES: Dict[str, Dict[str, SymbolAlias]] = { ), }, "app.schemas.message": _MESSAGE_NOTIFICATION_SYMBOL_ALIASES, + # 插件契约基类归 SDK:安装命名空间包根只保留说明,旧包根符号在此精确承接 + "app.plugins": { + "PluginChian": SymbolAlias( + target_module="app.sdk.plugin.base", + target_name="PluginChain", + replacement="app.sdk.plugin.PluginChain", + ), + "_PluginBase": SymbolAlias( + target_module="app.sdk.plugin.base", + target_name="_PluginBase", + replacement="app.sdk.plugin._PluginBase", + ), + }, } diff --git a/app/runtime/events.py b/app/runtime/events.py index 6316c0904..90a3cedeb 100644 --- a/app/runtime/events.py +++ b/app/runtime/events.py @@ -7,7 +7,7 @@ import uuid from contextvars import ContextVar from dataclasses import dataclass from queue import Empty, PriorityQueue -from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Tuple, Type, TypeVar, Union +from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Set, Tuple, Type, TypeVar, Union from app.foundation.singleton import Singleton from app.runtime.correlation import get_correlation_id @@ -123,22 +123,22 @@ class EventManager(metaclass=Singleton): EventManager 负责管理和调度广播事件和链式事件,包括订阅、发送和处理事件 """ - def __init__(self): + def __init__(self) -> None: """初始化订阅表、处理队列、解析器和消费者状态。""" # 动态线程池,用于消费事件 self.__executor = ThreadHelper() # 用于保存启动的事件消费者线程 - self.__consumer_threads = [] + self.__consumer_threads: List[threading.Thread] = [] # 优先级队列 - self.__event_queue = PriorityQueue() + self.__event_queue: PriorityQueue[Any] = PriorityQueue() # 广播事件的订阅者 self.__broadcast_subscribers: Dict[EventType, Dict[str, Callable]] = {} # 链式事件的订阅者 self.__chain_subscribers: Dict[ChainEventType, Dict[str, tuple[int, Callable]]] = {} # 禁用的事件处理器集合 - self.__disabled_handlers = set() + self.__disabled_handlers: Set[str] = set() # 禁用的事件处理器类集合 - self.__disabled_classes = set() + self.__disabled_classes: Set[str] = set() # 线程锁 self.__lock = threading.Lock() # 退出事件 diff --git a/app/sdk/plugin/__init__.py b/app/sdk/plugin/__init__.py new file mode 100644 index 000000000..1b9830504 --- /dev/null +++ b/app/sdk/plugin/__init__.py @@ -0,0 +1,30 @@ +"""插件契约与运行时管理器的惰性稳定公开入口。""" + +from importlib import import_module +from typing import Any + +_EXPORTS = { + "ModuleManager": ("app.sdk.plugin.manager", "ModuleManager"), + "PluginChain": ("app.sdk.plugin.base", "PluginChain"), + "PluginManager": ("app.sdk.plugin.manager", "PluginManager"), + "_PluginBase": ("app.sdk.plugin.base", "_PluginBase"), +} + + +def __getattr__(name: str) -> Any: + """首次访问时解析稳定插件符号,避免导入契约基类连带拉起管理器。""" + contract = _EXPORTS.get(name) + if contract is None: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + module_name, symbol_name = contract + value = getattr(import_module(module_name), symbol_name) + globals()[name] = value + return value + + +def __dir__() -> list[str]: + """向交互式工具暴露稳定公开面。""" + return sorted({*globals(), *_EXPORTS}) + + +__all__ = ["ModuleManager", "PluginChain", "PluginManager", "_PluginBase"] diff --git a/app/sdk/plugin/base.py b/app/sdk/plugin/base.py new file mode 100644 index 000000000..661535eb0 --- /dev/null +++ b/app/sdk/plugin/base.py @@ -0,0 +1,407 @@ +"""插件继承的契约基类与插件处理链。""" + +from abc import ABCMeta, abstractmethod +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple, Type, Union + +from app.application.messaging.message import MessageHelper +from app.chain.base import ChainBase +from app.db.oper.plugindata import PluginDataOper +from app.db.oper.plugininstance import PluginInstanceOper +from app.db.oper.systemconfig import SystemConfigOper +from app.db.plugin.container import PluginDatabaseHandle +from app.db.plugin.registry import get_database as get_plugin_database +from app.runtime.events import EventManager +from app.schemas.message import Message +from app.schemas.types import MessageType, NotificationChannel +from app.sdk.config import settings + +__all__ = ["PluginChain", "_PluginBase"] + + +class PluginChain(ChainBase): + """ + 插件处理链 + """ + pass + + +class _PluginBase(metaclass=ABCMeta): + """ + 插件模块基类,通过继续该类实现插件功能 + 除内置属性外,还有以下方法可以扩展或调用: + - stop_service() 停止插件服务 + - get_config() 获取配置信息 + - update_config() 更新配置信息 + - init_plugin() 生效配置信息 + - get_data_path() 获取插件数据保存目录 + """ + # 插件名称 + plugin_name: Optional[str] = "" + # 插件描述 + plugin_desc: Optional[str] = "" + # 插件顺序 + plugin_order: Optional[int] = 9999 + # 是否为插件分身 + is_clone: bool = False + + def __init__(self) -> None: + # 插件数据 + self.plugindata = PluginDataOper() + # 处理链 + self.chain = PluginChain() + # 系统配置 + self.systemconfig = SystemConfigOper() + # 插件实例配置,仅供 get_config()/update_config() 使用,不属于插件公开面 + self._plugininstance = PluginInstanceOper() + # 系统消息 + self.systemmessage = MessageHelper() + # 事件管理器 + self.eventmanager = EventManager() + + @abstractmethod + def init_plugin(self, config: Optional[Dict[str, Any]] = None) -> None: + """ + 生效配置信息 + :param config: 配置信息字典 + """ + pass + + def get_name(self) -> Optional[str]: + """ + 获取插件名称 + :return: 插件名称 + """ + return self.plugin_name + + @abstractmethod + def get_state(self) -> bool: + """ + 获取插件运行状态 + """ + pass + + @staticmethod + def get_command() -> Optional[List[Dict[str, Any]]]: + """ + 注册插件远程命令 + [{ + "cmd": "/xx", + "event": EventType.xx, + "desc": "名称", + "category": "分类,需要注册到Wechat时必须有分类", + "data": {} + }] + """ + pass + + @staticmethod + def get_render_mode() -> Tuple[str, Optional[str]]: + """ + 获取插件渲染模式 + :return: 1、渲染模式,支持:vue/vuetify,默认vuetify;2、vue模式下编译后文件的相对路径,默认为`dist/asserts`,vuetify模式下为None + """ + return "vuetify", None + + @abstractmethod + def get_api(self) -> List[Dict[str, Any]]: + """ + 注册插件API + [{ + "path": "/xx", + "endpoint": self.xxx, + "methods": ["GET", "POST"], + "auth: "apikey", # 鉴权类型:apikey/bear + "summary": "API名称", + "description": "API说明" + }] + """ + pass + + @abstractmethod + def get_form(self) -> Tuple[Optional[List[Dict[str, Any]]], Dict[str, Any]]: + """ + 拼装插件配置页面,插件配置页面使用Vuetify组件拼装,参考:https://vuetifyjs.com/ + :return: 1、页面配置(vuetify模式)或 None(vue模式);2、默认数据结构 + """ + pass + + @abstractmethod + def get_page(self) -> Optional[List[Dict[str, Any]]]: + """ + 拼装插件详情页面,需要返回页面配置,同时附带数据 + 插件详情页面使用Vuetify组件拼装,参考:https://vuetifyjs.com/ + :return: 页面配置(vuetify模式)或 None(vue模式) + """ + pass + + def get_service(self) -> Optional[List[Dict[str, Any]]]: + """ + 注册插件公共服务 + [{ + "id": "服务ID", + "name": "服务名称", + "trigger": "触发器:cron/interval/date/CronTrigger.from_crontab()", + "func": self.xxx, + "kwargs": {} # 定时器参数 + }] + """ + pass + + def get_dashboard( + self, key: str, **kwargs: Any + ) -> Optional[Tuple[Dict[str, Any], Dict[str, Any], Optional[List[Dict[str, Any]]]]]: + """ + 获取插件仪表盘页面,需要返回:1、仪表板col配置字典;2、全局配置(布局、自动刷新等);3、仪表板页面元素配置含数据json(vuetify)或 None(vue模式) + 1、col配置参考: + { + "cols": 12, "md": 6 + } + 2、全局配置参考: + { + "refresh": 10, // 自动刷新时间,单位秒 + "border": True, // 是否显示边框,默认True,为False时取消组件边框和边距,由插件自行控制 + "title": "组件标题", // 组件标题,如有将显示该标题,否则显示插件名称 + "subtitle": "组件子标题", // 组件子标题,缺省时不展示子标题 + } + 3、vuetify模式页面配置使用Vuetify组件拼装,参考:https://vuetifyjs.com/;vue模式为None + + kwargs参数可获取的值:1、user_agent:浏览器UA + + :param key: 仪表盘key,根据指定的key返回相应的仪表盘数据,缺省时返回一个固定的仪表盘数据(兼容旧版) + """ + pass + + def get_dashboard_meta(self) -> Optional[List[Dict[str, str]]]: + """ + 获取插件仪表盘元信息 + 返回示例: + [{ + "key": "dashboard1", // 仪表盘的key,在当前插件范围唯一 + "name": "仪表盘1" // 仪表盘的名称 + }, { + "key": "dashboard2", + "name": "仪表盘2" + }] + """ + pass + + def get_auth_providers(self) -> Optional[List[Dict[str, Any]]]: + """ + 声明插件提供的登录认证入口。 + + 返回示例: + [{ + "id": "oidc", + "name": "OIDC 登录", + "icon": "mdi-openid", + "component": "AuthPage", + "enabled": True + }] + """ + pass + + def get_module(self) -> Optional[Dict[str, Any]]: + """ + 获取插件模块声明,用于胁持系统模块实现(方法名:方法实现) + { + "id1": self.xxx1, + "id2": self.xxx2, + } + """ + pass + + def get_media_source(self) -> Optional[List[Dict[str, Any]]]: + """ + 注册插件提供的媒体数据源。 + + 返回的每项至少包含 ``name``、``media_source`` 和 ``media_types``;实际的 + 搜索、识别、图片和 NFO 刮削实现通过 ``get_module`` 暴露对应方法。 + """ + pass + + def get_actions(self) -> Optional[List[Dict[str, Any]]]: + """ + 获取插件工作流动作 + [{ + "id": "动作ID", + "name": "动作名称", + "func": self.xxx, + "kwargs": {} # 需要附加传递的参数 + }] + + 对实现函数的要求: + 1、函数的第一个参数固定为 ActionContent 实例,如需要传递额外参数,在kwargs中定义 + 2、函数的返回:执行状态 True / False,更新后的 ActionContent 实例 + """ + pass + + def get_agent_tools(self) -> Optional[List[Type[Any]]]: + """ + 获取插件智能体工具 + 返回工具类列表,每个工具类必须继承自 MoviePilotTool + [ToolClass1, ToolClass2, ...] + + 对工具类的要求: + 1、工具类必须继承自 app.agent.tools.base.MoviePilotTool + 2、工具类需要实现 run 方法(异步方法) + 3、工具类需要定义 name 和 description 属性 + 4、工具类可以定义 args_schema 来指定输入参数模型 + """ + pass + + def get_database_models(self) -> Optional[List[Type[Any]]]: + """ + 声明插件自有数据库中的模型 + [ModelClass1, ModelClass2, ...] + + 对模型类的要求: + 1、模型类必须继承 app.sdk.database.plugin_declarative_base() 产出的基类 + 2、同一插件的模型应继承同一个基类,它们的表在插件启动时一并建立 + 3、同时声明了迁移目录时本声明被忽略,改由 alembic 建表 + """ + pass + + def get_database_migrations(self) -> Optional[Union[str, Path]]: + """ + 声明插件自有数据库的 Alembic 迁移脚本目录 + 目录须符合 Alembic script_location 布局,且必须是绝对路径,否则插件启动时报错 + (相对路径按宿主进程的当前工作目录解析,插件无法预期它指向哪里, + 建议用 Path(__file__).parent / "migrations" 之类的写法取得) + + 声明后插件启动时执行 alembic upgrade head,不再按 get_database_models() 建表。 + PostgreSQL 下迁移脚本的 env.py 必须从 context.config.attributes["connection"] + 取用宿主传入的连接,否则迁移会落在 public schema 而不是本插件的 schema。 + """ + pass + + @abstractmethod + def stop_service(self) -> None: + """ + 停止插件 + """ + pass + + def update_config(self, config: Dict[str, Any], plugin_id: Optional[str] = None) -> Optional[bool]: + """ + 更新配置信息 + :param config: 配置信息字典 + :param plugin_id: 插件ID + """ + if not plugin_id: + plugin_id = self.__class__.__name__ + return self._plugininstance.save_config_data( + instance_id=plugin_id, + source_plugin_id=plugin_id, + config_data=config, + ) + + def get_config(self, plugin_id: Optional[str] = None) -> Any: + """ + 获取配置信息 + :param plugin_id: 插件ID + """ + if not plugin_id: + plugin_id = self.__class__.__name__ + return self._plugininstance.get_config_data(plugin_id) + + def get_data_path(self, plugin_id: Optional[str] = None) -> Path: + """ + 获取插件数据保存目录 + """ + if not plugin_id: + plugin_id = self.__class__.__name__ + data_path: Path = settings.PLUGIN_DATA_PATH / f"{plugin_id}" + if not data_path.exists(): + data_path.mkdir(parents=True) + return data_path + + def get_database(self, plugin_id: Optional[str] = None) -> PluginDatabaseHandle: + """ + 获取插件自有数据库句柄,用于取会话读写插件自有表 + 句柄不存在时按需建立,不要求先声明模型 + PostgreSQL 下句柄的会话与连接在每个事务开始时把 search_path 限定到本插件 schema, + 未限定的原生 SQL 因此同样解析到插件自己的表,找不到的表名直接报错而非落到宿主表 + :param plugin_id: 插件ID + """ + if not plugin_id: + plugin_id = self.__class__.__name__ + return get_plugin_database(plugin_id) + + def save_data(self, key: str, value: Any, plugin_id: Optional[str] = None) -> None: + """ + 保存插件数据 + :param key: 数据key + :param value: 数据值 + :param plugin_id: 插件ID + """ + if not plugin_id: + plugin_id = self.__class__.__name__ + self.plugindata.save(plugin_id, key, value) + + async def async_save_data( + self, key: str, value: Any, plugin_id: Optional[str] = None + ) -> None: + """ + 异步保存插件数据 + + :param key: 数据键 + :param value: 数据值 + :param plugin_id: 插件ID + """ + if not plugin_id: + plugin_id = self.__class__.__name__ + await self.plugindata.async_save(plugin_id, key, value) + + def get_data(self, key: Optional[str] = None, plugin_id: Optional[str] = None) -> Any: + """ + 获取插件数据 + :param key: 数据key + :param plugin_id: plugin_id + """ + if not plugin_id: + plugin_id = self.__class__.__name__ + return self.plugindata.get_data(plugin_id, key) + + async def async_get_data( + self, key: Optional[str] = None, plugin_id: Optional[str] = None + ) -> Any: + """ + 异步获取插件数据 + + :param key: 数据键 + :param plugin_id: 插件ID + :return: 指定键的数据值或插件的全部数据 + """ + if not plugin_id: + plugin_id = self.__class__.__name__ + return await self.plugindata.async_get_data(plugin_id, key) + + def del_data(self, key: str, plugin_id: Optional[str] = None) -> Any: + """ + 删除插件数据 + :param key: 数据key + :param plugin_id: plugin_id + """ + if not plugin_id: + plugin_id = self.__class__.__name__ + return self.plugindata.del_data(plugin_id, key) + + def post_message(self, channel: Optional[NotificationChannel] = None, + mtype: Optional[MessageType] = None, + title: Optional[str] = None, + text: Optional[str] = None, image: Optional[str] = None, link: Optional[str] = None, + userid: Optional[str] = None, username: Optional[str] = None, + **kwargs: Any) -> None: + """ + 发送消息 + """ + if not link: + link = settings.MP_DOMAIN(f"#/plugins?tab=installed&id={self.__class__.__name__}") + self.chain.post_message(Message( + channel=channel, mtype=mtype, title=title, text=text, + image=image, link=link, userid=userid, username=username, **kwargs + )) + + def close(self) -> None: + pass diff --git a/app/sdk/plugins.py b/app/sdk/plugin/manager.py similarity index 99% rename from app/sdk/plugins.py rename to app/sdk/plugin/manager.py index c456d0656..d5fe88696 100644 --- a/app/sdk/plugins.py +++ b/app/sdk/plugin/manager.py @@ -3,5 +3,4 @@ from app.runtime.extensions.module.manager import ModuleManager from app.runtime.extensions.plugin.manager import PluginManager - __all__ = ["ModuleManager", "PluginManager"] diff --git a/app/startup/initializers/modules.py b/app/startup/initializers/modules.py index 1f7f95216..548adaf87 100644 --- a/app/startup/initializers/modules.py +++ b/app/startup/initializers/modules.py @@ -359,7 +359,7 @@ def configure_config_reload_event_handler_resolver() -> None: owner_name=owner_class.__name__, ) - EventManager().register_handler_instance_resolver( # type: ignore[no-untyped-call] + EventManager().register_handler_instance_resolver( "config_reload", resolve, ) diff --git a/docker/update.sh b/docker/update.sh index 0b710d602..7c877dc4e 100644 --- a/docker/update.sh +++ b/docker/update.sh @@ -334,6 +334,8 @@ function stage_runtime_payload() { fi # 保留 app.plugins 兼容入口;V1/V2 插件仍从这里导入 _PluginBase。 # 删除后 app.plugins 会退化为 namespace package,旧插件会在启动时全部导入失败。 + # 该文件必须是不含实现的包说明:契约基类归 app.sdk.plugin,包根自带旧版实现会 + # 遮蔽兼容符号,此时 Compat 会直接拒绝导入并报出覆盖了哪个 canonical 路径。 if [ ! -f "${stage_plugin_dir}/__init__.py" ]; then ERROR "插件运行目录缺少 app.plugins 兼容入口" return 1 diff --git a/docs/adr/0008-plugin-contract-base-in-sdk.md b/docs/adr/0008-plugin-contract-base-in-sdk.md new file mode 100644 index 000000000..2b936769c --- /dev/null +++ b/docs/adr/0008-plugin-contract-base-in-sdk.md @@ -0,0 +1,60 @@ +# ADR-0008:插件契约基类归属 SDK + +- 状态:Accepted +- 日期:2026-09-14 +- 对应任务:ARCH-262 + +## 决策 + +`_PluginBase` 与插件处理链的实现本体落在 `app/sdk/plugin/base.py`。`app/plugins/__init__.py` +只保留包文档,旧的包根符号 `app.plugins._PluginBase` 和 `app.plugins.PluginChian` 由 +`app/runtime/compat` 的精确符号叠加承接。 + +`app/sdk/plugins.py` 拆为 `app/sdk/plugin/` 包:`base.py` 拥有契约基类,`manager.py` 拥有 +`ModuleManager` 与 `PluginManager` 门面,包根只有文档。旧路径 `app.sdk.plugins` 登记为精确 +模块别名,指向 `app.sdk.plugin.manager`。 + +处理链类在 SDK 中命名为 `PluginChain`。历史拼写 `PluginChian` 不进入 SDK,只作为 Compat +符号映射存在。 + +## 理由 + +`_PluginBase` 的构造依赖 `PluginDataOper`、`SystemConfigOper`、`MessageHelper` 和 +`PluginDatabaseHandle`,横跨 `app.db`、`app.application` 两个方向。 + +`ChainBase` 的先例(canonical 在 `app.chain.base`,SDK 薄导出,包根交给 Compat)在这里走不通: +`app.runtime` 被 `FORBIDDEN_IMPORT_PREFIXES` 禁止导入 `app.adapters`、`app.application` 和 +`app.sdk`,又被入口层门禁禁止导入 `app.db`。把契约基类放进 `app/runtime/extensions/plugin/` +必须先把四项依赖改成宿主注入的端口,等于改写插件构造语义和全部既装插件的 ABI。 + +`app/sdk` 是宿主中唯一没有 forbidden-import 约束的层,`app/sdk/_legacy/transferpending.py` +已有实现本体落在 SDK 的先例。契约基类是插件 ABI 本身而不是业务实现,由契约层拥有与 +「sdk 为目标契约、compat 设版本期限」的既定方向一致。 + +## 影响 + +`docs/rules/05-architecture.md` 的落点决策第 10 条由「never move implementation there」改为 +区分契约与实现:SDK 拥有插件契约类型本体,业务实现仍留 canonical 层。 + +`app/plugins/__init__.py` 此前直接导入 `app.core.config`、`app.core.event` 和 +`app.helper.message` 三个幻影名,即宿主源码自己消费兼容层。迁移后依赖改为 +`app.runtime.config`、`app.runtime.events`、`app.application.messaging.message` 和 +`app.chain.base`,宿主对 Compat 的反向依赖清零。 + +包根不再在导入插件时拉起 Chain、数据库和消息栈,`app.plugins` 恢复为纯粹的插件安装命名空间。 + +既装插件的 `from app.plugins import _PluginBase` 保持可用,Compat 按既有规则在 DEBUG 下 +每插件每符号发一次可执行的迁移提示。新插件使用 `from app.sdk.plugin import _PluginBase`。 + +## 包根遮蔽兼容符号的防护 + +模块级 `__getattr__` 只在属性查找失败时触发。物理包根一旦自带同名定义,符号叠加就完全 +不会被调用:旧版本的 `app/plugins/__init__.py` 被挂载或复制进运行目录时, +`app.plugins._PluginBase` 会命中那份旧实现,而宿主判定插件用的是 +`hasattr(module, "init_plugin") and hasattr(module, "plugin_name")` 这样的鸭子判据, +不看基类身份,于是插件照常加载、配置读写却落到与宿主不同的存储上,全程没有任何报错。 + +`detect_shadowed_exports()` 因此在符号叠加安装前比对身份:物理定义与 canonical 是同一 +对象时属于无害的重新导入(`app.agent.orchestrator.AgentChain` 等既有情况),身份不同才是 +遮蔽,此时直接抛 `ImportError` 并指出覆盖了哪个 canonical 路径。宁可插件装载失败也不接受 +静默分裂——后者会让用户在界面上改的配置对插件不可见,且难以归因。 diff --git a/docs/architecture-overview.md b/docs/architecture-overview.md index 3552c27bb..da54dcc42 100644 --- a/docs/architecture-overview.md +++ b/docs/architecture-overview.md @@ -667,7 +667,11 @@ flowchart TB `app/api/` 的宿主端点。插件若已经自行返回 `Response`、字典、列表或其它可序列化值,宿主不再二次包裹。 - `app/runtime/extensions/plugin/manager.py` 是 canonical 管理器 owner,发现、加载、生命周期、 目录、同步等实现共同归入 `app/runtime/extensions/plugin/`。旧插件仍从 `app.core.plugin` 或 - `app.sdk.plugins` 进入,并由 Compat 精确路由到同一个 `PluginManager` 身份。 + `app.sdk.plugins` 进入,并由 Compat 精确路由到同一个 `PluginManager` 身份;新入口是 + `app.sdk.plugin.manager`。 +- `app/sdk/plugin/base.py` 是插件契约基类 `_PluginBase` 与 `PluginChain` 的 owner。 + `app/plugins/` 只是插件安装命名空间,包根不再导出符号,`app.plugins._PluginBase` 与历史拼写 + `app.plugins.PluginChian` 由 Compat 精确叠加承接。 - 插件可参与 `run_module` 方法分发(同名方法优先响应)并注册事件处理器。 --- @@ -757,7 +761,7 @@ flowchart LR | 指标 | 当前值 | |---|---:| | Python 模块 | 1020 | -| 内部导入边 | 8,678 | +| 内部导入边 | 8,698 | | 非平凡 SCC | 1(精确 containment 的 TMDB 移植包环) | | Application / Chain 具体 Adapter 直连 | 0 / 0 | | Direct egress | 53(债务已清零,53 条精确 containment) | diff --git a/docs/refactor/optimization-checklist.md b/docs/refactor/optimization-checklist.md index 2846679f9..fdc5d39f5 100644 --- a/docs/refactor/optimization-checklist.md +++ b/docs/refactor/optimization-checklist.md @@ -94,7 +94,7 @@ ARCH-201 至 ARCH-204 均达到实现、验证、提交、推送和远端门禁 | 指标 | 当前值 | 解释 | |---|---:|---| -| 宿主 Python 模块 / 内部依赖边 | 1020 / 8,678 | `dependency-baseline.json` 当前快照;分类、下载资源归类、订阅搜索、整理恢复、Agent 计划、工具视觉、终端生命周期与终端作用域模块的受控依赖 | +| 宿主 Python 模块 / 内部依赖边 | 1022 / 8,698 | `dependency-baseline.json` 当前快照;分类、下载资源归类、订阅搜索、整理恢复、Agent 计划、工具视觉、终端生命周期与终端作用域模块的受控依赖 | | 非平凡 SCC | 1 | 仅保留精确 containment 的 29 模块 TMDB 移植包环 | | 跨层 DB 边界债务 | 0 | Application、Chain、API、Agent、Runtime、Workflow 到 DB 的受控债务均为零 | | Model/Oper 事务债务 | 0 | 自建 Session、自动事务装饰器、直接 commit/rollback 等基线均为零 | @@ -102,8 +102,8 @@ ARCH-201 至 ARCH-204 均达到实现、验证、提交、推送和远端门禁 | Event Contract | 53 | 均已有 payload model,但当前全部是 diagnostic enforcement | | Python 源码量 | 305,884 行 | 排除 `app/plugins/**`;61 个文件超过 1,000 行,11 个超过 2,000 行 | | 长方法 | 290 个超过 80 行 | AST 统计排除 `app/plugins/**`;65 个超过 150 行,21 个超过 250 行 | -| 全量 mypy 历史债务 | 9,360 / 509 文件 | Agent API 重构后的现状基线;canonical Facade 与 endpoint 类型边界已补齐,低水位只允许继续下降 | -| Ruff 历史诊断 | 518 | 低水位门禁通过,但规则集只覆盖 `E4/E7/E9/F/I` | +| 全量 mypy 历史债务 | 9,349 / 509 文件 | Agent API 重构后的现状基线;canonical Facade 与 endpoint 类型边界已补齐,低水位只允许继续下降 | +| Ruff 历史诊断 | 517 | 低水位门禁通过,但规则集只覆盖 `E4/E7/E9/F/I` | | 覆盖率固定基线 | Application 80.00%,Domain 80.00% | Chain、Runtime、Agent、Adapter、Startup 未进入包级覆盖率门禁 | ### 3.3 热点文件 @@ -687,7 +687,7 @@ Pylint 10/10、Ruff、mypy/复杂度 ratchet、宿主与最新官方插件基线 - [x] `PluginHelper` 继续作为兼容入口,但市场、包、依赖、备份/恢复、健康修复分别委托现有 owner。 - [x] `PluginManager` 的服务图构造移到 startup typed `PluginRuntime` factory,Facade 只保留稳定 API。 -- [x] `PluginHelper` 不进入推荐 SDK,仅保留 `app.helper.plugin` 精确 Compat;`app.sdk.plugins` 只保留 +- [x] `PluginHelper` 不进入推荐 SDK,仅保留 `app.helper.plugin` 精确 Compat;`app.sdk.plugin.manager` 只保留 审计过的 Manager ABI,不重复导出 canonical 实现。 - [x] 用最新官方插件仓 baseline 和真实插件导入/兼容探针决定退场,不按宿主“无人引用”判断。 diff --git a/docs/rules/05-architecture.md b/docs/rules/05-architecture.md index a1932552c..9b0747da0 100644 --- a/docs/rules/05-architecture.md +++ b/docs/rules/05-architecture.md @@ -540,7 +540,10 @@ Use these questions in order before creating or moving a migrated capability: 9. Does it implement a named external product/ecosystem? Put it in `app/adapters/external`. 10. Is it public to plugins or only preserving an old path? Curate it in - `app/sdk` or map it in `app/runtime/compat`; never move implementation there. + `app/sdk` or map it in `app/runtime/compat`. `app/sdk` owns plugin contract + types themselves, such as the `_PluginBase` abstract base class plugins + inherit; business implementation still belongs to its canonical layer and + never moves there. Do not create generic `common`, `helper` or `utils` buckets. Reuse does not erase ownership. @@ -1008,7 +1011,10 @@ or stale policy entry fails. The current policy has one classification: The target remains zero canonical host cycles except the precisely contained vendor component. `ChainBase` lives in `app.chain.base`; the physical package root has no eager export, and the old package-root symbol is available only through the -exact Compat overlay backed by `app.sdk.chain`. +exact Compat overlay backed by `app.sdk.chain`. `_PluginBase` and `PluginChain` +live in `app.sdk.plugin.base`; `app/plugins/` is the plugin install namespace and +its package root only documents that, so `app.plugins._PluginBase` and the historic +spelling `app.plugins.PluginChian` resolve solely through the exact Compat overlay. The same fact/policy split governs direct Adapter imports. The generated dependency baseline records the original runtime imports from `app.application` @@ -1191,6 +1197,7 @@ driven workflow registration. | `app/application/mediaserver.py` | Configured media-server discovery and identity matching | | `app/runtime/compat/manifest.py` | Exact legacy-to-canonical import manifest | | `app/sdk/` | Stable plugin imports, including provider-neutral browser launch functions | +| `app/sdk/plugin/` | Plugin contract base class (`base.py`) and runtime manager facade (`manager.py`); the package root is a lazy export map so importing the contract does not load the managers | Run `tests/test_architecture_dependencies.py` after every ownership or import change. It rejects physical legacy or retired canonical sources, forbidden diff --git a/scripts/architecture/service_locator.py b/scripts/architecture/service_locator.py index 69f80708f..5990cf6e2 100644 --- a/scripts/architecture/service_locator.py +++ b/scripts/architecture/service_locator.py @@ -46,7 +46,7 @@ RUNTIME_FACADE_POLICIES = ( dependency="app.runtime.extensions.module.manager", exact_consumers=frozenset( { - "app.sdk.plugins", + "app.sdk.plugin.manager", "app.startup.composition.chain", "app.startup.initializers.modules", } @@ -57,7 +57,7 @@ RUNTIME_FACADE_POLICIES = ( dependency="app.runtime.extensions.plugin.manager", exact_consumers=frozenset( { - "app.sdk.plugins", + "app.sdk.plugin.manager", "app.startup.composition.chain", "app.startup.initializers.plugins", } diff --git a/tests/fixtures/architecture/dependency-baseline.json b/tests/fixtures/architecture/dependency-baseline.json index 952ab8710..e6f52bd0a 100644 --- a/tests/fixtures/architecture/dependency-baseline.json +++ b/tests/fixtures/architecture/dependency-baseline.json @@ -1077,8 +1077,8 @@ "runtime_only": true } }, - "edge_count": 8678, - "edge_sha256": "9b87d035b9111bd3653439c36cbe2320e9847ca67eb550d7f7133626b9abc6aa", + "edge_count": 8698, + "edge_sha256": "633134fe072732f70b42225069c4e3356011ecf743b1f4e472adc1a7e2214ec2", "edges": [ "app -> app.foundation", "app -> app.foundation.environment", @@ -8787,12 +8787,32 @@ "app.sdk.network -> app.domain.site", "app.sdk.network -> app.foundation", "app.sdk.network -> app.foundation.url", - "app.sdk.plugins -> app.runtime", - "app.sdk.plugins -> app.runtime.extensions", - "app.sdk.plugins -> app.runtime.extensions.module", - "app.sdk.plugins -> app.runtime.extensions.module.manager", - "app.sdk.plugins -> app.runtime.extensions.plugin", - "app.sdk.plugins -> app.runtime.extensions.plugin.manager", + "app.sdk.plugin.base -> app.application", + "app.sdk.plugin.base -> app.application.messaging", + "app.sdk.plugin.base -> app.application.messaging.message", + "app.sdk.plugin.base -> app.chain", + "app.sdk.plugin.base -> app.chain.base", + "app.sdk.plugin.base -> app.db", + "app.sdk.plugin.base -> app.db.oper", + "app.sdk.plugin.base -> app.db.oper.plugindata", + "app.sdk.plugin.base -> app.db.oper.plugininstance", + "app.sdk.plugin.base -> app.db.oper.systemconfig", + "app.sdk.plugin.base -> app.db.plugin", + "app.sdk.plugin.base -> app.db.plugin.container", + "app.sdk.plugin.base -> app.db.plugin.registry", + "app.sdk.plugin.base -> app.runtime", + "app.sdk.plugin.base -> app.runtime.events", + "app.sdk.plugin.base -> app.schemas", + "app.sdk.plugin.base -> app.schemas.message", + "app.sdk.plugin.base -> app.schemas.types", + "app.sdk.plugin.base -> app.sdk", + "app.sdk.plugin.base -> app.sdk.config", + "app.sdk.plugin.manager -> app.runtime", + "app.sdk.plugin.manager -> app.runtime.extensions", + "app.sdk.plugin.manager -> app.runtime.extensions.module", + "app.sdk.plugin.manager -> app.runtime.extensions.module.manager", + "app.sdk.plugin.manager -> app.runtime.extensions.plugin", + "app.sdk.plugin.manager -> app.runtime.extensions.plugin.manager", "app.sdk.queries -> app.application", "app.sdk.queries -> app.application.query", "app.sdk.queries -> app.schemas", @@ -9759,7 +9779,7 @@ "app.workflow.actions.transfer_file -> app.workflow", "app.workflow.actions.transfer_file -> app.workflow.actions" ], - "module_count": 1020, + "module_count": 1022, "modules": [ "app", "app.adapters", @@ -10713,7 +10733,9 @@ "app.sdk.logging", "app.sdk.media", "app.sdk.network", - "app.sdk.plugins", + "app.sdk.plugin", + "app.sdk.plugin.base", + "app.sdk.plugin.manager", "app.sdk.queries", "app.sdk.scheduler", "app.sdk.security", diff --git a/tests/fixtures/architecture/mypy-baseline.json b/tests/fixtures/architecture/mypy-baseline.json index b2b3c5c89..9930fd7dc 100644 --- a/tests/fixtures/architecture/mypy-baseline.json +++ b/tests/fixtures/architecture/mypy-baseline.json @@ -1201,7 +1201,7 @@ }, "app/db/oper/systemconfig.py": { "no-any-return": 1, - "no-untyped-def": 3 + "no-untyped-def": 2 }, "app/db/oper/transferhistory.py": { "no-any-return": 2, @@ -2494,10 +2494,9 @@ "list-item": 1, "misc": 1, "no-any-return": 2, - "no-untyped-call": 3, - "no-untyped-def": 17, - "type-arg": 22, - "var-annotated": 3 + "no-untyped-call": 2, + "no-untyped-def": 16, + "type-arg": 22 }, "app/runtime/execution.py": { "no-untyped-def": 8, @@ -2766,7 +2765,7 @@ "attr-defined": 2, "misc": 1, "no-any-return": 2, - "no-untyped-call": 9, + "no-untyped-call": 5, "no-untyped-def": 6, "return-value": 2 }, @@ -2789,7 +2788,7 @@ "type-arg": 1 }, "app/testing/bootstrap.py": { - "no-untyped-call": 2, + "no-untyped-call": 1, "no-untyped-def": 3, "type-arg": 6 }, diff --git a/tests/fixtures/architecture/ruff-baseline.json b/tests/fixtures/architecture/ruff-baseline.json index 036383258..00c0f1fdd 100644 --- a/tests/fixtures/architecture/ruff-baseline.json +++ b/tests/fixtures/architecture/ruff-baseline.json @@ -498,9 +498,6 @@ "app/sdk/network.py": { "I001": 1 }, - "app/sdk/plugins.py": { - "I001": 1 - }, "app/sdk/services.py": { "I001": 1 }, diff --git a/tests/fixtures/architecture/runtime-contract-baseline.json b/tests/fixtures/architecture/runtime-contract-baseline.json index 79eb6d092..a11b5e81c 100644 --- a/tests/fixtures/architecture/runtime-contract-baseline.json +++ b/tests/fixtures/architecture/runtime-contract-baseline.json @@ -152,14 +152,14 @@ "introduced": "v3.0.0", "is_package": false, "owner": "runtime", - "replacement": "app.sdk.plugins", + "replacement": "app.sdk.plugin.manager", "target": "app.runtime.extensions.module.manager" }, "app.core.plugin": { "introduced": "v3.0.0", "is_package": false, "owner": "runtime", - "replacement": "app.sdk.plugins", + "replacement": "app.sdk.plugin.manager", "target": "app.runtime.extensions.plugin.manager" }, "app.core.security": { @@ -617,6 +617,13 @@ "replacement": "app.runtime.resources", "target": "app.runtime.resources" }, + "app.sdk.plugins": { + "introduced": "v3.0.0", + "is_package": false, + "owner": "sdk", + "replacement": "app.sdk.plugin.manager", + "target": "app.sdk.plugin.manager" + }, "app.utils.coalesce": { "introduced": "v3.0.0", "is_package": false, @@ -1086,6 +1093,18 @@ "target_name": "resolve_media_identity" } }, + "app.plugins": { + "PluginChian": { + "replacement": "app.sdk.plugin.PluginChain", + "target_module": "app.sdk.plugin.base", + "target_name": "PluginChain" + }, + "_PluginBase": { + "replacement": "app.sdk.plugin._PluginBase", + "target_module": "app.sdk.plugin.base", + "target_name": "_PluginBase" + } + }, "app.schemas": { "ChannelCapabilities": { "replacement": "app.schemas.notification.ChannelCapabilities", @@ -10168,18 +10187,6 @@ "target": "app.adapters.external.location.WebUtils" } ], - "app.sdk.plugins": [ - { - "kind": "import", - "name": "ModuleManager", - "target": "app.runtime.extensions.module.manager.ModuleManager" - }, - { - "kind": "import", - "name": "PluginManager", - "target": "app.runtime.extensions.plugin.manager.PluginManager" - } - ], "app.sdk.queries": [ { "kind": "import", diff --git a/tests/test_legacy_import_compat.py b/tests/test_legacy_import_compat.py index 09b40d2f5..b87bc60bc 100644 --- a/tests/test_legacy_import_compat.py +++ b/tests/test_legacy_import_compat.py @@ -3,6 +3,7 @@ import importlib import subprocess import sys from pathlib import Path +from types import ModuleType import pytest @@ -12,7 +13,10 @@ from app.runtime.compat.diagnostics import ( reset_legacy_import_diagnostics, scan_plugin_legacy_imports, ) -from app.runtime.compat.imports import install_legacy_import_hook +from app.runtime.compat.imports import ( + detect_shadowed_exports, + install_legacy_import_hook, +) from app.runtime.compat.manifest import ( _MESSAGE_NOTIFICATION_SYMBOL_ALIASES, MODULE_ALIASES, @@ -483,6 +487,7 @@ def test_plugin_scan_reports_moved_symbol_import(tmp_path: Path): def test_symbol_alias_manifest_covers_all_moved_public_symbols(): """符号级映射清单应覆盖媒体身份、整理工作项、刮削拆分与消息/通知命名统一的旧入口。""" assert set(SYMBOL_ALIASES["app.chain"]) == {"ChainBase"} + assert set(SYMBOL_ALIASES["app.plugins"]) == {"PluginChian", "_PluginBase"} assert set(SYMBOL_ALIASES["app.db.oper"]) == { "SiteOper", "SubscribeHistoryOper", @@ -551,3 +556,83 @@ def test_chain_base_legacy_sdk_and_canonical_imports_share_identity(monkeypatch) assert sdk is canonical assert legacy_chain.ChainBase._compat_identity_marker is marker assert sdk._compat_identity_marker is marker + + +def test_plugin_base_legacy_and_sdk_imports_share_canonical_identity(monkeypatch): + """插件安装包根与 SDK 必须解析到同一个契约基类,属性补丁对两条路径同时可见。""" + legacy_plugins = importlib.import_module("app.plugins") + canonical = importlib.import_module("app.sdk.plugin.base")._PluginBase + sdk = importlib.import_module("app.sdk.plugin")._PluginBase + marker = object() + + monkeypatch.setattr(canonical, "_compat_identity_marker", marker, raising=False) + + assert legacy_plugins._PluginBase is canonical + assert sdk is canonical + assert legacy_plugins._PluginBase._compat_identity_marker is marker + + +def test_legacy_plugin_chain_spelling_resolves_to_renamed_sdk_class(): + """历史拼写 PluginChian 只存在于兼容层,解析到 SDK 的正名 PluginChain。""" + configure_legacy_import_diagnostics(enabled=False, emitter=lambda _: None) + try: + legacy_plugins = importlib.import_module("app.plugins") + sdk = importlib.import_module("app.sdk.plugin") + + assert legacy_plugins.PluginChian is sdk.PluginChain + assert not hasattr(sdk, "PluginChian") + assert "PluginChian" not in legacy_plugins.__all__ + finally: + reset_legacy_import_diagnostics() + + +def test_shadow_detection_flags_second_implementation(): + """物理模块自带第二份实现时必须被判定为遮蔽兼容符号。""" + module = ModuleType("app.plugins") + module._PluginBase = type("_PluginBase", (), {}) + + shadowed = detect_shadowed_exports(module, SYMBOL_ALIASES["app.plugins"]) + + assert shadowed == [("_PluginBase", "app.sdk.plugin._PluginBase")] + + +def test_shadow_detection_allows_reimported_canonical_symbol(): + """物理模块重新导入同一个 canonical 对象不算遮蔽。""" + canonical = importlib.import_module("app.sdk.plugin.base") + module = ModuleType("app.plugins") + module._PluginBase = canonical._PluginBase + module.PluginChian = canonical.PluginChain + + assert detect_shadowed_exports(module, SYMBOL_ALIASES["app.plugins"]) == [] + + +def test_installed_symbol_overlay_modules_have_no_shadowed_exports(): + """已装载的兼容叠加模块都不得用第二份实现遮蔽登记符号。""" + configure_legacy_import_diagnostics(enabled=False, emitter=lambda _: None) + try: + violations: dict[str, list] = {} + for module_name, exports in SYMBOL_ALIASES.items(): + try: + module = importlib.import_module(module_name) + except ModuleNotFoundError: + continue + shadowed = detect_shadowed_exports(module, exports) + if shadowed: + violations[module_name] = shadowed + assert violations == {} + finally: + reset_legacy_import_diagnostics() + + +def test_sdk_plugins_module_alias_reuses_manager_module(): + """旧 SDK 路径 app.sdk.plugins 只经精确别名复用插件管理器模块。""" + alias = MODULE_ALIASES["app.sdk.plugins"] + configure_legacy_import_diagnostics(enabled=False, emitter=lambda _: None) + try: + assert alias.target == "app.sdk.plugin.manager" + assert alias.replacement == "app.sdk.plugin.manager" + assert importlib.import_module("app.sdk.plugins") is importlib.import_module( + "app.sdk.plugin.manager" + ) + finally: + reset_legacy_import_diagnostics() diff --git a/tests/test_plugin_compat_governance.py b/tests/test_plugin_compat_governance.py index 0ade44339..1ca3a1e5d 100644 --- a/tests/test_plugin_compat_governance.py +++ b/tests/test_plugin_compat_governance.py @@ -176,10 +176,10 @@ def test_plugin_manager_legacy_and_sdk_paths_share_canonical_identity() -> None: """V2 旧路径与 V3 SDK 必须复用同一个 PluginManager 身份及已验证成员。""" canonical = importlib.import_module("app.runtime.extensions.plugin.manager") legacy = importlib.import_module("app.core.plugin") - sdk = importlib.import_module("app.sdk.plugins") + sdk = importlib.import_module("app.sdk.plugin.manager") assert MODULE_ALIASES["app.core.plugin"].target == canonical.__name__ - assert MODULE_ALIASES["app.core.plugin"].replacement == "app.sdk.plugins" + assert MODULE_ALIASES["app.core.plugin"].replacement == "app.sdk.plugin.manager" assert legacy is canonical assert legacy.PluginManager is canonical.PluginManager assert sdk.PluginManager is canonical.PluginManager @@ -188,6 +188,26 @@ def test_plugin_manager_legacy_and_sdk_paths_share_canonical_identity() -> None: assert not hasattr(sdk, "PluginHelper") +def test_plugin_namespace_root_only_documents_the_install_directory() -> None: + """插件安装命名空间包根只保留说明,契约基类由 SDK 拥有。""" + tree = _parse(APP_ROOT / "plugins" / "__init__.py") + + assert _is_documentation_only_root(tree) + assert _public_names(tree) == set() + + +def test_plugin_contract_base_depends_on_canonical_modules_only() -> None: + """插件契约基类直接依赖 canonical 模块,宿主源码不消费幻影旧路径。""" + tree = _parse(APP_ROOT / "sdk" / "plugin" / "base.py") + legacy = { + module + for module in _module_imports(tree) + if module == "app.log" or module.startswith(("app.core.", "app.helper.", "app.utils.")) + } + + assert legacy == set() + + def test_plugin_helper_stays_on_exact_legacy_adapter_route() -> None: """PluginHelper 只保留精确旧 Helper 路由,不进入推荐插件 SDK。""" canonical = importlib.import_module("app.adapters.external.market") diff --git a/tests/test_plugin_database_hooks.py b/tests/test_plugin_database_hooks.py index 3d33501e5..65092b456 100644 --- a/tests/test_plugin_database_hooks.py +++ b/tests/test_plugin_database_hooks.py @@ -13,7 +13,7 @@ import app.db.plugin.registry as registry_module import app.runtime.extensions.plugin.datadir as datadir_module from app.db.decorators import db_query, db_update from app.db.plugin.base import plugin_declarative_base -from app.plugins import _PluginBase +from app.sdk.plugin import _PluginBase class _SamplePlugin(_PluginBase): diff --git a/tests/test_plugin_manager_async_methods.py b/tests/test_plugin_manager_async_methods.py index 7de906605..641624d17 100644 --- a/tests/test_plugin_manager_async_methods.py +++ b/tests/test_plugin_manager_async_methods.py @@ -7,7 +7,7 @@ from unittest.mock import AsyncMock import pytest from app.foundation.singleton import Singleton -from app.sdk.plugins import PluginManager +from app.sdk.plugin import PluginManager @pytest.fixture diff --git a/tests/test_plugin_sdk.py b/tests/test_plugin_sdk.py index 1df779a4d..8a70dae5d 100644 --- a/tests/test_plugin_sdk.py +++ b/tests/test_plugin_sdk.py @@ -17,7 +17,7 @@ from app.sdk.media import ( set_custom_separator, ) from app.sdk.network import RequestUtils, RssHelper, SitesHelper -from app.sdk.plugins import ModuleManager, PluginManager +from app.sdk.plugin import ModuleManager, PluginChain, PluginManager, _PluginBase from app.sdk.services import NotificationHelper from app.sdk.utilities import StringUtils as UtilityStringUtils from app.sdk.utilities import convert, decrypt, encrypt @@ -91,6 +91,50 @@ def test_legacy_common_crypto_aliases_round_trip(): assert legacy_decrypt(legacy_encrypt(message, passphrase), passphrase) == message +def test_sdk_plugin_package_exports_contract_base_and_managers(): + """插件 SDK 包根导出契约基类与管理器,并复用 canonical 身份。""" + from app.chain.base import ChainBase + from app.runtime.extensions.module.manager import ( + ModuleManager as CanonicalModuleManager, + ) + from app.runtime.extensions.plugin.manager import ( + PluginManager as CanonicalPluginManager, + ) + from app.sdk.plugin.base import PluginChain as CanonicalPluginChain + from app.sdk.plugin.base import _PluginBase as CanonicalPluginBase + + assert _PluginBase is CanonicalPluginBase + assert PluginChain is CanonicalPluginChain + assert issubclass(PluginChain, ChainBase) + assert ModuleManager is CanonicalModuleManager + assert PluginManager is CanonicalPluginManager + + +def test_sdk_plugin_contract_import_does_not_load_managers(): + """仅导入契约基类不得连带拉起插件与模块管理器。""" + script = """ +import sys +from app.sdk.plugin import _PluginBase + +assert _PluginBase is not None +for name in ( + "app.sdk.plugin.manager", + "app.runtime.extensions.plugin.manager", + "app.runtime.extensions.module.manager", +): + assert name not in sys.modules, name +""" + result = subprocess.run( + [sys.executable, "-c", script], + cwd=PROJECT_ROOT, + capture_output=True, + text=True, + check=False, + ) + + assert result.returncode == 0, result.stderr + + def test_browser_sdk_import_is_provider_free(): """仅导入浏览器 SDK 不得加载浏览器或虚拟显示实现。""" script = """ diff --git a/tests/test_runtime_manager_identity.py b/tests/test_runtime_manager_identity.py index faed869d1..ae2e26d5e 100644 --- a/tests/test_runtime_manager_identity.py +++ b/tests/test_runtime_manager_identity.py @@ -2,8 +2,8 @@ from app.foundation.singleton import Singleton from app.runtime.events import EventManager, eventmanager from app.runtime.extensions.module.manager import ModuleManager from app.runtime.extensions.plugin.manager import PluginManager -from app.sdk.plugins import ModuleManager as SdkModuleManager -from app.sdk.plugins import PluginManager as SdkPluginManager +from app.sdk.plugin import ModuleManager as SdkModuleManager +from app.sdk.plugin import PluginManager as SdkPluginManager def _singleton_key(manager_type: type) -> tuple: diff --git a/tests/test_service_locator_gate.py b/tests/test_service_locator_gate.py index 73a66e0ce..654059dbf 100644 --- a/tests/test_service_locator_gate.py +++ b/tests/test_service_locator_gate.py @@ -22,7 +22,7 @@ def test_service_locator_gate_detects_each_concrete_runtime_family() -> None: "app.api.plugin_bypass": {"app.runtime.extensions.plugin.manager"}, "app.api.command_bypass": {"app.command"}, "app.api.workflow_bypass": {"app.workflow"}, - "app.sdk.plugins": { + "app.sdk.plugin.manager": { "app.runtime.extensions.module.manager", "app.runtime.extensions.plugin.manager", },