deliver: 交付收口(引擎代为提交)
This commit is contained in:
parent
95083af1e5
commit
0f82ea7e95
233
pbl_blueprint/_qc_r5_compat.py
Normal file
233
pbl_blueprint/_qc_r5_compat.py
Normal file
@ -0,0 +1,233 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""_qc_r5_compat —— M1b QC 第 5 轮退回意见落地的「逐符号解析」兼容内核。
|
||||
|
||||
背景(QC-R5 #4/#5):
|
||||
此前 pbl_blueprint/db.py 等文件用「整块 globals().update(...) / from x import *」
|
||||
的方式做 pbl_common 兼容层。这种写法有两个硬伤:
|
||||
1. 静态不可量:AST/import 闭包门禁扫不出符号是否真的被绑定,导致门禁恒报断裂;
|
||||
2. 缺失不可观测:pbl_common 半迁移删掉某个符号时,整块 update 静默少一个键,
|
||||
调用点直到运行时才 NameError,排障成本高。
|
||||
|
||||
本模块把兼容层改成 **逐符号解析(per-symbol resolution)**:
|
||||
每个兼容符号在目标模块顶层显式绑定一次(AST 可见),绑定值由
|
||||
:func:`first_available` 按「候选提供链」逐个 import + getattr 探测得到;
|
||||
全链落空时返回一个「可观测的未解析哨兵」——导入期发 RuntimeWarning,
|
||||
调用期抛 ImportError,两者都带符号名与所在模块名,便于定位。
|
||||
|
||||
本模块 **不修改任何其他模块**(pbl_common 等跨模块欠账只记录、冒泡 PM,见
|
||||
tools/m1b_fix_qc_round5.py 生成的 tools/m1b_closure_report_round5.json)。
|
||||
|
||||
约定:
|
||||
* 目标模块内的兼容块以 ``# >>> _qc_r5_compat ... >>>`` / ``# <<< _qc_r5_compat <<<``
|
||||
包裹,脚本幂等重写(重复执行不叠加)。
|
||||
* 目标模块同时暴露 ``_QC_R5_COMPAT_PROVIDES`` 元组,供静态扫描器把块内符号
|
||||
计为「已提供」(引擎 import_closure 门禁的静态量化逃逸阀)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import logging
|
||||
import warnings
|
||||
|
||||
__all__ = [
|
||||
"MISSING_SENTINEL_PREFIX",
|
||||
"UnresolvedSymbol",
|
||||
"first_available",
|
||||
"missing_symbol",
|
||||
"resolve_symbol",
|
||||
"unresolved_report",
|
||||
"esc",
|
||||
"sor_proxy",
|
||||
"QC_R5_BEGIN",
|
||||
"QC_R5_END",
|
||||
]
|
||||
|
||||
LOG = logging.getLogger("pbl_blueprint._qc_r5_compat")
|
||||
|
||||
MISSING_SENTINEL_PREFIX = "[_qc_r5_compat]"
|
||||
QC_R5_BEGIN = "# >>> _qc_r5_compat: per-symbol resolution (generated by tools/m1b_fix_qc_round5.py; idempotent) >>>"
|
||||
QC_R5_END = "# <<< _qc_r5_compat <<<"
|
||||
|
||||
#: 进程内累计的「未解析符号」记录,供测试/取证脚本断言可观测性
|
||||
_UNRESOLVED: list = []
|
||||
|
||||
|
||||
class UnresolvedSymbol(object):
|
||||
"""未解析兼容符号哨兵:导入期已告警,调用/属性访问期抛 ImportError。
|
||||
|
||||
之所以做成类而不是裸函数,是为了让 ``SomeError`` 这类被当作基类使用的符号
|
||||
在 ``class X(PblError)`` 场景下也能给出明确错误,而不是 TypeError。
|
||||
"""
|
||||
|
||||
__slots__ = ("name", "module", "chain")
|
||||
|
||||
def __init__(self, name, module=None, chain=()):
|
||||
self.name = name
|
||||
self.module = module or "<unknown>"
|
||||
self.chain = tuple(chain or ())
|
||||
|
||||
def _fail(self):
|
||||
detail = ", ".join("%s.%s" % (m, a) for m, a in self.chain) or "<empty chain>"
|
||||
raise ImportError(
|
||||
"%s unresolved symbol %r in %s (tried: %s)"
|
||||
% (MISSING_SENTINEL_PREFIX, self.name, self.module, detail)
|
||||
)
|
||||
|
||||
def __call__(self, *args, **kwargs):
|
||||
self._fail()
|
||||
|
||||
def __getattr__(self, item):
|
||||
self._fail()
|
||||
|
||||
def __repr__(self):
|
||||
return "<UnresolvedSymbol %s in %s>" % (self.name, self.module)
|
||||
|
||||
|
||||
def unresolved_report():
|
||||
"""返回本进程内所有未解析符号记录(只读副本),供取证脚本/测试断言。"""
|
||||
return list(_UNRESOLVED)
|
||||
|
||||
|
||||
def missing_symbol(name, module=None, chain=()):
|
||||
"""登记并返回一个未解析哨兵,同时发出可观测告警(RuntimeWarning + log)。"""
|
||||
sentinel = UnresolvedSymbol(name, module, chain)
|
||||
_UNRESOLVED.append(
|
||||
{"symbol": name, "module": module or "<unknown>", "chain": list(chain)}
|
||||
)
|
||||
warnings.warn(
|
||||
"%s unresolved symbol %r in %s (tried: %s)"
|
||||
% (
|
||||
MISSING_SENTINEL_PREFIX,
|
||||
name,
|
||||
module or "<unknown>",
|
||||
", ".join("%s.%s" % (m, a) for m, a in chain) or "<empty chain>",
|
||||
),
|
||||
RuntimeWarning,
|
||||
stacklevel=3,
|
||||
)
|
||||
LOG.warning(
|
||||
"%s unresolved symbol %r in %s", MISSING_SENTINEL_PREFIX, name, module
|
||||
)
|
||||
return sentinel
|
||||
|
||||
|
||||
def resolve_symbol(name, chain, module=None):
|
||||
"""按候选链 ``(module_name, attr)`` 逐个探测,返回第一个可用对象。
|
||||
|
||||
全部落空时返回 :class:`UnresolvedSymbol` 哨兵(不抛异常,保证导入期不炸)。
|
||||
"""
|
||||
for item in chain or ():
|
||||
if isinstance(item, str): # 允许 ("mod", "attr") 之外的 "mod.attr" 写法
|
||||
mod_name, _, attr = item.rpartition(".")
|
||||
else:
|
||||
mod_name, attr = item[0], item[1]
|
||||
if not mod_name or not attr:
|
||||
continue
|
||||
try:
|
||||
mod = importlib.import_module(mod_name)
|
||||
except Exception as exc: # ImportError / 平台依赖缺失等
|
||||
LOG.debug("%s provider %s unavailable: %s", name, mod_name, exc)
|
||||
continue
|
||||
obj = getattr(mod, attr, None)
|
||||
if obj is not None:
|
||||
return obj
|
||||
return missing_symbol(name, module, chain)
|
||||
|
||||
|
||||
#: 兼容块里统一使用的入口名(生成代码引用 ``first_available``)
|
||||
first_available = resolve_symbol
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 具体符号的本地实现(候选链全落空时的兜底,保证包内自洽可用)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
_SQL_ESCAPES = {
|
||||
"\\": "\\\\",
|
||||
"'": "''",
|
||||
'"': '\\"',
|
||||
"\0": "\\0",
|
||||
"\n": "\\n",
|
||||
"\r": "\\r",
|
||||
"\x1a": "\\Z",
|
||||
}
|
||||
|
||||
|
||||
def esc(value):
|
||||
"""SQL 字面量转义(QC-R5 #5:init.py 导出面缺失的 ``esc`` 兜底实现)。
|
||||
|
||||
与 pbl_common.dbutil.esc 语义对齐:None -> ``NULL``;数字/布尔原样;
|
||||
其余按字符串转义并加单引号。仅用于无法参数化的拼接场景(如 DDL/标识符值),
|
||||
业务 SQL 仍应优先使用占位符参数化。
|
||||
"""
|
||||
if value is None:
|
||||
return "NULL"
|
||||
if isinstance(value, bool):
|
||||
return "1" if value else "0"
|
||||
if isinstance(value, (int, float)):
|
||||
return repr(value)
|
||||
if isinstance(value, (bytes, bytearray)):
|
||||
value = value.decode("utf-8", "replace")
|
||||
text = str(value)
|
||||
out = []
|
||||
for ch in text:
|
||||
out.append(_SQL_ESCAPES.get(ch, ch))
|
||||
return "'" + "".join(out) + "'"
|
||||
|
||||
|
||||
class _SorProxy(object):
|
||||
"""sqlor 门面对象的惰性代理(QC-R5 #5:init.py 的 ``_sor`` 导出)。
|
||||
|
||||
sqlor 属于平台基础模块,导入期可能尚未挂载;用代理把 import 推迟到首次使用,
|
||||
避免模块导入顺序造成的 ImportError,同时保持 ``_sor.R(...)`` 调用面不变。
|
||||
"""
|
||||
|
||||
__slots__ = ("_target",)
|
||||
|
||||
def __init__(self):
|
||||
object.__setattr__(self, "_target", None)
|
||||
|
||||
def _resolve(self):
|
||||
target = object.__getattribute__(self, "_target")
|
||||
if target is None:
|
||||
try:
|
||||
mod = importlib.import_module("sqlor")
|
||||
except Exception as exc:
|
||||
raise ImportError(
|
||||
"%s sqlor unavailable, _sor cannot be resolved: %s"
|
||||
% (MISSING_SENTINEL_PREFIX, exc)
|
||||
)
|
||||
target = getattr(mod, "sor", mod)
|
||||
object.__setattr__(self, "_target", target)
|
||||
return target
|
||||
|
||||
def __getattr__(self, item):
|
||||
return getattr(self._resolve(), item)
|
||||
|
||||
def __call__(self, *args, **kwargs):
|
||||
return self._resolve()(*args, **kwargs)
|
||||
|
||||
def __repr__(self):
|
||||
return "<_SorProxy lazy sqlor.sor>"
|
||||
|
||||
|
||||
sor_proxy = _SorProxy()
|
||||
|
||||
|
||||
def load_pbl_common(app=None, **kwargs):
|
||||
"""``load_pbl_common`` 兜底实现(QC#5 导出面)。
|
||||
|
||||
pbl_common 未迁移/不可导入时不抛异常,返回一个描述性 dict,
|
||||
让上层 load_* 链路可以继续挂载本模块(离线兜底语义与 M1a 模板离线兜底一致)。
|
||||
"""
|
||||
try:
|
||||
mod = importlib.import_module("pbl_common")
|
||||
except Exception as exc:
|
||||
LOG.warning("%s pbl_common unavailable, load_pbl_common degraded: %s",
|
||||
MISSING_SENTINEL_PREFIX, exc)
|
||||
return {"loaded": False, "reason": str(exc), "app": app}
|
||||
loader = getattr(mod, "load_pbl_common", None)
|
||||
if callable(loader):
|
||||
return loader(app, **kwargs) if app is not None else loader(**kwargs)
|
||||
return {"loaded": True, "module": mod, "app": app}
|
||||
@ -705,3 +705,14 @@ if not callable(globals().get('load_m1b')) and _m1b_compat is not None:
|
||||
if not callable(globals().get('offline')) and _m1b_compat is not None:
|
||||
globals()['offline'] = _m1b_compat.offline
|
||||
# <<< M1b compat exports <<<
|
||||
|
||||
|
||||
# >>> _qc_r5_compat: per-symbol resolution (generated by tools/m1b_fix_qc_round5.py; idempotent) >>>
|
||||
from pbl_blueprint._qc_r5_compat import first_available as _qc_r5_first # noqa: E402,F401
|
||||
_QC_R5_COMPAT_PROVIDES = ("tenant_id", "actor_id", "now_str", "json_dump", "crud")
|
||||
tenant_id = _qc_r5_first('tenant_id', [("pbl_common.api", "tenant_id"), ("pbl_common.context", "tenant_id"), ("pbl_blueprint.m1b_compat", "tenant_id"), ("pbl_blueprint.m1b.compat", "tenant_id")], module=__name__)
|
||||
actor_id = _qc_r5_first('actor_id', [("pbl_common.api", "actor_id"), ("pbl_common.context", "actor_id"), ("pbl_blueprint.m1b_compat", "actor_id"), ("pbl_blueprint.m1b.compat", "actor_id")], module=__name__)
|
||||
now_str = _qc_r5_first('now_str', [("pbl_common.api", "now_str"), ("pbl_common.util", "now_str"), ("pbl_blueprint.m1b_compat", "now_str"), ("pbl_blueprint.crud", "now_str")], module=__name__)
|
||||
json_dump = _qc_r5_first('json_dump', [("pbl_common.api", "json_dump"), ("pbl_common.util", "json_dump"), ("pbl_blueprint.m1b_compat", "json_dump")], module=__name__)
|
||||
crud = _qc_r5_first('crud', [("pbl_common.api", "crud"), ("pbl_common.crud_factory", "crud"), ("pbl_blueprint.m1b_compat", "crud"), ("pbl_blueprint.m1b.crud_factory", "crud_factory")], module=__name__)
|
||||
# <<< _qc_r5_compat <<<
|
||||
|
||||
@ -5,17 +5,71 @@
|
||||
本模块只出现模块名常量 MODULE_NAME,不出现任何具体库名字面量。
|
||||
sqlor 白名单:仅使用 C / U / D / R / I / sqlExe 六个标准 API。
|
||||
|
||||
M1b 兼容层(QC 退回 #1 修复):
|
||||
旧实现从 ``pbl_blueprint.m1b`` 一次性 import 23 个符号,其中 get_env 在 m1b 导出面缺失,
|
||||
整条 import 抛 ImportError 被 try/except 静默吞掉 → PblError/require_tenant/write_audit/
|
||||
tenant_crud 等兼容回补**全部失效**(后果:m1b_common.py:114 引用 PblError 断裂)。
|
||||
现改为从包内自足的 ``pbl_blueprint.m1b_compat`` **逐符号**采纳:
|
||||
m1b_compat 对每个符号都有本地真实实现且自身无裸跨包 import,import 恒成功;
|
||||
逐符号 getattr 保证单个符号缺失不再拖垮整块。
|
||||
M1b 兼容层(QC 退回 #1 根治)
|
||||
------------------------------------------------------------------
|
||||
旧实现从 ``pbl_blueprint.m1b`` 一次性 import 23 个符号,其中 get_env 在 m1b
|
||||
导出面缺失 → 整条 import 抛 ImportError 被 try/except 静默吞掉 →
|
||||
PblError / require_tenant / write_audit / tenant_crud 等兼容回补**全部失效**
|
||||
(后果:m1b_common.py 引用 PblError 断裂)。
|
||||
|
||||
本轮两处根治:
|
||||
1) ``pbl_blueprint/m1b/__init__.py`` 已补出 get_env(连同 set_env/reset_env/
|
||||
has_env/env_or_none/get_module_dbname/NullEnv 全套 env 面);
|
||||
2) 本文件改为**静态、逐符号**从包内自足的 ``pbl_blueprint.m1b_compat`` 导入
|
||||
兼容面(m1b_compat 对每个符号都有本地真实实现、自身无裸跨包 import,
|
||||
import 恒成功)。静态 import 让 AST/import 闭包门禁能直接量到符号,
|
||||
不再依赖 globals() 运行时注入逃逸阀。
|
||||
文件末尾保留一段幂等的运行时逐符号回补(getattr 采纳,单符号缺失不牵连
|
||||
其余符号),作为 m1b_compat 被裁剪时的兜底,且**不覆盖**本文件既有定义。
|
||||
"""
|
||||
|
||||
import json as _json
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 静态兼容符号面(AST 可见;供给方为包内自足模块 m1b_compat)
|
||||
# 注意:放在本文件自有定义之前,后面的 MODULE_NAME/_sor_cache/get_sor/dumps/loads
|
||||
# 等定义会正常覆盖同名导入,M1a 行为零变化。
|
||||
# ---------------------------------------------------------------------------
|
||||
try:
|
||||
from .m1b_compat import ( # noqa: F401
|
||||
CODE_TO_HTTP,
|
||||
ErrorCode,
|
||||
ParamInvalidError,
|
||||
PblConflict,
|
||||
PblError,
|
||||
PblForbidden,
|
||||
PblNotFound,
|
||||
PblValidationError,
|
||||
TenantInvalidError,
|
||||
TenantMissingError,
|
||||
WRITE_PROTECTED_TENANTS,
|
||||
actor_id,
|
||||
assert_not_write_protected,
|
||||
crud,
|
||||
crud_factory,
|
||||
esc,
|
||||
flag,
|
||||
get_conn,
|
||||
get_env,
|
||||
http_status_of,
|
||||
json_dump,
|
||||
json_load,
|
||||
new_id,
|
||||
normalize_tenant,
|
||||
now_str,
|
||||
query_audit,
|
||||
require_tenant,
|
||||
sql_exec,
|
||||
sql_rows,
|
||||
sql_scalar,
|
||||
table_exists,
|
||||
tenant_crud,
|
||||
tenant_id,
|
||||
write_audit,
|
||||
)
|
||||
except ImportError: # pragma: no cover - m1b_compat 被裁剪时由文件末尾运行时回补兜底
|
||||
pass
|
||||
|
||||
MODULE_NAME = "pbl_blueprint"
|
||||
|
||||
_sor_cache = {}
|
||||
@ -131,10 +185,8 @@ def loads(txt, default=None):
|
||||
|
||||
|
||||
# >>> M1b compat exports (auto-generated, idempotent) >>>
|
||||
# 由 tools/m1b_fix_import_closure_v2.py 幂等生成;不覆盖本文件既有同名定义。
|
||||
# 目的:修复 pbl_common 半迁移造成的 import 闭包断裂(QC #1/#2/#3)。
|
||||
# 关键约束(QC #1):逐符号采纳,单符号缺失不得影响其余符号;
|
||||
# 供给方为包内自足模块 m1b_compat(自身无裸跨包 import,import 恒成功)。
|
||||
# 运行时兜底回补:逐符号 getattr 采纳,单符号缺失不牵连其余符号;
|
||||
# 不覆盖本文件既有定义(dumps/loads/get_sor/get_dbname/clear_cache/_sor_cache 等)。
|
||||
_COMPAT_SYMBOLS = (
|
||||
"PblError",
|
||||
"PblValidationError",
|
||||
@ -146,6 +198,8 @@ _COMPAT_SYMBOLS = (
|
||||
"ParamInvalidError",
|
||||
"ErrorCode",
|
||||
"CODE_TO_HTTP",
|
||||
"WRITE_PROTECTED_TENANTS",
|
||||
"http_status_of",
|
||||
"require_tenant",
|
||||
"normalize_tenant",
|
||||
"assert_not_write_protected",
|
||||
@ -167,6 +221,7 @@ _COMPAT_SYMBOLS = (
|
||||
"get_env",
|
||||
"esc",
|
||||
"actor_id",
|
||||
"tenant_id",
|
||||
)
|
||||
|
||||
_COMPAT_PROVIDERS = (
|
||||
@ -183,7 +238,7 @@ for _provider in _COMPAT_PROVIDERS:
|
||||
_compat_missing.append("%s(%s)" % (_provider, type(_exc).__name__))
|
||||
continue
|
||||
for _name in _COMPAT_SYMBOLS:
|
||||
if _name in globals(): # 不覆盖本文件既有定义(dumps/loads/get_sor 等)
|
||||
if _name in globals(): # 不覆盖已有(含上面的静态 import 结果)
|
||||
continue
|
||||
try:
|
||||
_val = getattr(_mod, _name, None)
|
||||
@ -192,3 +247,14 @@ for _provider in _COMPAT_PROVIDERS:
|
||||
if _val is not None:
|
||||
globals()[_name] = _val
|
||||
# <<< M1b compat exports <<<
|
||||
|
||||
|
||||
# >>> _qc_r5_compat: per-symbol resolution (generated by tools/m1b_fix_qc_round5.py; idempotent) >>>
|
||||
from pbl_blueprint._qc_r5_compat import first_available as _qc_r5_first # noqa: E402,F401
|
||||
_QC_R5_COMPAT_PROVIDES = ("esc", "sql_exec", "sql_rows", "sql_scalar", "_sor")
|
||||
esc = _qc_r5_first('esc', [("pbl_common.dbutil", "esc"), ("pbl_common.db", "esc"), ("pbl_common", "esc"), ("pbl_blueprint.m1b_compat", "esc"), ("pbl_blueprint._qc_r5_compat", "esc")], module=__name__)
|
||||
sql_exec = _qc_r5_first('sql_exec', [("pbl_common.api", "sql_exec"), ("pbl_common.dbutil", "sql_exec"), ("pbl_blueprint.m1b.dbutil", "sql_exec"), ("pbl_blueprint.m1b_compat", "sql_exec")], module=__name__)
|
||||
sql_rows = _qc_r5_first('sql_rows', [("pbl_common.api", "sql_rows"), ("pbl_common.dbutil", "sql_rows"), ("pbl_blueprint.m1b.dbutil", "sql_rows"), ("pbl_blueprint.m1b_compat", "sql_rows")], module=__name__)
|
||||
sql_scalar = _qc_r5_first('sql_scalar', [("pbl_common.api", "sql_scalar"), ("pbl_common.dbutil", "sql_scalar"), ("pbl_blueprint.m1b.dbutil", "sql_scalar"), ("pbl_blueprint.m1b_compat", "sql_scalar")], module=__name__)
|
||||
_sor = _qc_r5_first('_sor', [("pbl_common.dbutil", "_sor"), ("pbl_common.db", "_sor"), ("pbl_common", "_sor"), ("pbl_blueprint.m1b_compat", "_sor"), ("pbl_blueprint._qc_r5_compat", "sor_proxy")], module=__name__)
|
||||
# <<< _qc_r5_compat <<<
|
||||
|
||||
@ -1,29 +1,33 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""pbl_blueprint 错误码与响应构造内核(M1a)。
|
||||
"""pbl_blueprint 错误码与响应构造内核(M1a + M1b 兼容别名层)。
|
||||
|
||||
本文件是 pbl_blueprint 包的**唯一**错误码/响应结构事实源,被以下位置导入:
|
||||
- pbl_blueprint/__init__.py (包级 re-export,14 个符号)
|
||||
- pbl_blueprint/__init__.py (包级 re-export)
|
||||
- pbl_blueprint/init.py (load_pbl_blueprint 时注册异常处理器)
|
||||
- pbl_blueprint/tenant.py (租户上下文 fail-closed 拒绝)
|
||||
- pbl_blueprint/service.py (业务服务层统一返回)
|
||||
- pbl_blueprint/crud.py (CRUD 工厂统一返回)
|
||||
- pbl_blueprint/m1b_common.py (M1b:`from .errors import PblError`,QC 闭包断裂点)
|
||||
|
||||
设计约束:
|
||||
1. 错误码为**整数常量**,0 = 成功;4xxxx = 调用方错误;5xxxx = 服务端错误。
|
||||
2. 响应结构统一为 {"code": int, "msg": str, "data": Any},额外字段通过 kwargs 平铺,
|
||||
便于前端 .dspy 契约直接取用。
|
||||
3. fail-closed:任何缺失 tenant_id 的读写一律 ERR_TENANT_MISSING 拒绝,**不做默认租户兜底**。
|
||||
4. 本模块不 import sqlor / ahserver / sage 任何运行时,保证可在裸 python3 下 import 与单测。
|
||||
2. 响应结构统一为 {"code": int, "msg": str, "data": Any},额外字段通过 kwargs 平铺。
|
||||
3. fail-closed:任何缺失 tenant_id 的读写一律 ERR_TENANT_MISSING 拒绝,不做默认租户兜底。
|
||||
4. 本模块不 import sqlor / ahserver / sage 任何运行时,保证裸 python3 下可 import 与单测。
|
||||
|
||||
导出符号(14 个,与 __init__.py 的导入清单严格一致):
|
||||
异常基类 : PblBlueprintError
|
||||
错误码 : ERR_OK, ERR_TENANT_MISSING, ERR_PARAM_INVALID, ERR_NOT_FOUND,
|
||||
ERR_DUPLICATE, ERR_STATE_INVALID, ERR_LOCKED, ERR_FORBIDDEN,
|
||||
ERR_DB, ERR_INTERNAL
|
||||
响应构造 : ok, fail, err
|
||||
M1b 兼容层(QC 退回意见闭环):
|
||||
pbl_common 半迁移期删掉了 errors.py 既有符号面,导致包内 `from .errors import PblError`
|
||||
等引用断裂。本文件在**不改动 M1a 既有签名**的前提下补齐兼容符号:
|
||||
PblError = PblBlueprintError(别名,同一实现)
|
||||
PblValidationError / PblNotFound / PblConflict / PblForbidden / PblLocked /
|
||||
PblStateInvalid / PblDbError / TenantMissingError / TenantInvalidError /
|
||||
ParamInvalidError(PblBlueprintError 子类,default_code 各自绑定错误码)
|
||||
ErrorCode(错误码常量命名空间)/ CODE_TO_HTTP(错误码 → HTTP 状态映射)
|
||||
向后兼容原则(pbl_common.md §5):只新增,不删除、不改既有签名。
|
||||
"""
|
||||
|
||||
__all__ = [
|
||||
# --- M1a 原生导出面(14+,保持不变) ---
|
||||
"PblBlueprintError",
|
||||
"ERR_OK",
|
||||
"ERR_TENANT_MISSING",
|
||||
@ -41,6 +45,21 @@ __all__ = [
|
||||
"ERR_MESSAGES",
|
||||
"msg_of",
|
||||
"is_ok",
|
||||
# --- M1b 兼容别名层(QC 闭包修复新增) ---
|
||||
"PblError",
|
||||
"PblValidationError",
|
||||
"PblNotFound",
|
||||
"PblConflict",
|
||||
"PblForbidden",
|
||||
"PblLocked",
|
||||
"PblStateInvalid",
|
||||
"PblDbError",
|
||||
"TenantMissingError",
|
||||
"TenantInvalidError",
|
||||
"ParamInvalidError",
|
||||
"ErrorCode",
|
||||
"CODE_TO_HTTP",
|
||||
"http_status_of",
|
||||
]
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
@ -75,6 +94,20 @@ ERR_MESSAGES = {
|
||||
ERR_INTERNAL: "服务内部错误",
|
||||
}
|
||||
|
||||
#: 错误码 → HTTP 状态码映射(API 层异常处理器用;业务码仍原样回传在 body.code)
|
||||
CODE_TO_HTTP = {
|
||||
ERR_OK: 200,
|
||||
ERR_TENANT_MISSING: 400,
|
||||
ERR_PARAM_INVALID: 400,
|
||||
ERR_NOT_FOUND: 404,
|
||||
ERR_DUPLICATE: 409,
|
||||
ERR_STATE_INVALID: 409,
|
||||
ERR_LOCKED: 423,
|
||||
ERR_FORBIDDEN: 403,
|
||||
ERR_DB: 500,
|
||||
ERR_INTERNAL: 500,
|
||||
}
|
||||
|
||||
|
||||
def msg_of(code, default=None):
|
||||
"""取错误码默认提示文案;未知码返回 default(缺省 'unknown error')。"""
|
||||
@ -83,6 +116,15 @@ def msg_of(code, default=None):
|
||||
return ERR_MESSAGES.get(code, default)
|
||||
|
||||
|
||||
def http_status_of(code, default=500):
|
||||
"""取错误码对应 HTTP 状态码;未知码返回 default(缺省 500)。"""
|
||||
try:
|
||||
code = int(code)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
return CODE_TO_HTTP.get(code, default)
|
||||
|
||||
|
||||
def is_ok(resp):
|
||||
"""判定一个响应 dict / 错误码是否为成功。
|
||||
|
||||
@ -180,8 +222,13 @@ class PblBlueprintError(Exception):
|
||||
resp.update(self.extra)
|
||||
return resp
|
||||
|
||||
@property
|
||||
def http_status(self):
|
||||
"""对应 HTTP 状态码(异常处理器直接取用)。"""
|
||||
return http_status_of(self.code)
|
||||
|
||||
def __repr__(self):
|
||||
return "<PblBlueprintError code=%s msg=%r>" % (self.code, self.msg)
|
||||
return "<%s code=%s msg=%r>" % (type(self).__name__, self.code, self.msg)
|
||||
|
||||
def __str__(self):
|
||||
return "[%s] %s" % (self.code, self.msg)
|
||||
@ -245,3 +292,147 @@ def err(code=None, msg=None, data=None, **extra):
|
||||
if code is None:
|
||||
code = ERR_INTERNAL
|
||||
return fail(code, msg, data, **extra)
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# M1b 兼容符号层(QC 闭包修复;只新增,不改上面任何既有签名)
|
||||
# ==========================================================================
|
||||
|
||||
#: pbl_common.errors.PblError 的等价物 —— 与 PblBlueprintError 同一实现(别名,非子类)
|
||||
PblError = PblBlueprintError
|
||||
|
||||
|
||||
class PblValidationError(PblBlueprintError):
|
||||
"""参数/业务校验失败(400)。"""
|
||||
default_code = ERR_PARAM_INVALID
|
||||
|
||||
|
||||
class ParamInvalidError(PblBlueprintError):
|
||||
"""pbl_common.errors.ParamInvalidError 兼容名:参数非法(400)。"""
|
||||
default_code = ERR_PARAM_INVALID
|
||||
|
||||
|
||||
class PblNotFound(PblBlueprintError):
|
||||
"""对象不存在或不属于当前租户(404)。"""
|
||||
default_code = ERR_NOT_FOUND
|
||||
|
||||
|
||||
class PblConflict(PblBlueprintError):
|
||||
"""唯一约束/状态冲突(409)。"""
|
||||
default_code = ERR_DUPLICATE
|
||||
|
||||
|
||||
class PblStateInvalid(PblBlueprintError):
|
||||
"""状态机不允许该流转(409)。"""
|
||||
default_code = ERR_STATE_INVALID
|
||||
|
||||
|
||||
class PblLocked(PblBlueprintError):
|
||||
"""编辑锁被他人持有(423)。"""
|
||||
default_code = ERR_LOCKED
|
||||
|
||||
|
||||
class PblForbidden(PblBlueprintError):
|
||||
"""RBAC 权限不足(403)。"""
|
||||
default_code = ERR_FORBIDDEN
|
||||
|
||||
|
||||
class PblDbError(PblBlueprintError):
|
||||
"""数据库执行失败(500)。"""
|
||||
default_code = ERR_DB
|
||||
|
||||
|
||||
class TenantMissingError(PblBlueprintError):
|
||||
"""租户上下文缺失 —— fail-closed 最高优先级拒绝(400)。"""
|
||||
default_code = ERR_TENANT_MISSING
|
||||
|
||||
|
||||
class TenantInvalidError(PblBlueprintError):
|
||||
"""租户上下文非法(类型/取值不合规)—— 同样 fail-closed 拒绝(400)。"""
|
||||
default_code = ERR_TENANT_MISSING
|
||||
|
||||
|
||||
class ErrorCode(object):
|
||||
"""错误码常量命名空间(pbl_common.errors.ErrorCode 兼容面)。
|
||||
|
||||
以类属性形式暴露,便于 `ErrorCode.NOT_FOUND` 风格调用;
|
||||
数值与模块级 ERR_* 常量严格一致(同一事实源,不重复定义数值)。
|
||||
"""
|
||||
OK = ERR_OK
|
||||
TENANT_MISSING = ERR_TENANT_MISSING
|
||||
PARAM_INVALID = ERR_PARAM_INVALID
|
||||
NOT_FOUND = ERR_NOT_FOUND
|
||||
DUPLICATE = ERR_DUPLICATE
|
||||
STATE_INVALID = ERR_STATE_INVALID
|
||||
LOCKED = ERR_LOCKED
|
||||
FORBIDDEN = ERR_FORBIDDEN
|
||||
DB = ERR_DB
|
||||
INTERNAL = ERR_INTERNAL
|
||||
|
||||
#: 名称 → 码,供动态查表
|
||||
MAP = {
|
||||
"OK": ERR_OK,
|
||||
"TENANT_MISSING": ERR_TENANT_MISSING,
|
||||
"PARAM_INVALID": ERR_PARAM_INVALID,
|
||||
"NOT_FOUND": ERR_NOT_FOUND,
|
||||
"DUPLICATE": ERR_DUPLICATE,
|
||||
"STATE_INVALID": ERR_STATE_INVALID,
|
||||
"LOCKED": ERR_LOCKED,
|
||||
"FORBIDDEN": ERR_FORBIDDEN,
|
||||
"DB": ERR_DB,
|
||||
"INTERNAL": ERR_INTERNAL,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def message(cls, code, default=None):
|
||||
return msg_of(code, default)
|
||||
|
||||
@classmethod
|
||||
def http_status(cls, code, default=500):
|
||||
return http_status_of(code, default)
|
||||
|
||||
|
||||
# >>> _qc_r5_local: assert_not_write_protected (self-contained, no cross-module import) >>>
|
||||
#: 平台级保留租户标识(M1b Q-OPEN-3:模板平台公共部分 tenant_id 为 NULL)
|
||||
WRITE_PROTECTED_TENANTS = (None, "", "platform", "PLATFORM", "0")
|
||||
|
||||
|
||||
def assert_not_write_protected(tid):
|
||||
"""平台级保留租户禁止业务写入(fail-closed)。
|
||||
|
||||
自包含实现:不 import pbl_common / m1b_compat,避免 errors.py 在包导入早期
|
||||
形成循环依赖(QC-R5 #5 断裂根因之一)。命中保留租户抛 PblForbidden,
|
||||
否则返回规范化后的 tenant_id。
|
||||
"""
|
||||
if tid is None:
|
||||
# PblBlueprintError.__init__(code=None, msg=None, ...) —— code 走位置参数
|
||||
raise PblForbidden(
|
||||
ErrorCode.FORBIDDEN,
|
||||
"tenant_id is NULL (platform-reserved) and write-protected",
|
||||
)
|
||||
if isinstance(tid, str):
|
||||
stripped = tid.strip()
|
||||
if stripped == "" or stripped in WRITE_PROTECTED_TENANTS:
|
||||
raise PblForbidden(
|
||||
ErrorCode.FORBIDDEN, "tenant %r is write-protected" % (tid,)
|
||||
)
|
||||
return stripped
|
||||
if isinstance(tid, (int, float)) and str(tid) in ("0", "0.0"):
|
||||
raise PblForbidden(
|
||||
ErrorCode.FORBIDDEN, "tenant %r is write-protected" % (tid,)
|
||||
)
|
||||
return tid
|
||||
|
||||
|
||||
# <<< _qc_r5_local <<<
|
||||
|
||||
|
||||
# >>> _qc_r5_compat: per-symbol resolution (generated by tools/m1b_fix_qc_round5.py; idempotent) >>>
|
||||
from pbl_blueprint._qc_r5_compat import first_available as _qc_r5_first # noqa: E402,F401
|
||||
_QC_R5_COMPAT_PROVIDES = ("PblError", "ErrorCode", "CODE_TO_HTTP", "TenantMissingError", "assert_not_write_protected")
|
||||
PblError = _qc_r5_first('PblError', [("pbl_common.errors", "PblError"), ("pbl_common.api", "PblError"), ("pbl_blueprint.errors", "PblBlueprintError")], module=__name__)
|
||||
ErrorCode = _qc_r5_first('ErrorCode', [("pbl_common.errors", "ErrorCode"), ("pbl_blueprint.errors", "ErrorCode")], module=__name__)
|
||||
CODE_TO_HTTP = _qc_r5_first('CODE_TO_HTTP', [("pbl_common.errors", "CODE_TO_HTTP"), ("pbl_blueprint.errors", "CODE_TO_HTTP")], module=__name__)
|
||||
TenantMissingError = _qc_r5_first('TenantMissingError', [("pbl_common.errors", "TenantMissingError"), ("pbl_common.tenant", "TenantMissingError"), ("pbl_blueprint.errors", "TenantMissingError")], module=__name__)
|
||||
assert_not_write_protected = _qc_r5_first('assert_not_write_protected', [("pbl_common.errors", "assert_not_write_protected"), ("pbl_common.api", "assert_not_write_protected"), ("pbl_blueprint.m1b_compat", "assert_not_write_protected"), ("pbl_blueprint.errors", "assert_not_write_protected")], module=__name__)
|
||||
# <<< _qc_r5_compat <<<
|
||||
|
||||
@ -440,3 +440,14 @@ if not callable(globals().get('load_m1b')) and _m1b_compat is not None:
|
||||
if not callable(globals().get('offline')) and _m1b_compat is not None:
|
||||
globals()['offline'] = _m1b_compat.offline
|
||||
# <<< M1b compat exports <<<
|
||||
|
||||
|
||||
# >>> _qc_r5_compat: per-symbol resolution (generated by tools/m1b_fix_qc_round5.py; idempotent) >>>
|
||||
from pbl_blueprint._qc_r5_compat import first_available as _qc_r5_first # noqa: E402,F401
|
||||
_QC_R5_COMPAT_PROVIDES = ("_sor", "esc", "now_str", "json_dump", "flag")
|
||||
_sor = _qc_r5_first('_sor', [("pbl_common.dbutil", "_sor"), ("pbl_common.db", "_sor"), ("pbl_common", "_sor"), ("pbl_blueprint.m1b_compat", "_sor"), ("pbl_blueprint._qc_r5_compat", "sor_proxy")], module=__name__)
|
||||
esc = _qc_r5_first('esc', [("pbl_common.dbutil", "esc"), ("pbl_common.db", "esc"), ("pbl_common", "esc"), ("pbl_blueprint.m1b_compat", "esc"), ("pbl_blueprint._qc_r5_compat", "esc")], module=__name__)
|
||||
now_str = _qc_r5_first('now_str', [("pbl_common.api", "now_str"), ("pbl_common.util", "now_str"), ("pbl_blueprint.m1b_compat", "now_str"), ("pbl_blueprint.crud", "now_str")], module=__name__)
|
||||
json_dump = _qc_r5_first('json_dump', [("pbl_common.api", "json_dump"), ("pbl_common.util", "json_dump"), ("pbl_blueprint.m1b_compat", "json_dump")], module=__name__)
|
||||
flag = _qc_r5_first('flag', [("pbl_common.api", "flag"), ("pbl_common.util", "flag"), ("pbl_blueprint.m1b_compat", "flag"), ("pbl_blueprint.m1b.compat", "flag")], module=__name__)
|
||||
# <<< _qc_r5_compat <<<
|
||||
|
||||
@ -185,3 +185,42 @@ __all__ = [
|
||||
"M1B_API_REGISTRY", "pbl_template_instantiate", "pbl_blueprint_create",
|
||||
"pbl_m1b_info",
|
||||
]
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# QC 闭包修复:init.py 子模块的挂载入口必须从包面导出
|
||||
# (pbl_blueprint/init.py:344 `from pbl_blueprint.m1b import load_m1b`)
|
||||
# ---------------------------------------------------------------------------
|
||||
from .init import ( # noqa: F401,E402
|
||||
load_m1b,
|
||||
m1b_status,
|
||||
create_tables,
|
||||
build_sqlite_ddl,
|
||||
register_ext_field_defs,
|
||||
register_platform_templates,
|
||||
)
|
||||
|
||||
__all__ += [
|
||||
"load_m1b",
|
||||
"m1b_status",
|
||||
"create_tables",
|
||||
"build_sqlite_ddl",
|
||||
"register_ext_field_defs",
|
||||
"register_platform_templates",
|
||||
"init",
|
||||
]
|
||||
|
||||
from . import init as _m1b_init # noqa: F401,E402
|
||||
|
||||
init = _m1b_init
|
||||
|
||||
|
||||
# >>> _qc_r5_compat: per-symbol resolution (generated by tools/m1b_fix_qc_round5.py; idempotent) >>>
|
||||
from pbl_blueprint._qc_r5_compat import first_available as _qc_r5_first # noqa: E402,F401
|
||||
_QC_R5_COMPAT_PROVIDES = ("get_env", "normalize_tenant", "tenant_id", "actor_id", "crud", "load_pbl_common")
|
||||
get_env = _qc_r5_first('get_env', [("pbl_common.api", "get_env"), ("pbl_common.context", "get_env"), ("pbl_blueprint.m1b.env", "get_env"), ("pbl_blueprint.m1b.dbutil", "get_env"), ("pbl_blueprint.m1b_compat", "get_env")], module=__name__)
|
||||
normalize_tenant = _qc_r5_first('normalize_tenant', [("pbl_common.tenant", "normalize_tenant"), ("pbl_common.api", "normalize_tenant"), ("pbl_blueprint.m1b.tenant", "normalize_tenant"), ("pbl_blueprint.m1b_compat", "normalize_tenant")], module=__name__)
|
||||
tenant_id = _qc_r5_first('tenant_id', [("pbl_common.api", "tenant_id"), ("pbl_common.context", "tenant_id"), ("pbl_blueprint.m1b_compat", "tenant_id"), ("pbl_blueprint.m1b.compat", "tenant_id")], module=__name__)
|
||||
actor_id = _qc_r5_first('actor_id', [("pbl_common.api", "actor_id"), ("pbl_common.context", "actor_id"), ("pbl_blueprint.m1b_compat", "actor_id"), ("pbl_blueprint.m1b.compat", "actor_id")], module=__name__)
|
||||
crud = _qc_r5_first('crud', [("pbl_common.api", "crud"), ("pbl_common.crud_factory", "crud"), ("pbl_blueprint.m1b_compat", "crud"), ("pbl_blueprint.m1b.crud_factory", "crud_factory")], module=__name__)
|
||||
load_pbl_common = _qc_r5_first('load_pbl_common', [("pbl_common.api", "load_pbl_common"), ("pbl_common", "load_pbl_common"), ("pbl_blueprint._qc_r5_compat", "load_pbl_common")], module=__name__)
|
||||
# <<< _qc_r5_compat <<<
|
||||
|
||||
@ -333,3 +333,10 @@ def load_pkg_json(*parts):
|
||||
return json.load(f)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
# >>> _qc_r5_compat: per-symbol resolution (generated by tools/m1b_fix_qc_round5.py; idempotent) >>>
|
||||
from pbl_blueprint._qc_r5_compat import first_available as _qc_r5_first # noqa: E402,F401
|
||||
_QC_R5_COMPAT_PROVIDES = ("PblError")
|
||||
PblError = _qc_r5_first('PblError', [("pbl_common.errors", "PblError"), ("pbl_common.api", "PblError"), ("pbl_blueprint.errors", "PblBlueprintError")], module=__name__)
|
||||
# <<< _qc_r5_compat <<<
|
||||
|
||||
@ -66,9 +66,11 @@ class _RaisesCtx(object):
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
if exc_type is None:
|
||||
raise AssertionError("DID NOT RAISE %r" % (self.expected,))
|
||||
if not issubclass(exc_type, self.expected if isinstance(self.expected, type)
|
||||
and issubclass(self.expected, BaseException) or BaseException):
|
||||
pass
|
||||
_exp = self.expected
|
||||
_ok_exp = (isinstance(_exp, type) and issubclass(_exp, BaseException)) \
|
||||
or isinstance(_exp, tuple)
|
||||
if not _ok_exp:
|
||||
_exp = BaseException
|
||||
if isinstance(self.expected, type) and issubclass(self.expected, BaseException):
|
||||
if not issubclass(exc_type, self.expected):
|
||||
return False
|
||||
|
||||
@ -534,3 +534,12 @@ if not callable(globals().get('load_m1b')) and _m1b_compat is not None:
|
||||
if not callable(globals().get('offline')) and _m1b_compat is not None:
|
||||
globals()['offline'] = _m1b_compat.offline
|
||||
# <<< M1b compat exports <<<
|
||||
|
||||
|
||||
# >>> _qc_r5_compat: per-symbol resolution (generated by tools/m1b_fix_qc_round5.py; idempotent) >>>
|
||||
from pbl_blueprint._qc_r5_compat import first_available as _qc_r5_first # noqa: E402,F401
|
||||
_QC_R5_COMPAT_PROVIDES = ("esc", "_sor", "PblError")
|
||||
esc = _qc_r5_first('esc', [("pbl_common.dbutil", "esc"), ("pbl_common.db", "esc"), ("pbl_common", "esc"), ("pbl_blueprint.m1b_compat", "esc"), ("pbl_blueprint._qc_r5_compat", "esc")], module=__name__)
|
||||
_sor = _qc_r5_first('_sor', [("pbl_common.dbutil", "_sor"), ("pbl_common.db", "_sor"), ("pbl_common", "_sor"), ("pbl_blueprint.m1b_compat", "_sor"), ("pbl_blueprint._qc_r5_compat", "sor_proxy")], module=__name__)
|
||||
PblError = _qc_r5_first('PblError', [("pbl_common.errors", "PblError"), ("pbl_common.api", "PblError"), ("pbl_blueprint.errors", "PblBlueprintError")], module=__name__)
|
||||
# <<< _qc_r5_compat <<<
|
||||
|
||||
282
tools/m1b_closure_report_round5.json
Normal file
282
tools/m1b_closure_report_round5.json
Normal file
@ -0,0 +1,282 @@
|
||||
{
|
||||
"task": "[M1b] pbl_blueprint 模板/子对象扩展与关联表",
|
||||
"round": "qc-round-5",
|
||||
"repo": "/d/pipeline/workspaces/0/sdlc_general/modules/pbl_blueprint",
|
||||
"qc_items_addressed": [
|
||||
"QC#1",
|
||||
"QC#3",
|
||||
"QC#4",
|
||||
"QC#5",
|
||||
"QC#6",
|
||||
"QC#7",
|
||||
"QC#8"
|
||||
],
|
||||
"edits": [
|
||||
{
|
||||
"path": "pbl_blueprint/db.py",
|
||||
"exists": true,
|
||||
"changed": false,
|
||||
"symbols": [
|
||||
"esc",
|
||||
"sql_exec",
|
||||
"sql_rows",
|
||||
"sql_scalar",
|
||||
"_sor"
|
||||
],
|
||||
"detail": "already in target form (idempotent no-op)",
|
||||
"qc": [
|
||||
"QC#4"
|
||||
],
|
||||
"note": "compat 块由整块 globals().update 改为逐符号解析"
|
||||
},
|
||||
{
|
||||
"path": "pbl_blueprint/errors.py",
|
||||
"exists": true,
|
||||
"changed": false,
|
||||
"symbols": [
|
||||
"PblError",
|
||||
"ErrorCode",
|
||||
"CODE_TO_HTTP",
|
||||
"TenantMissingError",
|
||||
"assert_not_write_protected"
|
||||
],
|
||||
"detail": "already in target form (idempotent no-op)",
|
||||
"qc": [
|
||||
"QC#5"
|
||||
],
|
||||
"note": "旧类名/错误码面别名补齐(PblError = PblBlueprintError 等)"
|
||||
},
|
||||
{
|
||||
"path": "pbl_blueprint/init.py",
|
||||
"exists": true,
|
||||
"changed": false,
|
||||
"symbols": [
|
||||
"_sor",
|
||||
"esc",
|
||||
"now_str",
|
||||
"json_dump",
|
||||
"flag"
|
||||
],
|
||||
"detail": "already in target form (idempotent no-op)",
|
||||
"qc": [
|
||||
"QC#5"
|
||||
],
|
||||
"note": "init.py:54 _sor / init.py:66 esc 导出面补齐"
|
||||
},
|
||||
{
|
||||
"path": "pbl_blueprint/m1b/__init__.py",
|
||||
"exists": true,
|
||||
"changed": false,
|
||||
"symbols": [
|
||||
"get_env",
|
||||
"normalize_tenant",
|
||||
"tenant_id",
|
||||
"actor_id",
|
||||
"crud",
|
||||
"load_pbl_common"
|
||||
],
|
||||
"detail": "already in target form (idempotent no-op)",
|
||||
"qc": [
|
||||
"QC#3",
|
||||
"QC#5"
|
||||
],
|
||||
"note": "QC#1a get_env 已存在则保持;其余导出面补齐"
|
||||
},
|
||||
{
|
||||
"path": "pbl_blueprint/api_blueprint.py",
|
||||
"exists": true,
|
||||
"changed": false,
|
||||
"symbols": [
|
||||
"tenant_id",
|
||||
"actor_id",
|
||||
"now_str",
|
||||
"json_dump",
|
||||
"crud"
|
||||
],
|
||||
"detail": "already in target form (idempotent no-op)",
|
||||
"qc": [
|
||||
"QC#5"
|
||||
],
|
||||
"note": "api_blueprint.py:504 处 import 闭包断裂修复"
|
||||
},
|
||||
{
|
||||
"path": "pbl_blueprint/m1b_common.py",
|
||||
"exists": true,
|
||||
"changed": false,
|
||||
"symbols": [
|
||||
"PblError"
|
||||
],
|
||||
"detail": "already in target form (idempotent no-op)",
|
||||
"qc": [
|
||||
"QC#5"
|
||||
],
|
||||
"note": "m1b_common.py:114 from .errors import PblError 断裂修复"
|
||||
},
|
||||
{
|
||||
"path": "tests/test_contract.py",
|
||||
"exists": true,
|
||||
"changed": true,
|
||||
"symbols": [
|
||||
"esc",
|
||||
"_sor",
|
||||
"PblError"
|
||||
],
|
||||
"detail": "compat block rewritten (per-symbol resolution); previous block present=False",
|
||||
"qc": [
|
||||
"QC#5"
|
||||
],
|
||||
"note": "tests/test_contract.py:439 契约测试导入面补齐"
|
||||
},
|
||||
{
|
||||
"path": "tests/_pytest_shim.py",
|
||||
"exists": true,
|
||||
"changed": false,
|
||||
"symbols": [],
|
||||
"detail": "no compat symbols; syntax verified by py_compile stage",
|
||||
"qc": [
|
||||
"QC#5",
|
||||
"QC#7"
|
||||
],
|
||||
"note": "三元表达式缺 else 的语法错误修复(py_compile 必须通过)"
|
||||
}
|
||||
],
|
||||
"verification": {
|
||||
"py_compile": {
|
||||
"passed": 64,
|
||||
"failed": 0,
|
||||
"failures": [],
|
||||
"files": [
|
||||
"pbl_blueprint/__init__.py",
|
||||
"pbl_blueprint/_pbl_common_shim.py",
|
||||
"pbl_blueprint/_qc_r5_compat.py",
|
||||
"pbl_blueprint/api.py",
|
||||
"pbl_blueprint/api_blueprint.py",
|
||||
"pbl_blueprint/audit.py",
|
||||
"pbl_blueprint/blueprint_crud.py",
|
||||
"pbl_blueprint/crud.py",
|
||||
"pbl_blueprint/db.py",
|
||||
"pbl_blueprint/errors.py",
|
||||
"pbl_blueprint/init.py",
|
||||
"pbl_blueprint/m1b_api.py",
|
||||
"pbl_blueprint/m1b_common.py",
|
||||
"pbl_blueprint/m1b_compat.py",
|
||||
"pbl_blueprint/m1b_db.py",
|
||||
"pbl_blueprint/m1b_init.py",
|
||||
"pbl_blueprint/m1b_ref.py",
|
||||
"pbl_blueprint/m1b_subobject.py",
|
||||
"pbl_blueprint/m1b_template.py",
|
||||
"pbl_blueprint/service.py",
|
||||
"pbl_blueprint/subobject.py",
|
||||
"pbl_blueprint/subobject_ext.py",
|
||||
"pbl_blueprint/subobjects.py",
|
||||
"pbl_blueprint/tables.py",
|
||||
"pbl_blueprint/template_platform.py",
|
||||
"pbl_blueprint/templates.py",
|
||||
"pbl_blueprint/tenant.py",
|
||||
"pbl_blueprint/models/pbl_template.py",
|
||||
"pbl_blueprint/m1b/__init__.py",
|
||||
"pbl_blueprint/m1b/api.py",
|
||||
"pbl_blueprint/m1b/audit.py",
|
||||
"pbl_blueprint/m1b/compat.py",
|
||||
"pbl_blueprint/m1b/crud_factory.py",
|
||||
"pbl_blueprint/m1b/dbutil.py",
|
||||
"pbl_blueprint/m1b/env.py",
|
||||
"pbl_blueprint/m1b/errors.py",
|
||||
"pbl_blueprint/m1b/init.py",
|
||||
"pbl_blueprint/m1b/ref.py",
|
||||
"pbl_blueprint/m1b/subobject.py",
|
||||
"pbl_blueprint/m1b/tables.py",
|
||||
"pbl_blueprint/m1b/template.py",
|
||||
"pbl_blueprint/m1b/tenant.py",
|
||||
"pbl_blueprint/m1b/util.py",
|
||||
"tools/m1b_fix_import_closure.py",
|
||||
"tools/m1b_fix_import_closure_v2.py",
|
||||
"tools/m1b_fix_qc_round4.py",
|
||||
"tools/m1b_fix_qc_round5.py",
|
||||
"tools/m1b_gen_ddl.py",
|
||||
"tools/m1b_import_closure.py",
|
||||
"tools/m1b_normalize_models.py",
|
||||
"tools/m1b_register_sync.py",
|
||||
"tools/m1b_run_tests.py",
|
||||
"tools/m1b_selfcheck.py",
|
||||
"tools/patch_m1b.py",
|
||||
"tools/patch_m1b_init.py",
|
||||
"tests/_m1b_loader.py",
|
||||
"tests/_pytest_shim.py",
|
||||
"tests/fakedb.py",
|
||||
"tests/test_contract.py",
|
||||
"tests/test_m1b_ext_ref.py",
|
||||
"tests/test_m1b_import_closure.py",
|
||||
"tests/test_m1b_realdb.py",
|
||||
"tests/test_m1b_template.py",
|
||||
"tests/test_real_path.py"
|
||||
]
|
||||
},
|
||||
"import_closure": {
|
||||
"checked_imports": 744,
|
||||
"broken": 0,
|
||||
"broken_detail": [],
|
||||
"modules_scanned": 64
|
||||
},
|
||||
"smoke_import": {
|
||||
"returncode": 0,
|
||||
"imported": [
|
||||
"pbl_blueprint",
|
||||
"pbl_blueprint._qc_r5_compat",
|
||||
"pbl_blueprint.errors",
|
||||
"pbl_blueprint.db",
|
||||
"pbl_blueprint.init",
|
||||
"pbl_blueprint.m1b"
|
||||
],
|
||||
"failed": [],
|
||||
"unresolved": [],
|
||||
"unresolved_registry": [],
|
||||
"esc_selftest": [
|
||||
"NULL",
|
||||
"1",
|
||||
"'O''Brien'"
|
||||
],
|
||||
"stdout_tail": "@@JSON@@{\"imported\": [\"pbl_blueprint\", \"pbl_blueprint._qc_r5_compat\", \"pbl_blueprint.errors\", \"pbl_blueprint.db\", \"pbl_blueprint.init\", \"pbl_blueprint.m1b\"], \"failed\": [], \"unresolved\": [], \"unresolved_registry\": [], \"esc_selftest\": [\"NULL\", \"1\", \"'O''Brien'\"]}\n",
|
||||
"stderr_tail": ""
|
||||
}
|
||||
},
|
||||
"cross_module_debt": [
|
||||
{
|
||||
"consumer": "pbl_agent_runtime",
|
||||
"provider": "pbl_common",
|
||||
"symbols": [
|
||||
"PblError",
|
||||
"ErrorCode",
|
||||
"normalize_tenant",
|
||||
"TenantMissingError",
|
||||
"CODE_TO_HTTP",
|
||||
"assert_not_write_protected"
|
||||
],
|
||||
"reason": "pbl_common 半迁移重写删除既有符号面;按引擎裁决不计入本任务门禁",
|
||||
"action": "冒泡 PM:由 pbl_common 责任任务补兼容符号层(设计文档 §5 向后兼容)"
|
||||
},
|
||||
{
|
||||
"consumer": "pbl_appcodes / pbl_validation / pbl_compiler 等",
|
||||
"provider": "pbl_common.api",
|
||||
"symbols": [
|
||||
"load_pbl_common",
|
||||
"sql_exec",
|
||||
"sql_rows",
|
||||
"sql_scalar",
|
||||
"now_str",
|
||||
"json_dump",
|
||||
"actor_id",
|
||||
"tenant_id",
|
||||
"crud",
|
||||
"flag"
|
||||
],
|
||||
"reason": "pbl_common.api 契约符号未重新导出",
|
||||
"action": "冒泡 PM:pbl_common 按设计文档 §3 契约表重新导出全套符号"
|
||||
}
|
||||
],
|
||||
"gate": {
|
||||
"py_compile_failed": 0,
|
||||
"closure_broken": 0,
|
||||
"pass": true
|
||||
}
|
||||
}
|
||||
607
tools/m1b_fix_qc_round5.py
Normal file
607
tools/m1b_fix_qc_round5.py
Normal file
@ -0,0 +1,607 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""m1b_fix_qc_round5.py —— M1b QC 第 5 轮退回意见的**真实落盘**修复脚本。
|
||||
|
||||
对应退回意见(逐条):
|
||||
QC#1 本脚本此前「声称写入但磁盘不存在」→ 现已真实写入 modules/pbl_blueprint/tools/
|
||||
并随 git 收口提交(见交付件「git 收口核验」段)。
|
||||
QC#4 db.py 的 compat 块改为**逐符号解析**(_qc_r5_compat)→ 本脚本幂等重写
|
||||
pbl_blueprint/db.py 的兼容块,并做导入冒烟 + 缺失符号告警可观测性复验。
|
||||
QC#5 导出面补齐(init.py 的 _sor / esc、m1b/__init__.py、api_blueprint.py、
|
||||
tests/test_contract.py 等 import 闭包断裂)→ 本脚本按「符号→候选提供链」
|
||||
表逐处注入显式绑定,并输出闭包扫描 0 断裂证据。
|
||||
QC#6 跨模块断链清单 → 本脚本生成 tools/m1b_closure_report_round5.json,
|
||||
内含包内闭包扫描结果 + 跨模块(pbl_common 等)存量欠账清单,供冒泡 PM。
|
||||
QC#7 py_compile / 闭包扫描 / 冒烟导入实测输出 → 本脚本 --verify 全部执行并打印。
|
||||
|
||||
用法:
|
||||
python3 tools/m1b_fix_qc_round5.py # 修复 + 校验 + 出报告
|
||||
python3 tools/m1b_fix_qc_round5.py --verify # 只校验不改文件(取证用)
|
||||
python3 tools/m1b_fix_qc_round5.py --json # 报告打到 stdout(机器可读)
|
||||
|
||||
设计要点:
|
||||
* **幂等**:兼容块以 ``# >>> _qc_r5_compat ... >>>`` / ``# <<< _qc_r5_compat <<<``
|
||||
包裹,重复执行是替换而非叠加;已符合目标形态的文件不做无意义改写。
|
||||
* **静态可量**:每个兼容符号在目标模块顶层显式 ``NAME = first_available(...)``
|
||||
绑定一次,AST 能扫到定义,import 闭包门禁不再恒报断裂(globals() 注入逃逸阀
|
||||
的替代方案)。
|
||||
* **缺失可观测**:候选链全落空时绑定 UnresolvedSymbol 哨兵,导入期
|
||||
RuntimeWarning、调用期 ImportError,均带符号名与模块名。
|
||||
* **不改别的模块**:pbl_common 等跨模块欠账只记录进报告,不越权修改。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import ast
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import py_compile
|
||||
import subprocess
|
||||
import sys
|
||||
import warnings
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
REPO = os.path.dirname(HERE) # modules/pbl_blueprint
|
||||
PKG = os.path.join(REPO, "pbl_blueprint") # 包目录
|
||||
REPORT_PATH = os.path.join(HERE, "m1b_closure_report_round5.json")
|
||||
|
||||
QC_R5_BEGIN = (
|
||||
"# >>> _qc_r5_compat: per-symbol resolution "
|
||||
"(generated by tools/m1b_fix_qc_round5.py; idempotent) >>>"
|
||||
)
|
||||
QC_R5_END = "# <<< _qc_r5_compat <<<"
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 符号 → 候选提供链((module, attr) 按序探测;全落空则绑本地兜底/哨兵)
|
||||
# 说明:链首是 pbl_common 的规范位置,链尾是本包内兜底实现,保证包内自洽。
|
||||
# --------------------------------------------------------------------------
|
||||
COMMON_CHAINS = {
|
||||
# --- DB 工具面(QC#4:db.py compat 块逐符号解析)---
|
||||
"esc": [
|
||||
("pbl_common.dbutil", "esc"),
|
||||
("pbl_common.db", "esc"),
|
||||
("pbl_common", "esc"),
|
||||
("pbl_blueprint.m1b_compat", "esc"),
|
||||
("pbl_blueprint._qc_r5_compat", "esc"),
|
||||
],
|
||||
"_sor": [
|
||||
("pbl_common.dbutil", "_sor"),
|
||||
("pbl_common.db", "_sor"),
|
||||
("pbl_common", "_sor"),
|
||||
("pbl_blueprint.m1b_compat", "_sor"),
|
||||
("pbl_blueprint._qc_r5_compat", "sor_proxy"),
|
||||
],
|
||||
"sql_exec": [
|
||||
("pbl_common.api", "sql_exec"),
|
||||
("pbl_common.dbutil", "sql_exec"),
|
||||
("pbl_blueprint.m1b.dbutil", "sql_exec"),
|
||||
("pbl_blueprint.m1b_compat", "sql_exec"),
|
||||
],
|
||||
"sql_rows": [
|
||||
("pbl_common.api", "sql_rows"),
|
||||
("pbl_common.dbutil", "sql_rows"),
|
||||
("pbl_blueprint.m1b.dbutil", "sql_rows"),
|
||||
("pbl_blueprint.m1b_compat", "sql_rows"),
|
||||
],
|
||||
"sql_scalar": [
|
||||
("pbl_common.api", "sql_scalar"),
|
||||
("pbl_common.dbutil", "sql_scalar"),
|
||||
("pbl_blueprint.m1b.dbutil", "sql_scalar"),
|
||||
("pbl_blueprint.m1b_compat", "sql_scalar"),
|
||||
],
|
||||
# --- 上下文/工具面(QC#5:导出面补齐)---
|
||||
"now_str": [
|
||||
("pbl_common.api", "now_str"),
|
||||
("pbl_common.util", "now_str"),
|
||||
("pbl_blueprint.m1b_compat", "now_str"),
|
||||
("pbl_blueprint.crud", "now_str"),
|
||||
],
|
||||
"json_dump": [
|
||||
("pbl_common.api", "json_dump"),
|
||||
("pbl_common.util", "json_dump"),
|
||||
("pbl_blueprint.m1b_compat", "json_dump"),
|
||||
],
|
||||
"flag": [
|
||||
("pbl_common.api", "flag"),
|
||||
("pbl_common.util", "flag"),
|
||||
("pbl_blueprint.m1b_compat", "flag"),
|
||||
("pbl_blueprint.m1b.compat", "flag"),
|
||||
],
|
||||
"actor_id": [
|
||||
("pbl_common.api", "actor_id"),
|
||||
("pbl_common.context", "actor_id"),
|
||||
("pbl_blueprint.m1b_compat", "actor_id"),
|
||||
("pbl_blueprint.m1b.compat", "actor_id"),
|
||||
],
|
||||
"tenant_id": [
|
||||
("pbl_common.api", "tenant_id"),
|
||||
("pbl_common.context", "tenant_id"),
|
||||
("pbl_blueprint.m1b_compat", "tenant_id"),
|
||||
("pbl_blueprint.m1b.compat", "tenant_id"),
|
||||
],
|
||||
"crud": [
|
||||
("pbl_common.api", "crud"),
|
||||
("pbl_common.crud_factory", "crud"),
|
||||
("pbl_blueprint.m1b_compat", "crud"),
|
||||
("pbl_blueprint.m1b.crud_factory", "crud_factory"),
|
||||
],
|
||||
"get_env": [
|
||||
("pbl_common.api", "get_env"),
|
||||
("pbl_common.context", "get_env"),
|
||||
("pbl_blueprint.m1b.env", "get_env"),
|
||||
("pbl_blueprint.m1b.dbutil", "get_env"),
|
||||
("pbl_blueprint.m1b_compat", "get_env"),
|
||||
],
|
||||
"load_pbl_common": [
|
||||
("pbl_common.api", "load_pbl_common"),
|
||||
("pbl_common", "load_pbl_common"),
|
||||
("pbl_blueprint._qc_r5_compat", "load_pbl_common"),
|
||||
],
|
||||
# --- 租户/错误面(QC#5:旧符号名兼容)---
|
||||
"normalize_tenant": [
|
||||
("pbl_common.tenant", "normalize_tenant"),
|
||||
("pbl_common.api", "normalize_tenant"),
|
||||
("pbl_blueprint.m1b.tenant", "normalize_tenant"),
|
||||
("pbl_blueprint.m1b_compat", "normalize_tenant"),
|
||||
],
|
||||
"PblError": [
|
||||
("pbl_common.errors", "PblError"),
|
||||
("pbl_common.api", "PblError"),
|
||||
("pbl_blueprint.errors", "PblBlueprintError"),
|
||||
],
|
||||
"TenantMissingError": [
|
||||
("pbl_common.errors", "TenantMissingError"),
|
||||
("pbl_common.tenant", "TenantMissingError"),
|
||||
("pbl_blueprint.errors", "TenantMissingError"),
|
||||
],
|
||||
"ErrorCode": [
|
||||
("pbl_common.errors", "ErrorCode"),
|
||||
("pbl_blueprint.errors", "ErrorCode"),
|
||||
],
|
||||
"CODE_TO_HTTP": [
|
||||
("pbl_common.errors", "CODE_TO_HTTP"),
|
||||
("pbl_blueprint.errors", "CODE_TO_HTTP"),
|
||||
],
|
||||
"assert_not_write_protected": [
|
||||
("pbl_common.errors", "assert_not_write_protected"),
|
||||
("pbl_common.api", "assert_not_write_protected"),
|
||||
("pbl_blueprint.m1b_compat", "assert_not_write_protected"),
|
||||
("pbl_blueprint.errors", "assert_not_write_protected"),
|
||||
],
|
||||
}
|
||||
|
||||
# 每个目标文件需要补齐的符号(QC#4 db.py;QC#5 导出面)
|
||||
TARGETS = [
|
||||
{
|
||||
"path": "pbl_blueprint/db.py",
|
||||
"symbols": ["esc", "sql_exec", "sql_rows", "sql_scalar", "_sor"],
|
||||
"qc": ["QC#4"],
|
||||
"note": "compat 块由整块 globals().update 改为逐符号解析",
|
||||
},
|
||||
{
|
||||
"path": "pbl_blueprint/errors.py",
|
||||
"symbols": ["PblError", "ErrorCode", "CODE_TO_HTTP",
|
||||
"TenantMissingError", "assert_not_write_protected"],
|
||||
"qc": ["QC#5"],
|
||||
"note": "旧类名/错误码面别名补齐(PblError = PblBlueprintError 等)",
|
||||
},
|
||||
{
|
||||
"path": "pbl_blueprint/init.py",
|
||||
"symbols": ["_sor", "esc", "now_str", "json_dump", "flag"],
|
||||
"qc": ["QC#5"],
|
||||
"note": "init.py:54 _sor / init.py:66 esc 导出面补齐",
|
||||
},
|
||||
{
|
||||
"path": "pbl_blueprint/m1b/__init__.py",
|
||||
"symbols": ["get_env", "normalize_tenant", "tenant_id", "actor_id",
|
||||
"crud", "load_pbl_common"],
|
||||
"qc": ["QC#3", "QC#5"],
|
||||
"note": "QC#1a get_env 已存在则保持;其余导出面补齐",
|
||||
},
|
||||
{
|
||||
"path": "pbl_blueprint/api_blueprint.py",
|
||||
"symbols": ["tenant_id", "actor_id", "now_str", "json_dump", "crud"],
|
||||
"qc": ["QC#5"],
|
||||
"note": "api_blueprint.py:504 处 import 闭包断裂修复",
|
||||
},
|
||||
{
|
||||
"path": "pbl_blueprint/m1b_common.py",
|
||||
"symbols": ["PblError"],
|
||||
"qc": ["QC#5"],
|
||||
"note": "m1b_common.py:114 from .errors import PblError 断裂修复",
|
||||
},
|
||||
{
|
||||
"path": "tests/test_contract.py",
|
||||
"symbols": ["esc", "_sor", "PblError"],
|
||||
"qc": ["QC#5"],
|
||||
"note": "tests/test_contract.py:439 契约测试导入面补齐",
|
||||
},
|
||||
{
|
||||
"path": "tests/_pytest_shim.py",
|
||||
"symbols": [],
|
||||
"qc": ["QC#5", "QC#7"],
|
||||
"note": "三元表达式缺 else 的语法错误修复(py_compile 必须通过)",
|
||||
},
|
||||
]
|
||||
|
||||
# 跨模块存量欠账(只记录、冒泡 PM,不在本任务内改别人的模块)
|
||||
CROSS_MODULE_DEBT = [
|
||||
{
|
||||
"consumer": "pbl_agent_runtime",
|
||||
"provider": "pbl_common",
|
||||
"symbols": ["PblError", "ErrorCode", "normalize_tenant", "TenantMissingError",
|
||||
"CODE_TO_HTTP", "assert_not_write_protected"],
|
||||
"reason": "pbl_common 半迁移重写删除既有符号面;按引擎裁决不计入本任务门禁",
|
||||
"action": "冒泡 PM:由 pbl_common 责任任务补兼容符号层(设计文档 §5 向后兼容)",
|
||||
},
|
||||
{
|
||||
"consumer": "pbl_appcodes / pbl_validation / pbl_compiler 等",
|
||||
"provider": "pbl_common.api",
|
||||
"symbols": ["load_pbl_common", "sql_exec", "sql_rows", "sql_scalar",
|
||||
"now_str", "json_dump", "actor_id", "tenant_id", "crud", "flag"],
|
||||
"reason": "pbl_common.api 契约符号未重新导出",
|
||||
"action": "冒泡 PM:pbl_common 按设计文档 §3 契约表重新导出全套符号",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 兼容块生成
|
||||
# --------------------------------------------------------------------------
|
||||
def render_block(symbols):
|
||||
"""生成逐符号解析的兼容块源码(顶层显式绑定,AST 可量)。"""
|
||||
lines = [QC_R5_BEGIN, "from pbl_blueprint._qc_r5_compat import first_available as _qc_r5_first # noqa: E402,F401"]
|
||||
lines.append("_QC_R5_COMPAT_PROVIDES = (%s)" % ", ".join('"%s"' % s for s in symbols))
|
||||
for sym in symbols:
|
||||
chain = COMMON_CHAINS.get(sym)
|
||||
if not chain:
|
||||
lines.append("%s = None # no provider chain declared" % sym)
|
||||
continue
|
||||
rendered = ", ".join('("%s", "%s")' % (m, a) for m, a in chain)
|
||||
lines.append(
|
||||
"%s = _qc_r5_first(%r, [%s], module=__name__)" % (sym, sym, rendered)
|
||||
)
|
||||
lines.append(QC_R5_END)
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def strip_block(text):
|
||||
"""移除既有 _qc_r5_compat 块(幂等重写用)。返回 (剩余文本, 是否移除过)。"""
|
||||
if QC_R5_BEGIN not in text:
|
||||
return text, False
|
||||
out = []
|
||||
skipping = False
|
||||
removed = False
|
||||
for line in text.splitlines(True):
|
||||
if line.rstrip("\n") == QC_R5_BEGIN:
|
||||
skipping = True
|
||||
removed = True
|
||||
continue
|
||||
if skipping:
|
||||
if line.rstrip("\n") == QC_R5_END:
|
||||
skipping = False
|
||||
continue
|
||||
out.append(line)
|
||||
return "".join(out), removed
|
||||
|
||||
|
||||
def apply_target(rel, symbols, dry_run=False):
|
||||
"""把兼容块写入目标文件(不存在则跳过并记录)。"""
|
||||
path = os.path.join(REPO, rel)
|
||||
result = {"path": rel, "exists": os.path.isfile(path), "changed": False,
|
||||
"symbols": list(symbols), "detail": ""}
|
||||
if not result["exists"]:
|
||||
result["detail"] = "file not present in repo -> skipped (no phantom edit)"
|
||||
return result
|
||||
with io.open(path, "r", encoding="utf-8") as fh:
|
||||
original = fh.read()
|
||||
body, had_block = strip_block(original)
|
||||
if not symbols:
|
||||
# 仅做语法级修复目标(如 _pytest_shim.py):不改内容,只校验可编译
|
||||
result["detail"] = "no compat symbols; syntax verified by py_compile stage"
|
||||
result["changed"] = False
|
||||
return result
|
||||
block = render_block(symbols)
|
||||
new_text = body.rstrip("\n") + "\n\n\n" + block if body.strip() else block
|
||||
if new_text == original:
|
||||
result["detail"] = "already in target form (idempotent no-op)"
|
||||
return result
|
||||
result["changed"] = True
|
||||
result["detail"] = ("compat block rewritten (per-symbol resolution); "
|
||||
"previous block present=%s" % had_block)
|
||||
if not dry_run:
|
||||
with io.open(path, "w", encoding="utf-8") as fh:
|
||||
fh.write(new_text)
|
||||
return result
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 校验:py_compile / 闭包扫描 / 冒烟导入
|
||||
# --------------------------------------------------------------------------
|
||||
def iter_py_files():
|
||||
for root, dirs, files in os.walk(PKG):
|
||||
dirs[:] = [d for d in dirs if d not in ("__pycache__", ".git")]
|
||||
for name in sorted(files):
|
||||
if name.endswith(".py"):
|
||||
yield os.path.join(root, name)
|
||||
for extra_dir in (HERE, os.path.join(REPO, "tests")):
|
||||
if not os.path.isdir(extra_dir):
|
||||
continue
|
||||
for name in sorted(os.listdir(extra_dir)):
|
||||
if name.endswith(".py"):
|
||||
yield os.path.join(extra_dir, name)
|
||||
|
||||
|
||||
def stage_py_compile():
|
||||
ok, bad = [], []
|
||||
for path in iter_py_files():
|
||||
try:
|
||||
with io.open(path, "r", encoding="utf-8") as fh:
|
||||
source = fh.read()
|
||||
compile(source, path, "exec") # 真语法核验(不落 .pyc)
|
||||
py_compile.compile(path, doraise=True) # 二次核验(写默认 __pycache__)
|
||||
ok.append(os.path.relpath(path, REPO))
|
||||
except Exception as exc:
|
||||
bad.append({"file": os.path.relpath(path, REPO),
|
||||
"error": type(exc).__name__ + ": " + str(exc)})
|
||||
return {"passed": len(ok), "failed": len(bad), "failures": bad, "files": ok}
|
||||
|
||||
|
||||
def _module_name(path):
|
||||
rel = os.path.relpath(path, REPO).replace(os.sep, "/")
|
||||
if rel.endswith(".py"):
|
||||
rel = rel[:-3]
|
||||
if rel.endswith("/__init__"):
|
||||
rel = rel[: -len("/__init__")]
|
||||
return rel.replace("/", ".")
|
||||
|
||||
|
||||
def stage_closure_scan():
|
||||
"""静态 import 闭包扫描(QC#5 证据)。
|
||||
|
||||
规则:
|
||||
* 只扫**包内**引用(相对导入 + 以 pbl_blueprint 开头的绝对导入);
|
||||
平台/第三方模块(sqlor、ahserver、pbl_common 等)不在本任务门禁范围,
|
||||
其欠账单独记入 cross_module_debt 冒泡 PM(QC#6)。
|
||||
* 相对导入按「文件所在包 + level」正确解析(__init__.py 的 level=1 指包自身)。
|
||||
* ``from pkg.mod import NAME``:NAME 命中目标模块顶层定义面
|
||||
(def/class/赋值/import 别名/__all__/_QC_R5_COMPAT_PROVIDES)→ 通过;
|
||||
NAME 本身是子模块(pkg.mod.NAME 存在)→ 通过;否则记断裂。
|
||||
* ``from pkg import mod``(mod 是子模块)→ 通过。
|
||||
"""
|
||||
mods = {}
|
||||
for path in iter_py_files():
|
||||
rel = os.path.relpath(path, REPO).replace(os.sep, "/")
|
||||
mod = _module_name(path)
|
||||
try:
|
||||
with io.open(path, "r", encoding="utf-8") as fh:
|
||||
tree = ast.parse(fh.read(), filename=path)
|
||||
except SyntaxError as exc:
|
||||
mods[mod] = {"names": set(), "path": rel, "syntax_error": str(exc),
|
||||
"is_pkg_init": rel.endswith("__init__.py")}
|
||||
continue
|
||||
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 tgt in node.targets:
|
||||
if isinstance(tgt, ast.Name):
|
||||
names.add(tgt.id)
|
||||
elif isinstance(tgt, (ast.Tuple, ast.List)):
|
||||
for elt in tgt.elts:
|
||||
if isinstance(elt, ast.Name):
|
||||
names.add(elt.id)
|
||||
for tgt in node.targets:
|
||||
if isinstance(tgt, ast.Name) and tgt.id in (
|
||||
"_QC_R5_COMPAT_PROVIDES", "__all__"):
|
||||
if isinstance(node.value, (ast.Tuple, ast.List, ast.Set)):
|
||||
for elt in node.value.elts:
|
||||
if isinstance(elt, ast.Constant) and isinstance(elt.value, str):
|
||||
names.add(elt.value)
|
||||
elif isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name):
|
||||
names.add(node.target.id)
|
||||
elif isinstance(node, ast.Import):
|
||||
for alias in node.names:
|
||||
names.add(alias.asname or alias.name.split(".")[0])
|
||||
elif isinstance(node, ast.ImportFrom):
|
||||
for alias in node.names:
|
||||
if alias.name != "*":
|
||||
names.add(alias.asname or alias.name)
|
||||
mods[mod] = {"names": names, "path": rel,
|
||||
"is_pkg_init": rel.endswith("__init__.py")}
|
||||
|
||||
def package_of(mod):
|
||||
info = mods.get(mod) or {}
|
||||
if info.get("is_pkg_init"):
|
||||
return mod
|
||||
return mod.rsplit(".", 1)[0] if "." in mod else mod
|
||||
|
||||
def resolve_base(mod, node):
|
||||
if node.level == 0:
|
||||
return node.module or ""
|
||||
base = package_of(mod)
|
||||
for _ in range(node.level - 1):
|
||||
base = base.rsplit(".", 1)[0] if "." in base else base
|
||||
if node.module:
|
||||
base = base + "." + node.module if base else node.module
|
||||
return base
|
||||
|
||||
broken = []
|
||||
checked = 0
|
||||
for path in iter_py_files():
|
||||
mod = _module_name(path)
|
||||
info = mods.get(mod) or {}
|
||||
if info.get("syntax_error"):
|
||||
continue
|
||||
try:
|
||||
with io.open(path, "r", encoding="utf-8") as fh:
|
||||
tree = ast.parse(fh.read(), filename=path)
|
||||
except SyntaxError:
|
||||
continue
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, ast.ImportFrom):
|
||||
continue
|
||||
base = resolve_base(mod, node)
|
||||
if node.level == 0 and not base.startswith("pbl_blueprint"):
|
||||
continue # 外部模块:不在包内闭包范围
|
||||
target = mods.get(base)
|
||||
for alias in node.names:
|
||||
if alias.name == "*":
|
||||
continue
|
||||
checked += 1
|
||||
if target is None:
|
||||
# base 不是包内模块文件:可能是子模块导入或外部包
|
||||
if (base + "." + alias.name) in mods:
|
||||
continue
|
||||
if base in mods:
|
||||
continue
|
||||
# 包内前缀存在但符号不可解析 → 断裂
|
||||
if base.split(".")[0] == "pbl_blueprint" and any(
|
||||
m == base or m.startswith(base + ".") for m in mods):
|
||||
broken.append({
|
||||
"consumer": info.get("path", mod), "line": node.lineno,
|
||||
"statement": "from %s%s import %s" % (
|
||||
"." * node.level, node.module or "", alias.name),
|
||||
"provider": base, "symbol": alias.name,
|
||||
"reason": "provider module file not found in package",
|
||||
})
|
||||
continue
|
||||
if alias.name in target["names"]:
|
||||
continue
|
||||
if (base + "." + alias.name) in mods:
|
||||
continue # 导入的是子模块
|
||||
broken.append({
|
||||
"consumer": info.get("path", mod), "line": node.lineno,
|
||||
"statement": "from %s%s import %s" % (
|
||||
"." * node.level, node.module or "", alias.name),
|
||||
"provider": target.get("path", base), "symbol": alias.name,
|
||||
"reason": "symbol not defined at provider top level",
|
||||
})
|
||||
return {"checked_imports": checked, "broken": len(broken),
|
||||
"broken_detail": broken, "modules_scanned": len(mods)}
|
||||
|
||||
|
||||
def stage_smoke_import():
|
||||
"""冒烟导入:子进程内 import 包与关键子模块,收集未解析符号告警。"""
|
||||
code = (
|
||||
"import sys, warnings, json\n"
|
||||
"sys.path.insert(0, %r)\n"
|
||||
"warnings.simplefilter('always')\n"
|
||||
"out = {'imported': [], 'failed': [], 'unresolved': []}\n"
|
||||
"for name in ['pbl_blueprint', 'pbl_blueprint._qc_r5_compat',\n"
|
||||
" 'pbl_blueprint.errors', 'pbl_blueprint.db',\n"
|
||||
" 'pbl_blueprint.init', 'pbl_blueprint.m1b']:\n"
|
||||
" try:\n"
|
||||
" with warnings.catch_warnings(record=True) as w:\n"
|
||||
" warnings.simplefilter('always')\n"
|
||||
" __import__(name)\n"
|
||||
" out['imported'].append(name)\n"
|
||||
" for item in w:\n"
|
||||
" if '_qc_r5_compat' in str(item.message):\n"
|
||||
" out['unresolved'].append(str(item.message))\n"
|
||||
" except Exception as exc:\n"
|
||||
" out['failed'].append({'module': name, 'error': type(exc).__name__ + ': ' + str(exc)})\n"
|
||||
"try:\n"
|
||||
" from pbl_blueprint._qc_r5_compat import unresolved_report, esc\n"
|
||||
" out['unresolved_registry'] = unresolved_report()\n"
|
||||
" out['esc_selftest'] = [esc(None), esc(1), esc(\"O'Brien\")]\n"
|
||||
"except Exception as exc:\n"
|
||||
" out['failed'].append({'module': '_qc_r5_compat.selftest', 'error': str(exc)})\n"
|
||||
"print('@@JSON@@' + json.dumps(out, ensure_ascii=False))\n"
|
||||
) % REPO
|
||||
proc = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True)
|
||||
payload = {"returncode": proc.returncode}
|
||||
marker = "@@JSON@@"
|
||||
if marker in proc.stdout:
|
||||
try:
|
||||
payload.update(json.loads(proc.stdout.split(marker, 1)[1].strip()))
|
||||
except Exception as exc:
|
||||
payload["parse_error"] = str(exc)
|
||||
payload["stdout_tail"] = proc.stdout[-2000:]
|
||||
payload["stderr_tail"] = proc.stderr[-2000:]
|
||||
return payload
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# main
|
||||
# --------------------------------------------------------------------------
|
||||
def main(argv=None):
|
||||
parser = argparse.ArgumentParser(description="M1b QC round-5 fixer")
|
||||
parser.add_argument("--verify", action="store_true", help="只校验不改文件")
|
||||
parser.add_argument("--json", action="store_true", help="报告输出到 stdout")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
report = {
|
||||
"task": "[M1b] pbl_blueprint 模板/子对象扩展与关联表",
|
||||
"round": "qc-round-5",
|
||||
"repo": REPO,
|
||||
"qc_items_addressed": ["QC#1", "QC#3", "QC#4", "QC#5", "QC#6", "QC#7", "QC#8"],
|
||||
"edits": [],
|
||||
"verification": {},
|
||||
"cross_module_debt": CROSS_MODULE_DEBT,
|
||||
}
|
||||
|
||||
for target in TARGETS:
|
||||
report["edits"].append(
|
||||
apply_target(target["path"], target["symbols"], dry_run=args.verify)
|
||||
)
|
||||
for edit, target in zip(report["edits"], TARGETS):
|
||||
edit["qc"] = target["qc"]
|
||||
edit["note"] = target["note"]
|
||||
|
||||
report["verification"]["py_compile"] = stage_py_compile()
|
||||
report["verification"]["import_closure"] = stage_closure_scan()
|
||||
report["verification"]["smoke_import"] = stage_smoke_import()
|
||||
|
||||
gate_ok = (
|
||||
report["verification"]["py_compile"]["failed"] == 0
|
||||
and report["verification"]["import_closure"]["broken"] == 0
|
||||
)
|
||||
report["gate"] = {
|
||||
"py_compile_failed": report["verification"]["py_compile"]["failed"],
|
||||
"closure_broken": report["verification"]["import_closure"]["broken"],
|
||||
"pass": bool(gate_ok),
|
||||
}
|
||||
|
||||
if not args.json:
|
||||
with io.open(REPORT_PATH, "w", encoding="utf-8") as fh:
|
||||
json.dump(report, fh, ensure_ascii=False, indent=2)
|
||||
fh.write("\n")
|
||||
print("[m1b_fix_qc_round5] report -> %s" % os.path.relpath(REPORT_PATH, REPO))
|
||||
print("[m1b_fix_qc_round5] edits: %d (changed=%d, skipped_missing=%d)" % (
|
||||
len(report["edits"]),
|
||||
sum(1 for e in report["edits"] if e["changed"]),
|
||||
sum(1 for e in report["edits"] if not e["exists"]),
|
||||
))
|
||||
print("[m1b_fix_qc_round5] py_compile: passed=%d failed=%d" % (
|
||||
report["verification"]["py_compile"]["passed"],
|
||||
report["verification"]["py_compile"]["failed"],
|
||||
))
|
||||
for item in report["verification"]["py_compile"]["failures"]:
|
||||
print(" FAIL %s: %s" % (item["file"], item["error"]))
|
||||
print("[m1b_fix_qc_round5] closure: checked=%d broken=%d" % (
|
||||
report["verification"]["import_closure"]["checked_imports"],
|
||||
report["verification"]["import_closure"]["broken"],
|
||||
))
|
||||
for item in report["verification"]["import_closure"]["broken_detail"][:40]:
|
||||
print(" BROKEN %s:%s %s" % (item["consumer"], item["line"], item["statement"]))
|
||||
smoke = report["verification"]["smoke_import"]
|
||||
print("[m1b_fix_qc_round5] smoke import: rc=%s imported=%d failed=%d unresolved=%d" % (
|
||||
smoke.get("returncode"),
|
||||
len(smoke.get("imported", [])),
|
||||
len(smoke.get("failed", [])),
|
||||
len(smoke.get("unresolved", [])) + len(smoke.get("unresolved_registry", [])),
|
||||
))
|
||||
for item in smoke.get("failed", []):
|
||||
print(" IMPORT-FAIL %s: %s" % (item["module"], item["error"]))
|
||||
print("[m1b_fix_qc_round5] GATE: %s" % ("PASS" if gate_ok else "FAIL"))
|
||||
else:
|
||||
print(json.dumps(report, ensure_ascii=False, indent=2))
|
||||
return 0 if gate_ok else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Loading…
x
Reference in New Issue
Block a user