问题 - 索引中该目录记为小写 `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 条。
254 lines
8.6 KiB
Python
Executable File
254 lines
8.6 KiB
Python
Executable File
#!/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())
|