mirror of
https://hubproxy.babadafafafafa.cn/https://github.com/jxxghp/MoviePilot.git
synced 2026-09-20 08:03:34 +08:00
fix(plugin): 修复分身与本体在事件定向和注册表键上的身份混淆 (#6672)
This commit is contained in:
@@ -372,12 +372,24 @@ class EventDispatcher:
|
||||
handler_identifier: str,
|
||||
target_plugin_id: str,
|
||||
) -> bool:
|
||||
"""只把定向输入事件投递给标识和声明均匹配的目标插件。"""
|
||||
"""只把定向输入事件投递给标识和声明均匹配的目标插件。
|
||||
|
||||
目标匹配按运行实例身份判断,不能只看处理器的限定名:分身共享源码,其
|
||||
``__qualname__`` 保持源类名不变,只比类名会让定向到分身的事件全部落空,
|
||||
而定向到本体的事件被本体连同它的全部分身一起收到。实例身份编码在处理器
|
||||
标识的模块段里(``app.plugins.<实例ID>``);不是插件处理器时回落到类名比较,
|
||||
保持宿主侧处理器的既有行为。
|
||||
"""
|
||||
class_name, method_name = EventBindingResolver.parse_handler_names(handler)
|
||||
if class_name != target_plugin_id:
|
||||
return False
|
||||
parts = (handler_identifier or "").split(".")
|
||||
return len(parts) >= 2 and parts[-2:] == [class_name, method_name]
|
||||
if len(parts) < 2 or parts[-2:] != [class_name, method_name]:
|
||||
return False
|
||||
module_path = ".".join(parts[:-2])
|
||||
prefix = "app.plugins."
|
||||
if module_path.startswith(prefix):
|
||||
owner_id = module_path[len(prefix):].split(".")[0]
|
||||
return owner_id.casefold() == target_plugin_id.casefold()
|
||||
return class_name == target_plugin_id
|
||||
|
||||
@staticmethod
|
||||
def _log_lifecycle(event: Any, stage: str) -> None:
|
||||
|
||||
@@ -268,11 +268,37 @@ class PluginLoader:
|
||||
if spec and getattr(spec, "name", None) == source_name:
|
||||
spec.name = instance_name
|
||||
for value in vars(module).values():
|
||||
if getattr(value, "__module__", None) == source_name:
|
||||
try:
|
||||
value.__module__ = instance_name
|
||||
except (AttributeError, TypeError):
|
||||
continue
|
||||
if getattr(value, "__module__", None) != source_name:
|
||||
continue
|
||||
try:
|
||||
value.__module__ = instance_name
|
||||
except (AttributeError, TypeError):
|
||||
continue
|
||||
if isinstance(value, type):
|
||||
PluginLoader._retarget_member_identity(
|
||||
value, source_name, instance_name
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _retarget_member_identity(
|
||||
owner: type,
|
||||
source_name: str,
|
||||
instance_name: str,
|
||||
) -> None:
|
||||
"""把类体内定义的函数一并改到实例模块名下。
|
||||
|
||||
事件处理器的注册键取自函数所在模块名,而类体内的函数是类的属性、不是模块
|
||||
的属性,只遍历模块顶层碰不到它们。漏改会让实例与源插件本体注册到同一个
|
||||
handler 键上:后注册的覆盖先注册的,实例收不到事件,停本体会把实例一起停掉。
|
||||
"""
|
||||
for member in vars(owner).values():
|
||||
target = getattr(member, "__func__", member)
|
||||
if getattr(target, "__module__", None) != source_name:
|
||||
continue
|
||||
try:
|
||||
target.__module__ = instance_name
|
||||
except (AttributeError, TypeError):
|
||||
continue
|
||||
|
||||
@staticmethod
|
||||
def _adapt_instance_class(candidate: Any, instance: PluginInstance) -> None:
|
||||
|
||||
125
tests/test_plugin_clone_event_targeting.py
Normal file
125
tests/test_plugin_clone_event_targeting.py
Normal file
@@ -0,0 +1,125 @@
|
||||
"""定向输入事件在源插件本体与其分身之间的投递归属测试。"""
|
||||
|
||||
import sys
|
||||
from types import ModuleType
|
||||
|
||||
from app.runtime.event.dispatch import EventDispatcher
|
||||
from app.runtime.event.registry import EventRegistry
|
||||
|
||||
|
||||
def _install_plugin_handler(
|
||||
monkeypatch,
|
||||
*,
|
||||
module_name: str,
|
||||
declared_class_name: str,
|
||||
runtime_class_name: str,
|
||||
):
|
||||
"""在指定模块命名空间下造出一个插件事件处理器,并返回其注册标识。
|
||||
|
||||
复刻装载器造出的运行事实:分身与本体共享源码,因此处理器的 ``__qualname__``
|
||||
恒为源类名;区别只在分身被放进自己的模块命名空间、并被改写了类的 ``__name__``。
|
||||
|
||||
:param monkeypatch: 用于把假模块登记进 ``sys.modules`` 并在用例结束后还原
|
||||
:param module_name: 该实例的模块名
|
||||
:param declared_class_name: 源码里声明的类名,即处理器限定名的前缀
|
||||
:param runtime_class_name: 运行身份的类名,分身为其实例 ID
|
||||
:return: 处理器函数与其注册标识
|
||||
"""
|
||||
module = ModuleType(module_name)
|
||||
|
||||
def handle_message_action(self, event):
|
||||
"""占位的定向输入事件处理器。"""
|
||||
return self, event
|
||||
|
||||
handle_message_action.__module__ = module_name
|
||||
handle_message_action.__qualname__ = f"{declared_class_name}.handle_message_action"
|
||||
plugin_class = type(
|
||||
declared_class_name,
|
||||
(),
|
||||
{"handle_message_action": handle_message_action},
|
||||
)
|
||||
plugin_class.__module__ = module_name
|
||||
plugin_class.__name__ = runtime_class_name
|
||||
setattr(module, declared_class_name, plugin_class)
|
||||
monkeypatch.setitem(sys.modules, module_name, module)
|
||||
|
||||
handler = plugin_class.handle_message_action
|
||||
return handler, EventRegistry.handler_identifier(handler)
|
||||
|
||||
|
||||
def test_targeted_event_reaches_the_clone_that_opened_the_input_session(monkeypatch):
|
||||
"""定向到分身的输入事件必须投递给该分身自己的处理器。
|
||||
|
||||
分身共享源码,处理器限定名里的类名始终是源类名,只比类名会让「目标是分身」
|
||||
这一判断恒不成立,用户在分身里发起的输入会话随后收不到任何回复。
|
||||
"""
|
||||
handler, handler_id = _install_plugin_handler(
|
||||
monkeypatch,
|
||||
module_name="app.plugins.demopluginwork",
|
||||
declared_class_name="DemoPlugin",
|
||||
runtime_class_name="DemoPluginWork",
|
||||
)
|
||||
|
||||
assert handler_id == "app.plugins.demopluginwork.DemoPlugin.handle_message_action"
|
||||
assert EventDispatcher.should_dispatch_to_target_plugin(
|
||||
handler,
|
||||
handler_id,
|
||||
"DemoPluginWork",
|
||||
) is True
|
||||
|
||||
|
||||
def test_targeted_event_for_the_host_is_not_broadcast_to_its_clones(monkeypatch):
|
||||
"""定向到本体的输入事件不得同时落进它的分身。
|
||||
|
||||
分身与本体的处理器限定名完全一致,只比类名会让本体的定向事件被全部分身一起
|
||||
收到——定向投递本来就是为了不让自由文本被别的实例看到。
|
||||
"""
|
||||
host_handler, host_id = _install_plugin_handler(
|
||||
monkeypatch,
|
||||
module_name="app.plugins.demoplugin",
|
||||
declared_class_name="DemoPlugin",
|
||||
runtime_class_name="DemoPlugin",
|
||||
)
|
||||
clone_handler, clone_id = _install_plugin_handler(
|
||||
monkeypatch,
|
||||
module_name="app.plugins.demopluginwork",
|
||||
declared_class_name="DemoPlugin",
|
||||
runtime_class_name="DemoPluginWork",
|
||||
)
|
||||
|
||||
assert EventDispatcher.should_dispatch_to_target_plugin(
|
||||
host_handler,
|
||||
host_id,
|
||||
"DemoPlugin",
|
||||
) is True
|
||||
assert EventDispatcher.should_dispatch_to_target_plugin(
|
||||
clone_handler,
|
||||
clone_id,
|
||||
"DemoPlugin",
|
||||
) is False
|
||||
assert EventDispatcher.should_dispatch_to_target_plugin(
|
||||
host_handler,
|
||||
host_id,
|
||||
"DemoPluginWork",
|
||||
) is False
|
||||
|
||||
|
||||
def test_non_plugin_handlers_keep_matching_by_declared_class_name(monkeypatch):
|
||||
"""宿主侧处理器不在插件命名空间里,仍按声明类名匹配。"""
|
||||
handler, handler_id = _install_plugin_handler(
|
||||
monkeypatch,
|
||||
module_name="tests.hosted.demo_plugin",
|
||||
declared_class_name="DemoPlugin",
|
||||
runtime_class_name="DemoPlugin",
|
||||
)
|
||||
|
||||
assert EventDispatcher.should_dispatch_to_target_plugin(
|
||||
handler,
|
||||
handler_id,
|
||||
"DemoPlugin",
|
||||
) is True
|
||||
assert EventDispatcher.should_dispatch_to_target_plugin(
|
||||
handler,
|
||||
handler_id,
|
||||
"OtherPlugin",
|
||||
) is False
|
||||
@@ -1,10 +1,14 @@
|
||||
"""虚拟插件实例的持久化、加载和创建行为测试。"""
|
||||
|
||||
import importlib.util
|
||||
import sys
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from types import ModuleType, SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from app.runtime.event.registry import EventRegistry
|
||||
from app.runtime.extensions.plugin.clone import PluginCloneService
|
||||
from app.runtime.extensions.plugin.loader import PluginLoader
|
||||
from app.runtime.extensions.plugin.storage import (
|
||||
@@ -13,7 +17,7 @@ from app.runtime.extensions.plugin.storage import (
|
||||
PluginStorage,
|
||||
)
|
||||
from app.schemas.plugin import PluginInstance, PluginRuntimeStatus
|
||||
from app.schemas.types import SystemConfigKey
|
||||
from app.schemas.types import EventType, SystemConfigKey
|
||||
|
||||
|
||||
def _make_directory() -> tuple[PluginInstanceDirectory, dict[str, PluginInstance]]:
|
||||
@@ -503,6 +507,114 @@ def test_loader_executes_each_instance_in_an_isolated_module_namespace(
|
||||
assert plugin_package.demoplugin is source_module
|
||||
|
||||
|
||||
def _import_host_package(module_name: str, source_dir: Path) -> ModuleType:
|
||||
"""按源插件本体的模块名导入磁盘源码,复刻本体已被装载的运行事实。
|
||||
|
||||
本体必须真的在 ``sys.modules`` 里:处理器的注册键要靠函数的 ``__module__``
|
||||
反查模块对象才能算出来,本体缺席会让两边都算成同一个占位名,掩盖分身与本体
|
||||
撞键这件事本身。
|
||||
"""
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
module_name,
|
||||
source_dir / "__init__.py",
|
||||
submodule_search_locations=[str(source_dir)],
|
||||
)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[module_name] = module
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def _forget_modules(*prefixes: str) -> None:
|
||||
"""清除用例在模块缓存里留下的插件模块,避免污染后续用例。"""
|
||||
for name in list(sys.modules):
|
||||
if any(name == prefix or name.startswith(f"{prefix}.") for prefix in prefixes):
|
||||
sys.modules.pop(name, None)
|
||||
|
||||
|
||||
def test_loader_gives_submodule_handlers_of_each_instance_distinct_registry_keys(
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
):
|
||||
"""分身在子模块类体内声明的处理器,注册键必须与本体的区分开。
|
||||
|
||||
事件处理器的注册键取自函数所在模块名。类体内的函数是类的属性而不是模块的
|
||||
属性,只遍历模块顶层碰不到它们;漏改会让分身与本体注册到同一个键上,后注册
|
||||
的顶掉先注册的:分身收不到事件,停掉本体会把分身一起停掉。
|
||||
"""
|
||||
source_dir = tmp_path / "identityplugin"
|
||||
source_dir.mkdir()
|
||||
(source_dir / "core.py").write_text(
|
||||
"class IdentityHandlers:\n"
|
||||
" def on_event(self, event):\n"
|
||||
" return event\n"
|
||||
"\n"
|
||||
" @staticmethod\n"
|
||||
" def on_static_event(event):\n"
|
||||
" return event\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(source_dir / "__init__.py").write_text(
|
||||
"from app.plugins.identityplugin.core import IdentityHandlers\n"
|
||||
"\n"
|
||||
"\n"
|
||||
"class IdentityPlugin(IdentityHandlers):\n"
|
||||
" plugin_name = 'Identity'\n"
|
||||
"\n"
|
||||
" def init_plugin(self, _config):\n"
|
||||
" return None\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
import app.plugins as plugin_package
|
||||
|
||||
loader = PluginLoader(
|
||||
plugins_root=tmp_path,
|
||||
import_preparer=lambda **_kwargs: None,
|
||||
import_scanner=lambda **_kwargs: None,
|
||||
log=_logger(),
|
||||
)
|
||||
try:
|
||||
host_module = _import_host_package("app.plugins.identityplugin", source_dir)
|
||||
monkeypatch.setattr(
|
||||
plugin_package, "identityplugin", host_module, raising=False
|
||||
)
|
||||
clone_class = loader.load_instance(
|
||||
PluginInstance(
|
||||
instance_id="IdentityPluginWork",
|
||||
source_plugin_id="IdentityPlugin",
|
||||
),
|
||||
lambda candidate: hasattr(candidate, "init_plugin"),
|
||||
)[0]
|
||||
host_handler = host_module.IdentityPlugin.on_event
|
||||
clone_handler = clone_class.on_event
|
||||
subscribers: dict = {}
|
||||
registry = EventRegistry(
|
||||
lock=threading.RLock(),
|
||||
broadcast_subscribers=lambda: subscribers,
|
||||
chain_subscribers=dict,
|
||||
disabled_handlers=set,
|
||||
disabled_classes=set,
|
||||
)
|
||||
registry.add(EventType.PluginAction, host_handler, 0)
|
||||
registry.add(EventType.PluginAction, clone_handler, 0)
|
||||
|
||||
assert len(subscribers[EventType.PluginAction]) == 2
|
||||
assert EventRegistry.handler_identifier(host_handler) == (
|
||||
"app.plugins.identityplugin.core.IdentityHandlers.on_event"
|
||||
)
|
||||
assert EventRegistry.handler_identifier(clone_handler) == (
|
||||
"app.plugins.identitypluginwork.core.IdentityHandlers.on_event"
|
||||
)
|
||||
assert EventRegistry.handler_identifier(clone_class.on_static_event) == (
|
||||
"app.plugins.identitypluginwork.core.IdentityHandlers.on_static_event"
|
||||
)
|
||||
finally:
|
||||
_forget_modules(
|
||||
"app.plugins.identityplugin",
|
||||
"app.plugins.identitypluginwork",
|
||||
)
|
||||
|
||||
|
||||
def test_loader_runtime_gate_only_rejects_explicit_incompatible_declarations(
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
|
||||
Reference in New Issue
Block a user