289 lines
11 KiB
Python
289 lines
11 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""ci_local.py - 本地一键跑"与 CI 等价"的门禁(纯标准库)。
|
||
|
||
为什么需要它(2026-10-01 T-6):
|
||
CI(`.gitea/workflows/ci.yml`)只在 push / PR 时跑,且需要 Gitea Actions +
|
||
Windows runner 就绪。**提交前想自查、或 runner 还没配好时**,用本脚本在本地
|
||
跑同一套判定,命令与退出码与 CI 一一对应。
|
||
|
||
覆盖范围(与 CI 完全一致):
|
||
L0 静态门禁 = `pre-commit run --all-files`
|
||
L0.5 主机侧纯逻辑单测 = `test/host/**/run_unit_test.py`
|
||
|
||
刻意**不含**(与 CI 同理由,见 workflow 文件头):
|
||
- `@build`(需 Keil 授权;CI 也不跑,真编译是开发者本地红线)
|
||
- `mem-numbers` 钩子(事实源 `.map` 是 Keil 产物——**本地若存在 .map 就照常跑**,
|
||
与 CI 的自适应逻辑一致)
|
||
|
||
退出码:
|
||
0 = 全部通过(或不适用项已按规则跳过)
|
||
1 = 有门禁/单测失败
|
||
2 = 环境错误(缺 pre-commit / python 模块等)
|
||
|
||
汇总行(S3,2026-10-02,对齐 CI 的 ::notice::):
|
||
[SUMMARY] L0: pre-commit hooks -- passed=N failed=M skipped=K
|
||
[SUMMARY] L0.5: K runner(s), A/B assertions passed, fail=F
|
||
|
||
用法(仓库根目录):
|
||
& '.venv\\Scripts\\python.exe' scripts/ci_local.py
|
||
& '.venv\\Scripts\\python.exe' scripts/ci_local.py --l0-only
|
||
& '.venv\\Scripts\\python.exe' scripts/ci_local.py --l05-only
|
||
& '.venv\\Scripts\\python.exe' scripts/ci_local.py --keep-going # 失败也继续跑完
|
||
|
||
设计依据:`.gitea/workflows/ci.yml`;docs/测试说明/测试开发标准流程.md §9
|
||
创建日期:2026-10-01
|
||
"""
|
||
|
||
import argparse
|
||
import os
|
||
import re
|
||
import shutil
|
||
import subprocess
|
||
import sys
|
||
from pathlib import Path
|
||
|
||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||
|
||
|
||
def _resolve_python():
|
||
r"""挑一个真正装了 pre-commit 的解释器。
|
||
|
||
本机可能有多个 python(.venv / 发行版 / 系统),而 **pre-commit 只装在其中一个**。
|
||
按优先级探测(见 docs/环境与部署/本地工具路径环境变量方案.md §1.1):
|
||
1. DTU_PRECOMMIT_PYTHON(显式配置:文件缺失或无 pre-commit 模块 → 立即报
|
||
配置错误,**不静默回退**到别的解释器)
|
||
2. 当前解释器(sys.executable)
|
||
3. 仓库 `.venv\Scripts\python.exe`
|
||
4. PATH 上的 `python`
|
||
找到即返回 (python_path, note);都找不到则返回 (当前解释器, None) 交由调用方报错。
|
||
"""
|
||
explicit = os.environ.get("DTU_PRECOMMIT_PYTHON", "").strip()
|
||
if explicit:
|
||
if not os.path.isfile(explicit):
|
||
print("[ERROR] DTU_PRECOMMIT_PYTHON 已设置但文件不存在: {}".format(explicit))
|
||
print(" 请修正该变量(python.exe 完整路径),或清除该变量改用自动探测。")
|
||
sys.exit(2)
|
||
try:
|
||
cp = subprocess.run([explicit, "-m", "pre_commit", "--version"],
|
||
capture_output=True, timeout=60)
|
||
except (OSError, subprocess.TimeoutExpired) as e:
|
||
print("[ERROR] DTU_PRECOMMIT_PYTHON 指定的解释器无法执行: {}({})".format(explicit, e))
|
||
sys.exit(2)
|
||
if cp.returncode != 0:
|
||
print("[ERROR] DTU_PRECOMMIT_PYTHON 指向的解释器没有 pre-commit 模块: {}".format(explicit))
|
||
print(" 请修正该变量(指向装有 pre-commit 的 python),或清除该变量改用自动探测。")
|
||
sys.exit(2)
|
||
return explicit, "(显式配置 DTU_PRECOMMIT_PYTHON)"
|
||
|
||
candidates = [
|
||
sys.executable,
|
||
str(REPO_ROOT / ".venv" / "Scripts" / "python.exe"),
|
||
shutil.which("python"),
|
||
]
|
||
seen = set()
|
||
for py in candidates:
|
||
if not py or py in seen or not os.path.isfile(py):
|
||
continue
|
||
seen.add(py)
|
||
try:
|
||
cp = subprocess.run([py, "-m", "pre_commit", "--version"],
|
||
capture_output=True, timeout=60)
|
||
except (OSError, subprocess.TimeoutExpired):
|
||
continue
|
||
if cp.returncode == 0:
|
||
note = None if py == sys.executable else "(自动选用,本解释器无 pre-commit)"
|
||
return py, note
|
||
return sys.executable, None
|
||
|
||
|
||
PY, PY_NOTE = _resolve_python()
|
||
|
||
|
||
def _find_map():
|
||
"""返回 Keil 构建产物 .map 的路径(不存在则 None)。"""
|
||
hits = sorted((REPO_ROOT / "MDK-ARM").rglob("*.map"))
|
||
return hits[0] if hits else None
|
||
|
||
|
||
def _find_unit_runners():
|
||
"""test/host 下所有模块运行器,按路径排序。"""
|
||
return sorted((REPO_ROOT / "test" / "host").rglob("run_unit_test.py"))
|
||
|
||
|
||
def _run(cmd, env=None):
|
||
"""跑子进程,输出直通终端。返回退出码。"""
|
||
print("$ " + " ".join(str(c) for c in cmd))
|
||
cp = subprocess.run(cmd, cwd=str(REPO_ROOT), env=env)
|
||
return cp.returncode
|
||
|
||
|
||
def _run_capture(cmd, env=None):
|
||
"""跑子进程,输出**既直通终端又捕获**(对齐 CI 的 Tee-Object)。
|
||
|
||
返回 (退出码, 全部输出文本)。用于 L0.5 解析各模块汇总行(S3 汇总)。
|
||
"""
|
||
print("$ " + " ".join(str(c) for c in cmd))
|
||
cp = subprocess.run(cmd, cwd=str(REPO_ROOT), env=env,
|
||
capture_output=True)
|
||
try:
|
||
text = cp.stdout.decode("utf-8")
|
||
except UnicodeDecodeError:
|
||
text = cp.stdout.decode("utf-8", errors="replace")
|
||
sys.stdout.write(text)
|
||
if cp.stderr:
|
||
try:
|
||
sys.stderr.write(cp.stderr.decode("utf-8"))
|
||
except UnicodeDecodeError:
|
||
sys.stderr.write(cp.stderr.decode("utf-8", errors="replace"))
|
||
return cp.returncode, text
|
||
|
||
|
||
def run_l0():
|
||
"""L0:pre-commit 全部钩子。返回退出码(0/1/2)。"""
|
||
print("")
|
||
print("=" * 64)
|
||
print(" L0 静态门禁(pre-commit --all-files)")
|
||
print("=" * 64)
|
||
|
||
env = dict(os.environ)
|
||
# 与 CI 相同的自适应:无 .map(未做 Keil 构建)=> mem-numbers 不适用 => 跳过
|
||
map_path = _find_map()
|
||
if map_path:
|
||
print("[INFO] 找到 .map:{}".format(map_path.relative_to(REPO_ROOT)))
|
||
print("[INFO] mem-numbers 照常运行")
|
||
else:
|
||
print("[INFO] 未找到 .map(未做 Keil 构建)-> 跳过 mem-numbers(与 CI 一致)")
|
||
print("[INFO] :warning: 体积门禁未执行——改代码后请本地 @build + "
|
||
"check_mem_numbers.py --scan 兜底")
|
||
skip = env.get("SKIP", "")
|
||
env["SKIP"] = (skip + "," if skip else "") + "mem-numbers"
|
||
|
||
rc, text = _run_capture([PY, "-m", "pre_commit", "run", "--all-files"], env=env)
|
||
# 汇总行(对齐 CI 的 ::notice::):统计钩子 Passed/Failed/Skipped
|
||
lines = text.splitlines()
|
||
passed = sum(1 for l in lines if l.rstrip().endswith("Passed"))
|
||
failed = sum(1 for l in lines if l.rstrip().endswith("Failed"))
|
||
skipd = sum(1 for l in lines if l.rstrip().endswith("Skipped"))
|
||
print("[SUMMARY] L0: pre-commit hooks -- passed={} failed={} skipped={}".format(
|
||
passed, failed, skipd))
|
||
return rc
|
||
|
||
|
||
def run_l05(args):
|
||
"""L0.5:主机侧单测。返回退出码(0/1/2)。"""
|
||
print("")
|
||
print("=" * 64)
|
||
print(" L0.5 主机侧纯逻辑单测(MSVC,零硬件)")
|
||
print("=" * 64)
|
||
|
||
runners = _find_unit_runners()
|
||
if not runners:
|
||
print("[WARN] 未找到 test/host/**/run_unit_test.py —— 跳过")
|
||
return 0
|
||
|
||
rc_all = 0
|
||
runner_count = 0
|
||
sum_pass = 0
|
||
sum_total = 0
|
||
for r in runners:
|
||
runner_count += 1
|
||
rel = r.relative_to(REPO_ROOT)
|
||
print("")
|
||
print("--- 运行:{} ---".format(rel))
|
||
rc, text = _run_capture([PY, str(r)])
|
||
# 解析模块自身汇总行,如 "=== System Clock Tests: 100/100 PASSED (failed=0) ==="
|
||
m = None
|
||
for line in text.splitlines():
|
||
mm = re.search(r":\s*(\d+)/(\d+)\s+PASSED\s+\(failed=(\d+)\)", line)
|
||
if mm:
|
||
m = mm
|
||
if m:
|
||
sum_pass += int(m.group(1))
|
||
sum_total += int(m.group(2))
|
||
print("[SUMMARY] {}: {}/{} PASSED (failed={})".format(
|
||
r.parent.name, m.group(1), m.group(2), m.group(3)))
|
||
else:
|
||
print("[WARN] {} 未输出 '=== ... N/M PASSED ===' 汇总行".format(rel))
|
||
if rc != 0:
|
||
print("[FAIL] {}(退出码 {})".format(rel, rc))
|
||
if rc == 2:
|
||
print(" (退出码 2 = 环境错误:多半是本机未装 MSVC,见 "
|
||
"docs/测试说明/测试开发标准流程.md §4.2)")
|
||
rc_all = 1
|
||
if not args.keep_going:
|
||
break
|
||
else:
|
||
print("[OK] {}".format(rel))
|
||
|
||
print("[SUMMARY] L0.5: {} runner(s), {}/{} assertions passed, fail={}".format(
|
||
runner_count, sum_pass, sum_total, rc_all))
|
||
return rc_all
|
||
|
||
|
||
def main(argv=None):
|
||
for stream in (sys.stdout, sys.stderr):
|
||
try:
|
||
stream.reconfigure(encoding="utf-8", errors="replace")
|
||
except Exception:
|
||
pass
|
||
|
||
ap = argparse.ArgumentParser(
|
||
description="本地跑与 CI 等价的门禁(L0 + L0.5,纯标准库)")
|
||
g = ap.add_mutually_exclusive_group()
|
||
g.add_argument("--l0-only", action="store_true", help="只跑 L0 静态门禁")
|
||
g.add_argument("--l05-only", action="store_true", help="只跑 L0.5 主机侧单测")
|
||
ap.add_argument("--keep-going", action="store_true",
|
||
help="某步失败也继续跑后续步骤(默认首错即停)")
|
||
args = ap.parse_args(argv)
|
||
|
||
print("=== 本地 CI(等价 .gitea/workflows/ci.yml)===")
|
||
print("仓库:{}".format(REPO_ROOT))
|
||
print("Python:{}".format(PY))
|
||
if PY_NOTE:
|
||
print(" {}".format(PY_NOTE))
|
||
|
||
# 环境预检:pre-commit 必须可用(CI 里由"环境自检"步骤拦)
|
||
try:
|
||
probe = subprocess.run([PY, "-m", "pre_commit", "--version"],
|
||
capture_output=True, timeout=60)
|
||
except (OSError, subprocess.TimeoutExpired) as e:
|
||
print("[ERROR] 无法调用 pre-commit:{}".format(e))
|
||
return 2
|
||
if probe.returncode != 0:
|
||
print("[ERROR] 该解释器没有 pre-commit 模块。请任选其一:")
|
||
print(" - 用装了 pre-commit 的解释器跑本脚本(如 "
|
||
"& '.venv\\Scripts\\python.exe' scripts/ci_local.py)")
|
||
print(" - 或设置 DTU_PRECOMMIT_PYTHON 指向该解释器")
|
||
print(" (见 docs/环境与部署/本地工具路径环境变量方案.md)")
|
||
print(" - 或在当前环境安装:pip install pre-commit")
|
||
return 2
|
||
|
||
rc = 0
|
||
try:
|
||
if not args.l05_only:
|
||
rc = run_l0()
|
||
if rc != 0 and not args.keep_going:
|
||
print("")
|
||
print("[FAIL] L0 未通过 —— 中止(--keep-going 可继续跑 L0.5)")
|
||
return 1
|
||
if not args.l0_only:
|
||
rc05 = run_l05(args)
|
||
rc = rc or rc05
|
||
except FileNotFoundError as e:
|
||
print("[ERROR] 环境错误:{}".format(e))
|
||
return 2
|
||
|
||
print("")
|
||
print("=" * 64)
|
||
if rc == 0:
|
||
print(" [OK] 本地 CI 全部通过(L0 + L0.5)")
|
||
else:
|
||
print(" [FAIL] 本地 CI 有未通过项(见上方输出)")
|
||
print("=" * 64)
|
||
return rc
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|