refactor(runtime): lazily activate host modules (#6331)

This commit is contained in:
InfinityPacer
2026-08-16 16:07:38 +08:00
committed by GitHub
parent 98276a68a8
commit 24671f8f18
67 changed files with 7364 additions and 253 deletions

View File

@@ -1,9 +1,10 @@
from __future__ import annotations
import json
import time
from typing import Union, Any, List, Optional
from typing import Protocol, Union, Any, List, Optional
from fastapi import BackgroundTasks, Depends, Request
from pywebpush import WebPushException, webpush
from sqlalchemy.ext.asyncio import AsyncSession
from starlette.responses import PlainTextResponse
@@ -27,7 +28,13 @@ router = ResponseAPIRouter()
_WNS_DEFAULT_TTL = 86400
def is_webpush_subscription_gone(error: WebPushException) -> bool:
class WebPushError(Protocol):
"""Web Push 订阅状态判断所需的最小异常协议。"""
response: Any # 推送服务响应,状态码字段由具体 SDK 提供
def is_webpush_subscription_gone(error: WebPushError) -> bool:
"""判断 Web Push 订阅是否已在浏览器或推送服务侧失效。"""
response: Any = getattr(error, "response", None)
status_code = getattr(response, "status_code", None) or getattr(
@@ -359,6 +366,8 @@ def send_notification(
"""
发送webpush通知
"""
from pywebpush import WebPushException, webpush
for sub in global_vars.get_subscriptions():
try:
webpush(

View File

@@ -1405,8 +1405,9 @@ def modulelist(_: schemas.TokenPayload = Depends(verify_token)):
查询已加载的模块ID列表
"""
modules = []
for module_id, module in ModuleManager().get_modules().items():
name = module.get_name()
for spec in ModuleManager().list_specs():
module_id = spec.id
name = str(spec.metadata["name"])
modules.append(
{
"id": module_id,

View File

@@ -1,9 +1,11 @@
from __future__ import annotations
import copy
import inspect
import pickle
import traceback
from abc import ABCMeta
from collections.abc import Callable
from collections.abc import Callable, Sequence
from datetime import datetime
from pathlib import Path
from typing import Optional, Any, Tuple, List, Set, Union, Dict

View File

@@ -9,6 +9,7 @@ class Singleton(abc.ABCMeta, type):
"""
_instances: dict = {}
_lock = threading.RLock()
def get_existing_instance(cls, *args, **kwargs):
"""按相同参数返回已创建实例,不触发初始化"""
@@ -18,9 +19,10 @@ class Singleton(abc.ABCMeta, type):
def __call__(cls, *args, **kwargs):
"""按类和构造参数创建或复用实例。"""
key = (cls, args, frozenset(kwargs.items()))
if key not in cls._instances:
cls._instances[key] = super().__call__(*args, **kwargs)
return cls._instances[key]
with cls._lock:
if key not in cls._instances:
cls._instances[key] = super().__call__(*args, **kwargs)
return cls._instances[key]
class AbstractSingleton(abc.ABC, metaclass=Singleton):
@@ -36,6 +38,7 @@ class SingletonClass(abc.ABCMeta, type):
"""
_instances: dict = {}
_lock = threading.RLock()
def get_existing_instance(cls):
"""返回已创建实例,不触发初始化"""
@@ -43,9 +46,10 @@ class SingletonClass(abc.ABCMeta, type):
def __call__(cls, *args, **kwargs):
"""按类创建或复用唯一实例。"""
if cls not in cls._instances:
cls._instances[cls] = super().__call__(*args, **kwargs)
return cls._instances[cls]
with cls._lock:
if cls not in cls._instances:
cls._instances[cls] = super().__call__(*args, **kwargs)
return cls._instances[cls]
class AbstractSingletonClass(abc.ABC, metaclass=SingletonClass):

View File

@@ -3,7 +3,7 @@ from abc import abstractmethod, ABCMeta
from typing import Generic, Tuple, Union, TypeVar, Type, Dict, Optional, Callable
from pathlib import Path
from app.runtime.extensions.service_registry import ServiceConfigHelper
from app.runtime.extensions.service_config import ServiceConfigHelper
from app.runtime.log import logger
from app.schemas import Notification, NotificationConf, MediaServerConf, DownloaderConf
from app.schemas.types import ModuleType, DownloaderType, MediaServerType, MessageChannel, StorageSchema, \
@@ -17,6 +17,9 @@ class _ModuleBase(ConfigReloadMixin, metaclass=ABCMeta):
输入参数与输出参数一致的,或没有输出的,可以被多个模块重复实现
"""
# Host Module 的配置事件由统一 Adapter 协调,避免同一 generation 被双重重载。
CONFIG_RELOAD_MANAGED_EXTERNALLY = True
def __init__(self) -> None:
"""初始化模块生命周期锁"""
super().__init__()

View File

@@ -0,0 +1,19 @@
schema_version = 1
id = "AcoustIdModule"
kind = "host_module"
entrypoint = "app.modules.acoustid:AcoustIdModule"
depends_on = []
[metadata]
name = "AcoustID"
type = "other"
subtype = "AcoustId"
priority = 0
[activation]
policy = "when_configured"
watch = ["ACOUSTID_API_KEY"]
[activation.selector]
kind = "setting_truthy"
key = "ACOUSTID_API_KEY"

View File

@@ -0,0 +1,15 @@
schema_version = 1
id = "AniListModule"
kind = "host_module"
entrypoint = "app.modules.anilist:AniListModule"
depends_on = []
[metadata]
name = "AniList"
type = "mediarecognize"
subtype = "AniList"
priority = 4
[activation]
policy = "bootstrap"
watch = ["PROXY_HOST"]

View File

@@ -0,0 +1,15 @@
schema_version = 1
id = "BangumiModule"
kind = "host_module"
entrypoint = "app.modules.bangumi:BangumiModule"
depends_on = []
[metadata]
name = "Bangumi"
type = "mediarecognize"
subtype = "Bangumi"
priority = 3
[activation]
policy = "bootstrap"
watch = ["PROXY_HOST"]

View File

@@ -0,0 +1,22 @@
schema_version = 1
id = "DiscordModule"
kind = "host_module"
entrypoint = "app.modules.discord:DiscordModule"
depends_on = []
[metadata]
name = "Discord"
type = "notification"
subtype = "Discord"
priority = 4
[activation]
policy = "when_configured"
watch = ["Notifications"]
[activation.selector]
kind = "system_config_item"
key = "Notifications"
match_field = "type"
match_value = "discord"
enabled_field = "enabled"

View File

@@ -0,0 +1,15 @@
schema_version = 1
id = "DoubanModule"
kind = "host_module"
entrypoint = "app.modules.douban:DoubanModule"
depends_on = []
[metadata]
name = "豆瓣"
type = "mediarecognize"
subtype = "Douban"
priority = 2
[activation]
policy = "bootstrap"
watch = []

View File

@@ -0,0 +1,22 @@
schema_version = 1
id = "EmbyModule"
kind = "host_module"
entrypoint = "app.modules.emby:EmbyModule"
depends_on = []
[metadata]
name = "Emby"
type = "mediaserver"
subtype = "Emby"
priority = 1
[activation]
policy = "when_configured"
watch = ["MediaServers"]
[activation.selector]
kind = "system_config_item"
key = "MediaServers"
match_field = "type"
match_value = "emby"
enabled_field = "enabled"

View File

@@ -0,0 +1,19 @@
schema_version = 1
id = "FanartModule"
kind = "host_module"
entrypoint = "app.modules.fanart:FanartModule"
depends_on = []
[metadata]
name = "Fanart"
type = "other"
subtype = "Fanart"
priority = 0
[activation]
policy = "when_configured"
watch = ["FANART_API_KEY"]
[activation.selector]
kind = "setting_truthy"
key = "FANART_API_KEY"

View File

@@ -0,0 +1,22 @@
schema_version = 1
id = "FeishuModule"
kind = "host_module"
entrypoint = "app.modules.feishu:FeishuModule"
depends_on = []
[metadata]
name = "飞书"
type = "notification"
subtype = "Feishu"
priority = 2
[activation]
policy = "when_configured"
watch = ["Notifications"]
[activation.selector]
kind = "system_config_item"
key = "Notifications"
match_field = "type"
match_value = "feishu"
enabled_field = "enabled"

View File

@@ -0,0 +1,15 @@
schema_version = 1
id = "FileManagerModule"
kind = "host_module"
entrypoint = "app.modules.filemanager:FileManagerModule"
depends_on = []
[metadata]
name = "文件整理"
type = "other"
subtype = "FileManager"
priority = 4
[activation]
policy = "bootstrap"
watch = []

View File

@@ -0,0 +1,15 @@
schema_version = 1
id = "FilterModule"
kind = "host_module"
entrypoint = "app.modules.filter:FilterModule"
depends_on = []
[metadata]
name = "过滤器"
type = "other"
subtype = "Filter"
priority = 4
[activation]
policy = "bootstrap"
watch = ["CustomFilterRules", "CustomIdentifiers", "CustomReleaseGroups", "Customization"]

View File

@@ -0,0 +1,15 @@
schema_version = 1
id = "IndexerModule"
kind = "host_module"
entrypoint = "app.modules.indexer:IndexerModule"
depends_on = []
[metadata]
name = "站点索引"
type = "indexer"
subtype = "Indexer"
priority = 0
[activation]
policy = "bootstrap"
watch = []

View File

@@ -0,0 +1,22 @@
schema_version = 1
id = "JellyfinModule"
kind = "host_module"
entrypoint = "app.modules.jellyfin:JellyfinModule"
depends_on = []
[metadata]
name = "Jellyfin"
type = "mediaserver"
subtype = "Jellyfin"
priority = 2
[activation]
policy = "when_configured"
watch = ["MediaServers"]
[activation.selector]
kind = "system_config_item"
key = "MediaServers"
match_field = "type"
match_value = "jellyfin"
enabled_field = "enabled"

View File

@@ -0,0 +1,15 @@
schema_version = 1
id = "ListenBrainzModule"
kind = "host_module"
entrypoint = "app.modules.listenbrainz:ListenBrainzModule"
depends_on = []
[metadata]
name = "ListenBrainz"
type = "other"
subtype = "ListenBrainz"
priority = 5
[activation]
policy = "bootstrap"
watch = []

View File

@@ -0,0 +1,15 @@
schema_version = 1
id = "LrclibModule"
kind = "host_module"
entrypoint = "app.modules.lrclib:LrclibModule"
depends_on = []
[metadata]
name = "LRCLIB"
type = "other"
subtype = "Lrclib"
priority = 5
[activation]
policy = "bootstrap"
watch = []

View File

@@ -0,0 +1,15 @@
schema_version = 1
id = "MusicBrainzModule"
kind = "host_module"
entrypoint = "app.modules.musicbrainz:MusicBrainzModule"
depends_on = []
[metadata]
name = "MusicBrainz"
type = "mediarecognize"
subtype = "MusicBrainz"
priority = 0
[activation]
policy = "bootstrap"
watch = []

View File

@@ -0,0 +1,22 @@
schema_version = 1
id = "NavidromeModule"
kind = "host_module"
entrypoint = "app.modules.navidrome:NavidromeModule"
depends_on = []
[metadata]
name = "Navidrome"
type = "mediaserver"
subtype = "Navidrome"
priority = 7
[activation]
policy = "when_configured"
watch = ["MediaServers"]
[activation.selector]
kind = "system_config_item"
key = "MediaServers"
match_field = "type"
match_value = "navidrome"
enabled_field = "enabled"

View File

@@ -0,0 +1,22 @@
schema_version = 1
id = "PlexModule"
kind = "host_module"
entrypoint = "app.modules.plex:PlexModule"
depends_on = []
[metadata]
name = "Plex"
type = "mediaserver"
subtype = "Plex"
priority = 3
[activation]
policy = "when_configured"
watch = ["MediaServers"]
[activation.selector]
kind = "system_config_item"
key = "MediaServers"
match_field = "type"
match_value = "plex"
enabled_field = "enabled"

View File

@@ -0,0 +1,15 @@
schema_version = 1
id = "PostgreSQLModule"
kind = "host_module"
entrypoint = "app.modules.postgresql:PostgreSQLModule"
depends_on = []
[metadata]
name = "PostgreSQL"
type = "other"
subtype = "PostgreSQL"
priority = 0
[activation]
policy = "bootstrap"
watch = []

View File

@@ -0,0 +1,22 @@
schema_version = 1
id = "QbittorrentModule"
kind = "host_module"
entrypoint = "app.modules.qbittorrent:QbittorrentModule"
depends_on = []
[metadata]
name = "Qbittorrent"
type = "downloader"
subtype = "Qbittorrent"
priority = 1
[activation]
policy = "when_configured"
watch = ["Downloaders"]
[activation.selector]
kind = "system_config_item"
key = "Downloaders"
match_field = "type"
match_value = "qbittorrent"
enabled_field = "enabled"

View File

@@ -0,0 +1,22 @@
schema_version = 1
id = "QQBotModule"
kind = "host_module"
entrypoint = "app.modules.qqbot:QQBotModule"
depends_on = []
[metadata]
name = "QQ"
type = "notification"
subtype = "QQ"
priority = 10
[activation]
policy = "when_configured"
watch = ["Notifications"]
[activation.selector]
kind = "system_config_item"
key = "Notifications"
match_field = "type"
match_value = "qqbot"
enabled_field = "enabled"

View File

@@ -0,0 +1,15 @@
schema_version = 1
id = "RedisModule"
kind = "host_module"
entrypoint = "app.modules.redis:RedisModule"
depends_on = []
[metadata]
name = "Redis缓存"
type = "other"
subtype = "Redis"
priority = 0
[activation]
policy = "bootstrap"
watch = []

View File

@@ -0,0 +1,22 @@
schema_version = 1
id = "RtorrentModule"
kind = "host_module"
entrypoint = "app.modules.rtorrent:RtorrentModule"
depends_on = []
[metadata]
name = "Rtorrent"
type = "downloader"
subtype = "Rtorrent"
priority = 3
[activation]
policy = "when_configured"
watch = ["Downloaders"]
[activation.selector]
kind = "system_config_item"
key = "Downloaders"
match_field = "type"
match_value = "rtorrent"
enabled_field = "enabled"

View File

@@ -0,0 +1,22 @@
schema_version = 1
id = "SlackModule"
kind = "host_module"
entrypoint = "app.modules.slack:SlackModule"
depends_on = []
[metadata]
name = "Slack"
type = "notification"
subtype = "Slack"
priority = 3
[activation]
policy = "when_configured"
watch = ["Notifications"]
[activation.selector]
kind = "system_config_item"
key = "Notifications"
match_field = "type"
match_value = "slack"
enabled_field = "enabled"

View File

@@ -0,0 +1,15 @@
schema_version = 1
id = "SubtitleModule"
kind = "host_module"
entrypoint = "app.modules.subtitle:SubtitleModule"
depends_on = []
[metadata]
name = "站点字幕"
type = "other"
subtype = "Subtitle"
priority = 0
[activation]
policy = "bootstrap"
watch = []

View File

@@ -0,0 +1,22 @@
schema_version = 1
id = "SynologyChatModule"
kind = "host_module"
entrypoint = "app.modules.synologychat:SynologyChatModule"
depends_on = []
[metadata]
name = "Synology Chat"
type = "notification"
subtype = "SynologyChat"
priority = 5
[activation]
policy = "when_configured"
watch = ["Notifications"]
[activation.selector]
kind = "system_config_item"
key = "Notifications"
match_field = "type"
match_value = "synologychat"
enabled_field = "enabled"

View File

@@ -0,0 +1,22 @@
schema_version = 1
id = "TelegramModule"
kind = "host_module"
entrypoint = "app.modules.telegram:TelegramModule"
depends_on = []
[metadata]
name = "Telegram"
type = "notification"
subtype = "Telegram"
priority = 0
[activation]
policy = "when_configured"
watch = ["Notifications"]
[activation.selector]
kind = "system_config_item"
key = "Notifications"
match_field = "type"
match_value = "telegram"
enabled_field = "enabled"

View File

@@ -0,0 +1,15 @@
schema_version = 1
id = "TheAudioDbModule"
kind = "host_module"
entrypoint = "app.modules.theaudiodb:TheAudioDbModule"
depends_on = []
[metadata]
name = "TheAudioDB"
type = "mediarecognize"
subtype = "TheAudioDB"
priority = 1
[activation]
policy = "bootstrap"
watch = []

View File

@@ -0,0 +1,15 @@
schema_version = 1
id = "TheMovieDbModule"
kind = "host_module"
entrypoint = "app.modules.themoviedb:TheMovieDbModule"
depends_on = []
[metadata]
name = "TheMovieDb"
type = "mediarecognize"
subtype = "TMDB"
priority = 1
[activation]
policy = "bootstrap"
watch = ["PROXY_HOST", "TMDB_API_DOMAIN", "TMDB_API_KEY", "TMDB_LOCALE"]

View File

@@ -0,0 +1,15 @@
schema_version = 1
id = "TheTvDbModule"
kind = "host_module"
entrypoint = "app.modules.thetvdb:TheTvDbModule"
depends_on = []
[metadata]
name = "TheTvDb"
type = "mediarecognize"
subtype = "TVDB"
priority = 4
[activation]
policy = "bootstrap"
watch = []

View File

@@ -0,0 +1,22 @@
schema_version = 1
id = "TransmissionModule"
kind = "host_module"
entrypoint = "app.modules.transmission:TransmissionModule"
depends_on = []
[metadata]
name = "Transmission"
type = "downloader"
subtype = "Transmission"
priority = 2
[activation]
policy = "when_configured"
watch = ["Downloaders"]
[activation.selector]
kind = "system_config_item"
key = "Downloaders"
match_field = "type"
match_value = "transmission"
enabled_field = "enabled"

View File

@@ -0,0 +1,22 @@
schema_version = 1
id = "TrimeMediaModule"
kind = "host_module"
entrypoint = "app.modules.trimemedia:TrimeMediaModule"
depends_on = []
[metadata]
name = "飞牛影视"
type = "mediaserver"
subtype = "TrimeMedia"
priority = 4
[activation]
policy = "when_configured"
watch = ["MediaServers"]
[activation.selector]
kind = "system_config_item"
key = "MediaServers"
match_field = "type"
match_value = "trimemedia"
enabled_field = "enabled"

View File

@@ -0,0 +1,22 @@
schema_version = 1
id = "UgreenModule"
kind = "host_module"
entrypoint = "app.modules.ugreen:UgreenModule"
depends_on = []
[metadata]
name = "绿联影视"
type = "mediaserver"
subtype = "Ugreen"
priority = 5
[activation]
policy = "when_configured"
watch = ["MediaServers"]
[activation.selector]
kind = "system_config_item"
key = "MediaServers"
match_field = "type"
match_value = "ugreen"
enabled_field = "enabled"

View File

@@ -0,0 +1,22 @@
schema_version = 1
id = "VoceChatModule"
kind = "host_module"
entrypoint = "app.modules.vocechat:VoceChatModule"
depends_on = []
[metadata]
name = "VoceChat"
type = "notification"
subtype = "VoceChat"
priority = 4
[activation]
policy = "when_configured"
watch = ["Notifications"]
[activation.selector]
kind = "system_config_item"
key = "Notifications"
match_field = "type"
match_value = "vocechat"
enabled_field = "enabled"

View File

@@ -0,0 +1,22 @@
schema_version = 1
id = "WebPushModule"
kind = "host_module"
entrypoint = "app.modules.webpush:WebPushModule"
depends_on = []
[metadata]
name = "WebPush"
type = "notification"
subtype = "WebPush"
priority = 6
[activation]
policy = "when_configured"
watch = ["Notifications"]
[activation.selector]
kind = "system_config_item"
key = "Notifications"
match_field = "type"
match_value = "webpush"
enabled_field = "enabled"

View File

@@ -0,0 +1,22 @@
schema_version = 1
id = "WechatModule"
kind = "host_module"
entrypoint = "app.modules.wechat:WechatModule"
depends_on = []
[metadata]
name = "企业微信"
type = "notification"
subtype = "Wechat"
priority = 1
[activation]
policy = "when_configured"
watch = ["Notifications"]
[activation.selector]
kind = "system_config_item"
key = "Notifications"
match_field = "type"
match_value = "wechat"
enabled_field = "enabled"

View File

@@ -0,0 +1,22 @@
schema_version = 1
id = "WechatClawBotModule"
kind = "host_module"
entrypoint = "app.modules.wechatclawbot:WechatClawBotModule"
depends_on = []
[metadata]
name = "微信 ClawBot"
type = "notification"
subtype = "WechatClawBot"
priority = 2
[activation]
policy = "when_configured"
watch = ["Notifications"]
[activation.selector]
kind = "system_config_item"
key = "Notifications"
match_field = "type"
match_value = "wechatclawbot"
enabled_field = "enabled"

View File

@@ -0,0 +1,22 @@
schema_version = 1
id = "ZSpaceModule"
kind = "host_module"
entrypoint = "app.modules.zspace:ZSpaceModule"
depends_on = []
[metadata]
name = "极影视"
type = "mediaserver"
subtype = "ZSpace"
priority = 6
[activation]
policy = "when_configured"
watch = ["MediaServers"]
[activation.selector]
kind = "system_config_item"
key = "MediaServers"
match_field = "type"
match_value = "zspace"
enabled_field = "enabled"

View File

@@ -0,0 +1,47 @@
"""MoviePilot 内部能力运行时的惰性公共导出。"""
from __future__ import annotations
from importlib import import_module
from typing import Any
_EXPORT_MODULES = {
"ActivationPolicy": "app.runtime.capabilities.model",
"AdapterExecutionMode": "app.runtime.capabilities.model",
"AsyncCapabilityAdapter": "app.runtime.capabilities.model",
"CapabilityAdapterContractError": "app.runtime.capabilities.errors",
"CapabilityAdapterModeError": "app.runtime.capabilities.errors",
"CapabilityError": "app.runtime.capabilities.errors",
"CapabilityLifecycleState": "app.runtime.capabilities.model",
"CapabilityManifestError": "app.runtime.capabilities.errors",
"CapabilityMaterializationState": "app.runtime.capabilities.model",
"CapabilityObservation": "app.runtime.capabilities.model",
"CapabilityOperationError": "app.runtime.capabilities.errors",
"CapabilityRegistry": "app.runtime.capabilities.registry",
"CapabilityRuntime": "app.runtime.capabilities.runtime",
"CapabilityRuntimeClosedError": "app.runtime.capabilities.errors",
"CapabilitySnapshot": "app.runtime.capabilities.model",
"CapabilitySpec": "app.runtime.capabilities.model",
"SelectorSchema": "app.runtime.capabilities.model",
"SelectorSpec": "app.runtime.capabilities.model",
"SyncCapabilityAdapter": "app.runtime.capabilities.model",
"UnknownCapabilityError": "app.runtime.capabilities.errors",
}
__all__ = sorted(_EXPORT_MODULES)
def __getattr__(name: str) -> Any:
"""仅在显式访问公共符号时导入对应叶模块,并缓存 canonical 对象。"""
module_name = _EXPORT_MODULES.get(name)
if module_name is None:
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
value = getattr(import_module(module_name), name)
globals()[name] = value
return value
def __dir__() -> list[str]:
"""向交互式工具公开稳定导出名,而不触发叶模块导入。"""
return sorted(set(globals()) | set(__all__))

View File

@@ -0,0 +1,32 @@
class CapabilityError(RuntimeError):
"""Capability Runtime 错误基类。"""
class CapabilityManifestError(CapabilityError, ValueError):
"""能力声明不完整、冲突或违反当前 schema。"""
class UnknownCapabilityError(CapabilityError, KeyError):
"""请求了 Registry 中不存在的能力。"""
class CapabilityAdapterModeError(CapabilityError, TypeError):
"""调用入口与适配器并发模型不匹配。"""
class CapabilityAdapterContractError(CapabilityError, TypeError):
"""适配器回调返回值不符合已声明的并发模型。"""
class CapabilityOperationError(CapabilityError):
"""能力物化或资源生命周期转换失败。"""
def __init__(self, capability_id: str, operation: str, error: BaseException):
self.capability_id = capability_id
self.operation = operation
self.error = error
super().__init__(f"能力 {capability_id} 执行 {operation} 失败:{error}")
class CapabilityRuntimeClosedError(CapabilityError):
"""Runtime 已进入关闭态,禁止启动或重新物化能力。"""

View File

@@ -0,0 +1,182 @@
from __future__ import annotations
from dataclasses import dataclass
from enum import Enum
from pathlib import Path
from types import MappingProxyType
from typing import Any, Awaitable, Callable, Mapping, Optional, Protocol
class ActivationPolicy(str, Enum):
"""能力的启动触发策略。"""
BOOTSTRAP = "bootstrap"
WHEN_CONFIGURED = "when_configured"
ON_FIRST_USE = "on_first_use"
class AdapterExecutionMode(str, Enum):
"""领域适配器执行回调所使用的并发模型。"""
SYNC = "sync"
ASYNC = "async"
class CapabilityMaterializationState(str, Enum):
"""Python 实现对象的解析状态。"""
UNRESOLVED = "unresolved"
RESOLVING = "resolving"
RESOLVED = "resolved"
FAILED = "failed"
class CapabilityLifecycleState(str, Enum):
"""能力所拥有外部资源的生命周期状态。"""
DISCOVERED = "discovered"
STARTING = "starting"
RUNNING = "running"
RELOADING = "reloading"
STOPPING = "stopping"
STOPPED = "stopped"
FAILED = "failed"
@dataclass(frozen=True, slots=True)
class SelectorSchema:
"""声明一个 selector 可接受的精确参数集合。"""
required_fields: frozenset[str] = frozenset()
optional_fields: frozenset[str] = frozenset()
validator: Optional[Callable[[Mapping[str, Any]], None]] = None
@dataclass(frozen=True, slots=True)
class SelectorSpec:
"""由领域适配器解释的配置选择器。"""
kind: str
config: Mapping[str, Any]
@dataclass(frozen=True, slots=True)
class CapabilitySpec:
"""从 data-only manifest 构建的不可变能力声明。"""
schema_version: int
id: str
kind: str
entrypoint: str
activation: ActivationPolicy
metadata: Mapping[str, Any]
selector: Optional[SelectorSpec]
watch: tuple[str, ...]
depends_on: tuple[str, ...]
source: Path
@dataclass(frozen=True, slots=True)
class CapabilitySnapshot:
"""能力状态的只读快照。"""
capability_id: str
materialization: CapabilityMaterializationState
lifecycle: CapabilityLifecycleState
generation: int
visible: bool
error: Optional[str]
@dataclass(frozen=True, slots=True)
class CapabilityObservation:
"""单次状态转换的可复现观测记录。"""
capability_id: str
generation: int
operation: str
outcome: str
reason: str
materialization: CapabilityMaterializationState
lifecycle: CapabilityLifecycleState
duration_ms: float
error: Optional[str]
class SyncCapabilityAdapter(Protocol):
"""同步领域适配器合同。"""
execution_mode: AdapterExecutionMode
def materialize(self, spec: CapabilitySpec) -> Any:
"""解析 manifest entrypoint 对应的 canonical 实现对象。"""
def create(
self,
spec: CapabilitySpec,
implementation: Any,
generation: int,
previous: Any = None,
) -> Any:
"""创建尚未对外发布的候选资源。"""
def start(self, spec: CapabilitySpec, candidate: Any, generation: int) -> None:
"""启动候选资源;返回前候选不会对普通查询可见。"""
def stop(self, spec: CapabilitySpec, instance: Any, generation: int) -> None:
"""停止已经撤销运行态可见性的资源。"""
def cleanup(
self,
spec: CapabilitySpec,
candidate: Any,
generation: int,
error: BaseException,
) -> None:
"""清理由失败启动留下的候选资源。"""
class AsyncCapabilityAdapter(Protocol):
"""异步领域适配器合同。"""
execution_mode: AdapterExecutionMode
def materialize(self, spec: CapabilitySpec) -> Awaitable[Any]:
"""异步解析 manifest entrypoint 对应的 canonical 实现对象。"""
def create(
self,
spec: CapabilitySpec,
implementation: Any,
generation: int,
previous: Any = None,
) -> Awaitable[Any]:
"""异步创建尚未对外发布的候选资源。"""
def start(
self,
spec: CapabilitySpec,
candidate: Any,
generation: int,
) -> Awaitable[None]:
"""异步启动候选资源。"""
def stop(
self,
spec: CapabilitySpec,
instance: Any,
generation: int,
) -> Awaitable[None]:
"""异步停止已经撤销运行态可见性的资源。"""
def cleanup(
self,
spec: CapabilitySpec,
candidate: Any,
generation: int,
error: BaseException,
) -> Awaitable[None]:
"""异步清理由失败启动留下的候选资源。"""
EMPTY_MAPPING: Mapping[str, Any] = MappingProxyType({})

View File

@@ -0,0 +1,287 @@
from __future__ import annotations
import re
import tomllib
from pathlib import Path
from types import MappingProxyType
from typing import Any, Collection, Iterable, Mapping
from app.runtime.capabilities.errors import (
CapabilityManifestError,
UnknownCapabilityError,
)
from app.runtime.capabilities.model import (
ActivationPolicy,
CapabilitySpec,
SelectorSchema,
SelectorSpec,
)
_SCHEMA_VERSION = 1
_MANIFEST_NAME = "capability.toml"
_TOP_LEVEL_FIELDS = frozenset({
"schema_version",
"id",
"kind",
"entrypoint",
"metadata",
"activation",
"depends_on",
})
_REQUIRED_FIELDS = _TOP_LEVEL_FIELDS
_ACTIVATION_FIELDS = frozenset({"policy", "watch", "selector"})
_ACTIVATION_REQUIRED_FIELDS = frozenset({"policy", "watch"})
_IDENTIFIER_PATTERN = re.compile(r"^[A-Za-z][A-Za-z0-9_.-]{0,127}$")
_KIND_PATTERN = re.compile(r"^[a-z][a-z0-9_.-]{0,63}$")
_ENTRYPOINT_PATTERN = re.compile(
r"^[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*"
r":[A-Za-z_][A-Za-z0-9_]*$"
)
def _freeze(value: Any, *, field: str) -> Any:
"""把 TOML 容器递归转换为不可变结构,并拒绝非配置标量。"""
if value is None or isinstance(value, (str, int, float, bool)):
return value
if isinstance(value, list):
return tuple(_freeze(item, field=field) for item in value)
if isinstance(value, dict):
if not all(isinstance(key, str) and key for key in value):
raise CapabilityManifestError(f"{field} 包含非法键")
return MappingProxyType({
key: _freeze(item, field=f"{field}.{key}")
for key, item in value.items()
})
raise CapabilityManifestError(f"{field} 包含不支持的 TOML 值类型 {type(value).__name__}")
def _string_list(value: Any, *, field: str, path: Path) -> tuple[str, ...]:
"""校验无重复的非空字符串列表。"""
if not isinstance(value, list):
raise CapabilityManifestError(f"{path}: {field} 必须是字符串数组")
if any(not isinstance(item, str) or not item.strip() for item in value):
raise CapabilityManifestError(f"{path}: {field} 只能包含非空字符串")
normalized = tuple(item.strip() for item in value)
if len(set(normalized)) != len(normalized):
raise CapabilityManifestError(f"{path}: {field} 不能包含重复值")
return normalized
class CapabilityRegistry:
"""只读取 data-only manifest 的不可变能力注册表。"""
def __init__(
self,
specs: Mapping[str, CapabilitySpec],
*,
kinds: Collection[str],
selector_schemas: Mapping[str, SelectorSchema],
) -> None:
self._specs = MappingProxyType(dict(specs))
self._kinds = frozenset(kinds)
self._selector_schemas = MappingProxyType(dict(selector_schemas))
@classmethod
def discover(
cls,
roots: Iterable[Path | str],
*,
kinds: Collection[str],
selector_schemas: Mapping[str, SelectorSchema],
) -> "CapabilityRegistry":
"""扫描全部声明根;任何根或 manifest 非法都会阻止 Registry 构建。"""
normalized_kinds = frozenset(kinds)
if not normalized_kinds:
raise CapabilityManifestError("至少需要注册一个 capability kind")
for kind in normalized_kinds:
if not isinstance(kind, str) or not _KIND_PATTERN.fullmatch(kind):
raise CapabilityManifestError(f"非法 capability kind{kind!r}")
normalized_selectors = dict(selector_schemas)
for selector_type, schema in normalized_selectors.items():
if not isinstance(selector_type, str) or not _KIND_PATTERN.fullmatch(selector_type):
raise CapabilityManifestError(f"非法 selector type{selector_type!r}")
if not isinstance(schema, SelectorSchema):
raise CapabilityManifestError(f"selector {selector_type} 未提供 SelectorSchema")
overlap = schema.required_fields & schema.optional_fields
if overlap:
raise CapabilityManifestError(
f"selector {selector_type} 字段同时声明为 required/optional{sorted(overlap)}"
)
specs: dict[str, CapabilitySpec] = {}
normalized_roots = tuple(Path(root) for root in roots)
if not normalized_roots:
raise CapabilityManifestError("至少需要一个 capability 声明根")
for root in normalized_roots:
if not root.is_dir():
raise CapabilityManifestError(f"声明根不存在或不是目录:{root}")
manifests = sorted(root.rglob(_MANIFEST_NAME))
if not manifests:
raise CapabilityManifestError(f"声明根没有 {_MANIFEST_NAME}{root}")
for manifest_path in manifests:
spec = cls._load_manifest(
manifest_path,
kinds=normalized_kinds,
selector_schemas=normalized_selectors,
)
previous = specs.get(spec.id)
if previous:
raise CapabilityManifestError(
f"capability id 重复:{spec.id},来源 {previous.source}{spec.source}"
)
specs[spec.id] = spec
return cls(
specs,
kinds=normalized_kinds,
selector_schemas=normalized_selectors,
)
@classmethod
def _load_manifest(
cls,
path: Path,
*,
kinds: Collection[str],
selector_schemas: Mapping[str, SelectorSchema],
) -> CapabilitySpec:
try:
with path.open("rb") as file:
data = tomllib.load(file)
except (OSError, tomllib.TOMLDecodeError) as error:
raise CapabilityManifestError(f"无法读取 {path}{error}") from error
unknown_fields = set(data) - _TOP_LEVEL_FIELDS
if unknown_fields:
raise CapabilityManifestError(f"{path}: 未知字段 {sorted(unknown_fields)}")
missing_fields = _REQUIRED_FIELDS - set(data)
if missing_fields:
raise CapabilityManifestError(f"{path}: 缺少字段 {sorted(missing_fields)}")
schema_version = data["schema_version"]
if type(schema_version) is not int or schema_version != _SCHEMA_VERSION:
raise CapabilityManifestError(
f"{path}: 不支持 schema_version={schema_version!r}"
)
capability_id = data["id"]
if not isinstance(capability_id, str) or not _IDENTIFIER_PATTERN.fullmatch(capability_id):
raise CapabilityManifestError(f"{path}: 非法 capability id={capability_id!r}")
kind = data["kind"]
if not isinstance(kind, str) or kind not in kinds:
raise CapabilityManifestError(f"{path}: 未注册 capability kind={kind!r}")
entrypoint = data["entrypoint"]
if not isinstance(entrypoint, str) or not _ENTRYPOINT_PATTERN.fullmatch(entrypoint):
raise CapabilityManifestError(f"{path}: 非法 entrypoint={entrypoint!r}")
metadata = data["metadata"]
if not isinstance(metadata, dict):
raise CapabilityManifestError(f"{path}: metadata 必须是 table")
name = metadata.get("name")
if not isinstance(name, str) or not name.strip():
raise CapabilityManifestError(f"{path}: metadata.name 必须是非空字符串")
immutable_metadata = _freeze(metadata, field="metadata")
activation_data = data["activation"]
if not isinstance(activation_data, dict):
raise CapabilityManifestError(f"{path}: activation 必须是 table")
unknown_activation_fields = set(activation_data) - _ACTIVATION_FIELDS
missing_activation_fields = _ACTIVATION_REQUIRED_FIELDS - set(activation_data)
if unknown_activation_fields or missing_activation_fields:
raise CapabilityManifestError(
f"{path}: activation 字段非法missing={sorted(missing_activation_fields)} "
f"unknown={sorted(unknown_activation_fields)}"
)
try:
activation = ActivationPolicy(activation_data["policy"])
except (TypeError, ValueError) as error:
raise CapabilityManifestError(
f"{path}: 非法 activation.policy={activation_data['policy']!r}"
) from error
selector = cls._parse_selector(
path,
activation=activation,
data=activation_data.get("selector"),
selector_schemas=selector_schemas,
)
watch = _string_list(activation_data["watch"], field="activation.watch", path=path)
depends_on = _string_list(data["depends_on"], field="depends_on", path=path)
if depends_on:
raise CapabilityManifestError(
f"{path}: 当前 schema 不支持非空 depends_on={list(depends_on)!r}"
)
return CapabilitySpec(
schema_version=schema_version,
id=capability_id,
kind=kind,
entrypoint=entrypoint,
activation=activation,
metadata=immutable_metadata,
selector=selector,
watch=watch,
depends_on=depends_on,
source=path,
)
@staticmethod
def _parse_selector(
path: Path,
*,
activation: ActivationPolicy,
data: Any,
selector_schemas: Mapping[str, SelectorSchema],
) -> SelectorSpec | None:
if activation is not ActivationPolicy.WHEN_CONFIGURED:
if data is not None:
raise CapabilityManifestError(
f"{path}: activation={activation.value} 时不允许 selector"
)
return None
if not isinstance(data, dict):
raise CapabilityManifestError(f"{path}: when_configured 必须提供 selector table")
selector_kind = data.get("kind")
if not isinstance(selector_kind, str) or selector_kind not in selector_schemas:
raise CapabilityManifestError(f"{path}: 未注册 selector kind={selector_kind!r}")
config = {key: value for key, value in data.items() if key != "kind"}
schema = selector_schemas[selector_kind]
missing = schema.required_fields - set(config)
unknown = set(config) - schema.required_fields - schema.optional_fields
if missing or unknown:
raise CapabilityManifestError(
f"{path}: selector {selector_kind} 字段非法,"
f"missing={sorted(missing)} unknown={sorted(unknown)}"
)
immutable_config = _freeze(config, field="selector")
if schema.validator:
try:
schema.validator(immutable_config)
except Exception as error:
raise CapabilityManifestError(
f"{path}: selector {selector_kind} 校验失败:{error}"
) from error
return SelectorSpec(kind=selector_kind, config=immutable_config)
@property
def kinds(self) -> frozenset[str]:
"""返回该 Registry 接受的 capability kind。"""
return self._kinds
def get_spec(self, capability_id: str) -> CapabilitySpec | None:
"""查询声明;不存在时返回 None。"""
return self._specs.get(capability_id)
def require_spec(self, capability_id: str) -> CapabilitySpec:
"""查询必需声明;不存在时给出稳定的领域错误。"""
spec = self.get_spec(capability_id)
if spec is None:
raise UnknownCapabilityError(f"未知 capability{capability_id}")
return spec
def list_specs(self) -> tuple[CapabilitySpec, ...]:
"""按 ID 返回稳定排序的声明快照。"""
return tuple(self._specs[key] for key in sorted(self._specs))

File diff suppressed because it is too large Load Diff

View File

@@ -415,21 +415,28 @@ class EventManager(metaclass=Singleton):
同步方式调度链式事件,按优先级顺序逐个调用事件处理器,并记录每个处理器的处理时间
:param event: 要调度的事件对象
"""
handlers = self.__chain_subscribers.get(event.event_type, {})
# 运行期可以动态注册或移除处理器;当前事件始终使用调度开始时的快照。
with self.__lock:
handlers = tuple(
self.__chain_subscribers.get(event.event_type, {}).items()
)
if not handlers:
logger.debug(f"No handlers found for chain event: {event}")
return False
# 过滤出启用的处理器
enabled_handlers = {handler_id: (priority, handler) for handler_id, (priority, handler) in handlers.items()
if self.__is_handler_enabled(handler)}
enabled_handlers = tuple(
(handler_id, priority, handler)
for handler_id, (priority, handler) in handlers
if self.__is_handler_enabled(handler)
)
if not enabled_handlers:
logger.debug(f"No enabled handlers found for chain event: {event}. Skipping execution.")
return False
self.__log_event_lifecycle(event, "Started")
for handler_id, (priority, handler) in enabled_handlers.items():
for handler_id, priority, handler in enabled_handlers:
start_time = time.time()
self.__safe_invoke_handler(handler, event)
logger.debug(
@@ -444,21 +451,28 @@ class EventManager(metaclass=Singleton):
异步方式调度链式事件,按优先级顺序逐个调用事件处理器,并记录每个处理器的处理时间
:param event: 要调度的事件对象
"""
handlers = self.__chain_subscribers.get(event.event_type, {})
# 快照在锁内建立、在锁外执行,处理器可以安全地修改后续订阅。
with self.__lock:
handlers = tuple(
self.__chain_subscribers.get(event.event_type, {}).items()
)
if not handlers:
logger.debug(f"No handlers found for chain event: {event}")
return False
# 过滤出启用的处理器
enabled_handlers = {handler_id: (priority, handler) for handler_id, (priority, handler) in handlers.items()
if self.__is_handler_enabled(handler)}
enabled_handlers = tuple(
(handler_id, priority, handler)
for handler_id, (priority, handler) in handlers
if self.__is_handler_enabled(handler)
)
if not enabled_handlers:
logger.debug(f"No enabled handlers found for chain event: {event}. Skipping execution.")
return False
self.__log_event_lifecycle(event, "Started")
for handler_id, (priority, handler) in enabled_handlers.items():
for handler_id, priority, handler in enabled_handlers:
start_time = time.time()
await self.__safe_invoke_handler_async(handler, event)
logger.debug(
@@ -473,7 +487,11 @@ class EventManager(metaclass=Singleton):
异步方式调度广播事件,通过线程池逐个调用事件处理器
:param event: 要调度的事件对象
"""
handlers = self.__broadcast_subscribers.get(event.event_type, {})
# 快照隔离当前调度与运行期订阅变更;变更从下一个事件开始生效。
with self.__lock:
handlers = tuple(
self.__broadcast_subscribers.get(event.event_type, {}).items()
)
if not handlers:
logger.debug(f"No handlers found for broadcast event: {event}")
return
@@ -481,7 +499,7 @@ class EventManager(metaclass=Singleton):
if event.event_type == EventType.MessageAction and isinstance(event.event_data, dict):
target_plugin_id = event.event_data.get("__mp_target_plugin_id")
# 为每个处理器提供独立的事件实例,防止某个处理器对 event_data 的修改影响其他处理器
for handler_id, handler in handlers.items():
for handler_id, handler in handlers:
if target_plugin_id and not self.__should_dispatch_to_target_plugin(
handler, handler_id, str(target_plugin_id)
):

View File

@@ -0,0 +1,260 @@
from __future__ import annotations
import importlib
from dataclasses import dataclass
from pathlib import Path
from types import MappingProxyType
from typing import Any, Mapping
from app.runtime.capabilities.model import (
ActivationPolicy,
AdapterExecutionMode,
CapabilitySpec,
SelectorSchema,
)
from app.runtime.capabilities.registry import CapabilityRegistry
from app.runtime.config import settings
from app.runtime.extensions.service_config import ServiceConfigHelper
from app.schemas.types import (
DownloaderType,
MediaRecognizeType,
MediaServerType,
MessageChannel,
ModuleType,
OtherModulesType,
StorageSchema,
SystemConfigKey,
)
HOST_MODULE_KIND = "host_module"
_SETTING_SELECTOR = "setting_truthy"
_SERVICE_SELECTOR = "system_config_item"
_MODULE_ROOT = Path(__file__).resolve().parents[2] / "modules"
_SERVICE_CONFIG_GETTERS = MappingProxyType({
SystemConfigKey.Downloaders.value: ServiceConfigHelper.get_downloader_configs,
SystemConfigKey.MediaServers.value: ServiceConfigHelper.get_mediaserver_configs,
SystemConfigKey.Notifications.value: ServiceConfigHelper.get_notification_configs,
})
_SUBTYPE_NAMES = frozenset(
item.name
for enum_type in (
DownloaderType,
MediaServerType,
MessageChannel,
StorageSchema,
OtherModulesType,
MediaRecognizeType,
)
for item in enum_type
)
@dataclass(frozen=True, slots=True)
class HostModuleConfigSnapshot:
"""一次 reconcile 使用的不可变配置视图,避免每个能力重复查询配置。"""
settings: Mapping[str, Any]
services: Mapping[str, tuple[Any, ...]]
def _validate_setting_selector(config: Mapping[str, Any]) -> None:
"""限制 setting selector 只能读取已声明的应用设置。"""
key = config["key"]
if not isinstance(key, str) or not key or not hasattr(settings, key):
raise ValueError(f"未知应用设置:{key!r}")
def _validate_service_selector(config: Mapping[str, Any]) -> None:
"""限制服务 selector 使用经过 Schema 校验的三个宿主服务配置。"""
key = config["key"]
if key not in _SERVICE_CONFIG_GETTERS:
raise ValueError(f"不支持的服务配置:{key!r}")
if config["match_field"] != "type":
raise ValueError("服务 selector 的 match_field 必须是 type")
if config["enabled_field"] != "enabled":
raise ValueError("服务 selector 的 enabled_field 必须是 enabled")
match_value = config["match_value"]
if not isinstance(match_value, str) or not match_value:
raise ValueError("服务 selector 的 match_value 必须是非空字符串")
HOST_MODULE_SELECTOR_SCHEMAS = MappingProxyType({
_SETTING_SELECTOR: SelectorSchema(
required_fields=frozenset({"key"}),
validator=_validate_setting_selector,
),
_SERVICE_SELECTOR: SelectorSchema(
required_fields=frozenset({
"key",
"match_field",
"match_value",
"enabled_field",
}),
validator=_validate_service_selector,
),
})
def _validate_manifest_inventory(registry: CapabilityRegistry) -> None:
"""校验一级模块包与 manifest 一一对应,并固定宿主声明合同。"""
module_packages = {
child.name
for child in _MODULE_ROOT.iterdir()
if child.is_dir()
and not child.name.startswith("_")
and (child / "__init__.py").is_file()
}
specs = registry.list_specs()
manifest_packages = {spec.source.parent.name for spec in specs}
if module_packages != manifest_packages:
missing = sorted(module_packages - manifest_packages)
unknown = sorted(manifest_packages - module_packages)
raise ValueError(
f"Host Module manifest inventory 不一致missing={missing} unknown={unknown}"
)
allowed_metadata = {"name", "type", "subtype", "priority"}
module_type_values = {item.value for item in ModuleType}
for spec in specs:
if spec.source.parent.parent != _MODULE_ROOT:
raise ValueError(f"Host Module manifest 必须位于一级模块包:{spec.source}")
module_name, symbol_name = spec.entrypoint.split(":", maxsplit=1)
expected_module = f"app.modules.{spec.source.parent.name}"
if module_name != expected_module or symbol_name != spec.id:
raise ValueError(
f"{spec.source}: entrypoint 必须指向同包且类名等于 capability id"
)
if set(spec.metadata) != allowed_metadata:
raise ValueError(
f"{spec.source}: metadata 字段必须是 {sorted(allowed_metadata)}"
)
if spec.metadata["type"] not in module_type_values:
raise ValueError(f"{spec.source}: 非法 metadata.type={spec.metadata['type']!r}")
if spec.metadata["subtype"] not in _SUBTYPE_NAMES:
raise ValueError(
f"{spec.source}: 非法 metadata.subtype={spec.metadata['subtype']!r}"
)
priority = spec.metadata["priority"]
if isinstance(priority, bool) or not isinstance(priority, int):
raise ValueError(f"{spec.source}: metadata.priority 必须是整数")
if spec.activation is ActivationPolicy.WHEN_CONFIGURED:
selector_key = str(spec.selector.config["key"])
if selector_key not in spec.watch:
raise ValueError(
f"{spec.source}: activation.watch 必须包含 selector 配置键"
)
def build_host_module_registry() -> CapabilityRegistry:
"""从现有物理模块包构建 import-free Host Module Registry。"""
registry = CapabilityRegistry.discover(
(_MODULE_ROOT,),
kinds={HOST_MODULE_KIND},
selector_schemas=HOST_MODULE_SELECTOR_SCHEMAS,
)
_validate_manifest_inventory(registry)
return registry
def capture_host_module_config(
specs: tuple[CapabilitySpec, ...],
) -> HostModuleConfigSnapshot:
"""对本轮涉及的设置和服务配置各读取一次并冻结容器。"""
setting_keys: set[str] = set()
service_keys: set[str] = set()
for spec in specs:
selector = spec.selector
if selector is None:
continue
key = str(selector.config["key"])
if selector.kind == _SETTING_SELECTOR:
setting_keys.add(key)
elif selector.kind == _SERVICE_SELECTOR:
service_keys.add(key)
setting_values = {
key: getattr(settings, key)
for key in sorted(setting_keys)
}
service_values = {
key: tuple(_SERVICE_CONFIG_GETTERS[key]())
for key in sorted(service_keys)
}
return HostModuleConfigSnapshot(
settings=MappingProxyType(setting_values),
services=MappingProxyType(service_values),
)
def should_run_host_module(
spec: CapabilitySpec,
snapshot: HostModuleConfigSnapshot,
) -> bool:
"""依据有限 selector 语法判断能力是否应拥有运行资源。"""
if spec.activation is ActivationPolicy.BOOTSTRAP:
return True
if spec.activation is ActivationPolicy.ON_FIRST_USE:
return False
selector = spec.selector
if selector is None:
return False
if selector.kind == _SETTING_SELECTOR:
return bool(snapshot.settings[selector.config["key"]])
if selector.kind == _SERVICE_SELECTOR:
config = selector.config
return any(
getattr(item, config["match_field"]) == config["match_value"]
and bool(getattr(item, config["enabled_field"]))
for item in snapshot.services[config["key"]]
)
raise ValueError(f"未支持的 Host Module selector{selector.kind}")
class HostModuleAdapter:
"""把现有模块类接入 Capability Runtime保持类路径与对象 identity 不变。"""
execution_mode = AdapterExecutionMode.SYNC
@staticmethod
def materialize(spec: CapabilitySpec) -> type:
"""按 manifest entrypoint 导入并返回原始模块类。"""
module_name, symbol_name = spec.entrypoint.split(":", maxsplit=1)
implementation = getattr(importlib.import_module(module_name), symbol_name)
if not isinstance(implementation, type):
raise TypeError(f"{spec.entrypoint} 不是模块类")
return implementation
@staticmethod
def create(
spec: CapabilitySpec,
implementation: type,
generation: int,
previous: Any = None,
) -> Any:
"""首次创建实例;配置重载继续使用原实例以保留既有模块语义。"""
del spec, generation
return previous if previous is not None else implementation()
@staticmethod
def start(spec: CapabilitySpec, candidate: Any, generation: int) -> None:
"""初始化候选实例拥有的连接、线程或客户端资源。"""
del spec, generation
candidate.init_module()
@staticmethod
def stop(spec: CapabilitySpec, instance: Any, generation: int) -> None:
"""停止实例拥有的资源Runtime 会先撤销其运行态可见性。"""
del spec, generation
instance.stop()
@staticmethod
def cleanup(
spec: CapabilitySpec,
candidate: Any,
generation: int,
error: BaseException,
) -> None:
"""启动失败后尽力回收候选实例已创建的部分资源。"""
del spec, generation, error
candidate.stop()

View File

@@ -1,22 +1,42 @@
import traceback
from typing import Generator, Optional, Tuple, Any, Union, List
from __future__ import annotations
import sys
import threading
from typing import Any, Generator, List, Optional, Tuple, Union
from app.runtime.config import settings
from app.runtime.events import EventHandlerBinding, eventmanager
from app.foundation.reflection import ModuleHelper
from app.runtime.log import logger
from app.schemas.types import EventType, ModuleType, DownloaderType, MediaServerType, MessageChannel, StorageSchema, \
OtherModulesType, MediaRecognizeType
from app.foundation.reflection import ObjectUtils
from app.foundation.singleton import Singleton
from app.runtime.capabilities.model import (
CapabilityLifecycleState,
CapabilityObservation,
CapabilitySpec,
)
from app.runtime.capabilities.runtime import CapabilityRuntime
from app.runtime.config import settings
from app.runtime.events import Event, EventHandlerBinding, eventmanager
from app.runtime.extensions.host_module_adapter import (
HOST_MODULE_KIND,
HostModuleAdapter,
build_host_module_registry,
capture_host_module_config,
should_run_host_module,
)
from app.runtime.log import logger
from app.schemas.types import (
DownloaderType,
EventType,
MediaRecognizeType,
MediaServerType,
MessageChannel,
ModuleType,
OtherModulesType,
StorageSchema,
)
class ModuleManager(metaclass=Singleton):
"""
模块管理器
"""
"""以 Capability Runtime 管理宿主模块,并保留旧插件同步查询合同。"""
# 子模块类型集合
SubType = Union[
DownloaderType,
MediaServerType,
@@ -26,176 +46,286 @@ class ModuleManager(metaclass=Singleton):
MediaRecognizeType,
]
def __init__(self):
"""初始化模块注册表并装载当前启用的运行模块。"""
# 模块列表
self._modules: dict = {}
# 运行态模块列表
self._running_modules: dict = {}
# 事件总线通过该解析器绑定已启用的模块实例。
def __init__(self) -> None:
"""发现 data-only manifest并按当前配置激活所需宿主模块。"""
self._lock = threading.RLock()
self._lifecycle_lock = threading.RLock()
self._modules: dict[str, type] = {}
self._running_modules: dict[str, Any] = {}
registry = build_host_module_registry()
self._runtime = CapabilityRuntime(
registry,
adapters={HOST_MODULE_KIND: HostModuleAdapter()},
observer=self._observe_transition,
)
# pkgutil 的既有发现顺序按一级包名稳定排列,兼容视图继续保持该顺序。
self._specs = tuple(
sorted(self._runtime.list_specs(), key=lambda item: item.source.parent.name)
)
eventmanager.register_handler_instance_resolver(
"modules",
self.resolve_event_handler_instance,
)
eventmanager.add_event_listener(
EventType.ConfigChanged,
self.handle_config_changed,
)
self.load_modules()
@staticmethod
def _observe_transition(observation: CapabilityObservation) -> None:
"""把 Runtime 的稳定转换结果接入现有日志面。"""
if observation.outcome == "failed":
logger.error(
"Host Module %s %s 失败:%s",
observation.capability_id,
observation.operation,
observation.error,
)
elif observation.outcome == "succeeded":
logger.debug(
"Host Module %s %s 完成generation=%s,耗时=%.2fms",
observation.capability_id,
observation.operation,
observation.generation,
observation.duration_ms,
)
@staticmethod
def _event_changed_keys(event: Optional[Event]) -> set[str]:
"""兼容对象和 dict 两种配置事件载荷。"""
if not event:
return set()
event_data = event.event_data
if isinstance(event_data, dict):
keys = event_data.get("key", set())
else:
keys = getattr(event_data, "key", set())
if isinstance(keys, str):
return {keys}
return {str(key) for key in (keys or set())}
def _remember_materialized(self, module_id: str, implementation: type) -> type:
"""更新旧 `_modules` 视图,但不改变能力资源生命周期。"""
with self._lock:
self._modules[module_id] = implementation
return implementation
def _consumer_materialized_class(self, spec: CapabilitySpec) -> Optional[type]:
"""识别插件显式旧导入产生的真实类,不触发新的 Python import。"""
module_name, symbol_name = spec.entrypoint.split(":", maxsplit=1)
module = sys.modules.get(module_name)
namespace = getattr(module, "__dict__", None) if module is not None else None
if not isinstance(namespace, dict):
return None
implementation = namespace.get(symbol_name)
return implementation if isinstance(implementation, type) else None
def _refresh_running_projection(self) -> None:
"""从 Runtime 已发布实例重建插件可见的运行模块字典。"""
running = {
spec.id: instance
for spec in self._specs
if (instance := self._runtime.get_running(spec.id)) is not None
}
with self._lock:
self._running_modules = running
def resolve_event_handler_instance(
self,
owner_class: type,
self,
owner_class: type,
) -> Optional[EventHandlerBinding]:
"""为模块声明的事件方法解析当前运行实例"""
module_id = owner_class.__name__
if module_id not in self._modules:
return None
module = self._running_modules.get(module_id)
owner_name = module_id
if module and callable(getattr(module, "get_name", None)):
owner_name = module.get_name()
return EventHandlerBinding(
instance=module,
owner_name=owner_name,
"""按 canonical class identity 绑定当前 generation停止态阻断 fallback 构造"""
for spec in self._specs:
with self._lock:
implementation = self._modules.get(spec.id)
if implementation is None:
implementation = self._consumer_materialized_class(spec)
if implementation is not None:
self._remember_materialized(spec.id, implementation)
# 同步 Runtime 的物化观测,但不创建或启动实例。
self._runtime.snapshot(spec.id)
if implementation is not owner_class:
continue
return EventHandlerBinding(
instance=self._runtime.get_running(spec.id),
owner_name=str(spec.metadata["name"]),
)
return None
def _reconcile(
self,
*,
reason: str,
changed_keys: Optional[set[str]] = None,
reload_running: bool = False,
) -> None:
"""以一次配置快照串行协调需要启动、重载或停止的能力。"""
with self._lifecycle_lock:
selected = tuple(
spec
for spec in self._specs
if changed_keys is None or changed_keys.intersection(spec.watch)
)
snapshot = capture_host_module_config(selected)
for spec in selected:
desired = should_run_host_module(spec, snapshot)
running = self._runtime.get_running(spec.id)
try:
if desired and running is None:
instance = self._runtime.activate(
spec.id,
reason=reason,
retry=True,
)
self._remember_materialized(spec.id, type(instance))
elif desired and running is not None and reload_running:
instance = self._runtime.reload(spec.id, reason=reason)
self._remember_materialized(spec.id, type(instance))
elif not desired and running is not None:
self._runtime.stop(spec.id, reason=reason)
except Exception:
# 单能力失败由 Runtime 完整记录;其它无依赖能力继续 reconcile。
continue
self._refresh_running_projection()
def load_modules(self) -> None:
"""按当前配置启动未运行模块;已运行模块保持当前 generation。"""
self._reconcile(reason="module_manager_load")
def handle_config_changed(self, event: Event) -> None:
"""配置变更时仅协调 watch 命中的能力,并保证单一生命周期 writer。"""
changed_keys = self._event_changed_keys(event)
if not changed_keys:
return
self._reconcile(
reason="config_changed",
changed_keys=changed_keys,
reload_running=True,
)
def load_modules(self):
"""
加载所有模块
"""
# 扫描模块目录
modules = ModuleHelper.load(
"app.modules",
filter_func=lambda _, obj: hasattr(obj, 'init_module') and hasattr(obj, 'init_setting')
)
self._running_modules = {}
self._modules = {}
for module in modules:
module_id = module.__name__
self._modules[module_id] = module
try:
# 生成实例
_module = module()
# 初始化模块
if self.check_setting(_module.init_setting()):
# 通过模板开关控制加载
_module.init_module()
self._running_modules[module_id] = _module
logger.debug(f"Moudle Loaded{module_id}")
except Exception as err:
logger.error(f"Load Moudle Error{module_id}{str(err)} - {traceback.format_exc()}", exc_info=True)
def stop(self):
"""
停止所有模块
"""
def stop(self) -> None:
"""停止全部运行模块但保留 Runtime使旧插件可随后再次 load。"""
logger.info("正在停止所有模块...")
for module_id, module in self._running_modules.items():
try:
module.stop()
logger.debug(f"Moudle Stoped{module_id}")
except Exception as err:
logger.error(f"Stop Moudle Error{module_id}{str(err)} - {traceback.format_exc()}", exc_info=True)
with self._lifecycle_lock:
for spec in reversed(self._specs):
snapshot = self._runtime.snapshot(spec.id)
if (
self._runtime.get_running(spec.id) is None
and snapshot.lifecycle is not CapabilityLifecycleState.FAILED
):
continue
try:
self._runtime.stop(spec.id, reason="module_manager_stop")
except Exception:
continue
self._refresh_running_projection()
logger.info("所有模块停止完成")
def reload(self):
"""
重新加载所有模块
"""
self.stop()
self.load_modules()
eventmanager.send_event(etype=EventType.ModuleReload, data={})
def shutdown(self) -> None:
"""进程关闭时不可逆停止 Runtime阻止并发能力重新发布。"""
logger.info("正在关闭模块运行时...")
with self._lifecycle_lock:
self._runtime.shutdown(reason="application_shutdown")
self._refresh_running_projection()
logger.info("模块运行时关闭完成")
def reload(self) -> None:
"""保留旧插件可观察的 stop、load、ModuleReload 同步顺序。"""
with self._lifecycle_lock:
self.stop()
self.load_modules()
eventmanager.send_event(etype=EventType.ModuleReload, data={})
def test(self, modleid: str) -> Tuple[bool, str]:
"""
测试模块
"""
if modleid not in self._running_modules:
"""测试已运行模块;未启用模块保持旧合同返回 `(False, "")`。"""
module = self.get_running_module(modleid)
if module is None:
return False, ""
module = self._running_modules[modleid]
if hasattr(module, "test") \
and ObjectUtils.check_method(getattr(module, "test")):
if hasattr(module, "test") and ObjectUtils.check_method(module.test):
result = module.test()
if not result:
return False, ""
return result
return result if result else (False, "")
return True, "模块不支持测试"
@staticmethod
def check_setting(setting: Optional[tuple]) -> bool:
"""
检查开关是否己打开,开关使用,分隔多个值,符合其中即代表开启
"""
"""保留旧模块开关的 truthy 与 membership 判定语义。"""
if not setting:
return True
switch, value = setting
option = getattr(settings, switch)
if not option:
return False
if option and value is True:
if value is True:
return True
if value in option:
return True
return False
return value in option
def get_running_module(self, module_id: str) -> Any:
"""
根据模块id获取模块运行实例
"""
if not module_id:
"""根据模块 ID 返回已发布的运行实例,不触发物化。"""
if not module_id or self._runtime.get_spec(module_id) is None:
return None
if not self._running_modules:
return None
return self._running_modules.get(module_id)
return self._runtime.get_running(module_id)
def _running_snapshot(self) -> tuple[Any, ...]:
"""直接读取 Runtime 发布视图,转换期间不暴露旧或候选实例。"""
return tuple(
instance
for spec in self._specs
if (instance := self._runtime.get_running(spec.id)) is not None
)
def get_running_modules(self, method: str) -> Generator:
"""
获取实现了同一方法的模块列表
"""
if not self._running_modules:
return
for _, module in self._running_modules.items():
if hasattr(module, method) \
and ObjectUtils.check_method(getattr(module, method)):
"""返回实现了指定方法的运行模块快照。"""
for module in self._running_snapshot():
candidate = getattr(module, method, None)
if callable(candidate) and ObjectUtils.check_method(candidate):
yield module
def get_running_type_modules(self, module_type: ModuleType) -> Generator:
"""
获取指定类型的模块列表
"""
if not self._running_modules:
return
for _, module in self._running_modules.items():
if hasattr(module, 'get_type') \
and module.get_type() == module_type:
"""返回指定类型的运行模块快照。"""
for module in self._running_snapshot():
if module.get_type() == module_type:
yield module
def get_running_subtype_module(self, module_subtype: SubType) -> Generator:
"""
获取指定子类型的模块
"""
if not self._running_modules:
return
for _, module in self._running_modules.items():
if hasattr(module, 'get_subtype') \
and module.get_subtype() == module_subtype:
"""返回指定子类型的运行模块快照。"""
for module in self._running_snapshot():
if module.get_subtype() == module_subtype:
yield module
def get_module(self, module_id: str) -> Any:
"""
根据模块id获取模块
"""
if not module_id:
"""显式物化并返回 canonical 模块类;失败保持旧合同返回 None。"""
if not module_id or self._runtime.get_spec(module_id) is None:
return None
if not self._modules:
with self._lock:
implementation = self._modules.get(module_id)
if implementation is not None:
return implementation
try:
implementation = self._runtime.materialize(
module_id,
reason="compat_get_module",
retry=True,
)
except Exception:
return None
return self._modules.get(module_id)
return self._remember_materialized(module_id, implementation)
def get_modules(self) -> dict:
"""
获取模块列表
"""
return self._modules
def get_modules(self) -> dict[str, type]:
"""兼容性显式物化全部真实类;单个失败不阻断其它模块。"""
for spec in self._specs:
self.get_module(spec.id)
with self._lock:
return dict(self._modules)
def get_module_ids(self) -> List[str]:
"""
获取模块id列表
"""
return list(self._modules.keys())
"""从 manifest 返回全部模块 ID不物化实现。"""
return [spec.id for spec in self._specs]
def list_specs(self) -> tuple[CapabilitySpec, ...]:
"""返回全部轻量模块声明,包含物化或启动失败的能力。"""
return self._specs
def get_specs(self) -> tuple[CapabilitySpec, ...]:
"""兼容内部调用命名,返回与 `list_specs` 相同的声明快照。"""
return self.list_specs()

View File

@@ -0,0 +1,76 @@
from typing import List, Optional, Type
from pydantic import ValidationError
from app.db.oper.systemconfig import SystemConfigOper
from app.runtime.log import logger
from app.schemas import (
DownloaderConf,
MediaServerConf,
NotificationConf,
NotificationSwitchConf,
)
from app.schemas.types import NotificationType, SystemConfigKey
class ServiceConfigHelper:
"""读取并校验通知、下载器和媒体服务器的宿主配置。"""
@staticmethod
def get_configs(config_key: SystemConfigKey, conf_type: Type) -> List:
"""按指定 Schema 过滤单条非法配置,避免影响同组其它服务。"""
config_data = SystemConfigOper().get(config_key)
if not config_data:
return []
configs = []
for conf in config_data:
if not isinstance(conf, dict):
logger.warning(f"{config_key.value} 配置格式不正确,已跳过:{conf}")
continue
try:
configs.append(conf_type(**conf))
except ValidationError as err:
logger.error(
f"{config_key.value} 配置 {conf.get('name')} 校验失败,已跳过:{err}"
)
return configs
@staticmethod
def get_downloader_configs() -> List[DownloaderConf]:
"""返回已通过结构校验的下载器配置。"""
return ServiceConfigHelper.get_configs(
SystemConfigKey.Downloaders,
DownloaderConf,
)
@staticmethod
def get_mediaserver_configs() -> List[MediaServerConf]:
"""返回已通过结构校验的媒体服务器配置。"""
return ServiceConfigHelper.get_configs(
SystemConfigKey.MediaServers,
MediaServerConf,
)
@staticmethod
def get_notification_configs() -> List[NotificationConf]:
"""返回已通过结构校验的通知配置。"""
return ServiceConfigHelper.get_configs(
SystemConfigKey.Notifications,
NotificationConf,
)
@staticmethod
def get_notification_switches() -> List[NotificationSwitchConf]:
"""返回已通过结构校验的通知场景开关。"""
return ServiceConfigHelper.get_configs(
SystemConfigKey.NotificationSwitchs,
NotificationSwitchConf,
)
@staticmethod
def get_notification_switch(mtype: NotificationType) -> Optional[str]:
"""返回指定通知场景的目标范围。"""
for switch in ServiceConfigHelper.get_notification_switches():
if switch.type == mtype.value:
return switch.action
return None

View File

@@ -1,84 +1,18 @@
from typing import Dict, List, Optional, Type, TypeVar, Generic, Iterator
from pydantic import ValidationError
from app.runtime.extensions.module_manager import ModuleManager
from app.db.oper.systemconfig import SystemConfigOper
from app.runtime.log import logger
from app.schemas import DownloaderConf, MediaServerConf, NotificationConf, NotificationSwitchConf, ServiceInfo
from app.schemas.types import NotificationType, SystemConfigKey, ModuleType
from app.runtime.extensions.module_manager import ModuleManager
from app.runtime.extensions.service_config import ServiceConfigHelper
from app.schemas import ServiceInfo
from app.schemas.types import SystemConfigKey, ModuleType
TConf = TypeVar("TConf")
class ServiceConfigHelper:
"""
配置帮助类,获取不同类型的服务配置
"""
@staticmethod
def get_configs(config_key: SystemConfigKey, conf_type: Type) -> List:
"""
通用获取配置的方法,根据 config_key 获取相应的配置并返回指定类型的配置列表
:param config_key: 系统配置的 key
:param conf_type: 用于实例化配置对象的类类型
:return: 配置对象列表
"""
config_data = SystemConfigOper().get(config_key)
if not config_data:
return []
configs = []
for conf in config_data:
if not isinstance(conf, dict):
logger.warn(f"{config_key.value} 配置格式不正确,已跳过:{conf}")
continue
try:
# 直接使用 conf_type 来实例化配置对象
configs.append(conf_type(**conf))
except ValidationError as e:
# 单条配置存在非法值时跳过,避免影响其它服务的初始化
logger.error(f"{config_key.value} 配置 {conf.get('name')} 校验失败,已跳过:{e}")
return configs
@staticmethod
def get_downloader_configs() -> List[DownloaderConf]:
"""
获取下载器的配置
"""
return ServiceConfigHelper.get_configs(SystemConfigKey.Downloaders, DownloaderConf)
@staticmethod
def get_mediaserver_configs() -> List[MediaServerConf]:
"""
获取媒体服务器的配置
"""
return ServiceConfigHelper.get_configs(SystemConfigKey.MediaServers, MediaServerConf)
@staticmethod
def get_notification_configs() -> List[NotificationConf]:
"""
获取消息通知渠道的配置
"""
return ServiceConfigHelper.get_configs(SystemConfigKey.Notifications, NotificationConf)
@staticmethod
def get_notification_switches() -> List[NotificationSwitchConf]:
"""
获取消息通知场景的开关
"""
return ServiceConfigHelper.get_configs(SystemConfigKey.NotificationSwitchs, NotificationSwitchConf)
@staticmethod
def get_notification_switch(mtype: NotificationType) -> Optional[str]:
"""
获取指定类型的消息通知场景的开关
"""
switchs = ServiceConfigHelper.get_notification_switches()
for switch in switchs:
if switch.type == mtype.value:
return switch.action
return None
__all__ = [
"ServiceBaseHelper",
"ServiceConfigHelper",
"SystemConfigOper",
]
class ServiceBaseHelper(Generic[TConf]):

View File

@@ -14,10 +14,16 @@ class ConfigReloadMixin:
可选地重写 get_reload_name 方法提供模块名称(用于日志显示)
"""
# 统一生命周期管理器可以继承此 Mixin 的重载方法,但由外部唯一负责事件绑定。
CONFIG_RELOAD_MANAGED_EXTERNALLY: bool = False
def __init_subclass__(cls, **kwargs):
"""为声明了 CONFIG_WATCH 的子类生成配置变更处理器。"""
super().__init_subclass__(**kwargs)
if getattr(cls, "CONFIG_RELOAD_MANAGED_EXTERNALLY", False):
return
config_watch = getattr(cls, "CONFIG_WATCH", None)
if not config_watch:
return

View File

@@ -184,7 +184,7 @@ async def stop_modules():
logger.error(f"关闭{name}失败:{err}")
await run_step("AI智能体", stop_agent)
await run_step("模块", lambda: ModuleManager().stop())
await run_step("模块", lambda: ModuleManager().shutdown())
await run_step("事件消费", lambda: EventManager().stop())
await run_step("虚拟显示", lambda: DisplayHelper().stop())
await run_step("DoH服务", lambda: DohHelper().shutdown())

106
scripts/perf/README.md Normal file
View File

@@ -0,0 +1,106 @@
# MoviePilot Docker A/B Harness
该工具用同一个冻结 Docker substrate 对两个 Git commit 做源码级 A/B。派生镜像会先清空
`/app`,复制目标 commit 的完整 `git archive`,再从 substrate 注入镜像构建阶段生成的插件目录、
`sites.*.so``user.sites.v3.bin`。如果依赖或 Docker substrate 输入发生变化,工具会拒绝继续,
避免把外部构建输入漂移误算成性能收益。
工具不会读取工作区或容器内的 `app.env`,不挂载真实配置、媒体目录或 Docker socket。固定配置只使用
内置实验室占位凭据,样本不发布主机端口,并运行在无外网的 internal network。
## 一次性预热浏览器
默认从固定命名 volume `mp-perf-v3-browser-seed` 复制 CloakBrowser 缓存。该 volume 只需联网预热
一次:
```bash
docker volume create mp-perf-v3-browser-seed
docker run --rm \
--mount source=mp-perf-v3-browser-seed,target=/moviepilot/.cloakbrowser \
--entrypoint python3 \
jxxghp/moviepilot-v3@sha256:925de1fdf1bb0312144bc818bc8ebaa999a9a159c6d14f1b48b0ff05edb7f720 \
-m cloakbrowser install
```
也可以在 `seed``run` 时显式传入 `--allow-browser-download`,但该选项会让 seed 阶段联网;
正式 Before/After 样本仍然只克隆预热结果并使用 internal network。
## 分阶段执行
所有全局参数必须放在子命令前。结果默认写到系统临时目录下的
`moviepilot-perf-results/<campaign>/`
```bash
PYTHON=../.venv/bin/python
CAMPAIGN=v3-perf-001
${PYTHON} scripts/perf/moviepilot_docker_ab.py \
--campaign "${CAMPAIGN}" \
build --before-ref upstream/v3 --after-ref HEAD
${PYTHON} scripts/perf/moviepilot_docker_ab.py \
--campaign "${CAMPAIGN}" \
seed
${PYTHON} scripts/perf/moviepilot_docker_ab.py \
--campaign "${CAMPAIGN}" \
sample --variant before --index 1 --points 1,5,10,30
```
开发 harness 时可以用小数分钟做短冒烟,例如 `--points 0,0.02`。正式数据必须保持
`1,5,10,30`
## 完整三组 A/B
```bash
../.venv/bin/python scripts/perf/moviepilot_docker_ab.py \
--campaign v3-perf-001 \
run \
--before-ref upstream/v3 \
--after-ref HEAD \
--points 1,5,10,30
```
执行顺序固定为:
```text
Before-1 → After-1 → After-2 → Before-2 → Before-3 → After-3
```
每个样本都从 SQLite 和浏览器 seed 克隆新的命名 volume。样本结束后立即移除容器和样本卷
完整 `run` 结束后还会移除 campaign seed 与 internal network。派生镜像和本地结果保留便于复核。
## 输出
```text
<output>/<campaign>/
├── build.json
├── seed.json
├── results.json
├── report.md
└── samples/
├── before-1/
│ ├── result.json
│ ├── container.log
│ └── modules/
└── ...
```
- `results.json`Engine stats、进程 PSS/USS/RSS/线程、网络累计值和模块前缀计数;
- `report.md`:三次原值与中位数 Before/After 汇总;
- `modules/`:由目标 MoviePilot Python 进程自身写出的完整 `sys.modules` 名称清单;
- `container.log`:已移除实验室凭据值和本地实例 UUID。
容器 working set 统一按 Docker Engine API 的
`memory_stats.usage - memory_stats.stats.inactive_file` 计算。进程 PSS 仅用于归因,不能替代容器指标。
## 清理
清理严格限定到 campaign 标签;不会执行 Docker prune
```bash
../.venv/bin/python scripts/perf/moviepilot_docker_ab.py \
--campaign v3-perf-001 cleanup --images
```
本地 JSON、Markdown 和日志不会被 `cleanup` 删除。

View File

@@ -0,0 +1,53 @@
#!/usr/bin/env bash
# 读取采样开始时已经存在的容器进程,避免把采样器自身计入结果。
set -uo pipefail
self_pid="$$"
process_dirs=(/proc/[0-9]*)
printf 'pid\tppid\tthreads\trss_kib\tpss_kib\tuss_kib\tcomm\texe\tcmdline\n'
for process_dir in "${process_dirs[@]}"; do
pid="${process_dir##*/}"
if [[ "${pid}" == "${self_pid}" ]] || [[ ! -r "${process_dir}/status" ]]; then
continue
fi
status_values="$(awk '
/^PPid:/ { ppid=$2 }
/^Threads:/ { threads=$2 }
END { printf "%d %d", ppid + 0, threads + 0 }
' "${process_dir}/status" 2>/dev/null || true)"
read -r ppid threads <<< "${status_values:-0 0}"
rss_kib=0
pss_kib=0
uss_kib=0
if [[ -r "${process_dir}/smaps_rollup" ]]; then
memory_values="$(awk '
/^Rss:/ { rss=$2 }
/^Pss:/ { pss=$2 }
/^Private_Clean:/ { uss += $2 }
/^Private_Dirty:/ { uss += $2 }
/^Private_Hugetlb:/ { uss += $2 }
END { printf "%d %d %d", rss + 0, pss + 0, uss + 0 }
' "${process_dir}/smaps_rollup" 2>/dev/null || true)"
read -r rss_kib pss_kib uss_kib <<< "${memory_values:-0 0 0}"
fi
comm=""
if [[ -r "${process_dir}/comm" ]]; then
IFS= read -r comm < "${process_dir}/comm" || true
fi
executable="$(readlink "${process_dir}/exe" 2>/dev/null || true)"
command_line="$(tr '\000\t' ' ' < "${process_dir}/cmdline" 2>/dev/null || true)"
comm="${comm//$'\t'/ }"
executable="${executable//$'\t'/ }"
command_line="${command_line//$'\t'/ }"
printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \
"${pid}" "${ppid:-0}" "${threads:-0}" \
"${rss_kib:-0}" "${pss_kib:-0}" "${uss_kib:-0}" \
"${comm}" "${executable}" "${command_line}"
done

View File

@@ -0,0 +1,35 @@
"""MoviePilot Docker A/B 测量时使用的最小 ``sys.modules`` 快照探针。"""
import os
import signal
import sys
_OUTPUT_DIR = os.environ.get("MP_PERF_OUTPUT_DIR")
_snapshot_index = 0
def _dump_modules(_signum, _frame) -> None:
"""收到 SIGUSR1 时原子写出当前解释器已经导入的模块名称。"""
global _snapshot_index
if not _OUTPUT_DIR:
return
_snapshot_index += 1
os.makedirs(_OUTPUT_DIR, exist_ok=True)
final_path = os.path.join(
_OUTPUT_DIR,
f"modules-{os.getpid()}-{_snapshot_index}.txt",
)
temporary_path = f"{final_path}.tmp"
with open(temporary_path, "w", encoding="utf-8") as output:
for module_name in sorted(sys.modules):
output.write(module_name)
output.write("\n")
output.flush()
os.fsync(output.fileno())
os.replace(temporary_path, final_path)
if _OUTPUT_DIR and hasattr(signal, "SIGUSR1"):
signal.signal(signal.SIGUSR1, _dump_modules)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,195 @@
from __future__ import annotations
import sys
from pathlib import Path
import pytest
from app.runtime.capabilities.errors import CapabilityManifestError
from app.runtime.capabilities.model import (
ActivationPolicy,
SelectorSchema,
)
from app.runtime.capabilities.registry import CapabilityRegistry
_BASE_MANIFEST = """
schema_version = 1
id = "sample.capability"
kind = "sample"
entrypoint = "sample_implementation:SampleCapability"
depends_on = []
[metadata]
name = "Sample capability"
priority = 10
[activation]
policy = "when_configured"
watch = ["sample.config"]
[activation.selector]
kind = "configured"
key = "sample.config"
enabled = true
"""
def _write_manifest(root: Path, content: str = _BASE_MANIFEST, name: str = "sample") -> Path:
manifest_dir = root / name
manifest_dir.mkdir(parents=True)
manifest_path = manifest_dir / "capability.toml"
manifest_path.write_text(content.strip() + "\n", encoding="utf-8")
return manifest_path
def _discover(root: Path) -> CapabilityRegistry:
return CapabilityRegistry.discover(
roots=[root],
kinds={"sample"},
selector_schemas={
"configured": SelectorSchema(
required_fields=frozenset({"key", "enabled"}),
)
},
)
def test_discovery_reads_toml_without_importing_entrypoint(tmp_path: Path) -> None:
"""能力发现只能读取声明,不能执行 entrypoint 对应的 Python 模块。"""
_write_manifest(tmp_path)
(tmp_path / "sample_implementation.py").write_text(
"raise AssertionError('entrypoint must not be imported during discovery')\n",
encoding="utf-8",
)
sys.modules.pop("sample_implementation", None)
registry = _discover(tmp_path)
spec = registry.get_spec("sample.capability")
assert spec is not None
assert spec.activation is ActivationPolicy.WHEN_CONFIGURED
assert spec.selector is not None
assert spec.selector.kind == "configured"
assert spec.selector.config == {"key": "sample.config", "enabled": True}
assert spec.watch == ("sample.config",)
assert spec.depends_on == ()
assert "sample_implementation" not in sys.modules
def test_discovered_specs_are_recursively_immutable(tmp_path: Path) -> None:
"""Registry 暴露的声明及嵌套 metadata/selector 都不能被调用方改写。"""
_write_manifest(tmp_path)
spec = _discover(tmp_path).require_spec("sample.capability")
with pytest.raises(TypeError):
spec.metadata["name"] = "changed"
with pytest.raises(TypeError):
spec.selector.config["key"] = "changed" # type: ignore[union-attr]
with pytest.raises(AttributeError):
spec.watch.append("changed") # type: ignore[attr-defined]
@pytest.mark.parametrize(
("replacement", "match"),
[
("schema_version = 1", "缺少字段"),
(_BASE_MANIFEST.replace("schema_version = 1", "schema_version = 2"), "schema_version"),
(_BASE_MANIFEST.replace("schema_version = 1", "schema_version = 1.0"), "schema_version"),
(_BASE_MANIFEST.replace('id = "sample.capability"', 'id = "bad id"'), "id"),
(_BASE_MANIFEST.replace('kind = "sample"', 'kind = "unknown"'), "kind"),
(
_BASE_MANIFEST.replace(
'entrypoint = "sample_implementation:SampleCapability"',
'entrypoint = "sample_implementation.SampleCapability"',
),
"entrypoint",
),
(_BASE_MANIFEST.replace('kind = "configured"', 'kind = "unknown"'), "selector"),
(_BASE_MANIFEST.replace('enabled = true', 'extra = true'), "selector"),
(_BASE_MANIFEST.replace("depends_on = []", 'depends_on = ["other"]'), "depends_on"),
(_BASE_MANIFEST.replace("watch =", "unknown_field = true\nwatch ="), "activation"),
],
)
def test_registry_fails_closed_for_invalid_manifest(
tmp_path: Path,
replacement: str,
match: str,
) -> None:
"""未知或不完整声明必须阻止 Registry 构建,不能静默丢失能力。"""
_write_manifest(tmp_path, replacement)
with pytest.raises(CapabilityManifestError, match=match):
_discover(tmp_path)
def test_selector_presence_must_match_activation_policy(tmp_path: Path) -> None:
"""只有 when_configured 声明可以且必须携带配置 selector。"""
bootstrap = _BASE_MANIFEST.replace(
'policy = "when_configured"', 'policy = "bootstrap"'
)
_write_manifest(tmp_path, bootstrap)
with pytest.raises(CapabilityManifestError, match="selector"):
_discover(tmp_path)
def test_registry_rejects_duplicate_ids_across_roots(tmp_path: Path) -> None:
"""多个声明根出现相同 capability ID 时必须 fail closed。"""
first_root = tmp_path / "first"
second_root = tmp_path / "second"
_write_manifest(first_root)
_write_manifest(second_root)
with pytest.raises(CapabilityManifestError, match="重复"):
CapabilityRegistry.discover(
roots=[first_root, second_root],
kinds={"sample"},
selector_schemas={
"configured": SelectorSchema(
required_fields=frozenset({"key", "enabled"}),
)
},
)
def test_registry_rejects_root_without_manifest(tmp_path: Path) -> None:
"""注册了声明根却没有任何 manifest 时应直接失败。"""
with pytest.raises(CapabilityManifestError, match="capability.toml"):
_discover(tmp_path)
def test_current_host_module_manifests_follow_the_strict_nested_schema() -> None:
"""仓内 Host Module 声明必须全部通过同一套嵌套 schema。"""
modules_root = Path(__file__).parents[1] / "app" / "modules"
imported_before = set(sys.modules)
registry = CapabilityRegistry.discover(
roots=[modules_root],
kinds={"host_module"},
selector_schemas={
"system_config_item": SelectorSchema(
required_fields=frozenset({
"key",
"match_field",
"match_value",
"enabled_field",
}),
),
"setting_truthy": SelectorSchema(
required_fields=frozenset({"key"}),
),
},
)
specs = registry.list_specs()
declared_directories = {spec.source.parent for spec in specs}
module_directories = {
path
for path in modules_root.iterdir()
if path.is_dir() and (path / "__init__.py").is_file()
}
entrypoint_modules = {spec.entrypoint.split(":", maxsplit=1)[0] for spec in specs}
assert declared_directories == module_directories
assert all(spec.kind == "host_module" for spec in specs)
assert not ((set(sys.modules) - imported_before) & entrypoint_modules)

View File

@@ -0,0 +1,834 @@
from __future__ import annotations
import asyncio
import sys
import threading
import types
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Optional
from unittest.mock import patch
import pytest
from app.runtime.capabilities.errors import (
CapabilityAdapterModeError,
CapabilityOperationError,
CapabilityRuntimeClosedError,
)
from app.runtime.capabilities.model import (
AdapterExecutionMode,
CapabilityLifecycleState,
CapabilityMaterializationState,
)
from app.runtime.capabilities.registry import CapabilityRegistry
from app.runtime.capabilities.runtime import CapabilityRuntime
_MANIFEST = """
schema_version = 1
id = "sample.capability"
kind = "sample"
entrypoint = "sample_implementation:SampleCapability"
depends_on = []
[metadata]
name = "Sample capability"
[activation]
policy = "on_first_use"
watch = []
"""
def _registry(tmp_path: Path) -> CapabilityRegistry:
manifest_dir = tmp_path / "sample"
manifest_dir.mkdir(parents=True)
(manifest_dir / "capability.toml").write_text(
_MANIFEST.strip() + "\n",
encoding="utf-8",
)
return CapabilityRegistry.discover(
roots=[tmp_path],
kinds={"sample"},
selector_schemas={},
)
@dataclass
class _Candidate:
generation: int
started: bool = False
stopped: bool = False
class _SyncAdapter:
execution_mode = AdapterExecutionMode.SYNC
def __init__(self) -> None:
self.materialize_calls = 0
self.create_calls = 0
self.start_calls = 0
self.stop_calls = 0
self.stop_instances = []
self.cleanup_calls = 0
self.fail_materialize = False
self.fail_start = False
self.fail_stop = False
self.start_entered: Optional[threading.Event] = None
self.start_release: Optional[threading.Event] = None
self.stop_entered: Optional[threading.Event] = None
self.stop_release: Optional[threading.Event] = None
def materialize(self, spec) -> object:
self.materialize_calls += 1
if self.fail_materialize:
raise RuntimeError("materialize failed")
return object()
def create(self, spec, implementation: object, generation: int, previous: Any = None) -> _Candidate:
self.create_calls += 1
return _Candidate(generation=generation)
def start(self, spec, candidate: _Candidate, generation: int) -> None:
self.start_calls += 1
if self.start_entered:
self.start_entered.set()
if self.start_release:
assert self.start_release.wait(timeout=5)
candidate.started = True
if self.fail_start:
raise RuntimeError("start failed")
def stop(self, spec, instance: _Candidate, generation: int) -> None:
self.stop_calls += 1
self.stop_instances.append(instance)
if self.stop_entered:
self.stop_entered.set()
if self.stop_release:
assert self.stop_release.wait(timeout=5)
instance.stopped = True
if self.fail_stop:
raise RuntimeError("stop failed")
def cleanup(self, spec, candidate: _Candidate, generation: int, error: BaseException) -> None:
self.cleanup_calls += 1
candidate.stopped = True
class _AsyncAdapter:
execution_mode = AdapterExecutionMode.ASYNC
def __init__(self) -> None:
self.materialize_calls = 0
self.create_calls = 0
self.start_calls = 0
self.stop_calls = 0
self.stop_instances = []
self.cleanup_calls = 0
self.fail_materialize = False
self.fail_start = False
self.fail_stop = False
self.start_entered = asyncio.Event()
self.start_release = asyncio.Event()
self.stop_entered: Optional[asyncio.Event] = None
self.stop_release: Optional[asyncio.Event] = None
async def materialize(self, spec) -> object:
self.materialize_calls += 1
await asyncio.sleep(0)
if self.fail_materialize:
raise RuntimeError("async materialize failed")
return object()
async def create(self, spec, implementation: object, generation: int, previous: Any = None) -> _Candidate:
self.create_calls += 1
await asyncio.sleep(0)
return _Candidate(generation=generation)
async def start(self, spec, candidate: _Candidate, generation: int) -> None:
self.start_calls += 1
self.start_entered.set()
await self.start_release.wait()
candidate.started = True
if self.fail_start:
raise RuntimeError("async start failed")
async def stop(self, spec, instance: _Candidate, generation: int) -> None:
self.stop_calls += 1
self.stop_instances.append(instance)
if self.stop_entered:
self.stop_entered.set()
if self.stop_release:
await self.stop_release.wait()
await asyncio.sleep(0)
instance.stopped = True
if self.fail_stop:
raise RuntimeError("async stop failed")
async def cleanup(self, spec, candidate: _Candidate, generation: int, error: BaseException) -> None:
self.cleanup_calls += 1
await asyncio.sleep(0)
candidate.stopped = True
def test_materialize_and_start_have_independent_state_axes(tmp_path: Path) -> None:
"""兼容查询只物化代码,资源必须等显式 activate 成功后才对外可见。"""
adapter = _SyncAdapter()
runtime = CapabilityRuntime(_registry(tmp_path), adapters={"sample": adapter})
implementation = runtime.materialize("sample.capability", reason="compat_lookup")
materialized = runtime.snapshot("sample.capability")
assert implementation is not None
assert materialized.materialization is CapabilityMaterializationState.RESOLVED
assert materialized.lifecycle is CapabilityLifecycleState.DISCOVERED
assert materialized.visible is False
assert adapter.start_calls == 0
instance = runtime.activate("sample.capability", reason="first_use")
running = runtime.snapshot("sample.capability")
assert runtime.get_running("sample.capability") is instance
assert running.lifecycle is CapabilityLifecycleState.RUNNING
assert running.visible is True
assert running.generation == 2
def test_materialize_failure_does_not_claim_resource_lifecycle_failure(tmp_path: Path) -> None:
"""仅解析代码失败时,资源轴尚未启动,必须保持 DISCOVERED。"""
adapter = _SyncAdapter()
adapter.fail_materialize = True
runtime = CapabilityRuntime(_registry(tmp_path), adapters={"sample": adapter})
with pytest.raises(CapabilityOperationError, match="materialize failed"):
runtime.materialize("sample.capability", reason="compat_lookup")
snapshot = runtime.snapshot("sample.capability")
assert snapshot.materialization is CapabilityMaterializationState.FAILED
assert snapshot.lifecycle is CapabilityLifecycleState.DISCOVERED
assert snapshot.visible is False
def test_state_read_calibrates_consumer_import_without_importing_new_module(tmp_path: Path) -> None:
"""显式旧导入存在时应复用 sys.modules 中的 canonical symbol。"""
adapter = _SyncAdapter()
runtime = CapabilityRuntime(_registry(tmp_path), adapters={"sample": adapter})
module = types.ModuleType("sample_implementation")
canonical = type("SampleCapability", (), {})
module.SampleCapability = canonical
with patch.dict(sys.modules, {"sample_implementation": module}):
snapshot = runtime.snapshot("sample.capability")
implementation = runtime.materialize(
"sample.capability",
reason="compat_lookup",
)
assert snapshot.materialization is CapabilityMaterializationState.RESOLVED
assert snapshot.lifecycle is CapabilityLifecycleState.DISCOVERED
assert snapshot.generation == 0
assert implementation is canonical
assert adapter.materialize_calls == 0
def test_state_read_does_not_invoke_module_level_lazy_export(tmp_path: Path) -> None:
"""sys.modules 校准只能读模块字典,不能触发模块级 __getattr__。"""
runtime = CapabilityRuntime(
_registry(tmp_path),
adapters={"sample": _SyncAdapter()},
)
module = types.ModuleType("sample_implementation")
lazy_reads = []
def resolve(name: str) -> object:
lazy_reads.append(name)
raise AssertionError("state read must not resolve lazy exports")
module.__getattr__ = resolve
with patch.dict(sys.modules, {"sample_implementation": module}):
snapshot = runtime.snapshot("sample.capability")
assert snapshot.materialization is CapabilityMaterializationState.UNRESOLVED
assert lazy_reads == []
def test_sync_activate_is_single_flight_and_publishes_only_after_start(tmp_path: Path) -> None:
"""并发首启只能创建一个候选实例start 返回前普通查询不可见。"""
adapter = _SyncAdapter()
adapter.start_entered = threading.Event()
adapter.start_release = threading.Event()
runtime = CapabilityRuntime(_registry(tmp_path), adapters={"sample": adapter})
results = []
errors = []
def activate() -> None:
try:
results.append(runtime.activate("sample.capability", reason="concurrent"))
except BaseException as error: # pragma: no cover - diagnostic collection
errors.append(error)
first = threading.Thread(target=activate)
second = threading.Thread(target=activate)
first.start()
assert adapter.start_entered.wait(timeout=5)
second.start()
assert runtime.get_running("sample.capability") is None
assert runtime.snapshot("sample.capability").lifecycle is CapabilityLifecycleState.STARTING
adapter.start_release.set()
first.join(timeout=5)
second.join(timeout=5)
assert not errors
assert len(results) == 2
assert results[0] is results[1]
assert adapter.materialize_calls == 1
assert adapter.create_calls == 1
assert adapter.start_calls == 1
assert runtime.snapshot("sample.capability").generation == 1
def test_failed_start_cleans_candidate_and_requires_explicit_retry(tmp_path: Path) -> None:
"""半初始化候选必须清理FAILED 不得被普通 activate 隐式重试。"""
adapter = _SyncAdapter()
adapter.fail_start = True
runtime = CapabilityRuntime(_registry(tmp_path), adapters={"sample": adapter})
with pytest.raises(CapabilityOperationError, match="start failed"):
runtime.activate("sample.capability", reason="first_attempt")
failed = runtime.snapshot("sample.capability")
assert failed.materialization is CapabilityMaterializationState.RESOLVED
assert failed.lifecycle is CapabilityLifecycleState.FAILED
assert failed.visible is False
assert adapter.cleanup_calls == 1
with pytest.raises(CapabilityOperationError, match="显式 retry"):
runtime.activate("sample.capability", reason="implicit_retry")
assert adapter.start_calls == 1
adapter.fail_start = False
instance = runtime.activate("sample.capability", reason="explicit_retry", retry=True)
assert instance.started is True
assert runtime.snapshot("sample.capability").generation == 2
assert adapter.start_calls == 2
def test_adapter_must_return_candidate_before_start(tmp_path: Path) -> None:
"""create 没有候选对象时不得进入 start 或伪造 RUNNING 可见性。"""
adapter = _SyncAdapter()
runtime = CapabilityRuntime(_registry(tmp_path), adapters={"sample": adapter})
with patch.object(adapter, "create", return_value=None), pytest.raises(
CapabilityOperationError,
match="candidate",
):
runtime.activate("sample.capability", reason="invalid_candidate")
assert adapter.start_calls == 0
assert runtime.snapshot("sample.capability").lifecycle is CapabilityLifecycleState.FAILED
assert runtime.get_running("sample.capability") is None
def test_stop_withdraws_visibility_before_adapter_callback(tmp_path: Path) -> None:
"""释放外部资源可能阻塞,但运行实例必须在 stop 回调前撤销发布。"""
adapter = _SyncAdapter()
runtime = CapabilityRuntime(_registry(tmp_path), adapters={"sample": adapter})
instance = runtime.activate("sample.capability", reason="start")
adapter.stop_entered = threading.Event()
adapter.stop_release = threading.Event()
stopper = threading.Thread(
target=lambda: runtime.stop("sample.capability", reason="configuration_removed")
)
stopper.start()
assert adapter.stop_entered.wait(timeout=5)
assert runtime.get_running("sample.capability") is None
assert runtime.snapshot("sample.capability").lifecycle is CapabilityLifecycleState.STOPPING
adapter.stop_release.set()
stopper.join(timeout=5)
assert instance.stopped is True
assert runtime.snapshot("sample.capability").lifecycle is CapabilityLifecycleState.STOPPED
def test_stop_failure_retains_ownership_until_same_instance_stops(tmp_path: Path) -> None:
"""stop 失败后的隐藏资源必须保留所有权,禁止用 retry 绕过清理。"""
adapter = _SyncAdapter()
runtime = CapabilityRuntime(_registry(tmp_path), adapters={"sample": adapter})
instance = runtime.activate("sample.capability", reason="start")
adapter.fail_stop = True
with pytest.raises(CapabilityOperationError, match="stop failed"):
runtime.stop("sample.capability", reason="configuration_removed")
assert runtime.get_running("sample.capability") is None
assert runtime.snapshot("sample.capability").lifecycle is CapabilityLifecycleState.FAILED
with pytest.raises(CapabilityOperationError, match="stop failed"):
runtime.activate("sample.capability", reason="unsafe_retry", retry=True)
assert adapter.create_calls == 1
adapter.fail_stop = False
runtime.stop("sample.capability", reason="stop_retry")
assert adapter.stop_instances == [instance, instance, instance]
assert runtime.snapshot("sample.capability").lifecycle is CapabilityLifecycleState.STOPPED
replacement = runtime.activate("sample.capability", reason="after_release")
assert replacement is not instance
assert adapter.create_calls == 2
def test_reload_withdraws_old_instance_and_publishes_one_new_generation(tmp_path: Path) -> None:
"""同步 reload 在 stop/start 回调期间不暴露旧实例或半初始化候选。"""
adapter = _SyncAdapter()
runtime = CapabilityRuntime(_registry(tmp_path), adapters={"sample": adapter})
old_instance = runtime.activate("sample.capability", reason="initial")
adapter.stop_entered = threading.Event()
adapter.stop_release = threading.Event()
results = []
reloader = threading.Thread(
target=lambda: results.append(runtime.reload("sample.capability", reason="config_changed"))
)
reloader.start()
assert adapter.stop_entered.wait(timeout=5)
assert runtime.get_running("sample.capability") is None
assert runtime.snapshot("sample.capability").lifecycle is CapabilityLifecycleState.RELOADING
adapter.stop_release.set()
reloader.join(timeout=5)
assert len(results) == 1
assert results[0] is not old_instance
assert runtime.get_running("sample.capability") is results[0]
assert runtime.snapshot("sample.capability").generation == 2
def test_failed_reload_cleans_candidate_and_keeps_instance_invisible(tmp_path: Path) -> None:
"""reload 新 generation 启动失败时不得恢复旧实例或发布候选。"""
adapter = _SyncAdapter()
runtime = CapabilityRuntime(_registry(tmp_path), adapters={"sample": adapter})
runtime.activate("sample.capability", reason="initial")
adapter.fail_start = True
with pytest.raises(CapabilityOperationError, match="start failed"):
runtime.reload("sample.capability", reason="config_changed")
snapshot = runtime.snapshot("sample.capability")
assert snapshot.lifecycle is CapabilityLifecycleState.FAILED
assert snapshot.visible is False
assert adapter.cleanup_calls == 1
def test_reload_stop_failure_does_not_create_or_reuse_live_previous(tmp_path: Path) -> None:
"""reload 未释放旧资源时必须失败关闭,不能创建或复用同一活对象。"""
adapter = _SyncAdapter()
runtime = CapabilityRuntime(_registry(tmp_path), adapters={"sample": adapter})
old_instance = runtime.activate("sample.capability", reason="initial")
adapter.fail_stop = True
with pytest.raises(CapabilityOperationError, match="stop failed"):
runtime.reload("sample.capability", reason="config_changed")
assert runtime.get_running("sample.capability") is None
assert runtime.snapshot("sample.capability").lifecycle is CapabilityLifecycleState.FAILED
assert adapter.stop_instances == [old_instance]
assert adapter.create_calls == 1
assert adapter.start_calls == 1
with pytest.raises(CapabilityOperationError, match="重试 stop"):
runtime.activate("sample.capability", reason="implicit_retry")
adapter.fail_stop = False
adapter.stop_entered = threading.Event()
adapter.stop_release = threading.Event()
recovered = []
recovery = threading.Thread(
target=lambda: recovered.append(
runtime.activate(
"sample.capability",
reason="recover_after_reload",
retry=True,
)
)
)
recovery.start()
assert adapter.stop_entered.wait(timeout=5)
assert runtime.snapshot("sample.capability").lifecycle is CapabilityLifecycleState.STOPPING
assert runtime.get_running("sample.capability") is None
assert adapter.create_calls == 1
adapter.stop_release.set()
recovery.join(timeout=5)
assert len(recovered) == 1
replacement = recovered[0]
assert adapter.stop_instances == [old_instance, old_instance]
assert replacement is not old_instance
assert adapter.create_calls == 2
def test_shutdown_prevents_inflight_start_from_resurrecting_instance(tmp_path: Path) -> None:
"""shutdown 与首启竞争时,候选只能清理,不能在关闭开始后重新发布。"""
adapter = _SyncAdapter()
adapter.start_entered = threading.Event()
adapter.start_release = threading.Event()
runtime = CapabilityRuntime(_registry(tmp_path), adapters={"sample": adapter})
activate_errors = []
def activate() -> None:
try:
runtime.activate("sample.capability", reason="racing_start")
except BaseException as error:
activate_errors.append(error)
starter = threading.Thread(target=activate)
starter.start()
assert adapter.start_entered.wait(timeout=5)
closer = threading.Thread(target=lambda: runtime.shutdown(reason="application_shutdown"))
closer.start()
adapter.start_release.set()
starter.join(timeout=5)
closer.join(timeout=5)
assert len(activate_errors) == 1
assert isinstance(activate_errors[0], CapabilityRuntimeClosedError)
assert adapter.cleanup_calls == 1
assert runtime.get_running("sample.capability") is None
assert runtime.is_shutdown is True
with pytest.raises(CapabilityRuntimeClosedError):
runtime.activate("sample.capability", reason="late_start")
def test_shutdown_cannot_return_between_open_check_and_sync_claim(tmp_path: Path) -> None:
"""open check 与 inflight claim 必须共享 barrier关闭扫描不能漏过首启。"""
adapter = _SyncAdapter()
adapter.start_entered = threading.Event()
adapter.start_release = threading.Event()
runtime = CapabilityRuntime(_registry(tmp_path), adapters={"sample": adapter})
check_entered = threading.Event()
check_release = threading.Event()
shutdown_returned = threading.Event()
activate_errors = []
original_ensure_open = runtime._ensure_open
first_check = True
check_lock = threading.Lock()
def gated_ensure_open() -> None:
nonlocal first_check
original_ensure_open()
with check_lock:
should_wait = first_check
first_check = False
if should_wait:
check_entered.set()
assert check_release.wait(timeout=5)
def activate() -> None:
try:
runtime.activate("sample.capability", reason="preclaim_race")
except BaseException as error:
activate_errors.append(error)
def shutdown() -> None:
runtime.shutdown(reason="application_shutdown")
shutdown_returned.set()
with patch.object(runtime, "_ensure_open", side_effect=gated_ensure_open):
starter = threading.Thread(target=activate)
starter.start()
assert check_entered.wait(timeout=5)
closer = threading.Thread(target=shutdown)
closer.start()
assert not shutdown_returned.wait(timeout=0.1)
check_release.set()
assert adapter.start_entered.wait(timeout=5)
assert not shutdown_returned.is_set()
adapter.start_release.set()
starter.join(timeout=5)
closer.join(timeout=5)
assert shutdown_returned.is_set()
assert len(activate_errors) <= 1
assert not activate_errors or isinstance(
activate_errors[0],
CapabilityRuntimeClosedError,
)
assert runtime.get_running("sample.capability") is None
@pytest.mark.asyncio
async def test_shutdown_cannot_return_between_open_check_and_async_claim(
tmp_path: Path,
) -> None:
"""异步 activate 的同步 claim 区间也必须受同一关闭 barrier 保护。"""
adapter = _AsyncAdapter()
runtime = CapabilityRuntime(_registry(tmp_path), adapters={"sample": adapter})
check_entered = threading.Event()
check_release = threading.Event()
shutdown_returned = threading.Event()
returned_before_release = []
closer_threads = []
original_ensure_open = runtime._ensure_open
first_check = True
check_lock = threading.Lock()
def gated_ensure_open() -> None:
nonlocal first_check
original_ensure_open()
with check_lock:
should_wait = first_check
first_check = False
if should_wait:
check_entered.set()
assert check_release.wait(timeout=5)
def shutdown() -> None:
asyncio.run(runtime.shutdown_async(reason="application_shutdown"))
shutdown_returned.set()
def coordinate_shutdown() -> None:
assert check_entered.wait(timeout=5)
closer = threading.Thread(target=shutdown)
closer_threads.append(closer)
closer.start()
returned_before_release.append(shutdown_returned.wait(timeout=0.1))
check_release.set()
coordinator = threading.Thread(target=coordinate_shutdown)
coordinator.start()
with patch.object(runtime, "_ensure_open", side_effect=gated_ensure_open):
activate_task = asyncio.create_task(
runtime.activate_async("sample.capability", reason="preclaim_race")
)
await adapter.start_entered.wait()
await asyncio.to_thread(coordinator.join, 5)
assert returned_before_release == [False]
assert not shutdown_returned.is_set()
adapter.start_release.set()
try:
await activate_task
except CapabilityRuntimeClosedError:
pass
await asyncio.to_thread(closer_threads[0].join, 5)
assert shutdown_returned.is_set()
assert runtime.get_running("sample.capability") is None
@pytest.mark.asyncio
async def test_async_adapter_uses_same_single_flight_state_machine(tmp_path: Path) -> None:
"""异步回调等待不能阻塞事件循环,并发调用共享同一 generation。"""
adapter = _AsyncAdapter()
runtime = CapabilityRuntime(_registry(tmp_path), adapters={"sample": adapter})
first = asyncio.create_task(runtime.activate_async("sample.capability", reason="first"))
await adapter.start_entered.wait()
second = asyncio.create_task(runtime.activate_async("sample.capability", reason="second"))
await asyncio.sleep(0)
assert runtime.get_running("sample.capability") is None
adapter.start_release.set()
first_instance, second_instance = await asyncio.gather(first, second)
assert first_instance is second_instance
assert adapter.materialize_calls == 1
assert adapter.start_calls == 1
assert runtime.snapshot("sample.capability").generation == 1
@pytest.mark.asyncio
async def test_stop_async_failure_retains_ownership_for_explicit_retry(tmp_path: Path) -> None:
"""异步 stop 失败后只能重试释放同一实例,不能直接启动新实例。"""
adapter = _AsyncAdapter()
runtime = CapabilityRuntime(_registry(tmp_path), adapters={"sample": adapter})
initial = asyncio.create_task(runtime.activate_async("sample.capability", reason="initial"))
await adapter.start_entered.wait()
adapter.start_release.set()
instance = await initial
adapter.fail_stop = True
with pytest.raises(CapabilityOperationError, match="async stop failed"):
await runtime.stop_async("sample.capability", reason="configuration_removed")
with pytest.raises(CapabilityOperationError, match="async stop failed"):
await runtime.activate_async("sample.capability", reason="unsafe_retry", retry=True)
assert adapter.create_calls == 1
adapter.fail_stop = False
await runtime.stop_async("sample.capability", reason="stop_retry")
assert adapter.stop_instances == [instance, instance, instance]
assert runtime.snapshot("sample.capability").lifecycle is CapabilityLifecycleState.STOPPED
@pytest.mark.asyncio
async def test_async_reload_uses_reloading_state_and_hides_candidate(tmp_path: Path) -> None:
"""异步 reload 与同步入口遵守相同状态和发布边界。"""
adapter = _AsyncAdapter()
runtime = CapabilityRuntime(_registry(tmp_path), adapters={"sample": adapter})
initial = asyncio.create_task(runtime.activate_async("sample.capability", reason="initial"))
await adapter.start_entered.wait()
adapter.start_release.set()
old_instance = await initial
adapter.start_entered = asyncio.Event()
adapter.start_release = asyncio.Event()
reload_task = asyncio.create_task(
runtime.reload_async("sample.capability", reason="config_changed")
)
await adapter.start_entered.wait()
assert runtime.get_running("sample.capability") is None
assert runtime.snapshot("sample.capability").lifecycle is CapabilityLifecycleState.RELOADING
adapter.start_release.set()
new_instance = await reload_task
assert new_instance is not old_instance
assert runtime.get_running("sample.capability") is new_instance
assert runtime.snapshot("sample.capability").generation == 2
@pytest.mark.asyncio
async def test_failed_async_reload_cleans_candidate_and_enters_failed(tmp_path: Path) -> None:
"""异步 reload 失败与同步入口一致,不发布半初始化候选。"""
adapter = _AsyncAdapter()
runtime = CapabilityRuntime(_registry(tmp_path), adapters={"sample": adapter})
initial = asyncio.create_task(runtime.activate_async("sample.capability", reason="initial"))
await adapter.start_entered.wait()
adapter.start_release.set()
await initial
adapter.start_entered = asyncio.Event()
adapter.start_release = asyncio.Event()
adapter.fail_start = True
reload_task = asyncio.create_task(
runtime.reload_async("sample.capability", reason="config_changed")
)
await adapter.start_entered.wait()
adapter.start_release.set()
with pytest.raises(CapabilityOperationError, match="async start failed"):
await reload_task
snapshot = runtime.snapshot("sample.capability")
assert snapshot.lifecycle is CapabilityLifecycleState.FAILED
assert snapshot.visible is False
assert adapter.cleanup_calls == 1
@pytest.mark.asyncio
async def test_async_reload_stop_failure_retains_previous_without_new_create(
tmp_path: Path,
) -> None:
"""异步 reload 也必须保留未释放旧实例并禁止创建第二份资源。"""
adapter = _AsyncAdapter()
runtime = CapabilityRuntime(_registry(tmp_path), adapters={"sample": adapter})
initial = asyncio.create_task(runtime.activate_async("sample.capability", reason="initial"))
await adapter.start_entered.wait()
adapter.start_release.set()
old_instance = await initial
adapter.fail_stop = True
with pytest.raises(CapabilityOperationError, match="async stop failed"):
await runtime.reload_async("sample.capability", reason="config_changed")
assert runtime.get_running("sample.capability") is None
assert runtime.snapshot("sample.capability").lifecycle is CapabilityLifecycleState.FAILED
assert adapter.stop_instances == [old_instance]
assert adapter.create_calls == 1
assert adapter.start_calls == 1
with pytest.raises(CapabilityOperationError, match="重试 stop"):
await runtime.activate_async("sample.capability", reason="implicit_retry")
adapter.fail_stop = False
adapter.start_entered = asyncio.Event()
adapter.start_release = asyncio.Event()
adapter.stop_entered = asyncio.Event()
adapter.stop_release = asyncio.Event()
recovery = asyncio.create_task(
runtime.activate_async(
"sample.capability",
reason="recover_after_reload",
retry=True,
)
)
await adapter.stop_entered.wait()
assert runtime.snapshot("sample.capability").lifecycle is CapabilityLifecycleState.STOPPING
assert runtime.get_running("sample.capability") is None
assert adapter.create_calls == 1
adapter.stop_release.set()
await adapter.start_entered.wait()
adapter.start_release.set()
replacement = await recovery
assert adapter.stop_instances == [old_instance, old_instance]
assert replacement is not old_instance
assert adapter.create_calls == 2
def test_one_failed_capability_does_not_remove_specs_or_block_other_capabilities(
tmp_path: Path,
) -> None:
"""单项失败只改变自身状态Registry 中的其它声明仍可继续运行。"""
first_dir = tmp_path / "first"
second_dir = tmp_path / "second"
first_dir.mkdir()
second_dir.mkdir()
(first_dir / "capability.toml").write_text(_MANIFEST.strip() + "\n", encoding="utf-8")
(second_dir / "capability.toml").write_text(
_MANIFEST.replace("sample.capability", "other.capability")
.replace("sample_implementation", "other_implementation")
.strip()
+ "\n",
encoding="utf-8",
)
registry = CapabilityRegistry.discover(
roots=[tmp_path],
kinds={"sample"},
selector_schemas={},
)
adapter = _SyncAdapter()
runtime = CapabilityRuntime(registry, adapters={"sample": adapter})
adapter.fail_start = True
with pytest.raises(CapabilityOperationError):
runtime.activate("sample.capability", reason="fail")
adapter.fail_start = False
other = runtime.activate("other.capability", reason="continue")
assert other.started is True
assert {spec.id for spec in runtime.list_specs()} == {
"sample.capability",
"other.capability",
}
assert runtime.snapshot("sample.capability").error == "start failed"
@pytest.mark.asyncio
async def test_sync_and_async_entrypoints_reject_wrong_adapter_mode(tmp_path: Path) -> None:
"""入口与 adapter 执行模型不匹配时应在执行回调前失败。"""
async_runtime = CapabilityRuntime(
_registry(tmp_path / "async"),
adapters={"sample": _AsyncAdapter()},
)
with pytest.raises(CapabilityAdapterModeError):
async_runtime.activate("sample.capability", reason="wrong_mode")
sync_runtime = CapabilityRuntime(
_registry(tmp_path / "sync"),
adapters={"sample": _SyncAdapter()},
)
with pytest.raises(CapabilityAdapterModeError):
await sync_runtime.activate_async("sample.capability", reason="wrong_mode")
def test_adapter_mode_requires_declared_enum_member(tmp_path: Path) -> None:
"""并发模型必须显式声明 enum不能依赖字符串相等的偶然兼容。"""
adapter = _SyncAdapter()
adapter.execution_mode = "sync"
runtime = CapabilityRuntime(_registry(tmp_path), adapters={"sample": adapter})
with pytest.raises(CapabilityAdapterModeError):
runtime.activate("sample.capability", reason="invalid_mode")

View File

@@ -126,3 +126,23 @@ async def test_resolve_falls_back_to_qualname_when_name_mismatched(monkeypatch):
assert wrapper.__name__ == "wrapper"
assert instance.reload_count == 1
def test_externally_managed_reload_class_does_not_register_listener(monkeypatch):
"""外部统一管理配置生命周期时Mixin 保留重载能力但不重复绑定事件。"""
registrations = []
monkeypatch.setattr(
eventmanager,
"add_event_listener",
lambda *args, **kwargs: registrations.append((args, kwargs)),
)
class _ExternallyManagedReloadRecorder(ConfigReloadMixin):
CONFIG_RELOAD_MANAGED_EXTERNALLY = True
CONFIG_WATCH = {"TEST_RELOAD_KEY"}
def on_config_changed(self):
pass
assert registrations == []
assert "handle_config_changed" not in _ExternallyManagedReloadRecorder.__dict__

View File

@@ -0,0 +1,160 @@
"""事件调度订阅快照的并发回归测试。"""
import pytest
from app.runtime.events import Event, eventmanager
from app.schemas.types import ChainEventType, EventType
class _ImmediateExecutor:
"""在当前线程执行广播 handler使订阅变更精确发生在调度迭代期间。"""
@staticmethod
def submit(func, *args, **kwargs):
return func(*args, **kwargs)
@pytest.fixture
def isolated_eventmanager(monkeypatch):
"""隔离全局事件总线的订阅表和广播执行器。"""
monkeypatch.setattr(
eventmanager,
"_EventManager__broadcast_subscribers",
{},
)
monkeypatch.setattr(
eventmanager,
"_EventManager__chain_subscribers",
{},
)
monkeypatch.setattr(
eventmanager,
"_EventManager__handler_instance_resolvers",
{},
)
monkeypatch.setattr(
eventmanager,
"_EventManager__executor",
_ImmediateExecutor(),
)
return eventmanager
def test_broadcast_dispatch_uses_subscription_snapshot(isolated_eventmanager):
"""广播事件中新增或移除的 handler 从下一个事件开始生效。"""
calls = []
def late_handler(_event):
calls.append("late")
def removed_handler(_event):
calls.append("removed")
def mutating_handler(_event):
calls.append("mutating")
isolated_eventmanager.remove_event_listener(
EventType.ConfigChanged,
removed_handler,
)
isolated_eventmanager.add_event_listener(
EventType.ConfigChanged,
late_handler,
)
isolated_eventmanager.add_event_listener(
EventType.ConfigChanged,
mutating_handler,
)
isolated_eventmanager.add_event_listener(
EventType.ConfigChanged,
removed_handler,
)
dispatch = isolated_eventmanager._EventManager__dispatch_broadcast_event
dispatch(Event(EventType.ConfigChanged, {}))
assert calls == ["mutating", "removed"]
calls.clear()
dispatch(Event(EventType.ConfigChanged, {}))
assert calls == ["mutating", "late"]
def test_sync_chain_dispatch_uses_subscription_snapshot(isolated_eventmanager):
"""同步链式事件中的订阅变更不影响当前处理器序列。"""
calls = []
def late_handler(_event):
calls.append("late")
def removed_handler(_event):
calls.append("removed")
def mutating_handler(_event):
calls.append("mutating")
isolated_eventmanager.remove_event_listener(
ChainEventType.NameRecognize,
removed_handler,
)
isolated_eventmanager.add_event_listener(
ChainEventType.NameRecognize,
late_handler,
)
isolated_eventmanager.add_event_listener(
ChainEventType.NameRecognize,
mutating_handler,
)
isolated_eventmanager.add_event_listener(
ChainEventType.NameRecognize,
removed_handler,
)
dispatch = isolated_eventmanager._EventManager__dispatch_chain_event
assert dispatch(Event(ChainEventType.NameRecognize, {})) is True
assert calls == ["mutating", "removed"]
calls.clear()
assert dispatch(Event(ChainEventType.NameRecognize, {})) is True
assert calls == ["mutating", "late"]
@pytest.mark.asyncio
async def test_async_chain_dispatch_uses_subscription_snapshot(
isolated_eventmanager,
):
"""异步链式事件中的订阅变更不影响当前处理器序列。"""
calls = []
async def late_handler(_event):
calls.append("late")
async def removed_handler(_event):
calls.append("removed")
async def mutating_handler(_event):
calls.append("mutating")
isolated_eventmanager.remove_event_listener(
ChainEventType.NameRecognize,
removed_handler,
)
isolated_eventmanager.add_event_listener(
ChainEventType.NameRecognize,
late_handler,
)
isolated_eventmanager.add_event_listener(
ChainEventType.NameRecognize,
mutating_handler,
)
isolated_eventmanager.add_event_listener(
ChainEventType.NameRecognize,
removed_handler,
)
dispatch = isolated_eventmanager._EventManager__dispatch_chain_event_async
assert await dispatch(Event(ChainEventType.NameRecognize, {})) is True
assert calls == ["mutating", "removed"]
calls.clear()
assert await dispatch(Event(ChainEventType.NameRecognize, {})) is True
assert calls == ["mutating", "late"]

View File

@@ -364,7 +364,7 @@ def _patch_module_shutdown_dependencies(monkeypatch) -> dict:
"""替换 stop_modules 的资源所有者,避免测试启动真实后台服务"""
dependencies = {}
for name, method_name in (
("ModuleManager", "stop"),
("ModuleManager", "shutdown"),
("EventManager", "stop"),
("DisplayHelper", "stop"),
("DohHelper", "shutdown"),

View File

@@ -0,0 +1,930 @@
"""Host Module Adapter 对 Capability Runtime 的兼容合同测试。"""
from __future__ import annotations
import importlib
import os
import subprocess
import sys
from pathlib import Path
from types import SimpleNamespace
from typing import Iterator
from unittest.mock import Mock
import pytest
from app.db.oper.systemconfig import SystemConfigOper
from app.foundation.singleton import Singleton
from app.runtime.capabilities.errors import CapabilityRuntimeClosedError
from app.runtime.capabilities.model import SelectorSchema
from app.runtime.capabilities.registry import CapabilityRegistry
from app.runtime.events import Event, EventHandlerBinding, eventmanager
from app.runtime.extensions import module_manager as module_manager_extension
from app.runtime.extensions.module_manager import ModuleManager
from app.schemas import ConfigChangeEventData
from app.schemas.types import EventType
_SAMPLE_MANIFEST = """
schema_version = 1
id = "SampleModule"
kind = "host_module"
entrypoint = "fixture_sample_module:SampleModule"
depends_on = []
[metadata]
name = "Sample"
type = "notification"
subtype = "Telegram"
priority = 10
[activation]
policy = "when_configured"
watch = ["Notifications"]
[activation.selector]
kind = "system_config_item"
key = "Notifications"
match_field = "type"
match_value = "sample"
enabled_field = "enabled"
"""
_OTHER_MANIFEST = """
schema_version = 1
id = "OtherModule"
kind = "host_module"
entrypoint = "fixture_other_module:OtherModule"
depends_on = []
[metadata]
name = "Other"
type = "notification"
subtype = "Telegram"
priority = 20
[activation]
policy = "when_configured"
watch = ["Notifications"]
[activation.selector]
kind = "system_config_item"
key = "Notifications"
match_field = "type"
match_value = "other"
enabled_field = "enabled"
"""
_MODULE_SOURCE = """
class {class_name}:
instances = []
def __init__(self):
self.events = ["create"]
type(self).instances.append(self)
def init_module(self):
self.events.append("start")
def stop(self):
self.events.append("stop")
def test(self):
return True, "ok"
def capability_method(self):
return "handled"
@staticmethod
def get_name():
return "{name}"
@staticmethod
def get_type():
return "notification"
@staticmethod
def get_subtype():
return "Telegram"
@staticmethod
def get_priority():
return {priority}
"""
def _write_capability(root: Path, directory: str, manifest: str) -> None:
"""写入一个合成 Host Module 声明。"""
capability_dir = root / directory
capability_dir.mkdir(parents=True)
(capability_dir / "capability.toml").write_text(
manifest.strip() + "\n",
encoding="utf-8",
)
def _build_registry(root: Path) -> CapabilityRegistry:
"""用生产 schema 构造只包含两个合成模块的 Registry。"""
_write_capability(root, "sample", _SAMPLE_MANIFEST)
_write_capability(root, "other", _OTHER_MANIFEST)
return CapabilityRegistry.discover(
roots=[root],
kinds={"host_module"},
selector_schemas={
"system_config_item": SelectorSchema(
required_fields=frozenset({
"key",
"match_field",
"match_value",
"enabled_field",
}),
),
"setting_truthy": SelectorSchema(
required_fields=frozenset({"key"}),
),
},
)
def _config_changed_listeners() -> dict:
"""读取 ConfigChanged 监听快照,用于验证全局测试状态完整恢复。"""
subscribers = getattr(eventmanager, "_EventManager__broadcast_subscribers")
return dict(subscribers.get(EventType.ConfigChanged, {}))
def _run_real_host_module_check(tmp_path: Path, body: str) -> None:
"""在隔离后端和进程内网络守卫下执行真实 Host Module 合同检查。"""
project_root = Path(__file__).parents[1]
prelude = r"""
import ipaddress
import socket
import sys
network_attempts = []
allowed_hosts = {"127.0.0.1", "::1", "localhost", "0.0.0.0", "::", ""}
real_getaddrinfo = socket.getaddrinfo
real_connect = socket.socket.connect
def is_allowed_host(host):
normalized = host.decode() if isinstance(host, (bytes, bytearray)) else host
if normalized is None or normalized in allowed_hosts:
return True
try:
address = ipaddress.ip_address(str(normalized).split("%", 1)[0])
return address.is_loopback or address.is_unspecified
except ValueError:
return False
def block_network(operation, host):
network_attempts.append((operation, host))
raise AssertionError(f"Host Module 合同测试禁止真实出站:{operation} {host!r}")
def guarded_getaddrinfo(host, *args, **kwargs):
if not is_allowed_host(host):
block_network("DNS", host)
return real_getaddrinfo(host, *args, **kwargs)
def guarded_connect(sock, address):
if isinstance(address, tuple) and address and not is_allowed_host(address[0]):
block_network("socket", address[0])
return real_connect(sock, address)
socket.getaddrinfo = guarded_getaddrinfo
socket.socket.connect = guarded_connect
from app.testing.bootstrap import prepare_backend
prepare_backend()
"""
code = f"{prelude}\n{body}\nassert network_attempts == [], network_attempts\n"
env = os.environ.copy()
env["CONFIG_DIR"] = str(tmp_path / "config")
env["PYTHONPATH"] = str(project_root)
result = subprocess.run(
[sys.executable, "-c", code],
cwd=project_root,
env=env,
capture_output=True,
text=True,
timeout=180,
check=False,
)
assert result.returncode == 0, (
f"真实 Host Module 合同检查失败:\nstdout:\n{result.stdout[-4000:]}\n"
f"stderr:\n{result.stderr[-8000:]}"
)
@pytest.fixture
def module_manager_harness(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> Iterator[SimpleNamespace]:
"""用合成声明和内存配置隔离 ModuleManager 单例。"""
source_root = tmp_path / "source"
source_root.mkdir()
(source_root / "fixture_sample_module.py").write_text(
_MODULE_SOURCE.format(class_name="SampleModule", name="Sample", priority=10),
encoding="utf-8",
)
(source_root / "fixture_other_module.py").write_text(
_MODULE_SOURCE.format(class_name="OtherModule", name="Other", priority=20),
encoding="utf-8",
)
monkeypatch.syspath_prepend(str(source_root))
registry = _build_registry(tmp_path / "capabilities")
monkeypatch.setattr(
module_manager_extension,
"build_host_module_registry",
lambda: registry,
)
config_values = {"Notifications": []}
def get_config(_self, key=None):
key_value = getattr(key, "value", key)
if key_value is None:
return dict(config_values)
return config_values.get(key_value)
monkeypatch.setattr(SystemConfigOper, "get", get_config)
singleton_key = (ModuleManager, (), frozenset())
previous_manager = Singleton._instances.pop(singleton_key, None)
resolver_attr = "_EventManager__handler_instance_resolvers"
previous_resolvers = dict(getattr(eventmanager, resolver_attr))
previous_config_changed_listeners = _config_changed_listeners()
for module_name in ("fixture_sample_module", "fixture_other_module"):
sys.modules.pop(module_name, None)
manager = ModuleManager()
restored = False
def restore() -> None:
"""撤销 Manager 构造写入的单例、resolver 和事件监听器。"""
nonlocal restored
if restored:
return
try:
manager.shutdown()
except (AttributeError, CapabilityRuntimeClosedError):
pass
Singleton._instances.pop(singleton_key, None)
if previous_manager is not None:
Singleton._instances[singleton_key] = previous_manager
setattr(eventmanager, resolver_attr, previous_resolvers)
subscribers = getattr(eventmanager, "_EventManager__broadcast_subscribers")
if previous_config_changed_listeners:
subscribers[EventType.ConfigChanged] = dict(
previous_config_changed_listeners
)
else:
subscribers.pop(EventType.ConfigChanged, None)
for module_name in ("fixture_sample_module", "fixture_other_module"):
sys.modules.pop(module_name, None)
restored = True
try:
yield SimpleNamespace(
manager=manager,
config_values=config_values,
previous_config_changed_listeners=previous_config_changed_listeners,
restore=restore,
)
finally:
restore()
def _enable_sample(config_values: dict) -> None:
"""写入可通过 sample selector 的最小合法通知配置。"""
config_values["Notifications"] = [
{
"name": "sample",
"type": "sample",
"config": {},
"switchs": [],
"enabled": True,
}
]
def test_harness_restores_module_manager_config_listener(
module_manager_harness,
) -> None:
"""Fixture teardown 不能把临时 Manager 的 bound listener 留在全局事件总线。"""
manager = module_manager_harness.manager
current_listeners = _config_changed_listeners()
assert current_listeners != (
module_manager_harness.previous_config_changed_listeners
)
assert any(
getattr(listener, "__self__", None) is manager
for listener in current_listeners.values()
)
module_manager_harness.restore()
assert _config_changed_listeners() == (
module_manager_harness.previous_config_changed_listeners
)
def test_specs_are_lightweight_and_do_not_materialize_modules(
module_manager_harness,
) -> None:
"""ModuleManager 的声明视图不能解析任何 Host Module 实现。"""
manager = module_manager_harness.manager
specs = manager.list_specs()
assert manager.get_specs() == specs
assert [spec.id for spec in specs] == ["OtherModule", "SampleModule"]
assert [spec.metadata["name"] for spec in specs] == ["Other", "Sample"]
assert "fixture_sample_module" not in sys.modules
assert "fixture_other_module" not in sys.modules
assert manager.get_running_module("SampleModule") is None
def test_get_module_materializes_one_canonical_class_without_starting_it(
module_manager_harness,
) -> None:
"""兼容查询返回 canonical class但不创建或启动资源。"""
manager = module_manager_harness.manager
module_class = manager.get_module("SampleModule")
canonical_class = importlib.import_module(
"fixture_sample_module"
).SampleModule
assert module_class is canonical_class
assert manager.get_module("SampleModule") is canonical_class
assert canonical_class.instances == []
assert manager.get_running_module("SampleModule") is None
assert "fixture_other_module" not in sys.modules
def test_get_modules_materializes_all_real_classes_without_starting_them(
module_manager_harness,
) -> None:
"""旧 get_modules 合同保留真实 class 字典,不返回代理或隐式激活。"""
manager = module_manager_harness.manager
modules = manager.get_modules()
sample_module = importlib.import_module("fixture_sample_module")
other_module = importlib.import_module("fixture_other_module")
assert modules == {
"OtherModule": other_module.OtherModule,
"SampleModule": sample_module.SampleModule,
}
assert sample_module.SampleModule.instances == []
assert other_module.OtherModule.instances == []
assert manager.get_running_module("SampleModule") is None
assert manager.get_running_module("OtherModule") is None
def test_config_reconcile_reload_and_stop_preserve_manager_contract(
module_manager_harness,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""配置激活、全量 reload 与可重启 stop 保持同步可观察顺序。"""
manager = module_manager_harness.manager
_enable_sample(module_manager_harness.config_values)
manager.load_modules()
first = manager.get_running_module("SampleModule")
assert first is not None
assert first.events == ["create", "start"]
send_event = Mock()
monkeypatch.setattr(eventmanager, "send_event", send_event)
manager.reload()
second = manager.get_running_module("SampleModule")
assert second is not None
assert second is not first
assert first.events == ["create", "start", "stop"]
assert second.events == ["create", "start"]
send_event.assert_called_once_with(etype=EventType.ModuleReload, data={})
manager.stop()
assert manager.get_running_module("SampleModule") is None
assert second.events == ["create", "start", "stop"]
manager.load_modules()
restarted = manager.get_running_module("SampleModule")
assert restarted is not None
assert restarted is not second
assert restarted.events == ["create", "start"]
module_manager_harness.config_values["Notifications"] = []
manager.load_modules()
assert manager.get_running_module("SampleModule") is None
assert restarted.events == ["create", "start", "stop"]
def test_config_event_reloads_same_instance_and_tracks_selector_changes(
module_manager_harness,
) -> None:
"""配置事件由 Host Adapter 唯一协调,并保留模块实例内的重载状态。"""
manager = module_manager_harness.manager
_enable_sample(module_manager_harness.config_values)
manager.load_modules()
running = manager.get_running_module("SampleModule")
manager.handle_config_changed(
Event(
EventType.ConfigChanged,
ConfigChangeEventData(key="Notifications"),
)
)
assert manager.get_running_module("SampleModule") is running
assert running.events == ["create", "start", "stop", "start"]
module_manager_harness.config_values["Notifications"] = []
manager.handle_config_changed(
Event(
EventType.ConfigChanged,
ConfigChangeEventData(key="Notifications"),
)
)
assert manager.get_running_module("SampleModule") is None
assert running.events == ["create", "start", "stop", "start", "stop"]
def test_shutdown_is_irreversible(module_manager_harness) -> None:
"""shutdown 撤销全部可见实例,并拒绝通过 load_modules 再次启动。"""
manager = module_manager_harness.manager
_enable_sample(module_manager_harness.config_values)
manager.load_modules()
running = manager.get_running_module("SampleModule")
assert running is not None
manager.shutdown()
assert manager.get_running_module("SampleModule") is None
assert running.events == ["create", "start", "stop"]
manager.load_modules()
assert manager.get_running_module("SampleModule") is None
assert type(running).instances == [running]
def test_all_real_host_modules_zero_arg_construct_without_starting_resources(
tmp_path: Path,
) -> None:
"""每份真实 manifest 都必须能解析 canonical class 并零参数构造且不启动资源。"""
body = r"""
from app.runtime.extensions.host_module_adapter import (
HostModuleAdapter,
build_host_module_registry,
)
registry = build_host_module_registry()
specs = registry.list_specs()
assert len(specs) == 37
adapter = HostModuleAdapter()
lifecycle_events = []
instances = {}
def make_recorder(operation, capability_id):
def record(instance):
lifecycle_events.append((operation, capability_id, id(instance)))
return record
for spec in specs:
implementation = adapter.materialize(spec)
module_name, symbol_name = spec.entrypoint.split(":", maxsplit=1)
assert implementation is getattr(sys.modules[module_name], symbol_name)
implementation.init_module = make_recorder("start", spec.id)
implementation.stop = make_recorder("stop", spec.id)
instance = adapter.create(spec, implementation, generation=1)
assert type(instance) is implementation
instances[spec.id] = instance
assert set(instances) == {spec.id for spec in specs}
assert lifecycle_events == []
"""
_run_real_host_module_check(tmp_path, body)
def test_real_manifest_inventory_drives_full_module_manager_lifecycle(
tmp_path: Path,
) -> None:
"""真实声明自动驱动全量激活、原实例重载、禁用停止和不可逆关闭门禁。"""
body = r"""
from app.db.oper.systemconfig import SystemConfigOper
from app.runtime.capabilities.model import ActivationPolicy
from app.runtime.config import settings
from app.runtime.events import Event
from app.runtime.extensions.host_module_adapter import (
HostModuleAdapter,
build_host_module_registry,
)
from app.schemas import ConfigChangeEventData
from app.schemas.types import EventType
registry = build_host_module_registry()
specs = registry.list_specs()
assert len(specs) == 37
spec_by_id = {spec.id: spec for spec in specs}
events = {spec.id: [] for spec in specs}
adapter = HostModuleAdapter()
def make_recorder(operation, capability_id):
def record(instance):
events[capability_id].append((operation, id(instance)))
return record
for spec in specs:
implementation = adapter.materialize(spec)
implementation.init_module = make_recorder("start", spec.id)
implementation.stop = make_recorder("stop", spec.id)
config_values = {}
enabled_service_values = {}
selector_keys = set()
configured_ids = set()
for spec in specs:
if spec.activation is not ActivationPolicy.WHEN_CONFIGURED:
continue
configured_ids.add(spec.id)
selector = spec.selector
assert selector is not None
key = str(selector.config["key"])
selector_keys.add(key)
if selector.kind == "setting_truthy":
setattr(settings, key, f"enabled:{spec.id}")
elif selector.kind == "system_config_item":
enabled_service_values.setdefault(key, []).append({
"name": f"contract-{spec.id}",
"type": selector.config["match_value"],
"config": {},
"enabled": True,
})
else:
raise AssertionError(f"未覆盖的 Host Module selector{selector.kind}")
config_values.update({key: list(value) for key, value in enabled_service_values.items()})
def get_config(_self, key=None):
key_value = getattr(key, "value", key)
if key_value is None:
return dict(config_values)
return config_values.get(key_value)
SystemConfigOper.get = get_config
from app.runtime.extensions.module_manager import ModuleManager
manager = ModuleManager()
bootstrap_ids = {
spec.id
for spec in specs
if spec.activation is ActivationPolicy.BOOTSTRAP
}
initial_ids = bootstrap_ids | configured_ids
assert bootstrap_ids
assert configured_ids
assert initial_ids == {spec.id for spec in specs}
initial_instances = {
capability_id: manager.get_running_module(capability_id)
for capability_id in initial_ids
}
assert all(initial_instances.values())
assert {
capability_id: [operation for operation, _instance_id in events[capability_id]]
for capability_id in initial_ids
} == {capability_id: ["start"] for capability_id in initial_ids}
watch_keys = {key for spec in specs for key in spec.watch}
watched_ids = {
spec.id
for spec in specs
if spec.id in initial_ids and watch_keys.intersection(spec.watch)
}
manager.handle_config_changed(
Event(
EventType.ConfigChanged,
ConfigChangeEventData(key=watch_keys),
)
)
for capability_id, initial_instance in initial_instances.items():
assert manager.get_running_module(capability_id) is initial_instance
operations = [operation for operation, _instance_id in events[capability_id]]
expected = ["start", "stop", "start"] if capability_id in watched_ids else ["start"]
assert operations == expected, (capability_id, operations)
assert {
instance_id for _operation, instance_id in events[capability_id]
} == {id(initial_instance)}
for spec in specs:
if spec.id not in configured_ids:
continue
selector = spec.selector
key = str(selector.config["key"])
if selector.kind == "setting_truthy":
setattr(settings, key, False)
for key in enabled_service_values:
config_values[key] = []
manager.handle_config_changed(
Event(
EventType.ConfigChanged,
ConfigChangeEventData(key=selector_keys),
)
)
for capability_id in configured_ids:
assert manager.get_running_module(capability_id) is None
assert [operation for operation, _instance_id in events[capability_id]] == [
"start",
"stop",
"start",
"stop",
]
for capability_id in bootstrap_ids:
assert manager.get_running_module(capability_id) is initial_instances[capability_id]
manager.shutdown()
assert all(manager.get_running_module(spec.id) is None for spec in specs)
events_after_shutdown = {
capability_id: list(capability_events)
for capability_id, capability_events in events.items()
}
for spec in specs:
if spec.id not in configured_ids:
continue
selector = spec.selector
key = str(selector.config["key"])
if selector.kind == "setting_truthy":
setattr(settings, key, f"re-enabled:{spec.id}")
for key, value in enabled_service_values.items():
config_values[key] = list(value)
manager.load_modules()
manager.handle_config_changed(
Event(
EventType.ConfigChanged,
ConfigChangeEventData(key=watch_keys),
)
)
assert all(manager.get_running_module(spec.id) is None for spec in specs)
assert events == events_after_shutdown
assert set(spec_by_id) == set(events)
"""
_run_real_host_module_check(tmp_path, body)
def test_default_config_keeps_every_manifest_configured_entrypoint_unimported(
tmp_path: Path,
) -> None:
"""默认配置惰性边界由全部 when-configured manifest 自动生成。"""
body = r"""
from app.db.oper.systemconfig import SystemConfigOper
from app.runtime.capabilities.model import ActivationPolicy
from app.runtime.config import settings
from app.runtime.extensions.host_module_adapter import (
HostModuleAdapter,
build_host_module_registry,
)
registry = build_host_module_registry()
specs = registry.list_specs()
assert len(specs) == 37
configured_specs = tuple(
spec for spec in specs
if spec.activation is ActivationPolicy.WHEN_CONFIGURED
)
configured_modules = {
spec.entrypoint.split(":", maxsplit=1)[0]
for spec in configured_specs
}
assert configured_modules
assert configured_modules.isdisjoint(sys.modules)
for spec in configured_specs:
selector = spec.selector
assert selector is not None
if selector.kind == "setting_truthy":
setattr(settings, str(selector.config["key"]), False)
SystemConfigOper.get = lambda _self, key=None: {} if key is None else []
adapter = HostModuleAdapter()
for spec in specs:
if spec.activation is not ActivationPolicy.BOOTSTRAP:
continue
implementation = adapter.materialize(spec)
implementation.init_module = lambda _self: None
implementation.stop = lambda _self: None
assert configured_modules.isdisjoint(sys.modules)
from app.runtime.extensions.module_manager import ModuleManager
manager = ModuleManager()
assert manager.get_specs() == manager.list_specs()
assert {spec.id for spec in manager.list_specs()} == {spec.id for spec in specs}
assert all(manager.get_running_module(spec.id) is None for spec in configured_specs)
assert configured_modules.isdisjoint(sys.modules)
manager.shutdown()
assert configured_modules.isdisjoint(sys.modules)
"""
_run_real_host_module_check(tmp_path, body)
def test_event_resolver_uses_exact_class_and_blocks_stopped_owner_fallback(
module_manager_harness,
) -> None:
"""同名 class 不能冒充 owner已停止 owner 必须返回 Binding(None)。"""
manager = module_manager_harness.manager
_enable_sample(module_manager_harness.config_values)
manager.load_modules()
module_class = manager.get_module("SampleModule")
running = manager.get_running_module("SampleModule")
active_binding = manager.resolve_event_handler_instance(module_class)
assert active_binding == EventHandlerBinding(
instance=running,
owner_name="Sample",
)
impostor = type("SampleModule", (), {})
impostor.__module__ = module_class.__module__
assert manager.resolve_event_handler_instance(impostor) is None
manager.stop()
stopped_binding = manager.resolve_event_handler_instance(module_class)
assert stopped_binding == EventHandlerBinding(
instance=None,
owner_name="Sample",
)
def test_default_modulelist_does_not_import_unconfigured_provider_sdks(
tmp_path: Path,
) -> None:
"""默认配置下构造 Manager 和查询模块列表都不能拉起重量 provider SDK。"""
project_root = Path(__file__).parents[1]
code = """
from app.testing.bootstrap import prepare_backend
prepare_backend()
from app.db.oper.systemconfig import SystemConfigOper
from app.runtime.config import settings
def empty_config(self, key=None):
return {} if key is None else []
SystemConfigOper.get = empty_config
settings.ACOUSTID_API_KEY = None
settings.FANART_API_KEY = None
from app.runtime.extensions.module_manager import ModuleManager
manager = ModuleManager()
assert len(manager.list_specs()) == 37
assert manager.get_specs() == manager.list_specs()
from app.api.endpoints.system import modulelist
response = modulelist(None)
assert len(response.data["modules"]) == 37
heavy_prefixes = (
"lark_oapi",
"slack_bolt",
"slack_sdk",
"discord",
"plexapi",
"telebot",
)
loaded = sorted(
name
for name in sys.modules
if any(name == prefix or name.startswith(prefix + ".") for prefix in heavy_prefixes)
)
assert loaded == [], loaded
manager.shutdown()
"""
env = os.environ.copy()
env["CONFIG_DIR"] = str(tmp_path / "config")
env["PYTHONPATH"] = str(project_root)
result = subprocess.run(
[sys.executable, "-c", "import sys\n" + code],
cwd=project_root,
env=env,
capture_output=True,
text=True,
timeout=180,
check=False,
)
assert result.returncode == 0, (
f"子进程模块发现失败:\nstdout:\n{result.stdout[-2000:]}\n"
f"stderr:\n{result.stderr[-4000:]}"
)
def test_lazy_boundary_annotations_are_reflectable_without_provider_sdks(
tmp_path: Path,
) -> None:
"""宿主公共注解可被反射,且反射过程不加载可选 provider SDK。"""
project_root = Path(__file__).parents[1]
code = """
from app.testing.bootstrap import prepare_backend
prepare_backend()
import sys
from typing import Any, Optional, get_type_hints
provider_prefixes = ("qbittorrentapi", "transmission_rpc", "pywebpush")
def loaded_provider_modules():
return sorted(
name
for name in sys.modules
if any(
name == prefix or name.startswith(prefix + ".")
for prefix in provider_prefixes
)
)
assert loaded_provider_modules() == []
from app.chain import ChainBase
from app.api.endpoints.message import WebPushError, is_webpush_subscription_gone
assert get_type_hints(ChainBase.torrent_files)["return"] == Optional[Any]
assert get_type_hints(is_webpush_subscription_gone)["error"] is WebPushError
assert loaded_provider_modules() == []
"""
env = os.environ.copy()
env["CONFIG_DIR"] = str(tmp_path / "config")
env["PYTHONPATH"] = str(project_root)
result = subprocess.run(
[sys.executable, "-c", code],
cwd=project_root,
env=env,
capture_output=True,
text=True,
timeout=180,
check=False,
)
assert result.returncode == 0, (
f"轻量注解反射失败:\nstdout:\n{result.stdout[-2000:]}\n"
f"stderr:\n{result.stderr[-4000:]}"
)
def test_manifest_metadata_matches_legacy_module_class_contract(tmp_path: Path) -> None:
"""manifest 投影必须与插件仍可调用的模块类 metadata 完全一致。"""
project_root = Path(__file__).parents[1]
code = """
from app.testing.bootstrap import prepare_backend
prepare_backend()
from app.db.oper.systemconfig import SystemConfigOper
SystemConfigOper.get = lambda self, key=None: {} if key is None else []
from app.runtime.config import settings
settings.ACOUSTID_API_KEY = None
settings.FANART_API_KEY = None
from app.runtime.extensions.module_manager import ModuleManager
manager = ModuleManager()
modules = manager.get_modules()
assert len(modules) == len(manager.list_specs()) == 37
for spec in manager.list_specs():
implementation = modules[spec.id]
assert implementation.get_name() == spec.metadata["name"]
assert implementation.get_type().value == spec.metadata["type"]
assert implementation.get_subtype().name == spec.metadata["subtype"]
assert implementation.get_priority() == spec.metadata["priority"]
manager.shutdown()
"""
env = os.environ.copy()
env["CONFIG_DIR"] = str(tmp_path / "config")
env["PYTHONPATH"] = str(project_root)
result = subprocess.run(
[sys.executable, "-c", "import sys\n" + code],
cwd=project_root,
env=env,
capture_output=True,
text=True,
timeout=180,
check=False,
)
assert result.returncode == 0, (
f"模块 metadata 兼容检查失败:\nstdout:\n{result.stdout[-2000:]}\n"
f"stderr:\n{result.stderr[-4000:]}"
)

View File

@@ -21,9 +21,12 @@ def test_navidrome_module_has_no_system_switch():
assert NavidromeModule().init_setting() is None
def test_navidrome_module_is_loaded_by_module_manager():
"""模块管理器应能加载 Navidrome否则媒体服务器列表里不会出现该类型"""
assert "NavidromeModule" in ModuleManager()._running_modules
def test_navidrome_module_is_discovered_without_unconfigured_activation():
"""Navidrome 始终可发现,但没有启用配置时不应创建服务资源"""
manager = ModuleManager()
assert "NavidromeModule" in manager.get_module_ids()
assert manager.get_running_module("NavidromeModule") is None
def test_navidrome_module_ignores_non_music_media():

View File

@@ -0,0 +1,41 @@
import threading
from app.foundation.singleton import Singleton, SingletonClass
def _construct_concurrently(singleton_type, count: int = 16):
"""让多个线程同时越过起跑线,放大首次构造竞态。"""
barrier = threading.Barrier(count)
instances = []
def construct() -> None:
barrier.wait()
instances.append(singleton_type())
threads = [threading.Thread(target=construct) for _ in range(count)]
for thread in threads:
thread.start()
for thread in threads:
thread.join(timeout=5)
assert not thread.is_alive()
return instances
def test_parameterized_singleton_first_construction_is_single_flight():
"""同一参数的并发首次构造只能发布一个完整实例。"""
class _ParameterizedSingleton(metaclass=Singleton):
pass
instances = _construct_concurrently(_ParameterizedSingleton)
assert len({id(instance) for instance in instances}) == 1
def test_class_singleton_first_construction_is_single_flight():
"""按类单例的并发首次构造只能发布一个完整实例。"""
class _ClassSingleton(metaclass=SingletonClass):
pass
instances = _construct_concurrently(_ClassSingleton)
assert len({id(instance) for instance in instances}) == 1

View File

@@ -1,24 +1,18 @@
from unittest.mock import patch
from types import SimpleNamespace
from app.api.endpoints import system as system_endpoint
from app.runtime.localization import LocaleHelper
class _FakeDoubanModule:
"""构造带中文名称的模块类,模拟真实 DoubanModule。"""
@staticmethod
def get_name() -> str:
"""获取模块中文名称"""
return "豆瓣"
class _FakeModuleManager:
"""提供 system 模块接口测试所需的最小模块管理器。"""
def get_modules(self) -> dict:
"""返回模块字典"""
return {"DoubanModule": _FakeDoubanModule}
def list_specs(self) -> tuple:
"""返回 manifest 元数据视图。"""
return (
SimpleNamespace(id="DoubanModule", metadata={"name": "豆瓣"}),
)
def test(self, moduleid: str) -> tuple[bool, str]:
"""返回模块测试结果"""