mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-19 05:03:57 +08:00
931 lines
29 KiB
Python
931 lines
29 KiB
Python
"""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:]}"
|
||
)
|