pbls/scripts/_patch_common_contract_symbols.py
2026-09-25 09:44:21 +08:00

331 lines
14 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""补齐 pbl_common 契约符号缺口(Bug HoSBcdZY4G7IxhaFiIE07 同类问题全量修复)。
背景
----
原 Bug YyDumUuSK9R9Q_3Ai23AD 的 develop 侧根因 G 是:``pbl_common.api`` 缺 ``crud``
符号 → 下游 ``pbl_assessment`` / ``pbl_scense_ext`` **包导入期 ImportError** →
应用进程起不来(healthz=000、pbls.py 进程数=0、9300 未监听)。
按任务要求「必须检查所有模块的同类问题,不能只修报出来的那一个」,用
``apps/pbls/scripts/verify_contract_symbols_all_modules.py`` 对全部 15 个 pbl_*
模块 + apps/pbls 共 266 个 .py 做静态 AST 闭包核验,查出 **4 处同类缺口**:
| # | 文件:行 | 缺失符号 | 严重度 |
|---|---------|----------|--------|
| 1 | modules/pbl_runtime_ext/pbl_runtime_ext/idempotency.py:21 | ``pbl_common.api.q_all`` / ``q_one`` | **致命**:模块顶层 import,无 try 兜底 → 包导入即 ImportError,与 crud 缺口完全同型 |
| 2 | modules/pbl_runtime_ext/pbl_runtime_ext/rtx_db.py:59 | ``pbl_common.api.module_dbname`` | 中:在 try/except 内,静默降级为「无法解析模块库名」PBL-RTX-0001 硬错 |
| 3 | modules/pbl_compiler/pbl_compiler/exporter.py:76 | ``pbl_common.db.get_sor_context`` | 中:在 try/except 内,静默降级到 DBPools 兜底路径(绕过公共内核租户适配) |
本脚本幂等地把上述符号补进 pbl_common(**只改本项目 generated_modules 的
pbl_common,不碰任何 referenced_modules / 基础模块**):
- ``pbl_common/api.py``:新增 ``q_all`` / ``q_one`` / ``module_dbname``,
并登记进 ``CONTRACT_SYMBOLS``(``__all__ = list(CONTRACT_SYMBOLS) + [...]``
自动随之导出,``verify_contract()`` 的 _MISSING 自检同步覆盖)。
- ``pbl_common/db.py``:新增 ``get_sor_context(env, module)``。
q_all / q_one 的**双模兼容**设计(关键)
----------------------------------------
实测两类调用点语义不同,必须同时满足,否则修一处坏另一处:
- ``idempotency.py``:**同步**调用且带 ``conn=`` 显式连接、SQL 用 ``%s`` 占位
(``existing = q_one(sql, params, conn=conn)``,无 await);
- ``rtx_db.py``:``async def q_all(...)`` 内 ``await`` 公共内核 reader。
故返回值用 ``list`` / ``dict`` 子类并实现 ``__await__``——同步取值与
``await`` 取值都成立;``q_one`` 无行时返回**空 _DualDict**(而非 None),
既保证 ``await`` 不炸,又与全部现存调用点的真值判断
(``if existing:`` / ``(max_row or {}).get(...)`` / ``if raced and ...``)语义一致。
用法::
python3 apps/pbls/scripts/_patch_common_contract_symbols.py # 应用补丁
python3 apps/pbls/scripts/_patch_common_contract_symbols.py --check # 只检查不落盘
"""
from __future__ import annotations
import argparse
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
APP_ROOT = os.path.dirname(HERE)
WS_ROOT = os.path.dirname(os.path.dirname(APP_ROOT))
API_PY = os.path.join(WS_ROOT, "modules", "pbl_common", "pbl_common", "api.py")
DB_PY = os.path.join(WS_ROOT, "modules", "pbl_common", "pbl_common", "db.py")
MARK_API = "# >>> q_all/q_one/module_dbname contract-fix (Bug HoSBcdZY4G7IxhaFiIE07) >>>"
MARK_DB = "# >>> get_sor_context contract-fix (Bug HoSBcdZY4G7IxhaFiIE07) >>>"
API_BLOCK = '''
# >>> q_all/q_one/module_dbname contract-fix (Bug HoSBcdZY4G7IxhaFiIE07) >>>
# ---------------------------------------------------------------------------
# 查询助手 q_all / q_one —— 同步与 await 双模兼容
#
# 缺口来源:modules/pbl_runtime_ext/pbl_runtime_ext/idempotency.py:21 在**模块
# 顶层**写 `from pbl_common.api import q_all, q_one`,而 pbl_common.api 从未定义
# 这两个符号 → 包导入期 ImportError,pbl_runtime_ext 整包不可用(与原 Bug 的
# crud 缺口完全同型:应用进程起不来、healthz=000)。
#
# 双模设计依据(两类真实调用点,必须同时成立):
# * idempotency.py -> 同步调用 + 显式 conn= + SQL 用 %s 占位(无 await)
# * rtx_db.py -> `async def q_all(...)` 内 await 公共内核 reader
# 因此返回 list/dict 子类并实现 __await__;q_one 无行返回**空 dict**而非 None,
# 保证 await 不炸,且与现存调用点真值判断(if existing: / (row or {}).get /
# if raced and ...)语义完全一致。
# ---------------------------------------------------------------------------
class _DualList(list):
"""list 子类,既可同步迭代/索引,也可被 await(await 后得到自身)。"""
__slots__ = ()
def __await__(self):
async def _identity():
return self
return _identity().__await__()
class _DualDict(dict):
"""dict 子类,既可同步 .get()/[],也可被 await(await 后得到自身)。
无行时返回空 _DualDict(falsy),与 `if row:` / `(row or {})` 判断兼容。
"""
__slots__ = ()
def __await__(self):
async def _identity():
return self
return _identity().__await__()
def _rows_from_conn(conn, sql, params):
"""在调用方显式传入的 DB-API 连接上执行查询,返回 list[dict]。
idempotency.py 的 SQL 使用 %s 占位(PyMySQL 风格)并自带事务连接,
必须原样在该连接上执行,不能改走 sqlor 的 ${name}$ 参数化路径。
"""
cur = conn.cursor()
try:
cur.execute(sql, params if params is not None else ())
desc = cur.description or []
cols = [d[0] for d in desc]
raw = cur.fetchall()
finally:
try:
cur.close()
except Exception:
pass
out = []
for row in raw:
if isinstance(row, dict):
out.append(dict(row))
elif cols:
out.append(dict(zip(cols, row)))
else:
out.append({'value': row})
return out
def _rows_via_common(sql, params, dbname, module):
"""无显式 conn 时走公共内核只读路径(sqlor 优先,租户/库名解析复用既有实现)。"""
from pbl_common import dbutil as _dbu
target_db = dbname
if target_db is None and module:
try:
target_db = get_module_dbname(module)
except Exception:
target_db = None
for fname in ('query', 'sql_rows', 'fetch_all'):
fn = getattr(_dbu, fname, None)
if not callable(fn):
continue
try:
res = fn(sql, params, dbname=target_db)
except TypeError:
try:
res = fn(sql, params)
except Exception:
continue
except Exception:
continue
rows = []
for r in (res or []):
if isinstance(r, dict):
rows.append(dict(r))
else:
keys = getattr(r, 'keys', None)
if callable(keys):
try:
rows.append({k: r[k] for k in keys()})
continue
except Exception:
pass
rows.append({'value': r})
return rows
# 最后兜底:api.sql_rows(内部已含 get_conn 直连路径)
return [dict(r) if isinstance(r, dict) else {'value': r}
for r in (sql_rows(sql, params, dbname=target_db) or [])]
def q_all(sql, params=None, conn=None, dbname=None, module=None, **_ignored):
"""只读查询 → list[dict](同步取值与 await 取值均可)。
参数
----
sql : SQL 文本。传 conn 时用驱动原生占位(%s);否则走公共内核路径。
params : 序列或 dict,与 sql 占位匹配。
conn : 可选,调用方已开启的 DB-API 连接(事务内查询必须传,见 idempotency)。
dbname : 可选,显式库名。**禁止硬编码库名**,缺省由 module 解析。
module : 可选,模块名,用于 get_module_dbname 解析库名。
"""
try:
if conn is not None:
return _DualList(_rows_from_conn(conn, sql, params))
return _DualList(_rows_via_common(sql, params, dbname, module))
except Exception as exc: # noqa: BLE001
log.warning('q_all 失败(返回空列表,不抛断链): %s', exc)
return _DualList()
def q_one(sql, params=None, conn=None, dbname=None, module=None, **_ignored):
"""只读查询首行 → dict(无行返回**空 dict**,非 None;同步/await 双模)。"""
rows = q_all(sql, params, conn=conn, dbname=dbname, module=module)
if rows:
return _DualDict(rows[0])
return _DualDict()
def module_dbname(module_name, default=None):
"""解析模块库名的公共入口(rtx_db.py:59 契约名)。
等价于 get_module_dbname(module_name),但**失败不抛**:返回 default(缺省
None),由调用方决定是降级还是 fail-closed。绝不返回硬编码库名。
"""
try:
name = get_module_dbname(module_name)
except Exception as exc: # noqa: BLE001
log.debug('module_dbname(%r) 解析失败: %s', module_name, exc)
return default
return name or default
# <<< q_all/q_one/module_dbname contract-fix (Bug HoSBcdZY4G7IxhaFiIE07) <<<
'''
DB_BLOCK = '''
# >>> get_sor_context contract-fix (Bug HoSBcdZY4G7IxhaFiIE07) >>>
def get_sor_context(env, module_name=None):
"""取得 sqlor 异步上下文的公共入口(pbl_compiler/exporter.py:76 契约名)。
缺口来源:exporter.open_sor() 优先 ``from pbl_common.db import
get_sor_context``,符号不存在 → 静默 except 降级到 DBPools 兜底路径,
绕过公共内核的库名解析与方言校验。补齐后主路径可用。
参数
----
env : ``request._run_ns``(.dspy 内)或 ServerEnv() 实例(load_* 阶段)。
module_name : 模块名,用于解析库名;**禁止硬编码 DBNAME**。
返回 async context manager,用法::
async with get_sor_context(request._run_ns, 'pbl_compiler') as sor:
rows = await sor.sqlExe('SELECT 1', {})
"""
# 1) 宿主已注入的公共 helper 优先(dspy-file-implementation-spec 铁律:
# 跨模块 DB 访问走 env + get_sor_context,不硬编码库名)
injected = getattr(env, 'get_sor_context', None)
if callable(injected):
return injected(env, module_name)
# 2) 平台 sqlor 上下文
dbname = get_module_dbname(module_name) if module_name else None
try:
from sqlor.dbpools import DBPools
except Exception:
DBPools = None # noqa: N806
if DBPools is not None and dbname:
return DBPools().sqlorContext(dbname)
raise RuntimeError(
'get_sor_context 不可用:env 未注入 get_sor_context 且 sqlor/库名解析失败'
'(module=%r, dbname=%r)' % (module_name, dbname))
# <<< get_sor_context contract-fix (Bug HoSBcdZY4G7IxhaFiIE07) <<<
'''
# CONTRACT_SYMBOLS 里新增的符号(__all__ = list(CONTRACT_SYMBOLS) + [...] 自动导出)
NEW_CONTRACT = (" # 查询助手/库名解析(Bug HoSBcdZY4G7IxhaFiIE07 同类缺口补齐)\n"
" 'q_all', 'q_one', 'module_dbname',\n")
CONTRACT_ANCHOR = "CONTRACT_SYMBOLS = (\n"
def patch_api(check_only: bool = False):
src = open(API_PY, encoding="utf-8").read()
changed = []
if MARK_API in src:
print("[api.py] q_all/q_one/module_dbname 已存在,跳过插入")
else:
# 必须插在 CONTRACT_SYMBOLS **之前**:api.py 的 verify_contract()/_MISSING
# 是 fail-closed 自检(缺符号直接 raise ImportError),若把定义追加到文件
# 末尾,登记在前、定义在后 → 导入期就炸。
if CONTRACT_ANCHOR not in src:
raise SystemExit("FATAL: api.py 未找到 CONTRACT_SYMBOLS 锚点,拒绝盲改")
src = src.replace(CONTRACT_ANCHOR, API_BLOCK.lstrip("\n") + "\n\n" + CONTRACT_ANCHOR, 1)
changed.append("insert q_all/q_one/module_dbname before CONTRACT_SYMBOLS")
if "'q_all'" in src.split("CONTRACT_SYMBOLS = (", 1)[-1].split(")", 1)[0]:
print("[api.py] CONTRACT_SYMBOLS 已含 q_all,跳过登记")
else:
if CONTRACT_ANCHOR not in src:
raise SystemExit("FATAL: api.py 未找到 CONTRACT_SYMBOLS 锚点,拒绝盲改")
src = src.replace(CONTRACT_ANCHOR, CONTRACT_ANCHOR + NEW_CONTRACT, 1)
changed.append("register CONTRACT_SYMBOLS")
if changed and not check_only:
open(API_PY, "w", encoding="utf-8").write(src)
print("[api.py] %s: %s" % ("待改" if check_only else "已改", ", ".join(changed) or "无变更"))
return bool(changed)
def patch_db(check_only: bool = False):
src = open(DB_PY, encoding="utf-8").read()
if MARK_DB in src or "\ndef get_sor_context(" in src:
print("[db.py] get_sor_context 已存在,跳过")
return False
src = src.rstrip("\n") + "\n" + DB_BLOCK
if not check_only:
open(DB_PY, "w", encoding="utf-8").write(src)
print("[db.py] %s: append get_sor_context" % ("待改" if check_only else "已改"))
return True
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--check", action="store_true", help="只检查需要哪些改动,不落盘")
args = ap.parse_args()
for p in (API_PY, DB_PY):
if not os.path.isfile(p):
print("FATAL: 目标文件不存在 %s" % p)
return 2
a = patch_api(args.check)
b = patch_db(args.check)
print("-" * 60)
if args.check:
print("CHECK 结论: %s" % ("需要打补丁" if (a or b) else "已是最新,无需补丁"))
else:
print("补丁应用完成(幂等,可重复执行)")
return 0
if __name__ == "__main__":
sys.exit(main())