zizi 50a5522014 W01 落实合同与逐用例工作清单:合同与逐用例工作清单落实(接口契约、文件设计与用例页)。
按 R2 串行阶段整理提交;包内文件为该阶段交付(含后续小增量),状态以工作包清单为准。
2026-09-10 19:25:40 +08:00

312 lines
12 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.

"""原生 pytest 的用例身份、进程隔离与共享夹具。
默认使用 OS 防护覆盖收集、驱动及子进程。外部环境必须显式选择;
完整 ID 绑定登记文件及符号,参数行和 Junit 保留可追溯身份。
本文件自包含,临时项目复制后验证相同测试入口。
"""
from __future__ import annotations
import ctypes
import ctypes.util
import errno
import json
import os
import socket
import sys
from pathlib import Path
from typing import Any
import pytest
# ---------------------------------------------------------------------------
# 标记与用例选择
# ---------------------------------------------------------------------------
外部标记 = ("数据库", "网络", "真实模型", "浏览器", "宿主")
# 执行计数:供会话结束守卫判断“无实际执行”
_执行计数 = {"passed": 0, "skipped": 0, "failed": 0}
def pytest_addoption(parser: pytest.Parser) -> None:
组 = parser.getgroup("muse", "Muse 用例选择")
组.addoption("--用例清单", default=None, help="显式的完整用例 ID 与目标符号清单")
组.addoption(
"--外部环境", action="store_true", help="显式允许外部用例;仍按标记选择并使用隔离配置"
)
组.addoption(
"--case",
action="append",
default=None,
metavar="CASE_ID",
help="按稳定用例 ID(TC-/NC- 前缀)选择;可多次给出,语义为并集",
)
def pytest_configure(config: pytest.Config) -> None:
for 名 in 外部标记:
config.addinivalue_line("markers", f"{名}: 外部环境用例;见 tests/conftest.py")
def _读取用例清单(config: pytest.Config) -> list[dict[str, Any]]:
指定 = config.getoption("--用例清单")
路径 = (
Path(指定)
if 指定
else config.rootpath / "docs/系统架构/新版设计/验证设计/测试用例清单.json"
)
if not 路径.exists() and not 指定 and not config.getoption("--case"):
return []
try:
清单 = json.loads(路径.read_text(encoding="utf-8"))
return [目标 for 旧例 in 清单["cases"] for 目标 in 旧例.get("target_cases", [])] + 清单[
"new_cases"
]
except (OSError, ValueError, TypeError, KeyError) as exc:
raise pytest.UsageError(f"用例清单无效:{路径.name}({type(exc).__name__})") from exc
def pytest_collection_modifyitems(config: pytest.Config, items: list[pytest.Item]) -> None:
登记 = _读取用例清单(config)
选择 = config.getoption("--case") or []
按ID = {条["case_id"]: 条 for 条 in 登记}
请求 = {str(值): str(值).split("[", 1)[0] for 值 in 选择}
for 值, 身份 in 请求.items():
if 身份 not in 按ID:
raise pytest.UsageError(f"--case {值} 未登记,必须使用完整用例 ID")
按目标: dict[tuple[str, str], list[dict]] = {}
for 条 in 登记:
按目标.setdefault((条["file"], 条["symbol"]), []).append(条)
保留, 排除, 命中 = [], [], set()
for 项 in items:
文件 = 项.path.relative_to(config.rootpath).as_posix()
符号 = getattr(项, "originalname", None) or 项.name.split("[", 1)[0]
参数 = getattr(getattr(项, "callspec", None), "id", None)
对应 = [
条["case_id"]
for 条 in 按目标.get((文件, 符号), [])
if not 条.get("parameter_ids") or 参数 in 条["parameter_ids"]
]
if len(对应) > 1:
raise pytest.UsageError(f"用例身份重叠:{项.nodeid} 对应 {对应}")
身份 = 对应[0] if 对应 else None
if 身份:
项.user_properties.append(("case_id", 身份))
if 参数 is not None:
项.user_properties.append(("parameter_id", 参数))
该项命中 = {
值
for 值, id_ in 请求.items()
if id_ == 身份 and ("[" not in 值 or 值 == f"{身份}[{参数}]")
}
命中.update(该项命中)
if not 选择 or 该项命中:
保留.append(项)
else:
排除.append(项)
if set(请求) - 命中:
raise pytest.UsageError(f"--case 已登记但未收集:{sorted(set(请求) - 命中)}")
if 排除:
config.hook.pytest_deselected(items=排除)
items[:] = 保留
def pytest_sessionstart(session: pytest.Session) -> None:
"""整个离线进程使用 OS 防护,收集、libpq 与子进程共用相同边界。"""
if session.config.getoption("--外部环境"):
return
if sys.platform == "darwin":
库 = ctypes.CDLL("/usr/lib/libsandbox.dylib")
初始化 = 库.sandbox_init
初始化.argtypes = [ctypes.c_char_p, ctypes.c_uint64, ctypes.POINTER(ctypes.c_char_p)]
初始化.restype = ctypes.c_int
错误 = ctypes.c_char_p()
if 初始化(b"(version 1)(allow default)(deny network*)", 0, ctypes.byref(错误)):
raise pytest.UsageError("操作系统离线隔离初始化失败")
elif sys.platform.startswith("linux"):
路径 = ctypes.util.find_library("seccomp")
if not 路径:
raise pytest.UsageError("离线测试需要系统 libseccomp;不能无防护运行")
库 = ctypes.CDLL(路径)
库.seccomp_init.argtypes = [ctypes.c_uint32]
库.seccomp_init.restype = ctypes.c_void_p
库.seccomp_syscall_resolve_name.argtypes = [ctypes.c_char_p]
库.seccomp_rule_add.argtypes = [
ctypes.c_void_p,
ctypes.c_uint32,
ctypes.c_int,
ctypes.c_uint,
]
库.seccomp_load.argtypes = [ctypes.c_void_p]
库.seccomp_release.argtypes = [ctypes.c_void_p]
上下文 = 库.seccomp_init(0x7FFF0000)
if not 上下文:
raise pytest.UsageError("无法创建离线系统调用规则")
try:
# 禁止建立网络 socket 和所有外部 connect;保留事件循环的内部 socketpair。
class 参数条件(ctypes.Structure):
_fields_ = [
("arg", ctypes.c_uint),
("op", ctypes.c_int),
("datum_a", ctypes.c_uint64),
("datum_b", ctypes.c_uint64),
]
库.seccomp_rule_add_array.argtypes = [
ctypes.c_void_p,
ctypes.c_uint32,
ctypes.c_int,
ctypes.c_uint,
ctypes.POINTER(参数条件),
]
拒绝 = 0x00050000 | errno.EPERM
connect = 库.seccomp_syscall_resolve_name(b"connect")
socket_call = 库.seccomp_syscall_resolve_name(b"socket")
if 库.seccomp_rule_add(上下文, 拒绝, connect, 0):
raise pytest.UsageError("无法登记离线连接规则")
for family in (socket.AF_INET, socket.AF_INET6):
条件 = 参数条件(0, 4, family, 0) # SCMP_CMP_EQ
if 库.seccomp_rule_add_array(上下文, 拒绝, socket_call, 1, ctypes.byref(条件)):
raise pytest.UsageError("无法登记离线网络规则")
if 库.seccomp_load(上下文):
raise pytest.UsageError("操作系统离线隔离初始化失败")
finally:
库.seccomp_release(上下文)
else:
raise pytest.UsageError("当前系统尚无离线进程隔离实现;使用受支持的 macOS 或 Linux 环境")
# ---------------------------------------------------------------------------
# 离线防护:未显式选择外部环境的测试禁止一切 socket 连接
# ---------------------------------------------------------------------------
class 离线连接被拒(RuntimeError):
"""离线测试尝试建立网络或数据库连接。"""
原始connect: Any = socket.socket.connect
原始connect_ex: Any = socket.socket.connect_ex
def _拒绝连接(self: socket.socket, address: Any) -> Any:
raise 离线连接被拒(
f"离线测试尝试连接 {address!r};需要外部环境时显式标记 {'/'.join(外部标记)} 并使用对应夹具"
)
def _安装防护() -> None:
socket.socket.connect = _拒绝连接 # type: ignore[method-assign]
socket.socket.connect_ex = _拒绝连接 # type: ignore[method-assign]
def _卸载防护() -> None:
socket.socket.connect = 原始connect # type: ignore[method-assign]
socket.socket.connect_ex = 原始connect_ex # type: ignore[method-assign]
def _需要外部环境(项: pytest.Item) -> bool:
标记 = {m.name for m in 项.iter_markers()}
return any(名 in 标记 for 名 in 外部标记)
@pytest.fixture(autouse=True)
def 离线防护(request: pytest.FixtureRequest):
"""默认给所有未选择外部环境的用例安装 socket 防护;用例结束后恢复。"""
if _需要外部环境(request.node):
yield
return
_安装防护()
try:
yield
finally:
_卸载防护()
def pytest_runtest_logreport(report: pytest.TestReport) -> None:
if report.when == "call":
if report.passed:
_执行计数["passed"] += 1
elif report.failed:
_执行计数["failed"] += 1
elif report.skipped:
_执行计数["skipped"] += 1
elif report.when == "setup" and report.skipped:
_执行计数["skipped"] += 1
def pytest_sessionfinish(session: pytest.Session, exitstatus: int) -> None:
"""全跳过不算通过:没有实际执行时改写退出码为 5(无测试运行)。"""
if exitstatus == 0 and _执行计数["passed"] == 0 and _执行计数["skipped"] > 0:
session.exitstatus = 5
print(
f"\n守卫:{_执行计数['skipped']} 个用例全部跳过、零实际执行;不计通过(退出码 5)",
file=sys.stderr,
)
# ---------------------------------------------------------------------------
# 外部环境夹具
# ---------------------------------------------------------------------------
数据库环境变量 = "MUSE_TEST_DATABASE_URL"
@pytest.fixture
def 隔离数据库URL() -> str:
"""隔离 PostgreSQL 连接串;缺失时跳过(跳过不计通过)。"""
值 = os.environ.get(数据库环境变量, "").strip()
if not 值:
pytest.skip(
f"未设置 {数据库环境变量};隔离库用例未执行(不计通过)。启动本地库并导出该变量后重跑。"
)
return 值
@pytest.fixture
def 夹具根() -> Path:
"""tests/夹具 的绝对路径;供用例读取稳定回归输入。"""
根 = Path(__file__).resolve().parent / "夹具"
assert 根.is_dir(), f"夹具目录不存在:{根}"
return 根
@pytest.fixture
def 应用测试库(隔离数据库URL: str, tmp_path: Path):
"""跨模块接入测试使用全新库;返回受控文件引用,全部数据为合成数据。"""
import uuid
import psycopg
from psycopg import sql
from muse.共享.调用身份 import 用途
from muse.基础设施.数据库.迁移 import 执行迁移
from muse.基础设施.数据库.连接 import 数据库工厂
from muse.配置 import 数据库引用
根 = Path(__file__).resolve().parents[1]
库名 = "muse_http_" + uuid.uuid4().hex[:12]
with psycopg.connect(隔离数据库URL, autocommit=True) as 管理:
管理.execute((根 / "数据库/初始化/用途角色.sql").read_text())
管理.execute(sql.SQL("CREATE DATABASE {} OWNER muse_maint").format(sql.Identifier(库名)))
工厂 = {}
try:
for 名, 角色 in [
(用途.维护, "muse_maint"),
(用途.生产, "muse_app"),
(用途.评测, "muse_eval"),
]:
参数 = psycopg.conninfo.conninfo_to_dict(隔离数据库URL)
参数.update(dbname=库名, user=角色)
文件 = tmp_path / f"{名.value}.txt"
文件.write_text(psycopg.conninfo.make_conninfo(**参数))
文件.chmod(0o600)
工厂[名] = 数据库工厂(数据库引用("受控存储", str(文件)), 名)
with 工厂[用途.维护].连接() as 连:
执行迁移(连, 根 / "数据库/迁移")
yield 工厂
finally:
with psycopg.connect(隔离数据库URL, autocommit=True) as 管理:
管理.execute(sql.SQL("DROP DATABASE {} WITH (FORCE)").format(sql.Identifier(库名)))