Files
MoviePilot/tests/test_plugin_instance_log_context_entrypoints.py
Aqr-K e47d7defe7 feat(plugin): 插件本体与分身可各自设置独立日志等级
全局日志等级是一个开关,调试单个插件时只能整体下调。一旦下调,被调试插件那几行
日志会淹没在其他几十个插件与宿主自身的 DEBUG 输出里,找不出来;不下调又什么都
看不见。插件实例数量只会继续增长,这个矛盾不会自己消失。

因此把等级下放到实例:plugininstance 行加 log_level / log_expires_at 两列,本体与
分身共用同一张表、同一套语义。等级落在实例自己那一行而不是另起一张按实例 ID 索引
的表——它和业务参数一样是用户按实例设置的东西,同一个生命周期,实例被删时应当随行
一起消失。清除覆盖后若本体行只剩一对身份列,整行按 carries_only_identity 回收,与
清空业务参数同一规则。

覆盖带失效时间,因为它是调试设置不是长期配置:开了 DEBUG 忘记关,下次谁也想不起
某个插件为什么一直在刷日志。过期判定在读取时惰性执行并就地清理,不另起后台线程去
扫一张最多几十条的表。expires_at 在写入侧先归一到 UTC 再分发给缓存与落盘两处:客户端
提交的时间可能不带时区,两处各按进程时区折算一次,就会与读取口返回的 UTC 值差出一个
时区,表现为「提交的失效时刻和读回来的对不上」。

运行期用 ContextVar 在宿主自己控制的四类调用点绑定当前实例——构造与 init_plugin、
事件处理器、定时服务回调、插件声明的 HTTP 端点——而不是沿用 logger() 已有的栈帧内省。
内省认的是栈顶的模块与类,插件通过宿主公共方法转发调用时栈顶是宿主而不是发起调用的
插件,会把日志记到错误的来源上;分身更甚:分身共享源码,类的 __qualname__ 保持源类名
不变,内省根本分不出是哪一个分身在打日志。内省结果继续只用于文件路由,不参与等级判定。

等级过滤必须排在全局等级判定之前。官方现状是先按全局等级短路、之后才做内省,先短路
就会把实例调低的等级挡在门外,后面再怎么识别来源都看不到这条日志。

回调在注册时被捕获、稍后才由调度器或 FastAPI 调用,那时已经不在任何绑定作用域内,
故这两处改用 wrap_for_plugin_instance 把绑定包进回调自身;同一实例重复包装直接返回
原对象,避免插件缓存声明时包装链随重载次数无限增长。

进程内覆盖表按实例 ID 常驻,删分身时必须一并清掉:只删库里那一行的话,同一进程内用
相同后缀重建的分身会继承上一个分身的等级,界面显示「跟随全局」而实际仍按旧等级输出。
进程重启后反向从库预热缓存。

迁移链在 F1 之后追加:c4e1a7b9d2f6(3.0.35)-> 487f7e681955(3.0.36 加两列)。

顺带补了 agent 策略里缺失的 media.classification.policy.get 的 template 字段说明:
少了它,api_mcp_schema.json 与技能文档的两个生成脚本都跑不起来。
2026-09-12 19:05:05 -04:00

335 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""插件实例日志上下文接入点契约测试。
覆盖四类宿主受控调用点:插件实例的构造与 `init_plugin`
(`PluginLifecycle.start`/`initialize`)、事件处理器回调(`EventDispatcher`
的四个 invoke 方法)、定时服务回调(`SchedulerReconcileOwner.update_plugin_job`)、
HTTP API 端点回调(`PluginProjection.apis`)。每个用例只断言绑定期间
`current_plugin_instance_id()` 能读到发起调用的实例 ID,且调用结束后上下文
必须恢复为未绑定,不污染后续用例。
"""
from __future__ import annotations
import threading
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
import app.scheduler.reconcile as reconcile_module
from app.runtime.event.binding import EventBindingResolver, EventHandlerBinding
from app.runtime.event.dispatch import EventDispatcher
from app.runtime.extensions.plugin.database import PluginDatabase
from app.runtime.extensions.plugin.lifecycle import PluginLifecycle
from app.runtime.extensions.plugin.projection import PluginProjection
from app.runtime.log import current_plugin_instance_id
from app.scheduler.reconcile import SchedulerReconcileOwner
# ---------------------------------------------------------------------------
# 1. 插件实例构造与 init_plugin
# ---------------------------------------------------------------------------
def _lifecycle(*, plugins, running=None):
"""构造隔离外部事件和模块清理的生命周期实例。"""
classes: dict = {}
running = running if running is not None else {}
lifecycle = PluginLifecycle(
classes=classes,
running=running,
load_plugins=lambda _pid, _installed, _check: list(plugins),
installed_plugins=lambda: ["DemoPluginWork"],
plugin_config=lambda _pid: {},
auth_checker=lambda _plugin: True,
clear_modules=MagicMock(),
clear_tools=MagicMock(),
enable_events=MagicMock(),
disable_events=MagicMock(),
runtime_status_writer=MagicMock(),
database=lambda: PluginDatabase(),
log=MagicMock(),
event_sender=MagicMock(),
)
return lifecycle, classes, running
def test_lifecycle_start_binds_instance_during_construct_and_init_plugin():
"""构造与 init_plugin 期间应能读到发起它的实例 ID,start 返回后上下文必须清空。"""
seen: list[tuple[str, str | None]] = []
class _Plugin:
plugin_name = "演示插件"
plugin_version = "1.0.0"
def __init__(self) -> None:
seen.append(("construct", current_plugin_instance_id()))
def init_plugin(self, _config: dict) -> None:
seen.append(("init", current_plugin_instance_id()))
@staticmethod
def get_state() -> bool:
return True
_Plugin.__name__ = "DemoPluginWork"
lifecycle, _classes, _running = _lifecycle(plugins=[_Plugin])
lifecycle.start("DemoPluginWork")
assert seen == [
("construct", "DemoPluginWork"),
("init", "DemoPluginWork"),
]
assert current_plugin_instance_id() is None
def test_lifecycle_initialize_binds_instance_during_reinit():
"""公开 init_plugin(配置页重新生效)同样要绑定发起它的实例。"""
seen: list[str | None] = []
class _Plugin:
def init_plugin(self, _config: dict) -> None:
seen.append(current_plugin_instance_id())
@staticmethod
def get_state() -> bool:
return True
lifecycle, _classes, _running = _lifecycle(
plugins=[],
running={"DemoPluginWork": _Plugin()},
)
lifecycle.initialize("DemoPluginWork", {"enable": True})
assert seen == ["DemoPluginWork"]
assert current_plugin_instance_id() is None
# ---------------------------------------------------------------------------
# 2. 事件处理器回调
# ---------------------------------------------------------------------------
class _FakeEventType:
"""提供 dispatch 内部读取的 `.value` 属性。"""
value = "test.event"
class _FakeEvent:
"""携带 dispatch 内部读取的最小事件属性集。"""
correlation_id = None
event_type = _FakeEventType()
def _dispatcher(resolvers: dict) -> EventDispatcher:
"""构造只依赖真实 EventBindingResolver 的最小事件调度器。"""
binding_resolver = EventBindingResolver(lock=threading.Lock(), resolvers=lambda: resolvers)
return EventDispatcher(
registry=MagicMock(),
binding_resolver=binding_resolver,
event_factory=MagicMock(),
error_handler=MagicMock(side_effect=AssertionError("handler must not error")),
async_handle_sink=MagicMock(),
sync_handle_sink=MagicMock(),
)
class _PluginEventHandler:
"""模拟虚拟实例克隆类:`__name__` 与运行实例 ID 相同。"""
def __init__(self) -> None:
"""记录事件处理期间观察到的实例上下文。"""
self.seen: list[str | None] = []
def on_event(self, _event: object) -> None:
"""记录当前绑定的实例 ID。"""
self.seen.append(current_plugin_instance_id())
async def on_event_async(self, _event: object) -> None:
"""异步处理器同样记录当前绑定的实例 ID。"""
self.seen.append(current_plugin_instance_id())
def test_invoke_sync_binds_owning_instance():
"""同步事件处理器执行期间应绑定声明它的插件实例。"""
instance = _PluginEventHandler()
_PluginEventHandler.__name__ = "DemoPluginWork"
resolvers = {
"plugins": lambda owner_class: (
EventHandlerBinding(instance=instance, owner_name="Demo", run_sync_in_threadpool=True)
if owner_class is _PluginEventHandler
else None
)
}
dispatcher = _dispatcher(resolvers)
dispatcher.invoke_sync(_PluginEventHandler.on_event, _FakeEvent())
assert instance.seen == ["DemoPluginWork"]
assert current_plugin_instance_id() is None
def test_invoke_sync_strict_binds_owning_instance():
"""strict 变体同样要绑定实例,且失败时仍需正确复位上下文。"""
instance = _PluginEventHandler()
_PluginEventHandler.__name__ = "DemoPluginWork"
resolvers = {
"plugins": lambda owner_class: (
EventHandlerBinding(instance=instance, owner_name="Demo")
if owner_class is _PluginEventHandler
else None
)
}
dispatcher = _dispatcher(resolvers)
dispatcher.invoke_sync_strict(_PluginEventHandler.on_event, _FakeEvent())
assert instance.seen == ["DemoPluginWork"]
assert current_plugin_instance_id() is None
@pytest.mark.asyncio
async def test_invoke_async_binds_owning_instance_for_coroutine_handler():
"""异步事件处理器(直接 await)执行期间应绑定声明它的插件实例。"""
instance = _PluginEventHandler()
_PluginEventHandler.__name__ = "DemoPluginWork"
resolvers = {
"plugins": lambda owner_class: (
EventHandlerBinding(instance=instance, owner_name="Demo")
if owner_class is _PluginEventHandler
else None
)
}
dispatcher = _dispatcher(resolvers)
await dispatcher.invoke_async(_PluginEventHandler.on_event_async, _FakeEvent())
assert instance.seen == ["DemoPluginWork"]
assert current_plugin_instance_id() is None
@pytest.mark.asyncio
async def test_invoke_async_binds_owning_instance_across_threadpool_hop():
"""同步处理器经线程池执行时,绑定必须跨越 `run_in_threadpool` 的执行上下文切换。"""
instance = _PluginEventHandler()
_PluginEventHandler.__name__ = "DemoPluginWork"
resolvers = {
"plugins": lambda owner_class: (
EventHandlerBinding(instance=instance, owner_name="Demo", run_sync_in_threadpool=True)
if owner_class is _PluginEventHandler
else None
)
}
dispatcher = _dispatcher(resolvers)
await dispatcher.invoke_async(_PluginEventHandler.on_event, _FakeEvent())
assert instance.seen == ["DemoPluginWork"]
assert current_plugin_instance_id() is None
def test_invoke_sync_does_not_bind_free_function_handler():
"""自由函数处理器不属于任何插件实例,不应绑定任何上下文。"""
seen: list[str | None] = []
def _free_handler(_event: object) -> None:
seen.append(current_plugin_instance_id())
dispatcher = _dispatcher({})
dispatcher.invoke_sync(_free_handler, _FakeEvent())
assert seen == [None]
# ---------------------------------------------------------------------------
# 3. 定时服务回调
# ---------------------------------------------------------------------------
def test_update_plugin_job_binds_instance_around_service_callback(monkeypatch):
"""插件定时服务被调度器实际调用时应绑定注册它的插件实例。"""
seen: list[str | None] = []
def _service_callback() -> None:
seen.append(current_plugin_instance_id())
fake_manager = SimpleNamespace(
get_plugin_services=lambda pid: [
{
"id": "job1",
"name": "演示任务",
"func": _service_callback,
"trigger": "interval",
"kwargs": {"seconds": 60},
}
],
get_plugin_attr=lambda _pid, _attr: "演示插件",
)
monkeypatch.setattr(reconcile_module, "get_plugin_manager", lambda: fake_manager)
owner = SchedulerReconcileOwner.__new__(SchedulerReconcileOwner)
owner._scheduler = MagicMock()
owner._lock = threading.RLock()
owner._jobs = {}
owner.start = MagicMock()
owner.remove_plugin_job = lambda _pid, job_id=None: None
owner._assign_job_generation = lambda _job_id, _job: None
owner.update_plugin_job("DemoPluginWork")
registered_job = owner._jobs["DemoPluginWork_job1"]
registered_job["func"]()
assert seen == ["DemoPluginWork"]
assert current_plugin_instance_id() is None
# ---------------------------------------------------------------------------
# 4. HTTP API 端点回调
# ---------------------------------------------------------------------------
class _ApiEndpointPlugin:
"""声明一条会读取实例上下文的 HTTP API 路由的最小插件桩。"""
plugin_name = "接口插件"
def __init__(self) -> None:
"""初始化调用记录。"""
self.seen: list[str | None] = []
def get_state(self) -> bool:
"""插件始终启用。"""
return True
def get_name(self) -> str:
"""返回插件展示名称。"""
return self.plugin_name
def get_api(self) -> list[dict]:
"""声明一条状态查询路由,endpoint 绑定到本实例的方法。"""
return [{"path": "/status", "endpoint": self.status, "methods": ["GET"]}]
def status(self) -> dict:
"""处理状态查询请求期间记录当前绑定的实例 ID。"""
self.seen.append(current_plugin_instance_id())
return {"ok": True}
def test_projection_api_endpoint_binds_owning_instance_without_any_caller_context():
"""路由被 FastAPI 直接调用(不经过任何宿主受控调用点)时仍应绑定实例。"""
plugin = _ApiEndpointPlugin()
projection = PluginProjection({"DemoPluginWork": plugin})
apis = projection.apis()
assert current_plugin_instance_id() is None
apis[0]["endpoint"]()
assert plugin.seen == ["DemoPluginWork"]
assert current_plugin_instance_id() is None