#!/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 模块等) 用法(仓库根目录): & '.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 subprocess import sys from pathlib import Path REPO_ROOT = Path(__file__).resolve().parent.parent def _resolve_python(): """挑一个真正装了 pre-commit 的解释器。 本机可能有多个 python(.venv / anaconda3 / 系统),而 **pre-commit 只装在其中一个**。 按优先级探测:当前解释器 → `.venv` → anaconda3 → PATH 上的 `pre-commit` 所在环境。 找到即返回 (python_path, note);都找不到则返回 (当前解释器, None) 交由调用方报错。 """ candidates = [sys.executable] venv = REPO_ROOT / ".venv" / "Scripts" / "python.exe" if venv.is_file(): candidates.append(str(venv)) candidates.append(r"C:\ProgramData\anaconda3\python.exe") 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_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 一致)") skip = env.get("SKIP", "") env["SKIP"] = (skip + "," if skip else "") + "mem-numbers" return _run([PY, "-m", "pre_commit", "run", "--all-files"], env=env) 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 for r in runners: rel = r.relative_to(REPO_ROOT) print("") print("--- 运行:{} ---".format(rel)) rc = _run([PY, str(r)]) 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)) 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 的解释器跑本脚本(如 " r"& 'C:\ProgramData\anaconda3\python.exe' scripts/ci_local.py)") 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())