Files
MoviePilot/tests/test_plugin_catalog_runtime.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

275 lines
9.3 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.
"""插件安装事实与运行状态投影测试。"""
from types import SimpleNamespace
import pytest
from app.runtime import log as log_module
from app.runtime.extensions.plugin.catalog import PluginCatalogFacade
from app.runtime.log import set_plugin_instance_log_level
from app.schemas.plugin import PluginInstance, PluginRuntimeStatus
from app.schemas.types import SystemConfigKey
@pytest.fixture(name="_isolated_log_overrides")
def fixture_isolated_log_overrides(monkeypatch):
"""隔离进程内日志等级覆盖缓存,避免与其他用例的实例 ID 相互污染。"""
monkeypatch.setattr(log_module, "_plugin_level_overrides", {})
def _facade(**overrides):
"""按测试所需覆盖最小可用回调集合,构造一个 PluginCatalogFacade。"""
defaults = dict(
classes=lambda: {},
running=lambda: {},
storage=lambda: SimpleNamespace(
read=lambda _key: None,
write=lambda _key, _value: None,
),
system=lambda: SimpleNamespace(),
market_catalog=lambda: None,
market_loader=lambda *_args, **_kwargs: [],
async_market_loader=lambda *_args, **_kwargs: [],
map_plugin=lambda **_kwargs: None,
auth_checker=lambda **_kwargs: True,
plugin_attr=lambda _plugin_id, _attr: None,
plugin_instance=lambda _plugin_id: None,
plugin_instances=lambda: {},
runtime_status=lambda _plugin_id: None,
log=SimpleNamespace(error=lambda *_args: None, info=lambda *_args: None),
)
defaults.update(overrides)
return PluginCatalogFacade(**defaults)
@pytest.mark.asyncio
async def test_async_online_normalizes_market_configuration(monkeypatch) -> None:
"""目录入口与候选库存使用相同的规范化市场列表。"""
from app.runtime.extensions.plugin import catalog as catalog_module
requested_markets: list[str] = []
class FakeMarketCatalog:
async def async_collect(self, **kwargs):
requested_markets.extend(kwargs["markets"])
return []
monkeypatch.setattr(
catalog_module,
"get_runtime_setting",
lambda key: {
"PLUGIN_MARKET": (
" https://github.com/Example/Plugins.git/ ,"
"https://github.com/example/plugins"
),
"VERSION_FLAG": "v3",
}[key],
)
facade = PluginCatalogFacade(
classes=lambda: {},
running=lambda: {},
storage=lambda: SimpleNamespace(read=lambda _key: []),
system=lambda: SimpleNamespace(
compatible_flags=lambda _flag: [],
),
market_catalog=lambda: FakeMarketCatalog(),
market_loader=lambda *_args, **_kwargs: [],
async_market_loader=lambda *_args, **_kwargs: [],
map_plugin=lambda **_kwargs: None,
auth_checker=lambda **_kwargs: True,
plugin_attr=lambda _plugin_id, _attr: None,
plugin_instance=lambda _plugin_id: None,
plugin_instances=lambda: {},
runtime_status=lambda _plugin_id: None,
log=SimpleNamespace(info=lambda *_args: None),
)
assert await facade.async_online() == []
assert requested_markets == ["https://github.com/Example/Plugins"]
def test_installed_catalog_keeps_plugins_that_are_not_loaded():
"""已安装清单中的插件即使缺依赖或源码也必须保留可观察卡片。"""
class ActivePlugin:
plugin_name = "已运行插件"
plugin_version = "1.0.0"
plugin_order = 0
active_instance = SimpleNamespace(get_state=lambda: True)
statuses = {
"ActivePlugin": PluginRuntimeStatus.ACTIVE,
"DependencyPending": PluginRuntimeStatus.DEPENDENCY_PENDING,
"SourceMissing": PluginRuntimeStatus.SOURCE_MISSING,
}
facade = PluginCatalogFacade(
classes=lambda: {"ActivePlugin": ActivePlugin},
running=lambda: {"ActivePlugin": active_instance},
storage=lambda: SimpleNamespace(
read=lambda key: [
"ActivePlugin",
"DependencyPending",
"SourceMissing",
] if key is SystemConfigKey.UserInstalledPlugins else None,
),
system=lambda: SimpleNamespace(),
market_catalog=lambda: None,
market_loader=lambda *_args, **_kwargs: [],
async_market_loader=lambda *_args, **_kwargs: [],
map_plugin=lambda **_kwargs: None,
auth_checker=lambda **_kwargs: True,
plugin_attr=lambda _plugin_id, _attr: None,
plugin_instance=lambda _plugin_id: None,
plugin_instances=lambda: {},
runtime_status=statuses.get,
log=SimpleNamespace(error=lambda *_args: None, info=lambda *_args: None),
)
plugins = facade.installed()
assert [plugin.id for plugin in plugins] == [
"ActivePlugin",
"DependencyPending",
"SourceMissing",
]
assert [plugin.runtime_status for plugin in plugins] == [
PluginRuntimeStatus.ACTIVE,
PluginRuntimeStatus.DEPENDENCY_PENDING,
PluginRuntimeStatus.SOURCE_MISSING,
]
assert plugins[1].plugin_name == "DependencyPending"
assert plugins[2].installed is True
def test_local_repository_failure_does_not_break_catalog_projection():
"""本地仓索引读取失败只影响本地候选展示,不能拖垮整个插件目录。"""
warnings: list[str] = []
def local_candidates():
raise RuntimeError("invalid local package")
facade = PluginCatalogFacade(
classes=lambda: {},
running=lambda: {},
storage=lambda: SimpleNamespace(read=lambda _key: []),
system=lambda: SimpleNamespace(
local_candidates=local_candidates,
),
market_catalog=lambda: None,
market_loader=lambda *_args, **_kwargs: [],
async_market_loader=lambda *_args, **_kwargs: [],
map_plugin=lambda **_kwargs: None,
auth_checker=lambda **_kwargs: True,
plugin_attr=lambda _plugin_id, _attr: None,
plugin_instance=lambda _plugin_id: None,
plugin_instances=lambda: {},
runtime_status=lambda _plugin_id: None,
log=SimpleNamespace(
error=lambda *_args: None,
info=lambda *_args: None,
warning=warnings.append,
),
)
assert facade.local_repository() == []
assert warnings == [
"读取本地插件仓候选失败,已跳过本地目录展示:invalid local package"
]
with pytest.raises(RuntimeError, match="invalid local package"):
facade.local_repository(raise_errors=True)
def test_local_projects_effective_log_level_for_virtual_instance(
_isolated_log_overrides,
):
"""分身设了日志等级覆盖时,生效等级要如实投影到它的卡片上。"""
set_plugin_instance_log_level("CatalogOverlayVirtual", "DEBUG")
class DemoPlugin:
plugin_name = "Demo"
plugin_order = 0
instance = PluginInstance(
instance_id="CatalogOverlayVirtual",
source_plugin_id="DemoPlugin",
)
facade = _facade(
classes=lambda: {"CatalogOverlayVirtual": DemoPlugin},
plugin_instance=lambda plugin_id: (
instance if plugin_id == "CatalogOverlayVirtual" else None
),
plugin_instances=lambda: {"CatalogOverlayVirtual": instance},
)
plugin = facade.local()[0]
assert plugin.log_level_effective == "DEBUG"
def test_local_reports_no_log_override_when_instance_follows_global_level(
_isolated_log_overrides,
):
"""没有覆盖的分身实例不应带任何日志等级徽标信息。"""
class DemoPlugin:
plugin_name = "Demo"
plugin_order = 0
instance = PluginInstance(
instance_id="CatalogOverlayDefaultVirtual",
source_plugin_id="DemoPlugin",
)
facade = _facade(
classes=lambda: {"CatalogOverlayDefaultVirtual": DemoPlugin},
plugin_instance=lambda plugin_id: (
instance if plugin_id == "CatalogOverlayDefaultVirtual" else None
),
plugin_instances=lambda: {"CatalogOverlayDefaultVirtual": instance},
)
plugin = facade.local()[0]
assert plugin.log_level_effective is None
def test_local_projects_effective_log_level_for_physical_plugin(
_isolated_log_overrides,
):
"""物理插件本体的覆盖同样要投影出来,实例 ID 就是插件 ID。
``is_instance``、``instance_mode`` 只看分身记录,不受日志等级覆盖影响,
现有语义保持不变。
"""
set_plugin_instance_log_level("CatalogOverlayHost", "WARNING")
class DemoPlugin:
plugin_name = "Demo"
plugin_order = 0
facade = _facade(classes=lambda: {"CatalogOverlayHost": DemoPlugin})
plugin = facade.local()[0]
assert plugin.is_instance is False
assert plugin.instance_mode is None
assert plugin.log_level_effective == "WARNING"
def test_installed_placeholder_projects_effective_log_level(
_isolated_log_overrides,
):
"""未加载插件的占位卡片同样要投影生效日志等级。"""
set_plugin_instance_log_level("CatalogOverlayPlaceholder", "ERROR")
facade = _facade(
storage=lambda: SimpleNamespace(
read=lambda key: (
["CatalogOverlayPlaceholder"]
if key is SystemConfigKey.UserInstalledPlugins
else None
)
),
)
plugin = facade.installed()[0]
assert plugin.log_level_effective == "ERROR"