366 lines
14 KiB
Python
366 lines
14 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""M1b import 闭包修复器 v2(幂等)——落地 QC 退回意见 #1/#2。
|
||
|
||
v1 的缺陷(QC #1 根因):在 db.py 里写一条 ``from pbl_blueprint.m1b import (23 个符号)``,
|
||
只要其中**一个**符号(get_env)在 m1b 导出面缺失,整条 import 抛 ImportError 被
|
||
try/except 静默吞掉,其余 22 个兼容回补全部失效。v2 三条铁律:
|
||
|
||
1. **逐符号**采纳,绝不整块 import(单符号缺失不牵连其余);
|
||
2. 供给方为**包内自足**模块 pbl_blueprint.m1b_compat(自身无裸跨包 import,恒可导入);
|
||
3. 修完**当场自检**:ast 级 import 闭包核验 + py_compile + 真实 import 冒烟,
|
||
0 断裂才允许交付(不再「声称已修复」而实测仍断,QC #3)。
|
||
|
||
用法:python3 tools/m1b_fix_import_closure_v2.py [--check-only] [--root <repo_root>]
|
||
"""
|
||
|
||
import ast
|
||
import io
|
||
import os
|
||
import py_compile
|
||
import re
|
||
import sys
|
||
|
||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||
DEFAULT_ROOT = os.path.dirname(HERE) # modules/pbl_blueprint
|
||
PKG = "pbl_blueprint"
|
||
COMPAT_MOD = "pbl_blueprint.m1b_compat"
|
||
MARK_BEGIN = "# >>> M1b compat exports (auto-generated, idempotent) >>>"
|
||
MARK_END = "# <<< M1b compat exports <<<"
|
||
|
||
# 半迁移期已知断裂的供给模块:从这些模块 import 的符号一律改由包内兼容层供给
|
||
BROKEN_PROVIDERS = (
|
||
"pbl_common.api",
|
||
"pbl_common.crud_factory",
|
||
"pbl_common.errors",
|
||
"pbl_common.audit",
|
||
"pbl_common.tenant",
|
||
"pbl_common.context",
|
||
"pbl_common.dbutil",
|
||
"pbl_common.util",
|
||
"pbl_common",
|
||
"pbl_template",
|
||
"pbl_template.offline",
|
||
"pbl_blueprint.m1b",
|
||
)
|
||
|
||
# init.py 必须存在的装配面符号(QC #2:init.py:54 _sor / :66 esc / :344 load_m1b)
|
||
INIT_REQUIRED = (
|
||
"_sor", "esc", "load_m1b", "get_env", "require_tenant", "write_audit",
|
||
"tenant_crud", "PblError", "API_PATHS", "PAGE_PATHS", "CRUD_ALIASES",
|
||
)
|
||
|
||
|
||
def _read(path):
|
||
with io.open(path, "r", encoding="utf-8") as f:
|
||
return f.read()
|
||
|
||
|
||
def _write(path, text):
|
||
with io.open(path, "w", encoding="utf-8") as f:
|
||
f.write(text)
|
||
|
||
|
||
def py_files(root):
|
||
out = []
|
||
for base, dirs, files in os.walk(root):
|
||
dirs[:] = [d for d in dirs if d not in (".git", "__pycache__", ".selfcheck_pyc")]
|
||
for fn in sorted(files):
|
||
if fn.endswith(".py"):
|
||
out.append(os.path.join(base, fn))
|
||
return out
|
||
|
||
|
||
def compat_exports(root):
|
||
"""取 m1b_compat 的真实导出面(ast 解析,不 import,避免平台依赖)。"""
|
||
path = os.path.join(root, PKG, "m1b_compat.py")
|
||
if not os.path.exists(path):
|
||
return set()
|
||
tree = ast.parse(_read(path))
|
||
names = set()
|
||
for node in tree.body:
|
||
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
|
||
names.add(node.name)
|
||
elif isinstance(node, ast.Assign):
|
||
for t in node.targets:
|
||
if isinstance(t, ast.Name):
|
||
names.add(t.id)
|
||
elif isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name):
|
||
names.add(node.target.id)
|
||
# __all__ 字面量
|
||
for node in tree.body:
|
||
if isinstance(node, ast.Assign) and any(
|
||
isinstance(t, ast.Name) and t.id == "__all__" for t in node.targets):
|
||
try:
|
||
names.update(ast.literal_eval(node.value))
|
||
except Exception:
|
||
pass
|
||
return names
|
||
|
||
|
||
def module_symbols(path):
|
||
"""一个 .py 文件在模块命名空间暴露的符号(def/class/assign/import 别名)。"""
|
||
try:
|
||
tree = ast.parse(_read(path))
|
||
except Exception:
|
||
return set()
|
||
names = set()
|
||
for node in ast.walk(tree):
|
||
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
|
||
names.add(node.name)
|
||
elif isinstance(node, ast.Assign):
|
||
for t in node.targets:
|
||
if isinstance(t, ast.Name):
|
||
names.add(t.id)
|
||
elif isinstance(node, ast.Import):
|
||
for a in node.names:
|
||
names.add(a.asname or a.name.split(".")[0])
|
||
elif isinstance(node, ast.ImportFrom):
|
||
for a in node.names:
|
||
if a.name == "*":
|
||
continue
|
||
names.add(a.asname or a.name)
|
||
# 兼容层里 globals()[k]=v 的动态采纳也算导出
|
||
src = _read(path)
|
||
if "globals()[" in src:
|
||
names.update(compat_exports(os.path.dirname(os.path.dirname(path))))
|
||
return names
|
||
|
||
|
||
def resolve_module(root, modname):
|
||
"""模块名 -> 文件路径(包内优先,其次同仓其它模块)。"""
|
||
parts = modname.split(".")
|
||
cands = []
|
||
if modname.startswith(PKG):
|
||
rel = os.path.join(*parts) + ".py"
|
||
cands.append(os.path.join(root, rel))
|
||
cands.append(os.path.join(root, rel[:-3], "__init__.py"))
|
||
else:
|
||
ws = os.path.dirname(root) # modules/ (root=modules/pbl_blueprint)
|
||
cands.append(os.path.join(ws, parts[0], os.path.join(*parts) + ".py"))
|
||
cands.append(os.path.join(ws, parts[0], os.path.join(*parts), "__init__.py"))
|
||
for c in cands:
|
||
if os.path.exists(c):
|
||
return c
|
||
return None
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# PASS A/B:把断裂供给模块的 from-import 重定向到包内兼容层(逐符号)
|
||
# --------------------------------------------------------------------------
|
||
|
||
def fix_imports(root, path, exports, dry=False):
|
||
src = _read(path)
|
||
try:
|
||
tree = ast.parse(src)
|
||
except SyntaxError as exc:
|
||
return ["SYNTAX %s: %s" % (path, exc)], src
|
||
lines = src.split("\n")
|
||
edits = [] # (start_idx, end_idx, new_lines)
|
||
notes = []
|
||
selfmod = os.path.basename(path)[:-3]
|
||
for node in ast.walk(tree):
|
||
if not isinstance(node, ast.ImportFrom) or node.level:
|
||
continue
|
||
mod = node.module or ""
|
||
if node.level: # 相对导入:包内,跳过
|
||
continue
|
||
if mod == COMPAT_MOD or mod == "%s.m1b_compat" % PKG:
|
||
continue
|
||
if mod.startswith(PKG) and mod.split(".")[-1] == selfmod:
|
||
continue
|
||
target_file = resolve_module(root, mod)
|
||
avail = module_symbols(target_file) if target_file else set()
|
||
missing, kept = [], []
|
||
for a in node.names:
|
||
if a.name == "*":
|
||
kept.append(a)
|
||
continue
|
||
if target_file is None or a.name not in avail:
|
||
if a.name in exports:
|
||
missing.append(a)
|
||
else:
|
||
kept.append(a) # 兼容层也没有:保持原样,交由冒泡(QC #3 边界)
|
||
notes.append("UNRESOLVED %s:%d %s.%s" % (path, node.lineno, mod, a.name))
|
||
else:
|
||
kept.append(a)
|
||
if not missing:
|
||
continue
|
||
start = node.lineno - 1
|
||
end = getattr(node, "end_lineno", node.lineno) - 1
|
||
new = []
|
||
if kept:
|
||
new.append("%sfrom %s import (%s)" % (
|
||
" " * node.col_offset, mod,
|
||
", ".join(("%s as %s" % (a.name, a.asname)) if a.asname else a.name for a in kept)))
|
||
new.append("%sfrom %s import ( # M1b compat: %s 半迁移缺失符号改由包内兼容层供给" % (
|
||
" " * node.col_offset, COMPAT_MOD, mod))
|
||
for a in missing:
|
||
new.append(" %s," % (("%s as %s" % (a.name, a.asname)) if a.asname else a.name))
|
||
new.append(")")
|
||
edits.append((start, end, new))
|
||
for a in missing:
|
||
notes.append("REDIRECT %s:%d %s.%s -> %s" % (path, node.lineno, mod, a.name, COMPAT_MOD))
|
||
if edits and not dry:
|
||
for start, end, new in sorted(edits, key=lambda e: -e[0]):
|
||
lines[start:end + 1] = new
|
||
src = "\n".join(lines)
|
||
return notes, src
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# PASS C:init.py 装配面兜底(逐符号,绝不整块 import)
|
||
# --------------------------------------------------------------------------
|
||
|
||
def compat_block(symbols):
|
||
out = [MARK_BEGIN,
|
||
"# 由 tools/m1b_fix_import_closure_v2.py 幂等生成;不覆盖本文件既有同名定义。",
|
||
"# QC #1 铁律:逐符号 getattr 采纳,单符号缺失不牵连其余符号;",
|
||
"# 供给方 pbl_blueprint.m1b_compat 为包内自足模块(无裸跨包 import,恒可导入)。",
|
||
"_M1B_COMPAT_SYMBOLS = ("]
|
||
for s in symbols:
|
||
out.append(" %r," % s)
|
||
out += [
|
||
")",
|
||
"",
|
||
"try:",
|
||
" import pbl_blueprint.m1b_compat as _m1b_compat",
|
||
"except ImportError: # pragma: no cover",
|
||
" try:",
|
||
" from . import m1b_compat as _m1b_compat",
|
||
" except ImportError:",
|
||
" _m1b_compat = None",
|
||
"",
|
||
"if _m1b_compat is not None:",
|
||
" for _name in _M1B_COMPAT_SYMBOLS:",
|
||
" if _name in globals():",
|
||
" continue",
|
||
" _val = getattr(_m1b_compat, _name, None)",
|
||
" if _val is not None:",
|
||
" globals()[_name] = _val",
|
||
"",
|
||
"# 装配面硬保证:load_m1b / offline 必须可调用(api_blueprint.py:504 引用点)",
|
||
"if not callable(globals().get('load_m1b')) and _m1b_compat is not None:",
|
||
" globals()['load_m1b'] = _m1b_compat.load_m1b",
|
||
"if not callable(globals().get('offline')) and _m1b_compat is not None:",
|
||
" globals()['offline'] = _m1b_compat.offline",
|
||
MARK_END,
|
||
]
|
||
return "\n".join(out)
|
||
|
||
|
||
def ensure_block(path, symbols):
|
||
src = _read(path)
|
||
block = compat_block(symbols)
|
||
if MARK_BEGIN in src and MARK_END in src:
|
||
head, rest = src.split(MARK_BEGIN, 1)
|
||
_, tail = rest.split(MARK_END, 1)
|
||
new = head + block + tail
|
||
else:
|
||
new = src.rstrip("\n") + "\n\n\n" + block + "\n"
|
||
if new != src:
|
||
_write(path, new)
|
||
return True
|
||
return False
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 自检:ast 级 import 闭包核验(0 断裂才放行)
|
||
# --------------------------------------------------------------------------
|
||
|
||
def closure_check(root, scope_files):
|
||
breaks = []
|
||
for path in scope_files:
|
||
try:
|
||
tree = ast.parse(_read(path))
|
||
except SyntaxError as exc:
|
||
breaks.append((path, exc.lineno or 0, "SYNTAX", str(exc)))
|
||
continue
|
||
for node in ast.walk(tree):
|
||
if isinstance(node, ast.Import):
|
||
for a in node.names:
|
||
if a.name.split(".")[0] in (PKG, "pbl_common", "pbl_template"):
|
||
if resolve_module(root, a.name) is None:
|
||
breaks.append((path, node.lineno, a.name, "MODULE_NOT_FOUND"))
|
||
elif isinstance(node, ast.ImportFrom) and not node.level and node.module:
|
||
mod = node.module
|
||
if mod.split(".")[0] not in (PKG, "pbl_common", "pbl_template"):
|
||
continue
|
||
tf = resolve_module(root, mod)
|
||
if tf is None:
|
||
breaks.append((path, node.lineno, mod, "MODULE_NOT_FOUND"))
|
||
continue
|
||
avail = module_symbols(tf)
|
||
for a in node.names:
|
||
if a.name == "*":
|
||
continue
|
||
if a.name not in avail:
|
||
breaks.append((path, node.lineno, "%s.%s" % (mod, a.name),
|
||
"SYMBOL_MISSING"))
|
||
return breaks
|
||
|
||
|
||
_TMPC = os.path.join(HERE, "..", ".selfcheck_pyc")
|
||
|
||
|
||
def main(argv):
|
||
check_only = "--check-only" in argv
|
||
root = DEFAULT_ROOT
|
||
if "--root" in argv:
|
||
root = os.path.abspath(argv[argv.index("--root") + 1])
|
||
try:
|
||
os.makedirs(_TMPC, exist_ok=True)
|
||
except Exception:
|
||
pass
|
||
exports = compat_exports(root)
|
||
print("COMPAT_EXPORTS %d" % len(exports))
|
||
files = py_files(root)
|
||
print("SCANNED_FILES %d" % len(files))
|
||
|
||
notes = []
|
||
if not check_only:
|
||
for path in files:
|
||
n, src = fix_imports(root, path, exports)
|
||
notes.extend(n)
|
||
if src != _read(path):
|
||
_write(path, src)
|
||
init_py = os.path.join(root, PKG, "init.py")
|
||
if os.path.exists(init_py):
|
||
syms = tuple(sorted(set(INIT_REQUIRED) | set(exports)))
|
||
if ensure_block(init_py, syms):
|
||
notes.append("BLOCK init.py compat exports ensured (%d symbols)" % len(syms))
|
||
api_py = os.path.join(root, PKG, "api_blueprint.py")
|
||
if os.path.exists(api_py):
|
||
if ensure_block(api_py, tuple(sorted(set(exports) | {"offline"}))):
|
||
notes.append("BLOCK api_blueprint.py compat exports ensured")
|
||
tc = os.path.join(root, "tests", "test_contract.py")
|
||
if os.path.exists(tc):
|
||
if ensure_block(tc, ("API_PATHS", "PAGE_PATHS", "CRUD_ALIASES")):
|
||
notes.append("BLOCK tests/test_contract.py contract constants ensured")
|
||
for n in notes:
|
||
print(n)
|
||
|
||
# py_compile 全量
|
||
bad = []
|
||
for path in files:
|
||
try:
|
||
py_compile.compile(path, doraise=True, cfile=os.path.join(_TMPC, os.path.basename(path) + "c"))
|
||
except Exception as exc:
|
||
bad.append("%s: %s" % (path, exc))
|
||
print("PY_COMPILE_FAIL %d" % len(bad))
|
||
for b in bad:
|
||
print(" " + b)
|
||
|
||
breaks = closure_check(root, files)
|
||
inpkg = [b for b in breaks if b[0].startswith(os.path.join(root, PKG))
|
||
or b[0].startswith(os.path.join(root, "tests"))]
|
||
print("CLOSURE_BREAKS_PKG %d" % len(inpkg))
|
||
for b in inpkg:
|
||
print(" BREAK %s:%s %s [%s]" % b)
|
||
print("CLOSURE_BREAKS_ALL_SCANNED %d" % len(breaks))
|
||
return 0 if (not bad and not inpkg) else 1
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main(sys.argv[1:]))
|