mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-10-31 04:14:38 +08:00
refactor(sdk): 插件契约基类迁入 SDK 并由 Compat 承接旧包根符号 (#6701)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 惰性解析。"""
|
||||
|
||||
@@ -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__
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
# 退出事件
|
||||
|
||||
30
app/sdk/plugin/__init__.py
Normal file
30
app/sdk/plugin/__init__.py
Normal 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
407
app/sdk/plugin/base.py
Normal 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
|
||||
@@ -3,5 +3,4 @@
|
||||
from app.runtime.extensions.module.manager import ModuleManager
|
||||
from app.runtime.extensions.plugin.manager import PluginManager
|
||||
|
||||
|
||||
__all__ = ["ModuleManager", "PluginManager"]
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
60
docs/adr/0008-plugin-contract-base-in-sdk.md
Normal file
60
docs/adr/0008-plugin-contract-base-in-sdk.md
Normal 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 路径。宁可插件装载失败也不接受
|
||||
静默分裂——后者会让用户在界面上改的配置对插件不可见,且难以归因。
|
||||
@@ -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) |
|
||||
|
||||
@@ -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 和真实插件导入/兼容探针决定退场,不按宿主“无人引用”判断。
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
13
tests/fixtures/architecture/mypy-baseline.json
vendored
13
tests/fixtures/architecture/mypy-baseline.json
vendored
@@ -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
|
||||
},
|
||||
|
||||
@@ -498,9 +498,6 @@
|
||||
"app/sdk/network.py": {
|
||||
"I001": 1
|
||||
},
|
||||
"app/sdk/plugins.py": {
|
||||
"I001": 1
|
||||
},
|
||||
"app/sdk/services.py": {
|
||||
"I001": 1
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = """
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user