mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-18 20:53:59 +08:00
refactor(runtime): lazily activate host modules (#6331)
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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__()
|
||||
|
||||
19
app/modules/acoustid/capability.toml
Normal file
19
app/modules/acoustid/capability.toml
Normal 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"
|
||||
15
app/modules/anilist/capability.toml
Normal file
15
app/modules/anilist/capability.toml
Normal 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"]
|
||||
15
app/modules/bangumi/capability.toml
Normal file
15
app/modules/bangumi/capability.toml
Normal 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"]
|
||||
22
app/modules/discord/capability.toml
Normal file
22
app/modules/discord/capability.toml
Normal 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"
|
||||
15
app/modules/douban/capability.toml
Normal file
15
app/modules/douban/capability.toml
Normal 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 = []
|
||||
22
app/modules/emby/capability.toml
Normal file
22
app/modules/emby/capability.toml
Normal 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"
|
||||
19
app/modules/fanart/capability.toml
Normal file
19
app/modules/fanart/capability.toml
Normal 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"
|
||||
22
app/modules/feishu/capability.toml
Normal file
22
app/modules/feishu/capability.toml
Normal 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"
|
||||
15
app/modules/filemanager/capability.toml
Normal file
15
app/modules/filemanager/capability.toml
Normal 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 = []
|
||||
15
app/modules/filter/capability.toml
Normal file
15
app/modules/filter/capability.toml
Normal 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"]
|
||||
15
app/modules/indexer/capability.toml
Normal file
15
app/modules/indexer/capability.toml
Normal 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 = []
|
||||
22
app/modules/jellyfin/capability.toml
Normal file
22
app/modules/jellyfin/capability.toml
Normal 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"
|
||||
15
app/modules/listenbrainz/capability.toml
Normal file
15
app/modules/listenbrainz/capability.toml
Normal 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 = []
|
||||
15
app/modules/lrclib/capability.toml
Normal file
15
app/modules/lrclib/capability.toml
Normal 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 = []
|
||||
15
app/modules/musicbrainz/capability.toml
Normal file
15
app/modules/musicbrainz/capability.toml
Normal 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 = []
|
||||
22
app/modules/navidrome/capability.toml
Normal file
22
app/modules/navidrome/capability.toml
Normal 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"
|
||||
22
app/modules/plex/capability.toml
Normal file
22
app/modules/plex/capability.toml
Normal 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"
|
||||
15
app/modules/postgresql/capability.toml
Normal file
15
app/modules/postgresql/capability.toml
Normal 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 = []
|
||||
22
app/modules/qbittorrent/capability.toml
Normal file
22
app/modules/qbittorrent/capability.toml
Normal 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"
|
||||
22
app/modules/qqbot/capability.toml
Normal file
22
app/modules/qqbot/capability.toml
Normal 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"
|
||||
15
app/modules/redis/capability.toml
Normal file
15
app/modules/redis/capability.toml
Normal 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 = []
|
||||
22
app/modules/rtorrent/capability.toml
Normal file
22
app/modules/rtorrent/capability.toml
Normal 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"
|
||||
22
app/modules/slack/capability.toml
Normal file
22
app/modules/slack/capability.toml
Normal 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"
|
||||
15
app/modules/subtitle/capability.toml
Normal file
15
app/modules/subtitle/capability.toml
Normal 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 = []
|
||||
22
app/modules/synologychat/capability.toml
Normal file
22
app/modules/synologychat/capability.toml
Normal 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"
|
||||
22
app/modules/telegram/capability.toml
Normal file
22
app/modules/telegram/capability.toml
Normal 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"
|
||||
15
app/modules/theaudiodb/capability.toml
Normal file
15
app/modules/theaudiodb/capability.toml
Normal 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 = []
|
||||
15
app/modules/themoviedb/capability.toml
Normal file
15
app/modules/themoviedb/capability.toml
Normal 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"]
|
||||
15
app/modules/thetvdb/capability.toml
Normal file
15
app/modules/thetvdb/capability.toml
Normal 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 = []
|
||||
22
app/modules/transmission/capability.toml
Normal file
22
app/modules/transmission/capability.toml
Normal 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"
|
||||
22
app/modules/trimemedia/capability.toml
Normal file
22
app/modules/trimemedia/capability.toml
Normal 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"
|
||||
22
app/modules/ugreen/capability.toml
Normal file
22
app/modules/ugreen/capability.toml
Normal 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"
|
||||
22
app/modules/vocechat/capability.toml
Normal file
22
app/modules/vocechat/capability.toml
Normal 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"
|
||||
22
app/modules/webpush/capability.toml
Normal file
22
app/modules/webpush/capability.toml
Normal 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"
|
||||
22
app/modules/wechat/capability.toml
Normal file
22
app/modules/wechat/capability.toml
Normal 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"
|
||||
22
app/modules/wechatclawbot/capability.toml
Normal file
22
app/modules/wechatclawbot/capability.toml
Normal 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"
|
||||
22
app/modules/zspace/capability.toml
Normal file
22
app/modules/zspace/capability.toml
Normal 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"
|
||||
47
app/runtime/capabilities/__init__.py
Normal file
47
app/runtime/capabilities/__init__.py
Normal 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__))
|
||||
32
app/runtime/capabilities/errors.py
Normal file
32
app/runtime/capabilities/errors.py
Normal 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 已进入关闭态,禁止启动或重新物化能力。"""
|
||||
182
app/runtime/capabilities/model.py
Normal file
182
app/runtime/capabilities/model.py
Normal 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({})
|
||||
287
app/runtime/capabilities/registry.py
Normal file
287
app/runtime/capabilities/registry.py
Normal 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))
|
||||
1369
app/runtime/capabilities/runtime.py
Normal file
1369
app/runtime/capabilities/runtime.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
):
|
||||
|
||||
260
app/runtime/extensions/host_module_adapter.py
Normal file
260
app/runtime/extensions/host_module_adapter.py
Normal 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()
|
||||
@@ -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()
|
||||
|
||||
76
app/runtime/extensions/service_config.py
Normal file
76
app/runtime/extensions/service_config.py
Normal 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
|
||||
@@ -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]):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
106
scripts/perf/README.md
Normal 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` 删除。
|
||||
53
scripts/perf/instrument/collect_proc.sh
Normal file
53
scripts/perf/instrument/collect_proc.sh
Normal 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
|
||||
35
scripts/perf/instrument/sitecustomize.py
Normal file
35
scripts/perf/instrument/sitecustomize.py
Normal 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)
|
||||
1677
scripts/perf/moviepilot_docker_ab.py
Normal file
1677
scripts/perf/moviepilot_docker_ab.py
Normal file
File diff suppressed because it is too large
Load Diff
195
tests/test_capability_registry.py
Normal file
195
tests/test_capability_registry.py
Normal 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)
|
||||
834
tests/test_capability_runtime.py
Normal file
834
tests/test_capability_runtime.py
Normal 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")
|
||||
@@ -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__
|
||||
|
||||
160
tests/test_event_dispatch_snapshot.py
Normal file
160
tests/test_event_dispatch_snapshot.py
Normal 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"]
|
||||
@@ -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"),
|
||||
|
||||
930
tests/test_module_manager_capability_adapter.py
Normal file
930
tests/test_module_manager_capability_adapter.py
Normal 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:]}"
|
||||
)
|
||||
@@ -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():
|
||||
|
||||
41
tests/test_singleton_thread_safety.py
Normal file
41
tests/test_singleton_thread_safety.py
Normal 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
|
||||
@@ -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]:
|
||||
"""返回模块测试结果"""
|
||||
|
||||
Reference in New Issue
Block a user