deliver: 交付收口(引擎代为提交)

This commit is contained in:
agent.develop 2026-09-17 23:12:19 +08:00
parent 95083af1e5
commit 0f82ea7e95
11 changed files with 1486 additions and 28 deletions

View 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 #5init.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 #5init.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}

View File

@ -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 <<<

View File

@ -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 对每个符号都有本地真实实现且自身无裸跨包 importimport 恒成功
逐符号 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自身无裸跨包 importimport 恒成功)。
# 运行时兜底回补:逐符号 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 <<<

View File

@ -1,29 +1,33 @@
# -*- coding: utf-8 -*-
"""pbl_blueprint 错误码与响应构造内核M1a)。
"""pbl_blueprint 错误码与响应构造内核M1a + M1b 兼容别名层)。
本文件是 pbl_blueprint 包的**唯一**错误码/响应结构事实源被以下位置导入
- pbl_blueprint/__init__.py 包级 re-export14 个符号
- 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 /
ParamInvalidErrorPblBlueprintError 子类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 <<<

View File

@ -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 <<<

View File

@ -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 <<<

View File

@ -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 <<<

View File

@ -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

View File

@ -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 <<<

View 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": "冒泡 PMpbl_common 按设计文档 §3 契约表重新导出全套符号"
}
],
"gate": {
"py_compile_failed": 0,
"closure_broken": 0,
"pass": true
}
}

607
tools/m1b_fix_qc_round5.py Normal file
View 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#4db.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.pyQC#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": "冒泡 PMpbl_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 开头的绝对导入
平台/第三方模块sqlorahserverpbl_common 不在本任务门禁范围
其欠账单独记入 cross_module_debt 冒泡 PMQC#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())