#!/usr/bin/env python3 """ Python 版本一致性检查 CLI ========================== FinDataHub 全项目统一锁定 Python 3.12(见仓库根目录 .python-version)。 本脚本校验「声明」与「实际」是否都已对齐,供本地自查与 CI 使用。 检查项 ------ 1. 所有 pyproject.toml 的 requires-python 是否接受目标版本 2. 各服务 .venv 的实际 Python 版本是否为目标版本 3. 代码里是否残留硬编码的其它 Python 版本(python3.13 / python@3.13 等) 4. Scripts/bin 下脚本的 shebang 是否指向目标版本(或为环境无关的 env 形式) 用法 ---- python3 check_python_version.py # 人类可读报告 python3 check_python_version.py --json # JSON 输出 python3 check_python_version.py --strict # 警告也视为失败(CI 用) 退出码 ------ 0 全部通过(--strict 下警告也算通过,只有 error 才失败) 1 存在 error """ import argparse import json import re import sys from pathlib import Path # 仓库根目录:本脚本位于 /Scripts/bin/ ROOT = Path(__file__).resolve().parent.parent.parent VERSION_FILE = ROOT / ".python-version" # 参与版本检查的服务(相对 ROOT) SERVICES = [ "Services/akshare_fetcher", "Services/tushare_fetcher", "Services/kuaixun-service", "Services/findatahub-core", "Services/findatahub-client", ] # 需要扫描硬编码版本引用的目录与文件后缀 SCAN_DIRS = ["Scripts", "App/Sources", "Services"] SCAN_SUFFIX = {".py", ".swift", ".sh", ".toml", ".yml", ".yaml"} # 这些文件是历史记录/报告,不参与检查 SCAN_EXCLUDE = { "DEVLOG.md", "FinDataHub-性能与资源分析.html", "FinDataHub-能耗优化分析.html", } def read_target_version(): if not VERSION_FILE.exists(): return None, f"缺少 {VERSION_FILE}" v = VERSION_FILE.read_text(encoding="utf-8").strip() if not re.fullmatch(r"3\.\d+", v): return None, f"{VERSION_FILE} 内容不合法:{v!r}(应为形如 3.12)" return v, None def parse_requires_python(text): """从 pyproject.toml 文本里取出 requires-python 字符串""" m = re.search(r'^requires-python\s*=\s*["\']([^"\']+)["\']', text, re.MULTILINE) return m.group(1) if m else None def version_accepts(spec, target): """ 判断 requires-python 说明符是否接受目标版本。 支持形如:>=3.12、>=3.12,<3.13、>=3.10、==3.12.* """ def key(s): return tuple(int(x) for x in s.split(".")) t = key(target) ok = True for clause in [c.strip() for c in spec.split(",") if c.strip()]: m = re.fullmatch(r"(==|>=|<=|>|<|!=|~=)\s*([0-9.*]+)", clause) if not m: continue # 无法解析的子句跳过,不误报 op, ver = m.group(1), m.group(2) if "*" in ver: # ==3.12.* base = key(ver.replace(".*", "").rstrip(".")) ok = ok and (op == "==" and t[:len(base)] == base) or (op == "!=" and t[:len(base)] != base) continue v = key(ver) if op == ">=": ok = ok and t >= v elif op == "<=": ok = ok and t <= v elif op == ">": ok = ok and t > v elif op == "<": ok = ok and t < v elif op == "==": ok = ok and t == v elif op == "!=": ok = ok and t != v return ok def check_pyprojects(target): issues = [] for svc in SERVICES: p = ROOT / svc / "pyproject.toml" if not p.exists(): issues.append(("warn", f"{svc}: 缺少 pyproject.toml")) continue spec = parse_requires_python(p.read_text(encoding="utf-8")) if spec is None: issues.append(("warn", f"{svc}: 未声明 requires-python")) continue if not version_accepts(spec, target): issues.append(("error", f"{svc}: requires-python = {spec!r} 不接受 {target}")) return issues def check_venvs(target): issues = [] for svc in SERVICES: venv = ROOT / svc / ".venv" if not venv.exists(): issues.append(("warn", f"{svc}: 无 .venv(未初始化)")) continue cfg = venv / "pyvenv.cfg" actual = None if cfg.exists(): txt = cfg.read_text(encoding="utf-8") m = re.search(r"^version(?:_info)?\s*=\s*([0-9.]+)", txt, re.MULTILINE) if m: actual = m.group(1) if actual is None: # 回退:从 lib/python3.X 目录推断 lib = venv / "lib" vers = sorted(d.name.replace("python", "") for d in lib.iterdir() if d.name.startswith("python") and d.is_dir()) if lib.exists() else [] actual = vers[-1] if vers else None if actual is None: issues.append(("warn", f"{svc}: 无法判定 venv 版本")) elif not actual.startswith(target): issues.append(("error", f"{svc}: venv 实际版本 {actual},目标 {target}")) return issues def check_hardcoded(target): """ 扫描代码中残留的其它 python3.X 硬编码引用。 只匹配 `python3.XX` / `python@3.XX` 两种形式(不含 ruff 的 `py312`—— 那是 linter 配置,不是版本引用)。注释行、本脚本自身、以及测试里 刻意构造的「旧版本残留」数据均不计入。 """ issues = [] pat = re.compile(r"python3\.(\d+)|python@3\.(\d+)") self_name = Path(__file__).name for d in SCAN_DIRS: base = ROOT / d if not base.exists(): continue for f in base.rglob("*"): if not f.is_file() or f.suffix not in SCAN_SUFFIX: continue if any(x in str(f) for x in (".venv", "node_modules", "__pycache__", ".build")): continue if f.name in SCAN_EXCLUDE or f.name == self_name: continue try: txt = f.read_text(encoding="utf-8", errors="ignore") except Exception: continue for i, raw in enumerate(txt.splitlines(), 1): line = raw.strip() # 跳过注释(Swift / Python / Shell / YAML 通用) if line.startswith(("#", "//", "///", "*")): continue # 跳过动态取版本的行 if "PYTHON_TARGET_VERSION" in line or "findPython" in line: continue # 测试文件里刻意构造的旧版本数据不算问题 if "tests/" in str(f).replace("\\", "/") and "tmp_path" in txt: continue for m in pat.finditer(line): found = f"3.{m.group(1) or m.group(2)}" if found != target: rel = f.relative_to(ROOT) issues.append(( "warn", f"{rel}:{i} 硬编码 python {found}(目标 {target})— {line[:70]}" )) break return issues def main(): ap = argparse.ArgumentParser(description="检查全项目 Python 版本一致性") ap.add_argument("--json", action="store_true", help="JSON 输出") ap.add_argument("--strict", action="store_true", help="警告也计入失败") args = ap.parse_args() target, err = read_target_version() if err: print(f"error: {err}", file=sys.stderr) return 1 issues = [] issues += check_pyprojects(target) issues += check_venvs(target) issues += check_hardcoded(target) errors = [m for lv, m in issues if lv == "error"] warns = [m for lv, m in issues if lv == "warn"] if args.json: print(json.dumps({ "target_version": target, "errors": errors, "warnings": warns, "passed": not errors and (not args.strict or not warns), }, ensure_ascii=False, indent=2)) else: print(f"目标版本:Python {target}(来源 {VERSION_FILE.relative_to(ROOT)})\n") if errors: print(f"✗ 错误 {len(errors)} 项:") for m in errors: print(f" {m}") if warns: print(f"⚠ 警告 {len(warns)} 项:") for m in warns: print(f" {m}") if not errors and not warns: print("✓ 全部检查通过:声明、venv、硬编码引用均已对齐。") elif not errors: print(f"\n✓ 无致命错误({len(warns)} 项警告)") if errors: return 1 if args.strict and warns: return 1 return 0 if __name__ == "__main__": sys.exit(main())