mirror of
https://github.com/jxxghp/MoviePilot-Plugins.git
synced 2026-06-04 07:26:50 +00:00
test: align plugin shared harness shim (#1034)
This commit is contained in:
11
pytest.ini
11
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
|
||||
|
||||
@@ -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__`,只测纯逻辑方法,避免依赖完整运行时。
|
||||
|
||||
@@ -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 或非同级布局覆盖);
|
||||
否则按工作区同级布局推导 ``<workspace>/MoviePilot``。校验 ``app/`` 存在,
|
||||
避免把错误路径塞进 ``sys.path`` 后产生误导性的 ``ImportError``。
|
||||
优先取环境变量 ``MOVIEPILOT_BACKEND_PATH``(便于 CI 或非同级布局覆盖),否则按工作区
|
||||
同级布局推导 ``<workspace>/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 <v2 插件包名>``。与 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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user