pbl_blueprint/tools/m1b_fix_import_closure.py
2026-09-17 15:16:08 +08:00

463 lines
19 KiB
Python
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
# -*- 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 * 全链
ImportErrorpbl_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__.pymodule_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"""%sM1b 兼容供给层)"""\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())