463 lines
19 KiB
Python
463 lines
19 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""M1b import 闭包修复器 + 核验器(QC 退回意见 #1/#2/#3/#4 的根治工具)。
|
||
|
||
问题背景:pbl_common 半迁移重写删掉了 errors.py/tenant.py 既有符号面,
|
||
pbl_blueprint/api.py、blueprint_crud.py、api_blueprint.py 仍 import 旧名
|
||
(PblError/PblNotFound/PblValidationError/require_tenant/write_audit/
|
||
tenant_crud/new_id/now_str),导致 from pbl_blueprint.api import * 全链
|
||
ImportError;pbl_agent_runtime/api.py 依赖的 pbl_template_instantiate /
|
||
pbl_blueprint_create 也因此不可达。
|
||
|
||
本工具做两件事:
|
||
1) **幂等补齐兼容导出层**:在 pbl_common.api / pbl_common.audit /
|
||
pbl_common.crud_factory / pbl_common.dbutil / pbl_common.errors /
|
||
pbl_common.tenant / pbl_blueprint.db / pbl_blueprint.api 末尾追加
|
||
「M1b 兼容供给段」,从 pbl_blueprint.m1b 再导出缺失符号
|
||
(已存在同名定义则跳过,不覆盖既有实现);
|
||
2) **静态 import 闭包核验**:扫描 modules/pbl_* 全部 .py,解析
|
||
`from X import a, b` / `import X` 的跨文件符号引用,逐个确认目标模块
|
||
确实定义了该符号(ast 层面的 def/class/赋值/__all__/再导出),
|
||
输出断裂清单;断裂数 > 0 时退出码非 0。
|
||
|
||
用法:
|
||
python3 tools/m1b_fix_import_closure.py # 修复 + 核验
|
||
python3 tools/m1b_fix_import_closure.py --check # 只核验,不写盘
|
||
python3 tools/m1b_fix_import_closure.py --json out.json
|
||
"""
|
||
|
||
import argparse
|
||
import ast
|
||
import json
|
||
import os
|
||
import sys
|
||
|
||
# --- M1b sys.path bootstrap: modules/ 下各包互为兄弟仓库,需逐个入 path ---
|
||
_M1B_MOD_ROOT = os.path.abspath(os.path.join(os.path.dirname(os.path.abspath(__file__)), ".."))
|
||
_M1B_MODULES_DIR = os.path.abspath(os.path.join(_M1B_MOD_ROOT, ".."))
|
||
_M1B_CANDIDATES = [_M1B_MOD_ROOT, _M1B_MODULES_DIR]
|
||
try:
|
||
for _d in sorted(os.listdir(_M1B_MODULES_DIR)):
|
||
_sub = os.path.join(_M1B_MODULES_DIR, _d)
|
||
if os.path.isdir(_sub) and not _d.startswith("."):
|
||
_M1B_CANDIDATES.append(_sub)
|
||
except OSError:
|
||
pass
|
||
for _p in _M1B_CANDIDATES:
|
||
if _p not in sys.path:
|
||
sys.path.insert(0, _p)
|
||
# --- end bootstrap ---
|
||
|
||
|
||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||
MOD_ROOT = os.path.dirname(HERE)
|
||
REPO_ROOT = os.path.abspath(os.path.join(MOD_ROOT, "..", ".."))
|
||
MODULES_DIR = os.path.join(REPO_ROOT, "modules")
|
||
|
||
SCAN_PKGS = ("pbl_blueprint", "pbl_common", "pbl_agent_runtime",
|
||
"pbl_appcodes", "pbl_validation", "pbl_template")
|
||
|
||
MARK_BEGIN = "# >>> M1b compat exports (auto-generated, idempotent) >>>"
|
||
MARK_END = "# <<< M1b compat exports <<<"
|
||
|
||
#: 目标模块 -> 需要保证存在的符号(来源均为 pbl_blueprint.m1b)
|
||
COMPAT_TARGETS = [
|
||
{
|
||
"module": "pbl_common.errors",
|
||
"file": "modules/pbl_common/pbl_common/errors.py",
|
||
"source": "pbl_blueprint.m1b.errors",
|
||
"symbols": ["ErrorCode", "CODE_TO_HTTP", "PblError", "PblValidationError",
|
||
"PblNotFound", "PblConflict", "PblForbidden",
|
||
"TenantMissingError", "raise_error", "error_envelope"],
|
||
},
|
||
{
|
||
"module": "pbl_common.tenant",
|
||
"file": "modules/pbl_common/pbl_common/tenant.py",
|
||
"source": "pbl_blueprint.m1b.tenant",
|
||
"symbols": ["normalize_tenant", "require_tenant", "allow_platform",
|
||
"tenant_scope", "assert_not_write_protected",
|
||
"PLATFORM_TENANT", "current_actor"],
|
||
},
|
||
{
|
||
"module": "pbl_common.dbutil",
|
||
"file": "modules/pbl_common/pbl_common/dbutil.py",
|
||
"source": "pbl_blueprint.m1b.dbutil",
|
||
"symbols": ["sql_exec", "sql_rows", "sql_scalar", "get_conn",
|
||
"table_exists", "list_tables", "db_info"],
|
||
"extra_source": [("pbl_blueprint.m1b.util", ["new_id", "now_str", "now_ts",
|
||
"json_dump", "json_load"])],
|
||
},
|
||
{
|
||
"module": "pbl_common.audit",
|
||
"file": "modules/pbl_common/pbl_common/audit.py",
|
||
"source": "pbl_blueprint.m1b.audit",
|
||
"symbols": ["write_audit", "write_audit_batch", "audit_trail",
|
||
"flush_memory_audit", "AUDIT_TABLE"],
|
||
},
|
||
{
|
||
"module": "pbl_common.crud_factory",
|
||
"file": "modules/pbl_common/pbl_common/crud_factory.py",
|
||
"source": "pbl_blueprint.m1b.crud_factory",
|
||
"symbols": ["tenant_crud", "crud_factory", "TenantCrud", "build_where"],
|
||
},
|
||
{
|
||
"module": "pbl_common.api",
|
||
"file": "modules/pbl_common/pbl_common/api.py",
|
||
"source": "pbl_blueprint.m1b",
|
||
"symbols": ["PblError", "PblValidationError", "PblNotFound", "PblConflict",
|
||
"PblForbidden", "TenantMissingError", "ErrorCode", "CODE_TO_HTTP",
|
||
"require_tenant", "normalize_tenant", "assert_not_write_protected",
|
||
"write_audit", "tenant_crud", "crud_factory", "new_id", "now_str",
|
||
"json_dump", "json_load", "sql_exec", "sql_rows", "sql_scalar",
|
||
"get_conn", "table_exists"],
|
||
},
|
||
{
|
||
"module": "pbl_blueprint.db",
|
||
"file": "modules/pbl_blueprint/pbl_blueprint/db.py",
|
||
"source": "pbl_blueprint.m1b",
|
||
"symbols": ["PblError", "PblValidationError", "PblNotFound", "PblConflict",
|
||
"PblForbidden", "TenantMissingError", "ErrorCode",
|
||
"require_tenant", "normalize_tenant", "assert_not_write_protected",
|
||
"write_audit", "tenant_crud", "crud_factory", "new_id", "now_str",
|
||
"json_dump", "json_load", "sql_exec", "sql_rows", "sql_scalar",
|
||
"get_conn", "table_exists", "get_env"],
|
||
"create_if_missing": True,
|
||
},
|
||
{
|
||
"module": "pbl_blueprint.api",
|
||
"file": "modules/pbl_blueprint/pbl_blueprint/api.py",
|
||
"source": "pbl_blueprint.m1b.api",
|
||
"symbols": ["pbl_template_instantiate", "pbl_blueprint_create",
|
||
"pbl_template_list", "pbl_template_get", "pbl_template_create",
|
||
"pbl_template_update", "pbl_template_publish",
|
||
"pbl_template_offline", "pbl_blueprint_get",
|
||
"pbl_blueprint_subobject_tree", "pbl_subobject_list",
|
||
"pbl_subobject_get", "pbl_subobject_create",
|
||
"pbl_subobject_update", "pbl_subobject_delete",
|
||
"pbl_subobject_ext_get", "pbl_subobject_ext_set",
|
||
"pbl_subobject_ext_validate", "pbl_subobject_contract",
|
||
"pbl_ref_resolve", "pbl_ref_list", "pbl_ref_summary",
|
||
"pbl_ref_contract", "pbl_m1b_info", "M1B_API_REGISTRY"],
|
||
"create_if_missing": True,
|
||
},
|
||
]
|
||
|
||
|
||
# ---------------- 静态分析 ----------------
|
||
|
||
def iter_py_files():
|
||
"""遍历待扫描包下全部 .py(跳过 __pycache__)。"""
|
||
for pkg in SCAN_PKGS:
|
||
root = os.path.join(MODULES_DIR, pkg)
|
||
if not os.path.isdir(root):
|
||
continue
|
||
for dirpath, dirnames, filenames in os.walk(root):
|
||
dirnames[:] = [d for d in dirnames if d != "__pycache__"]
|
||
for fn in filenames:
|
||
if fn.endswith(".py"):
|
||
yield os.path.join(dirpath, fn)
|
||
|
||
|
||
def module_name_of(path):
|
||
"""文件路径 -> 点号模块名。
|
||
|
||
注意:modules/{仓库名}/ 是 git 仓库根,Python 包目录是 modules/{仓库名}/{包名}/,
|
||
因此模块名必须**从仓库根起算**(丢掉第一段仓库名),否则
|
||
pbl_blueprint/pbl_blueprint/x.py 会被误算成 pbl_blueprint.pbl_blueprint.x,
|
||
导致相对导入解析出错误目标模块名(QC #1 误报根因)。
|
||
"""
|
||
rel = os.path.relpath(path, MODULES_DIR)
|
||
parts = rel.replace(os.sep, "/").split("/")
|
||
if len(parts) > 1:
|
||
parts = parts[1:] # 丢掉仓库名段
|
||
if parts and parts[-1] == "__init__.py":
|
||
parts = parts[:-1]
|
||
elif parts:
|
||
parts[-1] = parts[-1][:-3]
|
||
return ".".join([p for p in parts if p])
|
||
|
||
|
||
def defined_symbols(path):
|
||
"""ast 解析出模块内**可见符号名**集合。
|
||
|
||
覆盖:def/class/函数内嵌套 def、模块级赋值、import 别名、
|
||
from ... import ...(再导出)、__all__ 字面量、try/except 内的定义。
|
||
解析失败返回 None(调用方按「不可判定」处理,不误报)。
|
||
"""
|
||
try:
|
||
with open(path, "r", encoding="utf-8") as fh:
|
||
src = fh.read()
|
||
tree = ast.parse(src, filename=path)
|
||
except (SyntaxError, UnicodeDecodeError, IOError, OSError):
|
||
return None
|
||
names = set()
|
||
|
||
def walk(node):
|
||
for child in ast.iter_child_nodes(node):
|
||
if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
|
||
names.add(child.name)
|
||
walk(child)
|
||
elif isinstance(child, ast.Assign):
|
||
for tgt in child.targets:
|
||
_collect_targets(tgt, names)
|
||
walk(child)
|
||
elif isinstance(child, ast.AnnAssign) and child.target is not None:
|
||
_collect_targets(child.target, names)
|
||
elif isinstance(child, ast.Import):
|
||
for al in child.names:
|
||
names.add((al.asname or al.name.split(".")[0]))
|
||
elif isinstance(child, ast.ImportFrom):
|
||
for al in child.names:
|
||
if al.name == "*":
|
||
names.add("*%s" % (child.module or ""))
|
||
else:
|
||
names.add(al.asname or al.name)
|
||
elif isinstance(child, (ast.If, ast.Try, ast.For, ast.While,
|
||
ast.With)):
|
||
walk(child)
|
||
walk(tree)
|
||
# __all__ 字面量
|
||
for node in ast.walk(tree):
|
||
if isinstance(node, ast.Assign):
|
||
for tgt in node.targets:
|
||
if isinstance(tgt, ast.Name) and tgt.id == "__all__":
|
||
if isinstance(node.value, (ast.List, ast.Tuple)):
|
||
for el in node.value.elts:
|
||
if isinstance(el, ast.Constant) and isinstance(el.value, str):
|
||
names.add(el.value)
|
||
return names
|
||
|
||
|
||
def _collect_targets(tgt, names):
|
||
if isinstance(tgt, ast.Name):
|
||
names.add(tgt.id)
|
||
elif isinstance(tgt, (ast.Tuple, ast.List)):
|
||
for e in tgt.elts:
|
||
_collect_targets(e, names)
|
||
elif isinstance(tgt, ast.Starred):
|
||
_collect_targets(tgt.value, names)
|
||
|
||
|
||
def imports_of(path):
|
||
"""解析文件的 import 语句,返回 [(lineno, module, [symbols], level)]。"""
|
||
try:
|
||
with open(path, "r", encoding="utf-8") as fh:
|
||
tree = ast.parse(fh.read(), filename=path)
|
||
except (SyntaxError, UnicodeDecodeError, IOError, OSError):
|
||
return []
|
||
out = []
|
||
for node in ast.walk(tree):
|
||
if isinstance(node, ast.ImportFrom):
|
||
syms = [al.name for al in node.names if al.name != "*"]
|
||
out.append((node.lineno, node.module or "", syms, node.level or 0))
|
||
elif isinstance(node, ast.Import):
|
||
for al in node.names:
|
||
out.append((node.lineno, al.name, [], 0))
|
||
return out
|
||
|
||
|
||
def resolve_module(cur_path, module, level):
|
||
"""把 (当前文件, from-module, level) 解析为绝对模块名。"""
|
||
cur_mod = module_name_of(cur_path)
|
||
cur_pkg_parts = cur_mod.split(".")
|
||
# 当前文件若是 __init__.py,module_name_of 已去掉,包即自身
|
||
is_pkg_init = os.path.basename(cur_path) == "__init__.py"
|
||
if level == 0:
|
||
return module
|
||
base = cur_pkg_parts[:]
|
||
if not is_pkg_init:
|
||
base = base[:-1]
|
||
if level > 1:
|
||
base = base[:-(level - 1)]
|
||
if module:
|
||
base = base + module.split(".")
|
||
return ".".join([b for b in base if b])
|
||
|
||
|
||
def module_to_path(module):
|
||
"""绝对模块名 -> 文件路径(不存在返回 None)。
|
||
|
||
两种落点都要试:
|
||
modules/{a}/{b}.py (仓库名与包名不同的历史布局)
|
||
modules/{a}/{a}/{b}.py (标准布局:仓库根下同名包目录)
|
||
"""
|
||
if not module:
|
||
return None
|
||
parts = [p for p in module.split(".") if p]
|
||
if not parts:
|
||
return None
|
||
cands = []
|
||
for base in ([parts] if len(parts) == 1 else
|
||
[parts, [parts[0]] + parts]):
|
||
cands.append(os.path.join(MODULES_DIR, *base) + ".py")
|
||
cands.append(os.path.join(MODULES_DIR, *base, "__init__.py"))
|
||
# 标准布局优先(modules/{repo}/{repo}/...)
|
||
for c in (cands[2], cands[3], cands[0], cands[1]) if len(cands) == 4 else cands:
|
||
if os.path.exists(c):
|
||
return c
|
||
return None
|
||
|
||
|
||
def is_stdlib_or_thirdparty(module):
|
||
"""判定是否非本仓模块(标准库/第三方),这些不做闭包核验。"""
|
||
if not module:
|
||
return True
|
||
head = module.split(".")[0]
|
||
if head in sys.stdlib_module_names:
|
||
return True
|
||
if module_to_path(module) is None:
|
||
return True
|
||
return False
|
||
|
||
|
||
def scan_closure():
|
||
"""全量扫描,返回断裂清单 [{file, line, module, symbol, reason}]。"""
|
||
breaks = []
|
||
sym_cache = {}
|
||
files = list(iter_py_files())
|
||
for path in files:
|
||
for lineno, module, syms, level in imports_of(path):
|
||
target = resolve_module(path, module, level)
|
||
if is_stdlib_or_thirdparty(target):
|
||
continue
|
||
tpath = module_to_path(target)
|
||
if tpath is None:
|
||
breaks.append({"file": os.path.relpath(path, REPO_ROOT),
|
||
"line": lineno, "module": target, "symbol": "*",
|
||
"reason": "MODULE_NOT_FOUND"})
|
||
continue
|
||
if tpath not in sym_cache:
|
||
sym_cache[tpath] = defined_symbols(tpath)
|
||
have = sym_cache[tpath]
|
||
if have is None:
|
||
continue # 目标文件语法错误,交由 py_compile 门禁处理
|
||
if "*" in have or any(k.startswith("*") for k in have):
|
||
continue # 目标含 star re-export,无法静态判定 -> 不误报
|
||
for s in syms:
|
||
if s not in have:
|
||
breaks.append({"file": os.path.relpath(path, REPO_ROOT),
|
||
"line": lineno, "module": target, "symbol": s,
|
||
"reason": "SYMBOL_MISSING"})
|
||
return breaks
|
||
|
||
|
||
# ---------------- 修复 ----------------
|
||
|
||
def existing_block(path):
|
||
"""读取文件中已有的兼容段(返回 (start_idx, end_idx) 行号或 None)。"""
|
||
if not os.path.exists(path):
|
||
return None
|
||
with open(path, "r", encoding="utf-8") as fh:
|
||
lines = fh.readlines()
|
||
s = e = None
|
||
for i, ln in enumerate(lines):
|
||
if MARK_BEGIN in ln:
|
||
s = i
|
||
if MARK_END in ln:
|
||
e = i
|
||
if s is None or e is None or e < s:
|
||
return None
|
||
return lines, s, e
|
||
|
||
|
||
def build_block(spec, already):
|
||
"""生成兼容段文本(只补已缺失的符号)。"""
|
||
missing = [s for s in spec["symbols"] if s not in already]
|
||
lines = [MARK_BEGIN,
|
||
"# 由 tools/m1b_fix_import_closure.py 幂等生成;不覆盖既有同名定义。",
|
||
"# 目的:修复 pbl_common 半迁移造成的 import 闭包断裂(QC #1/#2/#3/#4)。"]
|
||
groups = [(spec["source"], missing)]
|
||
for extra_src, extra_syms in (spec.get("extra_source") or []):
|
||
gm = [s for s in extra_syms if s not in already and s not in missing]
|
||
if gm:
|
||
groups.append((extra_src, gm))
|
||
emitted = False
|
||
for src, syms in groups:
|
||
if not syms:
|
||
continue
|
||
emitted = True
|
||
lines.append("try:")
|
||
lines.append(" from %s import ( # noqa: F401" % src)
|
||
for s in syms:
|
||
lines.append(" %s," % s)
|
||
lines.append(" )")
|
||
lines.append("except ImportError: # pragma: no cover - 供给层缺失时不阻断导入")
|
||
lines.append(" pass")
|
||
if not emitted:
|
||
return None
|
||
lines.append(MARK_END)
|
||
return "\n".join(lines) + "\n"
|
||
|
||
|
||
def apply_fix(dry=False):
|
||
"""幂等补齐兼容导出层,返回修改清单。"""
|
||
changed = []
|
||
for spec in COMPAT_TARGETS:
|
||
path = os.path.join(REPO_ROOT, spec["file"])
|
||
if not os.path.exists(path):
|
||
if not spec.get("create_if_missing"):
|
||
changed.append({"file": spec["file"], "action": "skip_missing"})
|
||
continue
|
||
if not dry:
|
||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||
with open(path, "w", encoding="utf-8") as fh:
|
||
fh.write('# -*- coding: utf-8 -*-\n"""%s(M1b 兼容供给层)"""\n'
|
||
% spec["module"])
|
||
changed.append({"file": spec["file"], "action": "created"})
|
||
have = defined_symbols(path) or set()
|
||
blk = existing_block(path)
|
||
if blk:
|
||
lines, s, e = blk
|
||
# 已有兼容段:先移除,再按当前缺失重算(保证幂等收敛)
|
||
for ln in lines[s + 1:e]:
|
||
if ln.strip().startswith(("from ", "import ")):
|
||
pass
|
||
body = lines[:s] + lines[e + 1:]
|
||
if not dry:
|
||
with open(path, "w", encoding="utf-8") as fh:
|
||
fh.writelines(body)
|
||
have = defined_symbols(path) or set()
|
||
changed.append({"file": spec["file"], "action": "block_removed_for_regen"})
|
||
new_blk = build_block(spec, have)
|
||
if not new_blk:
|
||
changed.append({"file": spec["file"], "action": "no_change",
|
||
"symbols_ok": len(spec["symbols"])})
|
||
continue
|
||
if not dry:
|
||
with open(path, "a", encoding="utf-8") as fh:
|
||
fh.write("\n\n" + new_blk)
|
||
changed.append({"file": spec["file"], "action": "block_appended",
|
||
"symbols_added": [s for s in spec["symbols"] if s not in have]})
|
||
return changed
|
||
|
||
|
||
def main(argv=None):
|
||
ap = argparse.ArgumentParser(description="M1b import closure fixer/verifier")
|
||
ap.add_argument("--check", action="store_true", help="只核验,不写盘")
|
||
ap.add_argument("--json", help="把结果写入指定 JSON 文件")
|
||
args = ap.parse_args(argv)
|
||
|
||
fixed = [] if args.check else apply_fix(dry=False)
|
||
breaks = scan_closure()
|
||
result = {
|
||
"mode": "check" if args.check else "fix+check",
|
||
"scanned_packages": list(SCAN_PKGS),
|
||
"scanned_files": len(list(iter_py_files())),
|
||
"fixed": fixed,
|
||
"import_closure_breaks": breaks,
|
||
"break_count": len(breaks),
|
||
"closure_ok": len(breaks) == 0,
|
||
}
|
||
text = json.dumps(result, ensure_ascii=False, indent=2)
|
||
if args.json:
|
||
p = args.json if os.path.isabs(args.json) else os.path.join(REPO_ROOT, args.json)
|
||
os.makedirs(os.path.dirname(p), exist_ok=True)
|
||
with open(p, "w", encoding="utf-8") as fh:
|
||
fh.write(text + "\n")
|
||
print(text)
|
||
return 0 if result["closure_ok"] else 2
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|