mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-22 00:32:50 +08:00
refactor(runtime): lazily activate host modules (#6331)
This commit is contained in:
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:]}"
|
||||
)
|
||||
Reference in New Issue
Block a user