mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-10-29 03:18:14 +08:00
* 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
334 lines
12 KiB
Python
334 lines
12 KiB
Python
"""
|
||
主程序与插件仓共享的测试引导及进程级隔离装配。
|
||
|
||
调用方必须在导入应用配置前隔离 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)
|