mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-30 04:27:40 +08:00
159 lines
5.3 KiB
Python
159 lines
5.3 KiB
Python
"""后端单测入口:默认并行执行文件分片,也支持单分片和显式串行调试。"""
|
||
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())
|