#!/usr/bin/env python3 """verify_log.py - 对抓取的 MCU 调试日志做标记校验(纯标准库,2026-09-20 替代 verify_log.ps1)。 用于 @test 闭环:烧录后从串口助手日志提取的开机日志交给本脚本 grep 标记, 输出结构化判定(格式与旧 PowerShell 版逐字一致,下游文档/习惯不受影响)。 用法: & '.venv\\Scripts\\python.exe' test/host/verify_log.py --log-path [--required 'marker1;marker2'] [--forbidden 'FAIL;ERROR;HardFault;assert;[ERR]'] [--show-tail-lines 40] 退出码(与旧版一致): 0 = PASS(所有 Required 标记找到,Forbidden 零命中) 1 = FAIL(缺 Required 或命中 Forbidden) 2 = ERROR(日志文件不存在/不可读) 默认 Required 标记对应 STM32F4-Base 启动序列(见 App/app/app_main.c + App/task/net_task.c), 按测试用例覆盖,例如: --required 'App init OK;net init: OK;PHY: 100M FULL' --forbidden 'FAIL;ERROR;HardFault;assert;[ERR]' 语义约定(与旧 PowerShell 版对齐): - Required:大小写敏感子串匹配(全文) - Forbidden:大小写**不敏感**子串匹配、逐行计数(旧版 -like 语义; 如 Forbidden 'FAIL' 会命中 "write /log/run.log fail (7)" 这类行) - 固件错误级日志前缀是 `[ERR]`(`DBG_ERROR`,形如 `[时间][ERR][TAG] 消息`), 与 `ERROR` 子串**不匹配**——默认 Forbidden 因此显式包含 `[ERR]`,驱动/模块 错误行不再漏检(2026-09-21 修复)。测试套件中**预期**出现 `[ERR]` 的用例 (如 GD5F DMA 守卫负路径、FatFs 非法名校验)须用 --forbidden 覆盖默认值。 - 读取编码:先按 UTF-8,解码失败回退系统 locale 编码(旧版 Get-Content 行为) - 控制台输出重配置为 UTF-8(errors=replace):任何 Windows 控制台代码页 都只影响终端显示,不会导致本脚本崩溃 """ import argparse import locale import sys from pathlib import Path REQUIRED_DEFAULT = "App init OK;Network Init;net init: OK" FORBIDDEN_DEFAULT = "FAIL;ERROR;HardFault;assert;[ERR]" def _split_list(spec): """';' 分隔列表 → 去空白、去空项。""" return [p.strip() for p in spec.split(";") if p.strip()] def _read_lines(path): raw = Path(path).read_bytes() for enc in ("utf-8", locale.getpreferredencoding(False)): try: return raw.decode(enc).splitlines() except UnicodeDecodeError: continue return raw.decode("utf-8", errors="replace").splitlines() def verify(log_path, required=REQUIRED_DEFAULT, forbidden=FORBIDDEN_DEFAULT, show_tail=40, out=print): """标记校验核心(可被 run_boot_test.py 复用)。返回退出码 0/1/2。""" p = Path(log_path) if not p.is_file(): out("VERIFY_ERROR: log file not found: {}".format(log_path)) return 2 try: lines = _read_lines(log_path) except OSError: out("VERIFY_ERROR: cannot read log file: {}".format(log_path)) return 2 full = "\n".join(lines) req_list = _split_list(required) forb_list = _split_list(forbidden) out("=== Log verdict: {} ===".format(log_path)) out("Lines: {}, bytes: {}".format(len(lines), p.stat().st_size)) missing = [] for m in req_list: if m in full: out("[OK] required marker found: {}".format(m)) else: out("[MISS] required marker NOT found: {}".format(m)) missing.append(m) hits = [] for f in forb_list: fl = f.lower() n = sum(1 for ln in lines if fl in ln.lower()) if n > 0: out("[HIT] forbidden pattern: {} ({} line(s))".format(f, n)) hits.append(f) else: out("[OK] forbidden pattern absent: {}".format(f)) if show_tail > 0 and lines: out("") out("=== Last {} line(s) of log ===".format(show_tail)) for ln in lines[-show_tail:]: out(ln) out("") if missing or hits: out("VERDICT: FAIL") return 1 out("VERDICT: PASS") return 0 def main(argv=None): # 控制台输出固定 UTF-8:Windows 代码页(GBK/UTF-8 终端)均不会让本脚本崩溃 for stream in (sys.stdout, sys.stderr): try: stream.reconfigure(encoding="utf-8", errors="replace") except Exception: pass ap = argparse.ArgumentParser(description="MCU debug log marker check (pure stdlib)") ap.add_argument("--log-path", required=True, help="boot log file to check") ap.add_argument("--required", default=REQUIRED_DEFAULT, help="required markers, ';' separated (case-sensitive)") ap.add_argument("--forbidden", default=FORBIDDEN_DEFAULT, help="forbidden patterns, ';' separated (case-insensitive)") ap.add_argument("--show-tail-lines", type=int, default=40, help="show last N log lines (0 = off)") args = ap.parse_args(argv) return verify(args.log_path, args.required, args.forbidden, args.show_tail_lines) if __name__ == "__main__": sys.exit(main())