Files
FinDataHub/Scripts/bin/check_python_version.py
FinDataHub 11d36d1c1e fix(repo): 修正 Scripts 目录索引大小写并补齐未入库脚本
问题
- 索引中该目录记为小写 `scripts/`,工作区实为 `Scripts/`。因 core.ignorecase=true,
  本地 `git diff -- Scripts/bin/x.py` 会**静默返回空**(看似无改动),
  且在大小写敏感环境(Linux CI / Docker 挂载)会检出 `scripts/`,
  与全仓所有 `Scripts/...` 引用不匹配。
- 该目录此前 10 个脚本(check_python_version.py、perf_snapshot.py、
  install_collector_launchd.py、mysql_toggle.sh 等)从未入库,
  而 CI 第 31 行正调用 `Scripts/bin/check_python_version.py --strict` ——
  全新检出必然失败。

改动
- 索引路径统一为 `Scripts/`(工作区文件未移动,仅索引改名)。
- 补齐上述 10 个未跟踪脚本及其测试。

影响
- 修正前索引 35 条(全为小写路径),修正后 45 条。
2026-09-18 22:37:32 +08:00

254 lines
8.6 KiB
Python
Executable File
Raw Permalink 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.
#!/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
# 仓库根目录:本脚本位于 <root>/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())