From b564a71203e10ef3e2b3fd9110ac52aa68b130f5 Mon Sep 17 00:00:00 2001 From: InfinityPacer <160988576+InfinityPacer@users.noreply.github.com> Date: Wed, 3 Jun 2026 19:27:17 +0800 Subject: [PATCH] test: align plugin shared harness shim (#1034) --- pytest.ini | 11 ++- tests/README.md | 15 ++-- tests/_bootstrap.py | 104 ++++++++-------------------- tests/conftest.py | 56 ++++++++------- tests/v2/test_agenttokens_plugin.py | 13 +--- 5 files changed, 80 insertions(+), 119 deletions(-) diff --git a/pytest.ini b/pytest.ini index eba6f48..7796733 100644 --- a/pytest.ini +++ b/pytest.ini @@ -5,7 +5,16 @@ python_files = test_*.py # unittest.TestCase 子类无论命名都会被收集;此项额外兼容将来可能出现的纯函数式测试类 python_classes = *Test Test* python_functions = test_* -# v1/v2 必须分会话运行(同名插件包冲突);marker 由 conftest 按目录自动打,便于 -m 选择 +# v1/v2 必须分会话运行(同名插件包冲突);marker 供后续按代筛选扩展使用 markers = v1: v1 插件(plugins/)单测,需与 v2 分独立会话运行 v2: v2 插件(plugins.v2/)单测,需与 v1 分独立会话运行 +# 仅忽略主程序依赖链或三方库在 Python 3.12 下的已知弃用告警;插件仓自身告警应直接修复 +filterwarnings = + ignore:datetime.datetime.utcfromtimestamp\(\) is deprecated:DeprecationWarning + ignore:websockets.legacy is deprecated:DeprecationWarning + ignore:websockets.InvalidStatusCode is deprecated:DeprecationWarning + ignore:pkg_resources is deprecated as an API:DeprecationWarning + ignore:Deprecated call to .pkg_resources.declare_namespace:DeprecationWarning + ignore:'crypt' is deprecated:DeprecationWarning + ignore:'audioop' is deprecated:DeprecationWarning diff --git a/tests/README.md b/tests/README.md index ba5103f..b14c4c5 100644 --- a/tests/README.md +++ b/tests/README.md @@ -7,10 +7,10 @@ ``` tests/ -├─ _bootstrap.py 共享引导:隔离 CONFIG_DIR + 注入后端/插件目录到 sys.path -├─ conftest.py pytest 引导:收集前隔离 CONFIG_DIR,按目录自动打 v1/v2 marker +├─ _bootstrap.py 薄壳 shim:定位同级 MoviePilot 后端入 sys.path,引导逻辑委托主程序 app/testing.bootstrap +├─ conftest.py pytest 引导:按本次运行目标选择 v1/v2 插件环境并注册网络守卫 ├─ v2/ v2 插件(plugins.v2/)单测 -└─ v1/ v1 插件(plugins/)单测(当前预留骨架) +└─ v1/ v1 插件(plugins/)单测 ``` ## 运行 @@ -29,8 +29,10 @@ tests/ `tests/run.py` 把 v1/v2 放在独立子进程依次运行、无用例的代自动跳过——两代存在同名 插件包(如 `brushflowlowfreq`、`torrentclassifier`),同一解释器进程无法同时加载、混跑 -会相互覆盖。后端依赖(`app.*`)由 `_bootstrap.py` 注入 `sys.path`,并隔离临时 `CONFIG_DIR` -且建表;主程序 `app/testing` 的共享 harness(`stub_modules` 等)在 bootstrap 后可直接复用。 +会相互覆盖。隔离 `CONFIG_DIR`、建表、`app.helper.sites` 垫片、插件目录注入、v1/v2 marker、 +autouse 网络守卫等引导逻辑统一在主程序 `app/testing`(`bootstrap` / `network_guard`)维护一处; +本仓 `tests/_bootstrap.py` 仅是「定位后端入 `sys.path`」的薄壳 shim,故后端需为含 `app/testing/bootstrap` +的较新 MoviePilot。共享 harness(`stub_modules` 等)在 bootstrap 后可直接复用。 ## 提 PR / push 前 @@ -39,6 +41,5 @@ tests/ ## 新增用例 1. 放到对应代际目录(`tests/v2/` 或 `tests/v1/`),文件名 `test_*.py`; -2. 顶部调用 `prepare_v2_backend()` / `prepare_v1_backend()`(见 `_bootstrap.py`), - 必须早于首个 `import app.*` 或插件包导入; +2. 直接导入 `app.*` 与对应代际插件包;根 conftest 会按本次运行目标在用例导入前完成后端与插件目录注入; 3. 优先用 `object.__new__` 绕过插件 `__init__`,只测纯逻辑方法,避免依赖完整运行时。 diff --git a/tests/_bootstrap.py b/tests/_bootstrap.py index 07a23d4..eb441c0 100644 --- a/tests/_bootstrap.py +++ b/tests/_bootstrap.py @@ -1,68 +1,30 @@ -"""插件仓单测共享引导。 +"""插件仓单测引导薄壳:定位同级 MoviePilot 后端并入 ``sys.path``,引导逻辑委托主程序 ``app.testing.bootstrap``。 -为复用 MoviePilot 后端逻辑的插件单测提供统一的运行前准备。所有函数都必须在 -首次 ``import app.*`` 或导入任一插件包之前调用,否则隔离与路径注入不生效。 +chicken-egg:导入主程序共享引导之前,必须先由本仓定位后端、加入 ``sys.path``——这一步不可消除, +故每个插件仓只保留这层极薄 shim;隔离 CONFIG_DIR / 建表 / 插件目录注入 / v1·v2 marker 等 +实际逻辑均在主程序 ``app/testing`` 维护一处,所有插件仓行为与修复保持一致。 -职责: -1. 把 ``CONFIG_DIR`` 指向进程私有临时目录,隔离主程序真实数据库与配置; -2. 定位 MoviePilot 后端并加入 ``sys.path``,使 ``import app.*`` 可用; -3. 按 v1 / v2 分别加入插件源码目录到 ``sys.path``。 - -关键约束:v1(``plugins/``)与 v2(``plugins.v2/``)存在同名插件包, -同一解释器进程无法同时加载两代同名包,必须在各自独立的 pytest 会话中运行, -因此 v1 / v2 的引导函数分开提供、互斥使用。 +所有引导函数都必须在首次 ``import app.*`` 或导入任一插件包之前调用,否则隔离与路径注入不生效。 """ from __future__ import annotations -import atexit import os -import shutil import sys -import tempfile +from importlib import import_module from pathlib import Path -from typing import Optional # ``tests/`` 的父级即插件仓根;其同级 ``MoviePilot`` 为后端默认位置(工作区多仓同级布局) _TESTS_DIR = Path(__file__).resolve().parent _PLUGINS_REPO = _TESTS_DIR.parent _WORKSPACE_ROOT = _PLUGINS_REPO.parent -# 记录本进程隔离出的临时 CONFIG_DIR,兼作幂等标记 -_isolated_config_dir: Optional[str] = None - - -def isolate_config_dir() -> str: - """把 ``CONFIG_DIR`` 指向进程私有临时目录,隔离主程序真实库与配置。 - - ``import app.chain.*`` 会按 ``settings.CONFIG_PATH`` 直接连 ``user.db``;在 - 本地非容器布局下默认落到 ``MoviePilot/config/user.db``(线上真实库),因此必须 - 在首个 ``import app.*`` 之前改写。幂等:重复调用返回同一目录。 - - 若调用方已显式设置 ``CONFIG_DIR``(如 CI 指定的隔离目录),则尊重之、不覆盖。 - - :return: 实际生效的 CONFIG_DIR 绝对路径 - """ - global _isolated_config_dir - if _isolated_config_dir is not None: - return _isolated_config_dir - existing = os.environ.get("CONFIG_DIR") - if existing: - _isolated_config_dir = existing - return existing - tmp = tempfile.mkdtemp(prefix="mp-plugin-test-config-") - os.environ["CONFIG_DIR"] = tmp - _isolated_config_dir = tmp - # 进程退出时清理临时库与目录,避免 /tmp 堆积 - atexit.register(shutil.rmtree, tmp, ignore_errors=True) - return tmp - def _resolve_backend_path() -> Path: """定位 MoviePilot 后端根目录。 - 优先取环境变量 ``MOVIEPILOT_BACKEND_PATH``(便于 CI 或非同级布局覆盖); - 否则按工作区同级布局推导 ``/MoviePilot``。校验 ``app/`` 存在, - 避免把错误路径塞进 ``sys.path`` 后产生误导性的 ``ImportError``。 + 优先取环境变量 ``MOVIEPILOT_BACKEND_PATH``(便于 CI 或非同级布局覆盖),否则按工作区 + 同级布局推导 ``/MoviePilot``。校验 ``app/`` 存在,避免把错误路径塞进 + ``sys.path`` 后产生误导性的 ``ImportError``。 """ candidates = [] env = os.environ.get("MOVIEPILOT_BACKEND_PATH") @@ -78,41 +40,31 @@ def _resolve_backend_path() -> Path: ) -def _prepend_sys_path(path: Path) -> None: - """把目录前置到 ``sys.path``(去重),使其内的顶层包可被导入。""" - value = str(path) - if value not in sys.path: - sys.path.insert(0, value) +# 模块导入时即定位同级 MoviePilot 后端并前置到 ``sys.path``;随后用动态导入加载 +# ``app.testing``,避免 IDE 在插件仓内按静态导入误判 ``app`` 包不存在。 +_BACKEND_PATH = _resolve_backend_path() +if str(_BACKEND_PATH) not in sys.path: + sys.path.insert(0, str(_BACKEND_PATH)) + +_bootstrap = import_module("app.testing.bootstrap") +block_real_network = import_module("app.testing.network_guard").block_real_network + + +def isolate_config_dir() -> str: + """隔离 ``CONFIG_DIR`` 到进程私有临时目录(委托主程序共享实现)。""" + return _bootstrap.isolate_config_dir() def prepare_backend() -> None: - """隔离配置目录、加入后端到 ``sys.path`` 并建表(不加载任何插件源码)。 - - 仅需 ``import app.*`` 而不触碰具体插件包的用例可直接调用本函数。 - """ - isolate_config_dir() - _prepend_sys_path(_resolve_backend_path()) - # 隔离出的临时库为空,插件读取 systemconfig 等表会报 no such table;与主程序 conftest 一致建表。 - # init_db 仅 import models + create_all,无 alembic/网络、幂等、毫秒级。 - from app.db.init import init_db - init_db() + """隔离 ``CONFIG_DIR`` 并建表,仅需 ``import app.*`` 的用例可直接调用(委托主程序共享实现)。""" + _bootstrap.prepare_backend() def prepare_v2_backend() -> None: - """v2 插件单测引导:后端 + ``plugins.v2/`` 源码目录。 - - 调用后可 ``import app.*`` 与 ``import ``。与 v1 引导互斥, - 勿在同一进程内混用。 - """ - prepare_backend() - _prepend_sys_path(_PLUGINS_REPO / "plugins.v2") + """v2 插件单测引导:后端 + 本仓 ``plugins.v2/``(委托主程序共享实现)。""" + _bootstrap.prepare_v2_backend(_PLUGINS_REPO) def prepare_v1_backend() -> None: - """v1 插件单测引导:后端 + ``plugins/`` 源码目录。 - - 与 :func:`prepare_v2_backend` 互斥:v1/v2 存在同名插件包,同一进程同时加载会 - 相互覆盖,请在独立 pytest 会话中分别运行 ``tests/v1`` 与 ``tests/v2``。 - """ - prepare_backend() - _prepend_sys_path(_PLUGINS_REPO / "plugins") + """v1 插件单测引导:后端 + 本仓 ``plugins/``(委托主程序共享实现,与 v2 互斥)。""" + _bootstrap.prepare_v1_backend(_PLUGINS_REPO) diff --git a/tests/conftest.py b/tests/conftest.py index 471ade1..d98a1b1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,31 +1,39 @@ -"""pytest 全局引导。 +"""pytest 全局引导:按本次运行目标选择 v1/v2 插件环境并装载网络守卫。 -在收集任何用例之前隔离 ``CONFIG_DIR``,确保后续 ``import app.*`` 不会连到主程序 -真实库。``sys.path`` 的后端 / 插件目录注入交由各用例按 v1/v2 显式引导 -(见 :mod:`tests._bootstrap`),不在收集阶段引入任一代插件包,以规避 v1/v2 同名包冲突。 +``tests/run.py`` 会把 v1/v2 放到独立 pytest 进程中运行;这里据本次目标路径只注入对应 +插件目录,避免同一进程同时加载 ``plugins`` 与 ``plugins.v2`` 的同名包。 """ -import sys + +from __future__ import annotations + from pathlib import Path -import pytest - -# 将仓库根置于 sys.path,使共享引导 tests._bootstrap 可被导入(兼容 pytest 与直接运行) -sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) - -from tests._bootstrap import isolate_config_dir # noqa: E402 - -# conftest 早于测试模块收集执行,保证 CONFIG_DIR 在首个 import app.* 之前生效 -isolate_config_dir() +# 相对导入本仓薄壳,先定位同级 MoviePilot 后端并加入 ``sys.path``,再复用主程序共享引导。 +from ._bootstrap import ( + block_real_network, # noqa: F401 导入即注册主程序共享 autouse 网络守卫 + prepare_v1_backend, + prepare_v2_backend, +) -def pytest_collection_modifyitems(config, items): - """按所在目录自动为用例打 v1/v2 marker,支持 ``pytest -m v2`` 选择运行。 +def _selected_generation(config) -> str: + """根据 pytest 本次目标路径判断插件代际,禁止同一进程混跑 v1/v2。""" + generations = set() + for arg in config.args: + file_part = arg.split("::", 1)[0] + path = Path(file_part).resolve().as_posix().replace("\\", "/") + if "tests/v2" in path: + generations.add("v2") + elif "tests/v1" in path: + generations.add("v1") + if len(generations) == 1: + return next(iter(generations)) + raise RuntimeError("插件仓单测必须按 tests/run.py 分 v1/v2 独立会话运行,避免同名插件包冲突") - 避免每个用例手动标注;与按目录运行(``pytest tests/v2``)二选一皆可。 - """ - for item in items: - path = str(item.fspath).replace("\\", "/") - if "/tests/v2/" in path: - item.add_marker(pytest.mark.v2) - elif "/tests/v1/" in path: - item.add_marker(pytest.mark.v1) + +def pytest_configure(config) -> None: + """收集用例前隔离 CONFIG_DIR、建表并注入对应代际插件目录。""" + if _selected_generation(config) == "v2": + prepare_v2_backend() + else: + prepare_v1_backend() diff --git a/tests/v2/test_agenttokens_plugin.py b/tests/v2/test_agenttokens_plugin.py index fce4b75..4b16e57 100644 --- a/tests/v2/test_agenttokens_plugin.py +++ b/tests/v2/test_agenttokens_plugin.py @@ -1,20 +1,11 @@ """AgentTokens 插件单测(pytest 原生)。 覆盖侧栏入口受 show_sidebar_nav 配置控制的逻辑。依赖 MoviePilot 后端(app.*)与 -插件包:通过共享引导隔离 CONFIG_DIR 并把后端、plugins.v2 注入 sys.path,再以 -顶层包名导入插件。 +插件包:根 conftest 会先隔离 CONFIG_DIR 并把后端、plugins.v2 注入 sys.path, +再以顶层包名导入插件。 """ -import sys -from pathlib import Path from unittest.mock import patch -# 将仓库根置于 sys.path,使共享引导可被导入(兼容 pytest 与直接 python 运行) -sys.path.insert(0, str(Path(__file__).resolve().parents[2])) -from tests._bootstrap import prepare_v2_backend # noqa: E402 - -# 隔离 CONFIG_DIR 并注入后端 + plugins.v2,必须在 import app.* / 插件包之前完成 -prepare_v2_backend() - from agenttokens import AgentTokens # noqa: E402