#!/usr/bin/env python3 """ 检查 Alembic migration 文件命名规范。 规则: 1. 文件名必须以数字前缀开头(3位补零),如 001_xxx.py、052_add_table.py 2. 数字前缀必须连续递增(与 check_migration_chain.py 一致,但只看文件名) 3. 数字前缀后必须跟有描述性后缀(不能只有数字) 4. 文件名使用小写+下划线(snake_case) 5. revision 变量值必须与文件名数字前缀一致(可选带描述后缀) 用法: python3 scripts/ci/check_migration_naming.py [alembic_versions_dir] 默认目录: alembic/versions/ 退出码: 0 - 全部通过 1 - 有命名违规 """ from __future__ import annotations import re import sys from pathlib import Path # 文件名格式: 3位数字_描述.py FILE_NAME_PATTERN = re.compile(r"^(\d{3})_[a-z][a-z0-9_]*\.py$") # 纯数字文件名(不允许) PURE_NUM_PATTERN = re.compile(r"^\d{3}\.py$") # revision 值的数字前缀 REV_NUM_PATTERN = re.compile(r"^(\d{3})") # revision 变量行 REV_LINE_PATTERN = re.compile( r'^\s*revision\s*(?::\s*str\s*)?=\s*["\']([^"\']+)["\']', re.MULTILINE, ) def check_naming(versions_dir: Path) -> list[str]: """检查 migration 文件命名,返回错误列表。""" errors: list[str] = [] if not versions_dir.is_dir(): return [f"目录不存在: {versions_dir}"] py_files = sorted(f for f in versions_dir.iterdir() if f.suffix == ".py") if not py_files: return [f"目录下没有 migration 文件: {versions_dir}"] print(f"检查 migration 文件命名: {versions_dir}") print(f"共 {len(py_files)} 个文件") print() # 1. 文件名格式检查 print("1. 文件名格式检查...") file_nums: list[int] = [] for f in py_files: name = f.name if PURE_NUM_PATTERN.match(name): errors.append(f" ❌ {name}: 只有数字编号,缺少描述性后缀") continue m = FILE_NAME_PATTERN.match(name) if not m: errors.append(f" ❌ {name}: 命名格式不规范,应为 NNN_description.py " f"(3位数字前缀+下划线+小写描述)") continue file_nums.append(int(m.group(1))) if not any("命名格式不规范" in e or "缺少描述性后缀" in e for e in errors): print(f" ✅ 全部 {len(py_files)} 个文件名格式正确") else: for e in errors: if "命名格式不规范" in e or "缺少描述性后缀" in e: print(e) # 2. 编号连续性检查(基于文件名数字前缀) print() print("2. 编号连续性检查...") if file_nums: expected = set(range(min(file_nums), max(file_nums) + 1)) actual = set(file_nums) missing = sorted(expected - actual) if missing: errors.append(f" ❌ 编号不连续,缺少: {', '.join(f'{n:03d}' for n in missing)}") print(f" ❌ 编号不连续,缺少 {len(missing)} 个: " f"{', '.join(f'{n:03d}' for n in missing)}") else: print(f" ✅ 编号连续({min(file_nums):03d} ~ {max(file_nums):03d})") # 3. revision 变量与文件名前缀一致性检查 print() print("3. revision变量与文件名一致性检查...") rev_mismatch = 0 for f in py_files: m = FILE_NAME_PATTERN.match(f.name) if not m: continue # 格式不对的已经报过了 file_num = m.group(1) content = f.read_text(encoding="utf-8") rev_match = REV_LINE_PATTERN.search(content) if not rev_match: errors.append(f" ❌ {f.name}: 未找到 revision 变量定义") rev_mismatch += 1 continue rev_value = rev_match.group(1) rev_num_match = REV_NUM_PATTERN.match(rev_value) if not rev_num_match or rev_num_match.group(1) != file_num: errors.append(f" ❌ {f.name}: revision='{rev_value}' 与文件名前缀 {file_num} 不一致") rev_mismatch += 1 if rev_mismatch == 0: print(f" ✅ 全部 {len(py_files)} 个文件的 revision 与文件名一致") return errors def main() -> int: versions_dir = Path(sys.argv[1]) if len(sys.argv) > 1 else Path("alembic/versions") errors = check_naming(versions_dir) print() if errors: print(f"❌ 发现 {len(errors)} 个命名问题") print() print("命名规范:") print(" - 文件名格式: NNN_description.py(3位数字前缀 + 下划线 + 小写描述)") print(" - 编号必须连续,不能跳号") print(" - revision 变量的数字前缀必须与文件名一致") return 1 print("✅ 所有 migration 文件命名规范检查通过") return 0 if __name__ == "__main__": sys.exit(main())