mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-05 23:47:41 +08:00
refactor: define module contract v2
This commit is contained in:
@@ -2,22 +2,54 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import inspect
|
||||||
|
from collections.abc import Callable
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from enum import StrEnum
|
from enum import StrEnum
|
||||||
|
from typing import Any, Protocol
|
||||||
|
|
||||||
|
|
||||||
class ModuleResultAggregation(StrEnum):
|
class ModuleResultAggregation(StrEnum):
|
||||||
"""描述多模块结果沿调用链的兼容聚合方式。"""
|
"""描述多模块结果沿调用链的兼容聚合方式。"""
|
||||||
|
|
||||||
LEGACY = "legacy"
|
LEGACY = "legacy"
|
||||||
|
FIRST_NON_EMPTY = "first_non_empty"
|
||||||
|
ORDERED_LIST_MERGE = "ordered_list_merge"
|
||||||
|
|
||||||
|
|
||||||
|
class ModuleExecutionMode(StrEnum):
|
||||||
|
"""描述 provider 可以采用的执行形态。"""
|
||||||
|
|
||||||
|
SYNC_OR_ASYNC = "sync_or_async"
|
||||||
|
|
||||||
|
|
||||||
|
class ModuleErrorPolicy(StrEnum):
|
||||||
|
"""描述单个 provider 失败后的兼容处理策略。"""
|
||||||
|
|
||||||
|
ISOLATE_PROVIDER = "isolate_provider"
|
||||||
|
|
||||||
|
|
||||||
|
class ModuleCapability(Protocol):
|
||||||
|
"""宿主和新插件可用于声明动态能力的最小 Protocol。"""
|
||||||
|
|
||||||
|
def __call__(self, *args: Any, **kwargs: Any) -> Any:
|
||||||
|
"""执行模块能力并返回契约声明的结果。"""
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class ModuleMethodContract:
|
class ModuleMethodContract:
|
||||||
"""记录一个模块方法族的调用模式和结果规则。"""
|
"""记录模块方法的输入、结果、执行与兼容错误协议。"""
|
||||||
|
|
||||||
family: str
|
family: str
|
||||||
aggregation: ModuleResultAggregation = ModuleResultAggregation.LEGACY
|
aggregation: ModuleResultAggregation = ModuleResultAggregation.LEGACY
|
||||||
|
version: int = 1
|
||||||
|
input_contract: str = "legacy_args"
|
||||||
|
result_contract: str = "Any"
|
||||||
|
required_parameters: tuple[str, ...] = ()
|
||||||
|
execution: ModuleExecutionMode = ModuleExecutionMode.SYNC_OR_ASYNC
|
||||||
|
timeout_policy: str = "caller_budget"
|
||||||
|
error_policy: ModuleErrorPolicy = ModuleErrorPolicy.ISOLATE_PROVIDER
|
||||||
|
public_to_plugins: bool = True
|
||||||
supports_sync: bool = True
|
supports_sync: bool = True
|
||||||
supports_async: bool = True
|
supports_async: bool = True
|
||||||
plugin_short_circuit: bool = True
|
plugin_short_circuit: bool = True
|
||||||
@@ -28,28 +60,34 @@ _DEFAULT_CONTRACT = ModuleMethodContract(family="legacy")
|
|||||||
# 首批登记高频能力族。方法名仍保持开放字符串,以兼容第三方插件自定义模块能力;
|
# 首批登记高频能力族。方法名仍保持开放字符串,以兼容第三方插件自定义模块能力;
|
||||||
# 未命中项继续使用冻结的 legacy 规则,并由架构快照记录新增调用位置。
|
# 未命中项继续使用冻结的 legacy 规则,并由架构快照记录新增调用位置。
|
||||||
_METHOD_CONTRACTS = {
|
_METHOD_CONTRACTS = {
|
||||||
"recognize_media": ModuleMethodContract(family="media-recognition"),
|
"recognize_media": ModuleMethodContract(
|
||||||
"search_medias": ModuleMethodContract(family="media-recognition"),
|
family="media-recognition", input_contract="MediaRecognitionRequest",
|
||||||
"obtain_images": ModuleMethodContract(family="media-recognition"),
|
result_contract="MediaInfo | None", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY,
|
||||||
"media_category": ModuleMethodContract(family="media-recognition"),
|
),
|
||||||
"mediaserver_items": ModuleMethodContract(family="media-server"),
|
"search_medias": ModuleMethodContract(
|
||||||
"mediaserver_iteminfo": ModuleMethodContract(family="media-server"),
|
family="media-recognition", input_contract="MediaSearchRequest",
|
||||||
"mediaserver_play_url": ModuleMethodContract(family="media-server"),
|
result_contract="list[MediaInfo]", aggregation=ModuleResultAggregation.ORDERED_LIST_MERGE,
|
||||||
"mediaserver_tv_episodes": ModuleMethodContract(family="media-server"),
|
),
|
||||||
"download_file": ModuleMethodContract(family="storage"),
|
"obtain_images": ModuleMethodContract(family="media-recognition", input_contract="MediaInfo", result_contract="MediaInfo | None"),
|
||||||
"upload_file": ModuleMethodContract(family="storage"),
|
"media_category": ModuleMethodContract(family="media-recognition", input_contract="MediaCategoryRequest", result_contract="CategoryConfig | None"),
|
||||||
"list_files": ModuleMethodContract(family="storage"),
|
"mediaserver_items": ModuleMethodContract(family="media-server", input_contract="MediaServerItemsRequest", result_contract="list[MediaServerItem]", aggregation=ModuleResultAggregation.ORDERED_LIST_MERGE),
|
||||||
"get_file_item": ModuleMethodContract(family="storage"),
|
"mediaserver_iteminfo": ModuleMethodContract(family="media-server", input_contract="MediaServerItemRequest", result_contract="MediaServerItem | None", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY),
|
||||||
"get_folder": ModuleMethodContract(family="storage"),
|
"mediaserver_play_url": ModuleMethodContract(family="media-server", input_contract="MediaServerPlayRequest", result_contract="str | None", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY),
|
||||||
"get_parent_item": ModuleMethodContract(family="storage"),
|
"mediaserver_tv_episodes": ModuleMethodContract(family="media-server", input_contract="MediaServerEpisodesRequest", result_contract="list[MediaServerPlayItem]", aggregation=ModuleResultAggregation.ORDERED_LIST_MERGE),
|
||||||
"rename_file": ModuleMethodContract(family="storage"),
|
"download_file": ModuleMethodContract(family="storage", input_contract="StorageDownloadRequest", result_contract="FileItem | None", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY),
|
||||||
"storage_manage": ModuleMethodContract(family="storage"),
|
"upload_file": ModuleMethodContract(family="storage", input_contract="StorageUploadRequest", result_contract="FileItem | None", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY),
|
||||||
"snapshot_storage": ModuleMethodContract(family="storage"),
|
"list_files": ModuleMethodContract(family="storage", input_contract="StorageListRequest", result_contract="list[FileItem]", aggregation=ModuleResultAggregation.ORDERED_LIST_MERGE),
|
||||||
"send_message": ModuleMethodContract(family="messaging"),
|
"get_file_item": ModuleMethodContract(family="storage", input_contract="StorageItemRequest", result_contract="FileItem | None", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY),
|
||||||
"finalize_message": ModuleMethodContract(family="messaging"),
|
"get_folder": ModuleMethodContract(family="storage", input_contract="StorageFolderRequest", result_contract="FileItem | None", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY),
|
||||||
"register_commands": ModuleMethodContract(family="messaging"),
|
"get_parent_item": ModuleMethodContract(family="storage", input_contract="StorageParentRequest", result_contract="FileItem | None", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY),
|
||||||
"scheduler_job": ModuleMethodContract(family="scheduling"),
|
"rename_file": ModuleMethodContract(family="storage", input_contract="StorageRenameRequest", result_contract="bool | FileItem", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY),
|
||||||
"webhook_parser": ModuleMethodContract(family="integration"),
|
"storage_manage": ModuleMethodContract(family="storage", input_contract="StorageManageRequest", result_contract="Any", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY),
|
||||||
|
"snapshot_storage": ModuleMethodContract(family="storage", input_contract="StorageSnapshotRequest", result_contract="dict[str, dict] | None", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY),
|
||||||
|
"send_message": ModuleMethodContract(family="messaging", input_contract="MessageSendRequest", result_contract="Message | None", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY),
|
||||||
|
"finalize_message": ModuleMethodContract(family="messaging", input_contract="MessageFinalizeRequest", result_contract="Message | None", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY),
|
||||||
|
"register_commands": ModuleMethodContract(family="messaging", input_contract="CommandRegistrationRequest", result_contract="None"),
|
||||||
|
"scheduler_job": ModuleMethodContract(family="scheduling", input_contract="SchedulerJobRequest", result_contract="None"),
|
||||||
|
"webhook_parser": ModuleMethodContract(family="integration", input_contract="WebhookRequest", result_contract="WebhookEventInfo | None", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY),
|
||||||
}
|
}
|
||||||
|
|
||||||
_PREFIX_CONTRACTS = (
|
_PREFIX_CONTRACTS = (
|
||||||
@@ -80,3 +118,29 @@ def get_module_method_contract(method: str) -> ModuleMethodContract:
|
|||||||
def is_explicit_module_method(method: str) -> bool:
|
def is_explicit_module_method(method: str) -> bool:
|
||||||
"""判断方法是否已进入首批显式能力族清单。"""
|
"""判断方法是否已进入首批显式能力族清单。"""
|
||||||
return get_module_method_contract(method) is not _DEFAULT_CONTRACT
|
return get_module_method_contract(method) is not _DEFAULT_CONTRACT
|
||||||
|
|
||||||
|
|
||||||
|
def diagnose_module_callable(method: str, callback: Callable[..., Any]) -> tuple[str, ...]:
|
||||||
|
"""诊断显式能力的基础签名;兼容阶段只返回问题,不拒绝 provider。"""
|
||||||
|
contract = get_module_method_contract(method)
|
||||||
|
if contract is _DEFAULT_CONTRACT:
|
||||||
|
return ()
|
||||||
|
try:
|
||||||
|
parameters = inspect.signature(callback).parameters
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return ("signature-unavailable",)
|
||||||
|
missing = tuple(
|
||||||
|
name
|
||||||
|
for name in contract.required_parameters
|
||||||
|
if name not in parameters
|
||||||
|
and not any(
|
||||||
|
parameter.kind is inspect.Parameter.VAR_KEYWORD
|
||||||
|
for parameter in parameters.values()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return tuple(f"missing-parameter:{name}" for name in missing)
|
||||||
|
|
||||||
|
|
||||||
|
def list_explicit_module_contracts() -> dict[str, ModuleMethodContract]:
|
||||||
|
"""返回显式方法清单的副本,供架构基线和 SDK 文档使用。"""
|
||||||
|
return dict(_METHOD_CONTRACTS)
|
||||||
|
|||||||
@@ -9,7 +9,10 @@ from typing import Any, Protocol
|
|||||||
from app.foundation.reflection import ObjectUtils
|
from app.foundation.reflection import ObjectUtils
|
||||||
from app.runtime.execution import run_in_threadpool
|
from app.runtime.execution import run_in_threadpool
|
||||||
from app.runtime.log import logger
|
from app.runtime.log import logger
|
||||||
from app.runtime.extensions.module.contracts import get_module_method_contract
|
from app.runtime.extensions.module.contracts import (
|
||||||
|
diagnose_module_callable,
|
||||||
|
get_module_method_contract,
|
||||||
|
)
|
||||||
from app.schemas.exception import RateLimitExceededException
|
from app.schemas.exception import RateLimitExceededException
|
||||||
|
|
||||||
|
|
||||||
@@ -108,6 +111,7 @@ class ModuleInvocationDispatcher:
|
|||||||
func = module_dict.get(method)
|
func = module_dict.get(method)
|
||||||
if not func:
|
if not func:
|
||||||
continue
|
continue
|
||||||
|
self._diagnose_callable(method, func, f"插件 {plugin_id}")
|
||||||
logger.info("请求插件 %s 执行:%s ...", plugin_name, method)
|
logger.info("请求插件 %s 执行:%s ...", plugin_name, method)
|
||||||
if self.is_valid_empty(result):
|
if self.is_valid_empty(result):
|
||||||
result = func(*args, **kwargs)
|
result = func(*args, **kwargs)
|
||||||
@@ -154,6 +158,7 @@ class ModuleInvocationDispatcher:
|
|||||||
func = module_dict.get(method)
|
func = module_dict.get(method)
|
||||||
if not func:
|
if not func:
|
||||||
continue
|
continue
|
||||||
|
self._diagnose_callable(method, func, f"插件 {plugin_id}")
|
||||||
logger.info("请求插件 %s 执行:%s ...", plugin_name, method)
|
logger.info("请求插件 %s 执行:%s ...", plugin_name, method)
|
||||||
if self.is_valid_empty(result):
|
if self.is_valid_empty(result):
|
||||||
result = await self._async_call(func, *args, **kwargs)
|
result = await self._async_call(func, *args, **kwargs)
|
||||||
@@ -199,6 +204,7 @@ class ModuleInvocationDispatcher:
|
|||||||
module_name = self._module_name(module, module_id)
|
module_name = self._module_name(module, module_id)
|
||||||
try:
|
try:
|
||||||
func = getattr(module, method)
|
func = getattr(module, method)
|
||||||
|
self._diagnose_callable(method, func, f"宿主模块 {module_id}")
|
||||||
if self.is_valid_empty(result):
|
if self.is_valid_empty(result):
|
||||||
result = func(*args, **kwargs)
|
result = func(*args, **kwargs)
|
||||||
elif ObjectUtils.check_signature(func, result):
|
elif ObjectUtils.check_signature(func, result):
|
||||||
@@ -245,6 +251,7 @@ class ModuleInvocationDispatcher:
|
|||||||
module_name = self._module_name(module, module_id)
|
module_name = self._module_name(module, module_id)
|
||||||
try:
|
try:
|
||||||
func = getattr(module, method)
|
func = getattr(module, method)
|
||||||
|
self._diagnose_callable(method, func, f"宿主模块 {module_id}")
|
||||||
if self.is_valid_empty(result):
|
if self.is_valid_empty(result):
|
||||||
result = await self._async_call(func, *args, **kwargs)
|
result = await self._async_call(func, *args, **kwargs)
|
||||||
elif ObjectUtils.check_signature(func, result):
|
elif ObjectUtils.check_signature(func, result):
|
||||||
@@ -273,6 +280,22 @@ class ModuleInvocationDispatcher:
|
|||||||
)
|
)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _diagnose_callable(
|
||||||
|
method: str,
|
||||||
|
callback: Callable[..., Any],
|
||||||
|
owner: str,
|
||||||
|
) -> None:
|
||||||
|
"""记录 Contract V2 签名偏差,兼容阶段不阻断旧插件执行。"""
|
||||||
|
problems = diagnose_module_callable(method, callback)
|
||||||
|
if problems:
|
||||||
|
logger.warning(
|
||||||
|
"%s 的模块方法 %s 与契约不一致:%s;当前仅诊断",
|
||||||
|
owner,
|
||||||
|
method,
|
||||||
|
", ".join(problems),
|
||||||
|
)
|
||||||
|
|
||||||
async def _async_call(
|
async def _async_call(
|
||||||
self,
|
self,
|
||||||
func: Callable[..., Any],
|
func: Callable[..., Any],
|
||||||
|
|||||||
@@ -553,6 +553,17 @@ ModuleMethodSpec(
|
|||||||
|
|
||||||
**量化目标**:legacy 方法数从 96 开始只降不升;新增宿主调用必须先有显式 spec。
|
**量化目标**:legacy 方法数从 96 开始只降不升;新增宿主调用必须先有显式 spec。
|
||||||
|
|
||||||
|
**实施记录(2026-08-21)**:
|
||||||
|
|
||||||
|
- `ModuleMethodContract` 已升级为 V2,显式记录 version、input/result contract、aggregation、
|
||||||
|
execution、timeout、error、plugin visibility 与基础签名要求;首批 22 个识别、搜索、媒体服务器、
|
||||||
|
存储、消息和调度/集成能力完成登记。
|
||||||
|
- `run_module()` 与插件优先、短路、列表顺序合并、空值和异常隔离算法保持不变。Dispatcher 在真实
|
||||||
|
provider 调用边界执行基础签名诊断;旧插件不匹配只写可读 warning,不拒绝加载或执行,未知自定义
|
||||||
|
方法继续使用 legacy contract。
|
||||||
|
- runtime contract baseline 现包含稳定的 `module_method_specs`,后续字段或显式方法变化必须审查;
|
||||||
|
`ModuleCapability` Protocol 为宿主和新插件提供静态声明入口,但不替换字符串 dispatcher ABI。
|
||||||
|
|
||||||
#### ARCH-241:Event Contract Registry
|
#### ARCH-241:Event Contract Registry
|
||||||
|
|
||||||
**目标**:为每个 EventType/ChainEventType 明确 payload、可见范围、投递和可靠性,不改变旧装饰器 API。
|
**目标**:为每个 EventType/ChainEventType 明确 payload、可见范围、投递和可靠性,不改变旧装饰器 API。
|
||||||
|
|||||||
@@ -801,12 +801,29 @@ def collect_runtime_baseline() -> dict[str, Any]:
|
|||||||
return {
|
return {
|
||||||
"schema_version": 2,
|
"schema_version": 2,
|
||||||
"run_module": collect_run_module_contracts(),
|
"run_module": collect_run_module_contracts(),
|
||||||
|
"module_method_specs": collect_module_method_specs(),
|
||||||
"events": collect_event_contracts(),
|
"events": collect_event_contracts(),
|
||||||
"sdk_exports": collect_sdk_exports(),
|
"sdk_exports": collect_sdk_exports(),
|
||||||
"compat_manifest": collect_compat_manifest(),
|
"compat_manifest": collect_compat_manifest(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def collect_module_method_specs() -> dict[str, Any]:
|
||||||
|
"""加载无运行资源副作用的 Module Contract V2 清单。"""
|
||||||
|
path = APP_ROOT / "runtime" / "extensions" / "module" / "contracts.py"
|
||||||
|
spec = importlib.util.spec_from_file_location("architecture_module_contracts", path)
|
||||||
|
if spec is None or spec.loader is None:
|
||||||
|
raise RuntimeError(f"无法加载模块契约清单:{path}")
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
sys.modules[spec.name] = module
|
||||||
|
try:
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
contracts = module.list_explicit_module_contracts()
|
||||||
|
return json_compatible(contracts)
|
||||||
|
finally:
|
||||||
|
sys.modules.pop(spec.name, None)
|
||||||
|
|
||||||
|
|
||||||
def collect_runtime_diagnostics() -> dict[str, Any]:
|
def collect_runtime_diagnostics() -> dict[str, Any]:
|
||||||
"""生成带当前源码行号的运行契约诊断视图,不写入语义 fixture。"""
|
"""生成带当前源码行号的运行契约诊断视图,不写入语义 fixture。"""
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1766,6 +1766,338 @@
|
|||||||
},
|
},
|
||||||
"producer_count": 66
|
"producer_count": 66
|
||||||
},
|
},
|
||||||
|
"module_method_specs": {
|
||||||
|
"download_file": {
|
||||||
|
"aggregation": "first_non_empty",
|
||||||
|
"error_policy": "isolate_provider",
|
||||||
|
"execution": "sync_or_async",
|
||||||
|
"family": "storage",
|
||||||
|
"input_contract": "StorageDownloadRequest",
|
||||||
|
"plugin_short_circuit": true,
|
||||||
|
"public_to_plugins": true,
|
||||||
|
"required_parameters": [],
|
||||||
|
"result_contract": "FileItem | None",
|
||||||
|
"supports_async": true,
|
||||||
|
"supports_sync": true,
|
||||||
|
"timeout_policy": "caller_budget",
|
||||||
|
"version": 1
|
||||||
|
},
|
||||||
|
"finalize_message": {
|
||||||
|
"aggregation": "first_non_empty",
|
||||||
|
"error_policy": "isolate_provider",
|
||||||
|
"execution": "sync_or_async",
|
||||||
|
"family": "messaging",
|
||||||
|
"input_contract": "MessageFinalizeRequest",
|
||||||
|
"plugin_short_circuit": true,
|
||||||
|
"public_to_plugins": true,
|
||||||
|
"required_parameters": [],
|
||||||
|
"result_contract": "Message | None",
|
||||||
|
"supports_async": true,
|
||||||
|
"supports_sync": true,
|
||||||
|
"timeout_policy": "caller_budget",
|
||||||
|
"version": 1
|
||||||
|
},
|
||||||
|
"get_file_item": {
|
||||||
|
"aggregation": "first_non_empty",
|
||||||
|
"error_policy": "isolate_provider",
|
||||||
|
"execution": "sync_or_async",
|
||||||
|
"family": "storage",
|
||||||
|
"input_contract": "StorageItemRequest",
|
||||||
|
"plugin_short_circuit": true,
|
||||||
|
"public_to_plugins": true,
|
||||||
|
"required_parameters": [],
|
||||||
|
"result_contract": "FileItem | None",
|
||||||
|
"supports_async": true,
|
||||||
|
"supports_sync": true,
|
||||||
|
"timeout_policy": "caller_budget",
|
||||||
|
"version": 1
|
||||||
|
},
|
||||||
|
"get_folder": {
|
||||||
|
"aggregation": "first_non_empty",
|
||||||
|
"error_policy": "isolate_provider",
|
||||||
|
"execution": "sync_or_async",
|
||||||
|
"family": "storage",
|
||||||
|
"input_contract": "StorageFolderRequest",
|
||||||
|
"plugin_short_circuit": true,
|
||||||
|
"public_to_plugins": true,
|
||||||
|
"required_parameters": [],
|
||||||
|
"result_contract": "FileItem | None",
|
||||||
|
"supports_async": true,
|
||||||
|
"supports_sync": true,
|
||||||
|
"timeout_policy": "caller_budget",
|
||||||
|
"version": 1
|
||||||
|
},
|
||||||
|
"get_parent_item": {
|
||||||
|
"aggregation": "first_non_empty",
|
||||||
|
"error_policy": "isolate_provider",
|
||||||
|
"execution": "sync_or_async",
|
||||||
|
"family": "storage",
|
||||||
|
"input_contract": "StorageParentRequest",
|
||||||
|
"plugin_short_circuit": true,
|
||||||
|
"public_to_plugins": true,
|
||||||
|
"required_parameters": [],
|
||||||
|
"result_contract": "FileItem | None",
|
||||||
|
"supports_async": true,
|
||||||
|
"supports_sync": true,
|
||||||
|
"timeout_policy": "caller_budget",
|
||||||
|
"version": 1
|
||||||
|
},
|
||||||
|
"list_files": {
|
||||||
|
"aggregation": "ordered_list_merge",
|
||||||
|
"error_policy": "isolate_provider",
|
||||||
|
"execution": "sync_or_async",
|
||||||
|
"family": "storage",
|
||||||
|
"input_contract": "StorageListRequest",
|
||||||
|
"plugin_short_circuit": true,
|
||||||
|
"public_to_plugins": true,
|
||||||
|
"required_parameters": [],
|
||||||
|
"result_contract": "list[FileItem]",
|
||||||
|
"supports_async": true,
|
||||||
|
"supports_sync": true,
|
||||||
|
"timeout_policy": "caller_budget",
|
||||||
|
"version": 1
|
||||||
|
},
|
||||||
|
"media_category": {
|
||||||
|
"aggregation": "legacy",
|
||||||
|
"error_policy": "isolate_provider",
|
||||||
|
"execution": "sync_or_async",
|
||||||
|
"family": "media-recognition",
|
||||||
|
"input_contract": "MediaCategoryRequest",
|
||||||
|
"plugin_short_circuit": true,
|
||||||
|
"public_to_plugins": true,
|
||||||
|
"required_parameters": [],
|
||||||
|
"result_contract": "CategoryConfig | None",
|
||||||
|
"supports_async": true,
|
||||||
|
"supports_sync": true,
|
||||||
|
"timeout_policy": "caller_budget",
|
||||||
|
"version": 1
|
||||||
|
},
|
||||||
|
"mediaserver_iteminfo": {
|
||||||
|
"aggregation": "first_non_empty",
|
||||||
|
"error_policy": "isolate_provider",
|
||||||
|
"execution": "sync_or_async",
|
||||||
|
"family": "media-server",
|
||||||
|
"input_contract": "MediaServerItemRequest",
|
||||||
|
"plugin_short_circuit": true,
|
||||||
|
"public_to_plugins": true,
|
||||||
|
"required_parameters": [],
|
||||||
|
"result_contract": "MediaServerItem | None",
|
||||||
|
"supports_async": true,
|
||||||
|
"supports_sync": true,
|
||||||
|
"timeout_policy": "caller_budget",
|
||||||
|
"version": 1
|
||||||
|
},
|
||||||
|
"mediaserver_items": {
|
||||||
|
"aggregation": "ordered_list_merge",
|
||||||
|
"error_policy": "isolate_provider",
|
||||||
|
"execution": "sync_or_async",
|
||||||
|
"family": "media-server",
|
||||||
|
"input_contract": "MediaServerItemsRequest",
|
||||||
|
"plugin_short_circuit": true,
|
||||||
|
"public_to_plugins": true,
|
||||||
|
"required_parameters": [],
|
||||||
|
"result_contract": "list[MediaServerItem]",
|
||||||
|
"supports_async": true,
|
||||||
|
"supports_sync": true,
|
||||||
|
"timeout_policy": "caller_budget",
|
||||||
|
"version": 1
|
||||||
|
},
|
||||||
|
"mediaserver_play_url": {
|
||||||
|
"aggregation": "first_non_empty",
|
||||||
|
"error_policy": "isolate_provider",
|
||||||
|
"execution": "sync_or_async",
|
||||||
|
"family": "media-server",
|
||||||
|
"input_contract": "MediaServerPlayRequest",
|
||||||
|
"plugin_short_circuit": true,
|
||||||
|
"public_to_plugins": true,
|
||||||
|
"required_parameters": [],
|
||||||
|
"result_contract": "str | None",
|
||||||
|
"supports_async": true,
|
||||||
|
"supports_sync": true,
|
||||||
|
"timeout_policy": "caller_budget",
|
||||||
|
"version": 1
|
||||||
|
},
|
||||||
|
"mediaserver_tv_episodes": {
|
||||||
|
"aggregation": "ordered_list_merge",
|
||||||
|
"error_policy": "isolate_provider",
|
||||||
|
"execution": "sync_or_async",
|
||||||
|
"family": "media-server",
|
||||||
|
"input_contract": "MediaServerEpisodesRequest",
|
||||||
|
"plugin_short_circuit": true,
|
||||||
|
"public_to_plugins": true,
|
||||||
|
"required_parameters": [],
|
||||||
|
"result_contract": "list[MediaServerPlayItem]",
|
||||||
|
"supports_async": true,
|
||||||
|
"supports_sync": true,
|
||||||
|
"timeout_policy": "caller_budget",
|
||||||
|
"version": 1
|
||||||
|
},
|
||||||
|
"obtain_images": {
|
||||||
|
"aggregation": "legacy",
|
||||||
|
"error_policy": "isolate_provider",
|
||||||
|
"execution": "sync_or_async",
|
||||||
|
"family": "media-recognition",
|
||||||
|
"input_contract": "MediaInfo",
|
||||||
|
"plugin_short_circuit": true,
|
||||||
|
"public_to_plugins": true,
|
||||||
|
"required_parameters": [],
|
||||||
|
"result_contract": "MediaInfo | None",
|
||||||
|
"supports_async": true,
|
||||||
|
"supports_sync": true,
|
||||||
|
"timeout_policy": "caller_budget",
|
||||||
|
"version": 1
|
||||||
|
},
|
||||||
|
"recognize_media": {
|
||||||
|
"aggregation": "first_non_empty",
|
||||||
|
"error_policy": "isolate_provider",
|
||||||
|
"execution": "sync_or_async",
|
||||||
|
"family": "media-recognition",
|
||||||
|
"input_contract": "MediaRecognitionRequest",
|
||||||
|
"plugin_short_circuit": true,
|
||||||
|
"public_to_plugins": true,
|
||||||
|
"required_parameters": [],
|
||||||
|
"result_contract": "MediaInfo | None",
|
||||||
|
"supports_async": true,
|
||||||
|
"supports_sync": true,
|
||||||
|
"timeout_policy": "caller_budget",
|
||||||
|
"version": 1
|
||||||
|
},
|
||||||
|
"register_commands": {
|
||||||
|
"aggregation": "legacy",
|
||||||
|
"error_policy": "isolate_provider",
|
||||||
|
"execution": "sync_or_async",
|
||||||
|
"family": "messaging",
|
||||||
|
"input_contract": "CommandRegistrationRequest",
|
||||||
|
"plugin_short_circuit": true,
|
||||||
|
"public_to_plugins": true,
|
||||||
|
"required_parameters": [],
|
||||||
|
"result_contract": "None",
|
||||||
|
"supports_async": true,
|
||||||
|
"supports_sync": true,
|
||||||
|
"timeout_policy": "caller_budget",
|
||||||
|
"version": 1
|
||||||
|
},
|
||||||
|
"rename_file": {
|
||||||
|
"aggregation": "first_non_empty",
|
||||||
|
"error_policy": "isolate_provider",
|
||||||
|
"execution": "sync_or_async",
|
||||||
|
"family": "storage",
|
||||||
|
"input_contract": "StorageRenameRequest",
|
||||||
|
"plugin_short_circuit": true,
|
||||||
|
"public_to_plugins": true,
|
||||||
|
"required_parameters": [],
|
||||||
|
"result_contract": "bool | FileItem",
|
||||||
|
"supports_async": true,
|
||||||
|
"supports_sync": true,
|
||||||
|
"timeout_policy": "caller_budget",
|
||||||
|
"version": 1
|
||||||
|
},
|
||||||
|
"scheduler_job": {
|
||||||
|
"aggregation": "legacy",
|
||||||
|
"error_policy": "isolate_provider",
|
||||||
|
"execution": "sync_or_async",
|
||||||
|
"family": "scheduling",
|
||||||
|
"input_contract": "SchedulerJobRequest",
|
||||||
|
"plugin_short_circuit": true,
|
||||||
|
"public_to_plugins": true,
|
||||||
|
"required_parameters": [],
|
||||||
|
"result_contract": "None",
|
||||||
|
"supports_async": true,
|
||||||
|
"supports_sync": true,
|
||||||
|
"timeout_policy": "caller_budget",
|
||||||
|
"version": 1
|
||||||
|
},
|
||||||
|
"search_medias": {
|
||||||
|
"aggregation": "ordered_list_merge",
|
||||||
|
"error_policy": "isolate_provider",
|
||||||
|
"execution": "sync_or_async",
|
||||||
|
"family": "media-recognition",
|
||||||
|
"input_contract": "MediaSearchRequest",
|
||||||
|
"plugin_short_circuit": true,
|
||||||
|
"public_to_plugins": true,
|
||||||
|
"required_parameters": [],
|
||||||
|
"result_contract": "list[MediaInfo]",
|
||||||
|
"supports_async": true,
|
||||||
|
"supports_sync": true,
|
||||||
|
"timeout_policy": "caller_budget",
|
||||||
|
"version": 1
|
||||||
|
},
|
||||||
|
"send_message": {
|
||||||
|
"aggregation": "first_non_empty",
|
||||||
|
"error_policy": "isolate_provider",
|
||||||
|
"execution": "sync_or_async",
|
||||||
|
"family": "messaging",
|
||||||
|
"input_contract": "MessageSendRequest",
|
||||||
|
"plugin_short_circuit": true,
|
||||||
|
"public_to_plugins": true,
|
||||||
|
"required_parameters": [],
|
||||||
|
"result_contract": "Message | None",
|
||||||
|
"supports_async": true,
|
||||||
|
"supports_sync": true,
|
||||||
|
"timeout_policy": "caller_budget",
|
||||||
|
"version": 1
|
||||||
|
},
|
||||||
|
"snapshot_storage": {
|
||||||
|
"aggregation": "first_non_empty",
|
||||||
|
"error_policy": "isolate_provider",
|
||||||
|
"execution": "sync_or_async",
|
||||||
|
"family": "storage",
|
||||||
|
"input_contract": "StorageSnapshotRequest",
|
||||||
|
"plugin_short_circuit": true,
|
||||||
|
"public_to_plugins": true,
|
||||||
|
"required_parameters": [],
|
||||||
|
"result_contract": "dict[str, dict] | None",
|
||||||
|
"supports_async": true,
|
||||||
|
"supports_sync": true,
|
||||||
|
"timeout_policy": "caller_budget",
|
||||||
|
"version": 1
|
||||||
|
},
|
||||||
|
"storage_manage": {
|
||||||
|
"aggregation": "first_non_empty",
|
||||||
|
"error_policy": "isolate_provider",
|
||||||
|
"execution": "sync_or_async",
|
||||||
|
"family": "storage",
|
||||||
|
"input_contract": "StorageManageRequest",
|
||||||
|
"plugin_short_circuit": true,
|
||||||
|
"public_to_plugins": true,
|
||||||
|
"required_parameters": [],
|
||||||
|
"result_contract": "Any",
|
||||||
|
"supports_async": true,
|
||||||
|
"supports_sync": true,
|
||||||
|
"timeout_policy": "caller_budget",
|
||||||
|
"version": 1
|
||||||
|
},
|
||||||
|
"upload_file": {
|
||||||
|
"aggregation": "first_non_empty",
|
||||||
|
"error_policy": "isolate_provider",
|
||||||
|
"execution": "sync_or_async",
|
||||||
|
"family": "storage",
|
||||||
|
"input_contract": "StorageUploadRequest",
|
||||||
|
"plugin_short_circuit": true,
|
||||||
|
"public_to_plugins": true,
|
||||||
|
"required_parameters": [],
|
||||||
|
"result_contract": "FileItem | None",
|
||||||
|
"supports_async": true,
|
||||||
|
"supports_sync": true,
|
||||||
|
"timeout_policy": "caller_budget",
|
||||||
|
"version": 1
|
||||||
|
},
|
||||||
|
"webhook_parser": {
|
||||||
|
"aggregation": "first_non_empty",
|
||||||
|
"error_policy": "isolate_provider",
|
||||||
|
"execution": "sync_or_async",
|
||||||
|
"family": "integration",
|
||||||
|
"input_contract": "WebhookRequest",
|
||||||
|
"plugin_short_circuit": true,
|
||||||
|
"public_to_plugins": true,
|
||||||
|
"required_parameters": [],
|
||||||
|
"result_contract": "WebhookEventInfo | None",
|
||||||
|
"supports_async": true,
|
||||||
|
"supports_sync": true,
|
||||||
|
"timeout_policy": "caller_budget",
|
||||||
|
"version": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
"run_module": {
|
"run_module": {
|
||||||
"call_count": 260,
|
"call_count": 260,
|
||||||
"dynamic_call_count": 0,
|
"dynamic_call_count": 0,
|
||||||
|
|||||||
@@ -4,9 +4,13 @@ import json
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from app.runtime.extensions.module.contracts import (
|
from app.runtime.extensions.module.contracts import (
|
||||||
|
ModuleErrorPolicy,
|
||||||
|
ModuleExecutionMode,
|
||||||
ModuleResultAggregation,
|
ModuleResultAggregation,
|
||||||
|
diagnose_module_callable,
|
||||||
get_module_method_contract,
|
get_module_method_contract,
|
||||||
is_explicit_module_method,
|
is_explicit_module_method,
|
||||||
|
list_explicit_module_contracts,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -23,7 +27,7 @@ def test_all_scanned_module_methods_resolve_a_contract() -> None:
|
|||||||
assert methods
|
assert methods
|
||||||
for method in methods:
|
for method in methods:
|
||||||
contract = get_module_method_contract(method)
|
contract = get_module_method_contract(method)
|
||||||
assert contract.aggregation is ModuleResultAggregation.LEGACY
|
assert isinstance(contract.aggregation, ModuleResultAggregation)
|
||||||
assert contract.plugin_short_circuit is True
|
assert contract.plugin_short_circuit is True
|
||||||
|
|
||||||
|
|
||||||
@@ -53,3 +57,38 @@ def test_unknown_plugin_method_keeps_legacy_compatibility() -> None:
|
|||||||
assert contract.family == "legacy"
|
assert contract.family == "legacy"
|
||||||
assert contract.supports_sync is True
|
assert contract.supports_sync is True
|
||||||
assert contract.supports_async is True
|
assert contract.supports_async is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_contract_v2_freezes_at_least_twenty_high_value_methods() -> None:
|
||||||
|
"""首批能力必须具备可生成文档和诊断的完整 V2 字段。"""
|
||||||
|
contracts = list_explicit_module_contracts()
|
||||||
|
|
||||||
|
assert len(contracts) >= 20
|
||||||
|
for contract in contracts.values():
|
||||||
|
assert contract.version == 1
|
||||||
|
assert contract.input_contract != "legacy_args"
|
||||||
|
assert contract.result_contract
|
||||||
|
assert contract.execution is ModuleExecutionMode.SYNC_OR_ASYNC
|
||||||
|
assert contract.timeout_policy == "caller_budget"
|
||||||
|
assert contract.error_policy is ModuleErrorPolicy.ISOLATE_PROVIDER
|
||||||
|
assert contract.public_to_plugins is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_signature_diagnostics_do_not_reject_legacy_callable() -> None:
|
||||||
|
"""无法检查的旧插件 callable 只产生诊断,仍由 dispatcher 决定是否执行。"""
|
||||||
|
class _OpaqueCallable:
|
||||||
|
"""模拟 inspect 无法解析签名的第三方 callable。"""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def __signature__(self):
|
||||||
|
"""模拟扩展对象不提供 Python signature。"""
|
||||||
|
raise ValueError("opaque")
|
||||||
|
|
||||||
|
def __call__(self):
|
||||||
|
"""保留可调用行为。"""
|
||||||
|
return "ok"
|
||||||
|
|
||||||
|
assert diagnose_module_callable("recognize_media", _OpaqueCallable()) == (
|
||||||
|
"signature-unavailable",
|
||||||
|
)
|
||||||
|
assert _OpaqueCallable()() == "ok"
|
||||||
|
|||||||
Reference in New Issue
Block a user