Files
MoviePilot/tests/run.py
T

159 lines
5.3 KiB
Python
Raw 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.
"""后端单测入口:默认并行执行文件分片,也支持单分片和显式串行调试。"""
from __future__ import annotations
import argparse
import subprocess
import sys
from pathlib import Path
from typing import Sequence
import pytest
TESTS_DIR = Path(__file__).resolve().parent
RUNNER_PATH = Path(__file__).resolve()
DEFAULT_SHARD_COUNT = 4
def collect_test_files() -> list[Path]:
"""按稳定路径顺序返回根测试目录中的全部测试文件。"""
return sorted(TESTS_DIR.glob("test_*.py"))
def split_test_files(
test_files: Sequence[Path], shard_count: int
) -> list[list[Path]]:
"""把排序后的文件连续均分,保持 CI 分片归属稳定且易于复现。"""
if shard_count <= 0:
raise ValueError("shard_count 必须大于 0")
shard_size = (len(test_files) + shard_count - 1) // shard_count
if shard_size == 0:
return [[] for _ in range(shard_count)]
shards = [
list(test_files[start:start + shard_size])
for start in range(0, len(test_files), shard_size)
]
return shards + [[] for _ in range(shard_count - len(shards))]
def parse_shard(value: str) -> tuple[int, int]:
"""解析一基的 ``N/TOTAL`` 分片标识,供本地与 CI 共享稳定参数。"""
try:
index_text, count_text = value.split("/", maxsplit=1)
index = int(index_text)
count = int(count_text)
except (TypeError, ValueError) as error:
raise argparse.ArgumentTypeError("--shard 必须使用 N/TOTAL 格式") from error
if count <= 0 or not 1 <= index <= count:
raise argparse.ArgumentTypeError("--shard 必须满足 1 <= N <= TOTAL")
return index, count
def parse_args(argv: Sequence[str]) -> tuple[argparse.Namespace, list[str]]:
"""解析 runner 参数,其余参数原样传给 pytest。"""
parser = argparse.ArgumentParser(
description="默认以 4 个独立 pytest 文件分片并行运行后端全量测试。"
)
parser.add_argument(
"--serial",
action="store_true",
help="在当前进程串行运行 tests 目录,适合断点和顺序污染调试。",
)
parser.add_argument(
"--shard",
type=parse_shard,
metavar="N/TOTAL",
help="只运行指定文件分片;CI 使用同一参数启动独立 job。",
)
args, pytest_args = parser.parse_known_args(argv)
if args.serial and args.shard is not None:
parser.error("--serial 不能与 --shard 同时使用")
return args, pytest_args
def run_pytest(paths: Sequence[Path], pytest_args: Sequence[str]) -> int:
"""在当前进程运行完整目录或一个文件分片。"""
return pytest.main([*(str(path) for path in paths), *pytest_args])
def _worker_command(
shard_index: int, shard_count: int, pytest_args: Sequence[str]
) -> list[str]:
"""构造与 CI 完全相同的单分片 worker 命令。"""
return [
sys.executable,
str(RUNNER_PATH),
"--shard",
f"{shard_index}/{shard_count}",
*pytest_args,
]
def run_parallel_shards(
shards: Sequence[Sequence[Path]], pytest_args: Sequence[str]
) -> int:
"""启动独立 pytest 进程并等待全部文件分片结束。"""
shard_count = len(shards)
processes: list[tuple[int, subprocess.Popen]] = []
for shard_index, shard in enumerate(shards, start=1):
if not shard:
continue
print(
f"启动测试分片 {shard_index}/{shard_count}{len(shard)} 个文件",
flush=True,
)
processes.append((
shard_index,
subprocess.Popen(_worker_command(shard_index, shard_count, pytest_args)),
))
exit_code = 0
try:
for shard_index, process in processes:
return_code = process.wait()
if return_code != 0:
print(
f"测试分片 {shard_index}/{shard_count} 失败,退出码 {return_code}",
file=sys.stderr,
flush=True,
)
exit_code = exit_code or return_code
except KeyboardInterrupt:
for _, process in processes:
if process.poll() is None:
process.terminate()
for _, process in processes:
process.wait()
return 130
return exit_code
def main(argv: Sequence[str] | None = None) -> int:
"""执行串行全量、指定单分片或默认四分片并行全量。"""
args, pytest_args = parse_args(sys.argv[1:] if argv is None else argv)
if args.serial:
return run_pytest([TESTS_DIR], pytest_args)
test_files = collect_test_files()
if not test_files:
print(f"未在 {TESTS_DIR} 找到 test_*.py", file=sys.stderr)
return 2
if args.shard is not None:
shard_index, shard_count = args.shard
selected = split_test_files(test_files, shard_count)[shard_index - 1]
if not selected:
print(f"测试分片 {shard_index}/{shard_count} 为空", file=sys.stderr)
return 2
print(
f"运行测试分片 {shard_index}/{shard_count}{len(selected)} 个文件",
flush=True,
)
return run_pytest(selected, pytest_args)
shards = split_test_files(test_files, DEFAULT_SHARD_COUNT)
return run_parallel_shards(shards, pytest_args)
if __name__ == "__main__":
sys.exit(main())