mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-11-09 15:19:54 +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 与技能文档的两个生成脚本都跑不起来。
177 lines
6.1 KiB
Python
177 lines
6.1 KiB
Python
"""插件实例日志等级查询与设置接口测试。"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import inspect
|
||
from datetime import datetime, timezone
|
||
|
||
import pytest
|
||
from fastapi import HTTPException
|
||
|
||
from app.api.dependencies.auth import get_current_active_superuser
|
||
from app.api.endpoints import pluginloglevel as pluginloglevel_endpoint
|
||
from app.api.endpoints.pluginloglevel import (
|
||
clear_plugin_instance_log_level,
|
||
plugin_instance_log_levels,
|
||
set_plugin_instance_log_level,
|
||
)
|
||
from app.schemas.plugin import PluginInstanceLogLevelUpdateRequest
|
||
|
||
|
||
def _depends_default(func, parameter_name: str):
|
||
"""取出端点函数指定参数的 FastAPI Depends 默认值。"""
|
||
return inspect.signature(func).parameters[parameter_name].default
|
||
|
||
|
||
def _manager(**methods):
|
||
"""按方法名快速拼装一个鸭子类型的 Manager 替身。"""
|
||
return type("Manager", (), methods)()
|
||
|
||
|
||
def test_all_endpoints_require_superuser_dependency():
|
||
"""三个日志等级端点都要求超级管理员,不能被低权限用户直接调用。"""
|
||
for func in (
|
||
plugin_instance_log_levels,
|
||
set_plugin_instance_log_level,
|
||
clear_plugin_instance_log_level,
|
||
):
|
||
depends = _depends_default(func, "_")
|
||
assert depends.dependency is get_current_active_superuser
|
||
|
||
|
||
def test_get_returns_manager_levels_wrapped_in_plugin_overview(monkeypatch):
|
||
"""查询接口把 Manager 组装好的等级列表包进插件级总览对象,而不是裸列表。
|
||
|
||
响应模型直接裸露列表会被宿主分页契约门禁判定为集合接口、要求声明分页参数;
|
||
插件实例数量始终很小,因而包一层 plugin_id + instances。
|
||
"""
|
||
levels = [
|
||
{
|
||
"instance_id": "DemoPlugin",
|
||
"configured_level": None,
|
||
"expires_at": None,
|
||
"effective_level": "INFO",
|
||
},
|
||
{
|
||
"instance_id": "DemoPluginWork",
|
||
"configured_level": "DEBUG",
|
||
"expires_at": None,
|
||
"effective_level": "DEBUG",
|
||
},
|
||
]
|
||
manager = _manager(get_plugin_instance_log_levels=lambda self, _plugin_id: levels)
|
||
monkeypatch.setattr(pluginloglevel_endpoint, "get_plugin_manager", lambda: manager)
|
||
|
||
result = plugin_instance_log_levels("DemoPlugin", None)
|
||
|
||
assert result.success is True
|
||
assert result.data == {"plugin_id": "DemoPlugin", "instances": levels}
|
||
|
||
|
||
def test_get_reports_missing_plugin_as_404(monkeypatch):
|
||
"""插件不存在时查询接口返回 404,而不是让异常穿透或吞掉。"""
|
||
|
||
def _raise(_plugin_id):
|
||
raise LookupError("插件 Missing 不存在")
|
||
|
||
manager = _manager(
|
||
get_plugin_instance_log_levels=lambda self, plugin_id: _raise(plugin_id)
|
||
)
|
||
monkeypatch.setattr(pluginloglevel_endpoint, "get_plugin_manager", lambda: manager)
|
||
|
||
with pytest.raises(HTTPException) as excinfo:
|
||
plugin_instance_log_levels("Missing", None)
|
||
|
||
assert excinfo.value.status_code == 404
|
||
|
||
|
||
def test_put_delegates_to_manager_and_reports_success(monkeypatch):
|
||
"""设置请求原样转交给 Manager,携带等级与失效时间。"""
|
||
calls: list = []
|
||
manager = _manager(
|
||
set_plugin_instance_log_level=lambda self, *a, **kw: calls.append((a, kw))
|
||
)
|
||
monkeypatch.setattr(pluginloglevel_endpoint, "get_plugin_manager", lambda: manager)
|
||
expires_at = datetime(2026, 1, 1, tzinfo=timezone.utc)
|
||
|
||
result = set_plugin_instance_log_level(
|
||
"DemoPlugin",
|
||
"DemoPluginWork",
|
||
PluginInstanceLogLevelUpdateRequest(level="DEBUG", expires_at=expires_at),
|
||
None,
|
||
)
|
||
|
||
assert result.success is True
|
||
assert calls == [(("DemoPlugin", "DemoPluginWork", "DEBUG", expires_at), {})]
|
||
|
||
|
||
def test_put_reports_missing_plugin_or_instance_as_404(monkeypatch):
|
||
"""未知插件或实例时设置接口返回 404。"""
|
||
|
||
def _raise(*_a, **_kw):
|
||
raise LookupError("插件实例 Missing 不存在")
|
||
|
||
manager = _manager(set_plugin_instance_log_level=_raise)
|
||
monkeypatch.setattr(pluginloglevel_endpoint, "get_plugin_manager", lambda: manager)
|
||
|
||
with pytest.raises(HTTPException) as excinfo:
|
||
set_plugin_instance_log_level(
|
||
"DemoPlugin",
|
||
"Missing",
|
||
PluginInstanceLogLevelUpdateRequest(level="DEBUG"),
|
||
None,
|
||
)
|
||
|
||
assert excinfo.value.status_code == 404
|
||
|
||
|
||
def test_put_reports_invalid_level_as_400(monkeypatch):
|
||
"""非法等级时设置接口返回 400。"""
|
||
|
||
def _raise(*_a, **_kw):
|
||
raise ValueError("不支持的日志等级:LOUD")
|
||
|
||
manager = _manager(set_plugin_instance_log_level=_raise)
|
||
monkeypatch.setattr(pluginloglevel_endpoint, "get_plugin_manager", lambda: manager)
|
||
|
||
with pytest.raises(HTTPException) as excinfo:
|
||
set_plugin_instance_log_level(
|
||
"DemoPlugin",
|
||
"DemoPlugin",
|
||
PluginInstanceLogLevelUpdateRequest(level="LOUD"),
|
||
None,
|
||
)
|
||
|
||
assert excinfo.value.status_code == 400
|
||
|
||
|
||
def test_delete_delegates_to_manager_and_is_idempotent(monkeypatch):
|
||
"""清除请求原样转交给 Manager,重复调用同样返回成功。"""
|
||
calls: list = []
|
||
manager = _manager(
|
||
clear_plugin_instance_log_level=lambda self, *a: calls.append(a)
|
||
)
|
||
monkeypatch.setattr(pluginloglevel_endpoint, "get_plugin_manager", lambda: manager)
|
||
|
||
first = clear_plugin_instance_log_level("DemoPlugin", "DemoPluginWork", None)
|
||
second = clear_plugin_instance_log_level("DemoPlugin", "DemoPluginWork", None)
|
||
|
||
assert first.success is True
|
||
assert second.success is True
|
||
assert calls == [("DemoPlugin", "DemoPluginWork"), ("DemoPlugin", "DemoPluginWork")]
|
||
|
||
|
||
def test_delete_reports_missing_plugin_or_instance_as_404(monkeypatch):
|
||
"""未知插件或实例时清除接口返回 404。"""
|
||
|
||
def _raise(*_a):
|
||
raise LookupError("插件实例 Missing 不存在")
|
||
|
||
manager = _manager(clear_plugin_instance_log_level=_raise)
|
||
monkeypatch.setattr(pluginloglevel_endpoint, "get_plugin_manager", lambda: manager)
|
||
|
||
with pytest.raises(HTTPException) as excinfo:
|
||
clear_plugin_instance_log_level("DemoPlugin", "Missing", None)
|
||
|
||
assert excinfo.value.status_code == 404
|