refactor(sdk): 插件契约基类迁入 SDK 并由 Compat 承接旧包根符号 (#6701)

This commit is contained in:
Aqr-K
2026-09-15 07:01:00 +08:00
committed by GitHub
parent 1030028dfe
commit 61aa3cd649
26 changed files with 805 additions and 464 deletions

View File

@@ -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

View File

@@ -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 惰性解析。"""

View File

@@ -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__

View File

@@ -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",
),
},
}

View File

@@ -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()
# 退出事件

View File

@@ -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"]

407
app/sdk/plugin/base.py Normal file
View File

@@ -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

View File

@@ -3,5 +3,4 @@
from app.runtime.extensions.module.manager import ModuleManager
from app.runtime.extensions.plugin.manager import PluginManager
__all__ = ["ModuleManager", "PluginManager"]

View File

@@ -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,
)

View File

@@ -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

View File

@@ -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 路径。宁可插件装载失败也不接受
静默分裂——后者会让用户在界面上改的配置对插件不可见,且难以归因。

View File

@@ -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) |

View File

@@ -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 和真实插件导入/兼容探针决定退场,不按宿主“无人引用”判断。

View File

@@ -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

View File

@@ -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",
}

View File

@@ -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",

View File

@@ -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
},

View File

@@ -498,9 +498,6 @@
"app/sdk/network.py": {
"I001": 1
},
"app/sdk/plugins.py": {
"I001": 1
},
"app/sdk/services.py": {
"I001": 1
},

View File

@@ -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",

View File

@@ -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()

View File

@@ -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")

View File

@@ -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):

View File

@@ -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

View File

@@ -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 = """

View File

@@ -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:

View File

@@ -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",
},