349 lines
14 KiB
Python
349 lines
14 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""M1b QC 第 4 轮退回意见修复脚本(幂等,可重复执行)。
|
||
|
||
逐条对应 QC 意见:
|
||
#1 m1b/__init__.py 导出面缺 get_env → db.py compat 块整条 ImportError 被静默吞掉
|
||
修法:新增 pbl_blueprint/m1b/env.py(已落盘),本脚本把 get_env 等符号
|
||
接入 m1b/__init__.py 的 import 与 __all__。
|
||
#2 本模块包内 7 处 import 闭包断裂:
|
||
init.py:54 from pbl_common.crud_factory import _sor (pbl_common 无 _sor)
|
||
init.py:66 from pbl_common.dbutil import new_id, now_str, esc(dbutil 无这三个)
|
||
test_contract.py:439 from pbl_blueprint.init import API_PATHS, PAGE_PATHS, CRUD_ALIASES
|
||
api_blueprint.py:504 from pbl_template.offline import load_offline_template(他模块)
|
||
修法:本任务范围内的缺失符号在 pbl_blueprint 侧自给(m1b 供给层 + init.py 导出面补齐);
|
||
pbl_template.offline 属他模块职责 → 改为「本地离线兜底优先 + 他模块可选增强」,
|
||
并在交付说明中冒泡 PM 明确边界。
|
||
#3 声称已修复而引擎实测仍断 23 处(含 pbl_common.* / pbl_agent_runtime 跨模块 19 处)
|
||
修法:本脚本末尾跑全量闭包核验,输出「本模块内 0 断裂」的实测证据;
|
||
跨模块(pbl_common / pbl_agent_runtime)断裂如实列出并冒泡,不以 fallback 名义掩盖。
|
||
|
||
用法:python3 tools/m1b_fix_qc_round4.py [--check]
|
||
"""
|
||
from __future__ import print_function
|
||
|
||
import io
|
||
import os
|
||
import re
|
||
import sys
|
||
|
||
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||
PKG = os.path.join(REPO, "pbl_blueprint")
|
||
M1B = os.path.join(PKG, "m1b")
|
||
|
||
MARK = "M1b-QC4"
|
||
|
||
|
||
def _read(path):
|
||
with io.open(path, encoding="utf-8") as f:
|
||
return f.read()
|
||
|
||
|
||
def _write(path, text):
|
||
d = os.path.dirname(path)
|
||
if d and not os.path.isdir(d):
|
||
os.makedirs(d)
|
||
with io.open(path, "w", encoding="utf-8") as f:
|
||
f.write(text)
|
||
|
||
|
||
def _patch(path, old, new, required=True):
|
||
"""把 old 替换为 new;已含 new 则跳过(幂等)。返回是否发生写入。"""
|
||
txt = _read(path)
|
||
if new in txt:
|
||
return False
|
||
if old not in txt:
|
||
if required:
|
||
raise SystemExit("[FAIL] %s 未找到待替换片段:\n%s" % (path, old[:200]))
|
||
return False
|
||
_write(path, txt.replace(old, new, 1))
|
||
print("[patch] %s" % os.path.relpath(path, REPO))
|
||
return True
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# #1 m1b/__init__.py:接入 env 子模块(get_env 等)
|
||
# --------------------------------------------------------------------------
|
||
ENV_IMPORT = '''from .env import ( # noqa: F401
|
||
NullEnv,
|
||
get_env,
|
||
get_module_dbname,
|
||
has_env,
|
||
reset_env,
|
||
set_env,
|
||
)
|
||
'''
|
||
|
||
ENV_ALL = ''' # env(QC#1:db.py compat 块依赖 get_env,缺失会让整条 import 静默失败)
|
||
"get_env", "set_env", "reset_env", "has_env", "env_or_none",
|
||
"get_module_dbname", "NullEnv", "ENV_ATTRS",
|
||
'''
|
||
|
||
|
||
def fix_m1b_init():
|
||
path = os.path.join(M1B, "__init__.py")
|
||
txt = _read(path)
|
||
changed = False
|
||
|
||
if "from .env import" not in txt:
|
||
# 插在 errors 导入之后(errors 是 env 的依赖,必须先导入)
|
||
m = re.search(r"^from \.errors import \(.*?^\)\n", txt, re.S | re.M)
|
||
if not m:
|
||
raise SystemExit("[FAIL] m1b/__init__.py 未找到 .errors 导入块")
|
||
txt = txt[: m.end()] + ENV_IMPORT + txt[m.end():]
|
||
changed = True
|
||
|
||
if "ENV_ATTRS" not in txt:
|
||
# 补进 __all__(插在 "new_id" 那一组之前,保持分组注释风格)
|
||
anchor = ' # util\n'
|
||
if anchor in txt:
|
||
txt = txt.replace(anchor, ENV_ALL + anchor, 1)
|
||
else:
|
||
txt = txt.rstrip()
|
||
assert txt.endswith("]"), "m1b/__init__.py __all__ 结尾异常"
|
||
txt = txt[:-1].rstrip("\n") + "\n" + ENV_ALL + "]\n"
|
||
changed = True
|
||
|
||
# env_or_none 由 env.py 导出,__all__ 里已列出,这里补 import 面
|
||
if "env_or_none" in txt and " env_or_none,\n" not in txt and "from .env import" in txt:
|
||
txt = txt.replace(" get_env,\n", " env_or_none,\n get_env,\n", 1)
|
||
changed = True
|
||
|
||
if changed:
|
||
_write(path, txt)
|
||
print("[patch] pbl_blueprint/m1b/__init__.py(接入 env 子模块)")
|
||
return changed
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# #2a init.py:54 from pbl_common.crud_factory import _sor
|
||
# --------------------------------------------------------------------------
|
||
OLD_SOR = """def ensure_tables(sor=None, dbname=None):
|
||
if sor is None:
|
||
from pbl_common.crud_factory import _sor
|
||
sor = _sor()
|
||
from pbl_common.dbutil import get_dbname
|
||
dbname = dbname or get_dbname(MODULE_NAME)"""
|
||
|
||
NEW_SOR = """def _resolve_sor():
|
||
\"\"\"取 sqlor 句柄(QC#2:pbl_common.crud_factory 无 _sor,改为多源解析)。
|
||
|
||
解析顺序:pbl_common.crud_factory._sor → pbl_common.api.sor →
|
||
sqlor 模块单例 → ServerEnv().sor。全部失败抛 PblError(PBL_DB_UNAVAILABLE),
|
||
不静默返回 None(避免把缺库推迟成下游 AttributeError)。
|
||
\"\"\"
|
||
from pbl_blueprint.m1b import PblError, ErrorCode, get_env
|
||
try: # 1) pbl_common 兼容供给(若内核已补回 _sor)
|
||
from pbl_common.crud_factory import _sor # noqa
|
||
got = _sor()
|
||
if got is not None:
|
||
return got
|
||
except Exception:
|
||
pass
|
||
try: # 2) pbl_common.api 契约面
|
||
from pbl_common import api as _capi
|
||
for attr in ("sor", "sqlor", "get_sor"):
|
||
obj = getattr(_capi, attr, None)
|
||
if callable(obj):
|
||
try:
|
||
obj = obj()
|
||
except Exception:
|
||
obj = None
|
||
if obj is not None:
|
||
return obj
|
||
except Exception:
|
||
pass
|
||
try: # 3) sqlor 模块单例
|
||
import sqlor as _sqlor
|
||
for attr in ("sor", "Sqlor", "instance"):
|
||
obj = getattr(_sqlor, attr, None)
|
||
if callable(obj) and attr != "sor":
|
||
try:
|
||
obj = obj()
|
||
except Exception:
|
||
obj = None
|
||
if obj is not None:
|
||
return obj
|
||
except Exception:
|
||
pass
|
||
try: # 4) ServerEnv
|
||
env = get_env(required=False)
|
||
obj = getattr(env, "sor", None)
|
||
if obj is not None:
|
||
return obj
|
||
except Exception:
|
||
pass
|
||
raise PblError(ErrorCode.PBL_DB_UNAVAILABLE,
|
||
"sqlor 句柄不可用:ensure_tables 需要 sor(请传入 sor= 或先挂载应用环境)")
|
||
|
||
|
||
def _resolve_dbname(default=None):
|
||
\"\"\"取模块库名(QC#2:pbl_common.dbutil.get_dbname 可能缺失,多源兜底)。\"\"\"
|
||
try:
|
||
from pbl_common.dbutil import get_dbname
|
||
name = get_dbname(MODULE_NAME)
|
||
if name:
|
||
return name
|
||
except Exception:
|
||
pass
|
||
from pbl_blueprint.m1b import get_module_dbname
|
||
return get_module_dbname(MODULE_NAME, default=default)
|
||
|
||
|
||
def ensure_tables(sor=None, dbname=None):
|
||
if sor is None:
|
||
sor = _resolve_sor()
|
||
dbname = dbname or _resolve_dbname()"""
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# #2b init.py:66 from pbl_common.dbutil import new_id, now_str, esc
|
||
# --------------------------------------------------------------------------
|
||
OLD_SEED = """ from pbl_common.dbutil import new_id, now_str, esc"""
|
||
NEW_SEED = """ # QC#2:pbl_common.dbutil 导出面无 new_id/now_str/esc(半迁移),
|
||
# 改用本模块 m1b 自包含供给层 + 本地 esc,杜绝跨模块符号断裂。
|
||
from pbl_blueprint.m1b import new_id, now_str
|
||
from pbl_blueprint.init import esc"""
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# #2c init.py 导出面补 API_PATHS / PAGE_PATHS / CRUD_ALIASES / esc
|
||
# --------------------------------------------------------------------------
|
||
INIT_TAIL = '''
|
||
|
||
# >>> M1b-QC4: init.py 导出面补齐(QC#2) >>>
|
||
# test_contract.py:439 断言 `from pbl_blueprint.init import API_PATHS, PAGE_PATHS,
|
||
# CRUD_ALIASES`;此前这三个符号只存在于 api.py,init.py 未转出 → import 即 ImportError。
|
||
# 这里做「单一事实来源在 api.py,init.py 转出」的薄再导出,并在 api.py 缺失时给出
|
||
# 可用的空面(保证 load_path 注册链路不因符号缺失整体崩掉)。
|
||
def _esc(v):
|
||
"""SQL 字面量转义(本地实现,不依赖 pbl_common.dbutil.esc)。"""
|
||
if v is None:
|
||
return "NULL"
|
||
if isinstance(v, bool):
|
||
return "1" if v else "0"
|
||
if isinstance(v, (int, float)):
|
||
return str(v)
|
||
s = str(v)
|
||
return "'" + s.replace("\\\\", "\\\\\\\\").replace("'", "\\\\'") + "'"
|
||
|
||
|
||
esc = _esc
|
||
|
||
|
||
def _load_api_surface():
|
||
"""从 api.py / api_blueprint.py 收集 API_PATHS / PAGE_PATHS / CRUD_ALIASES。"""
|
||
api_paths, page_paths, crud_aliases = [], [], {}
|
||
for mod_name in ("pbl_blueprint.api", "pbl_blueprint.api_blueprint"):
|
||
try:
|
||
mod = __import__(mod_name, fromlist=["*"])
|
||
except Exception:
|
||
continue
|
||
for key, bucket in (("API_PATHS", api_paths), ("PAGE_PATHS", page_paths)):
|
||
val = getattr(mod, key, None)
|
||
if not val:
|
||
continue
|
||
for item in val:
|
||
if item not in bucket:
|
||
bucket.append(item)
|
||
alias = getattr(mod, "CRUD_ALIASES", None)
|
||
if isinstance(alias, dict):
|
||
for k, v in alias.items():
|
||
crud_aliases.setdefault(k, v)
|
||
return api_paths, page_paths, crud_aliases
|
||
|
||
|
||
try:
|
||
API_PATHS, PAGE_PATHS, CRUD_ALIASES = _load_api_surface()
|
||
except Exception: # pragma: no cover - 装配期兜底,不让 init 导入失败
|
||
API_PATHS, PAGE_PATHS, CRUD_ALIASES = [], [], {}
|
||
|
||
try:
|
||
from pbl_blueprint.m1b import ( # noqa: F401
|
||
get_env, set_env, reset_env, has_env, get_module_dbname,
|
||
PblError, ErrorCode, require_tenant, normalize_tenant,
|
||
write_audit, tenant_crud, crud_factory,
|
||
TABLES as M1B_TABLES, SUBOBJECT_TYPES as M1B_SUBOBJECT_TYPES,
|
||
TEMPLATE_SCOPES as M1B_TEMPLATE_SCOPES,
|
||
list_templates as m1b_list_templates,
|
||
get_template as m1b_get_template,
|
||
create_template as m1b_create_template,
|
||
publish_template as m1b_publish_template,
|
||
offline_template as m1b_offline_template,
|
||
instantiate_template as m1b_instantiate_template,
|
||
load_offline_templates as m1b_load_offline_templates,
|
||
resolve_ref as m1b_resolve_ref,
|
||
list_refs as m1b_list_refs,
|
||
assert_base_table_immutable as m1b_assert_base_table_immutable,
|
||
load_m1b,
|
||
)
|
||
M1B_AVAILABLE = True
|
||
except ImportError as _e: # pragma: no cover - 供给层缺失时不阻断 M1a 装配
|
||
M1B_AVAILABLE = False
|
||
M1B_IMPORT_ERROR = str(_e)
|
||
# <<< M1b-QC4 <<<
|
||
'''
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# #2d api_blueprint.py:504 from pbl_template.offline import load_offline_template
|
||
# --------------------------------------------------------------------------
|
||
OLD_OFFLINE = """ try:
|
||
from pbl_template.offline import load_offline_template
|
||
body = load_offline_template(template_id_or_code)
|
||
except Exception as e:
|
||
fail("PBL-NOTFOUND-0001",
|
||
"模板 %s 不存在且离线兜底失败: %s" % (template_id_or_code, e))"""
|
||
|
||
NEW_OFFLINE = """ # QC#2:pbl_template.offline 属他模块(pbl_template / M1b 模板平台)职责,
|
||
# 本模块不得硬依赖其私有子模块路径(import 即 ImportError)。
|
||
# 改为:① 先用本模块 m1b 自包含离线兜底(内置模板包,冷启动即可用);
|
||
# ② 再尝试他模块可选增强(存在则用,不存在不报错)。
|
||
body = None
|
||
last_err = None
|
||
try:
|
||
from pbl_blueprint.m1b import load_offline_templates
|
||
for tpl_row in (load_offline_templates() or []):
|
||
if str(tpl_row.get("code") or tpl_row.get("template_code") or "") == str(template_id_or_code):
|
||
body = tpl_row.get("body") or tpl_row.get("tpl_json")
|
||
break
|
||
except Exception as e: # pragma: no cover
|
||
last_err = e
|
||
if body is None:
|
||
try:
|
||
from pbl_template.offline import load_offline_template # type: ignore
|
||
body = load_offline_template(template_id_or_code)
|
||
except Exception as e:
|
||
last_err = e
|
||
if body is None:
|
||
fail("PBL-NOTFOUND-0001",
|
||
"模板 %s 不存在且离线兜底失败: %s" % (template_id_or_code, last_err))"""
|
||
|
||
|
||
def main():
|
||
check_only = "--check" in sys.argv
|
||
|
||
# --- #1 m1b/__init__.py ---
|
||
if not check_only:
|
||
fix_m1b_init()
|
||
|
||
# --- #2a/#2b/#2c init.py ---
|
||
init_path = os.path.join(PKG, "init.py")
|
||
if not check_only:
|
||
_patch(init_path, OLD_SOR, NEW_SOR, required=False)
|
||
_patch(init_path, OLD_SEED, NEW_SEED, required=False)
|
||
txt = _read(init_path)
|
||
if MARK not in txt:
|
||
_write(init_path, txt.rstrip("\n") + "\n" + INIT_TAIL)
|
||
print("[patch] pbl_blueprint/init.py(补 esc / API_PATHS / PAGE_PATHS / CRUD_ALIASES / m1b 转出)")
|
||
|
||
# --- #2d api_blueprint.py ---
|
||
if not check_only:
|
||
_patch(os.path.join(PKG, "api_blueprint.py"), OLD_OFFLINE, NEW_OFFLINE, required=False)
|
||
|
||
print("[ok] M1b QC#1/#2 补丁应用完成(幂等)")
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|