mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-11-13 00:59:01 +08:00
全局日志等级是一个开关,调试单个插件时只能整体下调。一旦下调,被调试插件那几行 日志会淹没在其他几十个插件与宿主自身的 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 与技能文档的两个生成脚本都跑不起来。
275 lines
9.3 KiB
Python
275 lines
9.3 KiB
Python
"""插件安装事实与运行状态投影测试。"""
|
||
|
||
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"
|