Files
MoviePilot/app/testing/bootstrap.py
DDSRem e0642eafa7 chore(types): reduce v3 host mypy debt (#7057)
* chore(types): clear AniList endpoint mypy debt

* chore(types): clear Douban endpoint mypy debt

* chore(types): clear recommendation endpoint mypy debt

* chore(types): clear subscription notification mypy debt

* chore(types): clear SDK string compatibility mypy debt

* chore(types): ratchet v3 host mypy across remaining files

* chore(types): reduce v3 host mypy debt and retire ledger

* chore(types): reconcile host snapshot with upstream HTTP pool

* test(types): cover host paths exposed by mypy cleanup
2026-10-18 09:37:49 +08:00

334 lines
12 KiB
Python
Raw Permalink 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.
"""
主程序与插件仓共享的测试引导及进程级隔离装配。
调用方必须在导入应用配置前隔离 CONFIG_DIR。插件仓先把后端路径加入 sys.path,
再调用对应代际的引导函数;各代插件可能同名,需使用独立的 pytest 会话。
本模块导入时仅依赖标准库,数据库和应用依赖在引导函数调用时才加载。
"""
from __future__ import annotations
import atexit
import os
import shutil
import sys
import tempfile
from pathlib import Path
from types import ModuleType
from typing import TYPE_CHECKING, Callable, Iterable, Optional
if TYPE_CHECKING:
import pytest
# 本进程隔离出的临时 CONFIG_DIR,兼作幂等标记
_isolated_config_dir: Optional[str] = None
class _SitesHelperStub:
"""
用空结果替代测试中不应加载的动态站点资源。
:cvar auth_level: 固定为 0,表示不提供认证资源等级
:cvar auth_version: 空字符串,表示没有认证资源版本
:cvar indexer_version: 空字符串,表示没有索引资源版本
"""
auth_level = 0
auth_version = ""
indexer_version = ""
def get_indexers(self) -> list[object]:
"""
提供同步站点索引列表占位。
:return: 固定返回空列表
"""
return []
async def async_get_indexers(self) -> list[object]:
"""
提供异步站点索引列表占位。
:return: 等待后固定返回空列表
"""
return []
def get_indexer(self, *_args: object, **_kwargs: object) -> Optional[dict[str, object]]:
"""
提供同步站点详情查询占位。
:param _args: 为兼容真实接口接收的位置参数,当前忽略
:param _kwargs: 为兼容真实接口接收的关键字参数,当前忽略
:return: 固定返回 None,表示没有站点详情
"""
return None
async def async_get_indexer(self, *_args: object, **_kwargs: object) -> Optional[dict[str, object]]:
"""
提供异步站点详情查询占位。
:param _args: 为兼容真实接口接收的位置参数,当前忽略
:param _kwargs: 为兼容真实接口接收的关键字参数,当前忽略
:return: 等待后固定返回 None,表示没有站点详情
"""
return None
def get_authsites(self) -> list[object]:
"""
提供认证站点列表占位。
:return: 固定返回空列表
"""
return []
def get_indexsites(self) -> list[object]:
"""
提供索引站点列表占位。
:return: 固定返回空列表
"""
return []
def check(self, *_args: object, **_kwargs: object) -> tuple[bool, str]:
"""
声明测试占位未加载真实站点资源。
:param _args: 兼容真实接口的位置参数,当前忽略
:param _kwargs: 兼容真实接口的关键字参数,当前忽略
:return: False 和站点资源未加载的提示
"""
return False, "站点资源未加载"
def check_user(self, *_args: object, **_kwargs: object) -> tuple[bool, str]:
"""
声明测试占位不提供用户站点认证。
:param _args: 兼容真实接口的位置参数,当前忽略
:param _kwargs: 兼容真实接口的关键字参数,当前忽略
:return: False 和站点资源未加载的提示
"""
return False, "站点资源未加载"
def isolate_config_dir() -> str:
"""
为测试设置进程私有配置目录,重复调用返回首次选择的目录。
必须早于任何会导入 app.runtime.config 的操作,因为配置对象首次创建后不会随环境变量更新。
调用方已设置 CONFIG_DIR 时直接复用,不验证或清理该目录,调用方必须确保它与真实配置隔离。
仅对本函数新建的临时目录注册退出清理。
: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-test-config-")
os.environ["CONFIG_DIR"] = tmp
_isolated_config_dir = tmp
def _cleanup(
path: str = tmp,
rmtree: Callable[..., None] = shutil.rmtree,
sys_mod: ModuleType = sys,
) -> None:
"""
退出时释放已创建的数据库连接池,再尽力删除测试临时目录。
只查看已加载引擎,避免清理时反而创建新数据库连接;释放连接池异常和目录删除错误均忽略。
:param path: 本次创建的隔离目录,默认值在注册回调时绑定
:param rmtree: 提前绑定的目录删除函数,避免解释器退出时模块被回收
:param sys_mod: 提前绑定的 sys 模块,用于查找已加载的引擎模块
"""
try:
engine_mod = sys_mod.modules.get("app.db.engine")
peek = getattr(engine_mod, "peek_sync_engine", None)
engine = peek() if peek is not None else None
if engine is not None:
engine.dispose()
except Exception:
pass
rmtree(path, ignore_errors=True)
atexit.register(_cleanup)
return tmp
def _expose_plugin_source(path: Path) -> None:
"""
把插件仓源码目录加入生产插件包的搜索路径。
仅在尚未加入时前置该目录;修改在当前进程持续有效,不在函数退出时还原。
:param path: 当前测试代际的插件源码目录
"""
from importlib import import_module
plugins_package = import_module("app.plugins")
value = str(path)
if value not in plugins_package.__path__:
plugins_package.__path__.insert(0, value)
def ensure_sites_stub() -> None:
"""
在进程模块缓存中安装确定性的站点资源占位。
必须在业务模块导入前调用;始终替换该模块,即使本机存在真实扩展。
此修改持续到进程结束或由调用方恢复,真实资源的加载和 ABI 由专项测试验证。
"""
from importlib.util import spec_from_loader
from types import ModuleType
stub = ModuleType("app.application.site.sites")
setattr(stub, "SitesHelper", _SitesHelperStub)
stub.__spec__ = spec_from_loader("app.application.site.sites", None)
sys.modules["app.application.site.sites"] = stub
def ensure_optional_stub(name: str, **attrs: object) -> None:
"""
导入可选依赖,导入失败时为当前进程安装占位模块。
模块已在 sys.modules 中或可成功导入时不修改;捕获 ImportError 后创建占位,
包括依赖内部导入失败的情况。占位不会自动还原,需要作用域隔离时应使用 stub_modules。
:param name: 待导入或替换的模块全名
:param attrs: 新占位模块需要暴露的属性
"""
if name in sys.modules:
return
try:
__import__(name)
return
except ImportError:
pass
from types import ModuleType
module = ModuleType(name)
for key, value in attrs.items():
setattr(module, key, value)
sys.modules[name] = module
def prepare_backend() -> None:
"""
按顺序隔离配置、替换站点资源并装配测试后端。
先设置 CONFIG_DIR,再创建模型表和事务执行器,加载配置快照,并配置缓存、领域解析和
类型化运行配置。服务目录返回空集合,不启动真实服务模块。此过程会修改进程级依赖配置,
调用方应在专用测试会话中使用,或自行保存并恢复相关状态。
"""
isolate_config_dir()
ensure_sites_stub()
from app.startup.initializers.database import init_db
init_db()
from app.db.adapters.transaction import TransactionalWriteRunner
from app.db.session import SessionFactory, async_session_scope
from app.db.uow import configure_transaction_runners
transaction_runner = TransactionalWriteRunner(
sync_session=SessionFactory,
async_session=async_session_scope,
)
configure_transaction_runners(
sync=transaction_runner.sync,
async_=transaction_runner.async_,
)
from app.application.service import configure_service_directory
# 共享引导不启动真实服务模块;需要具体服务实例的测试应在自身边界显式覆盖该目录。
configure_service_directory(
configs=lambda _config_key, _conf_type: [],
modules=lambda _module_type: [],
)
from app.db.adapters.configuration import TransactionalUserConfigurationRepository
from app.db.oper.systemconfig import SystemConfigOper
with SessionFactory() as session:
SystemConfigOper().load_snapshot(session)
TransactionalUserConfigurationRepository(SessionFactory).load_snapshot()
# 缓存装饰器在测试模块导入时即创建后端,先装配隔离配置对应的适配器。
from app.startup.initializers.cache import configure_cache_dependencies
configure_cache_dependencies()
# 测试与生产使用同一组合入口,确保领域解析器获得隔离库和测试 settings。
from app.startup.initializers.domain import configure_domain_dependencies
configure_domain_dependencies()
# 搜索、调度与 API 链路读取组合根登记的类型化配置快照;插件仓集成测试会驱动这些真实链路,
# 故按生产 publish_configuration 的同一组构建函数登记,快照随测试 settings 实时生成。
from app.application.configuration import RuntimeConfiguration, configure_runtime_configuration
from app.runtime.config import settings
from app.startup.composition.configuration import (
build_api_runtime_config,
build_chain_runtime_config,
build_scheduler_runtime_config,
)
configure_runtime_configuration(
RuntimeConfiguration(
api=lambda: build_api_runtime_config(settings),
scheduler=lambda: build_scheduler_runtime_config(settings),
chain=lambda: build_chain_runtime_config(settings),
)
)
def prepare_v2_backend(plugins_repo: Path) -> None:
"""
准备后端并向插件命名空间加入 v2 插件源码。
不同代际可能存在同名插件,必须在独立 pytest 会话中加载。
:param plugins_repo: 插件仓根目录;使用其 plugins.v2 子目录
"""
prepare_backend()
_expose_plugin_source(Path(plugins_repo) / "plugins.v2")
def prepare_v3_backend(plugins_repo: Path) -> None:
"""
准备后端并向插件命名空间加入 v3 插件源码。
不同代际可能存在同名插件,必须在独立 pytest 会话中加载。
:param plugins_repo: 插件仓根目录;使用其 plugins.v3 子目录
"""
prepare_backend()
_expose_plugin_source(Path(plugins_repo) / "plugins.v3")
def prepare_v1_backend(plugins_repo: Path) -> None:
"""
准备后端并向插件命名空间加入 v1 插件源码。
不同代际可能存在同名插件,必须在独立 pytest 会话中加载。
:param plugins_repo: 插件仓根目录;使用其 plugins 子目录
"""
prepare_backend()
_expose_plugin_source(Path(plugins_repo) / "plugins")
def mark_plugin_generation(items: Iterable[pytest.Item], pytest_module: ModuleType) -> None:
"""
按用例路径添加插件代际标记,供 pytest 按代筛选。
优先读取 item.path,旧版回退至 item.fspath;统一路径分隔符后按 tests/vN/ 子串匹配,
因此绝对和相对路径都适用。未匹配代际目录的用例保持原样。
:param items: 已收集的测试用例,原地追加 v1、v2 或 v3 标记
:param pytest_module: 调用方提供的 pytest 模块,避免模块导入时依赖 pytest
"""
markers = getattr(pytest_module, "mark")
for item in items:
item_path = getattr(item, "path", None)
path = str(item_path if item_path is not None else item.fspath).replace("\\", "/")
if "tests/v3/" in path:
item.add_marker(markers.v3)
elif "tests/v2/" in path:
item.add_marker(markers.v2)
elif "tests/v1/" in path:
item.add_marker(markers.v1)