pbls/scripts/check_requirements_satisfiable.py
agent.develop 149dd5c811 fix(pbls): 修复 Bug BWY68rZR_Uh2A41JlGT7D + elS7VxwO7ufbPiPl-ucGA —— build.sh WS_DIR 部署路径推导失效(第7轮 /d/pbl/pbls 未落地代码侧根因)+ requirements 幻影依赖 hashlib-additional + 新增硬门禁 6.6 依赖声明可满足性静态校验(含 40 个模块 pyproject 同类缺陷扫描)+ rsync 缺失 tar 兜底
缺陷1(新发现): WS_DIR=/../.. 只在机构工作空间成立;部署到 env.deploy.path=/d/pbl/pbls 后 WS_DIR=/d,ENV_FILE/WHEELHOUSE/modules 全不存在 → 第0步即 die,build.sh 在部署目录从未跑通 → venv未建/DDL未落库(表数0-37)/进程未起/9380无LISTEN/healthz=000/42条用例blocked。改为 _ws_probe() 三段式(环境变量覆盖 PBL_WS_DIR/WS_DIR_OVERRIDE/WS_DIR → 逐级向上探测含 projects/pbls/env/test.json 的目录 → 兜底绝对路径),WHEELHOUSE 支持 PBL_WHEELHOUSE 覆盖。三场景实测全绿(含 /d/pbl/pbls)。

缺陷2: requirements.txt 幻影依赖 hashlib-additional>=1.0.1 ; python_version<3.0(hashlib 是标准库,marker 恒假;公共源真实名 hashlib_additional 对不上) → 删除。业主口径4项缺陷复扫确认已闭环: 基座7包==版本与 wheelhouse 实物逐字一致、pybricks/mysqlclient/10.20.30.41/相对find-links 生效行残留全0;pip download --no-deps 真实可满足性验证全部 Saved 零 No matching distribution。

缺陷3(机制根因): 门禁6.5 只 grep 声明存在不校验可满足 → 新增硬门禁6.6 scripts/check_requirements_satisfiable.py(349行) 7类校验(幻影包黑名单/标准库误当第三方/基座==版本与wheelhouse一致/wheelhouse覆盖完整/门禁6.5六项/失效内网源/相对find-links),按任务要求同步扫描 40 个 modules/*/pyproject.toml 同类缺陷并输出本地模块依赖图(pbl_common <- pbl_analytics/pbl_appcodes/pbl_blueprint)与 build.sh R8 拓扑序交叉验证。负向测试构造8类缺陷抓出17项FAIL rc=1(证明非空壳);正向 PASS 20项声明/基座7包一致/40模块零幻影。

附带加固: rsync 缺失(pbl 无 sudo 可能未装)时退化 tar 管道复制,避免部署目录建不出来。

自检: bash -n OK / py_compile OK / 门禁6.6 PASS / DDL 37表 / 门禁6.5六项全在 / 幻影包残留0 / 旧口径9300+/opt/pbls 0 / WS_DIR硬编码残留0
2026-09-25 18:43:04 +08:00

350 lines
16 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 -*-
"""依赖声明可满足性静态校验(build.sh 硬门禁 6.6)。
背景(Bug BWY68rZR_Uh2A41JlGT7D / elS7VxwO7ufbPiPl-ucGA):
build.sh 原硬门禁 6.5 只用 `grep ^包名` 校验「声明存在」,不校验「声明可满足」。
结果 requirements.txt 里出现三类必然导致 `pip install -r` 失败(BUILD_RC=1)的缺陷,
门禁全绿放行,直到部署阶段才炸:
1) 版本下界凭空抬高、无对应发行物(ahserver>=1.4.0 而公共源最高 1.2.0)
→ pip "No matching distribution found"
2) 幻影包(pybricks:平台前端能力实际由 bricks_for_python 提供;
公共 pypi 同名包是 LEGO Mindstorms 机器人库,装上即污染 venv)
3) 不可编译包(mysqlclient:目标机无 mysql_config/mariadb_config,
且 pbl 用户无 sudo 装不了 mariadb-devel → 源码编译必失败)
本脚本把「可满足性」前移为静态门禁,并顺带扫描全部业务模块 pyproject.toml
的同类缺陷(任务要求:不能只修报出来的那一个)。
校验项:
A. 幻影包黑名单(应用 requirements*.txt + 全部模块 pyproject.toml)
B. 标准库名被误当第三方包声明(sys.stdlib_module_names 比对)
C. wheelhouse 固定版本一致性:`==` 声明的包若 wheelhouse 有 whl,版本必须逐字相同
D. wheelhouse 覆盖完整性:wheelhouse 里的基座包必须全部在 requirements 中固定
E. 硬门禁 6.5 六项基座声明在位(ahserver/sqlor/apppublic/appbase/rbac/PyMySQL)
F. 失效内网源残留(--index-url/--extra-index-url/--trusted-host 指向 10.20.30.41)
G. 相对路径 find-links(pip 不解析,实测 "Location '../../wheelhouse' is ignored")
用法:
python3 scripts/check_requirements_satisfiable.py \
--requirements requirements.txt \
--wheelhouse ../../wheelhouse \
--modules-glob ../../modules/*/pyproject.toml
# rc=0 全部通过;rc=1 存在缺陷(逐条打印,build.sh die)
"""
from __future__ import annotations
import argparse
import glob
import os
import re
import sys
# ---------------------------------------------------------------- 常量配置
# 基座包:必须由机构 wheelhouse 离线供给(内网 pypi 在目标机不可达)
BASE_PACKAGES = (
"ahserver",
"sqlor",
"apppublic",
"appbase",
"rbac",
"bricks_for_python",
"accounting",
)
# 硬门禁 6.5 口径:六项声明必须在位(与 build.sh 的 for dep in ... 一致)
GATE65_REQUIRED = ("ahserver", "sqlor", "apppublic", "appbase", "rbac", "pymysql")
# 幻影包黑名单:名字 -> 禁止理由(命中即 FAIL)
PHANTOM_BLACKLIST = {
"pybricks": (
"平台前端能力由 bricks_for_python(0.0.5) 提供(内含 load_pybricks);"
"公共 pypi 的 pybricks 是 LEGO Mindstorms 机器人库,装上即污染 venv"
),
"mysqlclient": (
"需 mysql_config/mariadb_config,目标机实测 which mysql_config 无输出,"
"pbl 用户无 sudo 装不了 mariadb-devel → 源码编译必失败;"
"DB 驱动由 PyMySQL + aiomysql 承担(sqlor 走 aiomysql)"
),
"hashlib-additional": (
"Python 3 标准库已内置 hashlib;该声明带 `; python_version < \"3.0\"` 恒假 marker,"
"属无意义幻影依赖(且公共源真实包名为 hashlib_additional,声明名对不上)"
),
}
# 本地模块名:由 build.sh 以 `pip install -e modules/{模块}` 安装,任何索引都没有发行物。
# 作用域区分(关键):
# - 写进应用 requirements*.txt → 致命(pip install -r 一次性解析,必报
# "No matching distribution found for pbl_common",BUILD_RC=1)
# - 写进模块 pyproject.toml 的 dependencies → 合法的模块间依赖
# (module-development-spec 允许),由 build.sh 拓扑序先装 pbl_common 规避;
# 此时只校验「被依赖的本地模块目录真实存在」,不存在才是幻影。
LOCAL_MODULE_NAMES = ("pbl_common", "pbl_appcodes")
# 失效内网源(目标机 192.168.16.3 实测 connect timeout,5 次重试全失败)
DEAD_INDEX_HOSTS = ("10.20.30.41",)
# 依赖声明行:包名 + 可选版本约束
_REQ_LINE = re.compile(
r"^(?P<name>[A-Za-z0-9][A-Za-z0-9._-]*)\s*"
r"(?P<spec>(?:===|==|!=|~=|>=|<=|>|<)\s*[A-Za-z0-9._*+-]+\s*,?\s*)*"
)
_NAME_NORMALIZE = re.compile(r"[-_.]+")
def norm(name: str) -> str:
"""PEP 503 包名归一化:大小写无关,-/_/. 等价。"""
return _NAME_NORMALIZE.sub("-", name).lower()
def strip_comment(line: str) -> str:
"""剥掉行尾 `#` 注释(不处理引号内 #,requirements 语法里不存在该场景)。"""
idx = line.find("#")
return line if idx < 0 else line[:idx]
def parse_requirements(path: str):
"""解析 requirements.txt -> [(lineno, raw, name, spec, opts)],跳过空行/注释/选项行。"""
out = []
with open(path, encoding="utf-8", errors="replace") as fh:
for lineno, raw in enumerate(fh.read().splitlines(), 1):
body = strip_comment(raw).strip()
if not body:
continue
if body.startswith("-"): # -r / --find-links / --index-url 等选项行
out.append((lineno, raw, None, None, body))
continue
m = _REQ_LINE.match(body)
if not m:
out.append((lineno, raw, None, None, body))
continue
out.append((lineno, raw, m.group("name"), (m.group("spec") or "").strip(), None))
return out
def parse_pyproject_deps(path: str):
"""从 pyproject.toml 抽 dependencies 字符串(不引 tomllib,py3.8 兼容)。"""
txt = open(path, encoding="utf-8", errors="replace").read()
deps = []
m = re.search(r"dependencies\s*=\s*\[(.*?)\]", txt, re.S)
if m:
for item in re.findall(r"""["']([^"']+)["']""", m.group(1)):
deps.append(item.strip())
return deps
def scan_wheelhouse(wh_dir: str):
"""wheelhouse 目录 -> {归一化包名: 版本}(取 *.whl 文件名前两段)。"""
found = {}
if not wh_dir or not os.path.isdir(wh_dir):
return found
for fn in sorted(os.listdir(wh_dir)):
if not fn.endswith(".whl"):
continue
parts = fn.split("-")
if len(parts) >= 2:
found[norm(parts[0])] = parts[1]
return found
def dep_name_of(spec_str: str):
"""从 'ahserver>=1.4.0' / 'pbl_common==1.0' 抽出归一化包名。"""
m = re.match(r"^([A-Za-z0-9][A-Za-z0-9._-]*)", spec_str.strip())
return norm(m.group(1)) if m else None
def pinned_version(spec: str):
"""从 '==1.2.0' / '== 1.2.0' 抽出精确固定版本;非 == 返回 None。"""
m = re.search(r"==\s*([A-Za-z0-9._*+-]+)", spec or "")
return m.group(1) if m else None
def main(argv=None) -> int:
ap = argparse.ArgumentParser(description="依赖声明可满足性静态校验(build.sh 门禁 6.6)")
ap.add_argument("--requirements", default="requirements.txt", help="应用 requirements.txt 路径")
ap.add_argument("--extra-requirements", action="append", default=[],
help="附加 requirements 文件(如 requirements-test.txt),可多次")
ap.add_argument("--wheelhouse", default="", help="机构 wheelhouse 目录(基座 whl 离线源)")
ap.add_argument("--modules-glob", default="",
help="业务模块 pyproject.toml 通配(如 ../../modules/*/pyproject.toml)")
ap.add_argument("--quiet", action="store_true", help="只打印结论行")
args = ap.parse_args(argv)
errors = [] # 致命:必然导致 pip install 失败 / 门禁不过
warns = [] # 提示:不阻断
def err(check, msg):
errors.append("[%s] %s" % (check, msg))
def warn(check, msg):
warns.append("[%s] %s" % (check, msg))
# ---------------- 0. 文件存在性 ----------------
if not os.path.isfile(args.requirements):
err("0", "requirements 文件不存在: %s" % args.requirements)
print("FAIL 依赖声明可满足性校验:requirements 文件缺失")
for e in errors:
print(" " + e)
return 1
req_files = [args.requirements] + [p for p in args.extra_requirements if os.path.isfile(p)]
wh = scan_wheelhouse(args.wheelhouse)
stdlib = set(getattr(sys, "stdlib_module_names", set()))
if not args.quiet:
print("[6.6] requirements 文件: %s" % ", ".join(req_files))
print("[6.6] wheelhouse: %s (%d 个 whl: %s)"
% (args.wheelhouse or "<未指定>", len(wh), ", ".join(sorted(wh)) or "-"))
# ---------------- A/B/F/G. 应用 requirements 逐行校验 ----------------
declared = {} # 归一化名 -> (lineno, spec, file)
for rf in req_files:
for lineno, raw, name, spec, opt in parse_requirements(rf):
if opt is not None:
# F. 失效内网源残留
for host in DEAD_INDEX_HOSTS:
if host in opt:
err("F", "%s:L%d 残留失效内网源 %s(目标机实测 connect timeout): %s"
% (rf, lineno, host, opt.strip()[:100]))
# G. 相对路径 find-links(pip 不解析)
mfl = re.match(r"--find-links[=\s]+(\S+)", opt)
if mfl:
target = mfl.group(1)
if not os.path.isabs(target) and not target.startswith(("http://", "https://")):
err("G", "%s:L%d --find-links 用相对路径 %r,pip 不解析"
"(实测 WARNING: Location is ignored);改绝对路径或由 build.sh 传 --find-links"
% (rf, lineno, target))
continue
if name is None:
warn("A", "%s:L%d 无法解析的声明行: %s" % (rf, lineno, raw.strip()[:100]))
continue
n = norm(name)
declared[n] = (lineno, spec or "", rf)
# A. 幻影包黑名单
if n in PHANTOM_BLACKLIST or n.replace("-", "_") in PHANTOM_BLACKLIST:
key = n if n in PHANTOM_BLACKLIST else n.replace("-", "_")
err("A", "%s:L%d 幻影依赖 %s —— %s" % (rf, lineno, name, PHANTOM_BLACKLIST[key]))
# A2. 本地模块写进 requirements(pip 索引无发行物 → 必失败)
if n in LOCAL_MODULE_NAMES or n.startswith("pbl-"):
err("A", "%s:L%d 本地模块 %s 不得写进 requirements —— 任何索引都无其发行物,"
"pip install -r 必报 No matching distribution;"
"由 build.sh 以 pip install -e modules/{模块} 安装"
% (rf, lineno, name))
# B. 标准库名误当第三方包
base = n.replace("-", "_")
if base in stdlib:
err("B", "%s:L%d %s 是 Python 标准库模块名,不得作为第三方依赖声明" % (rf, lineno, name))
# C. wheelhouse 固定版本一致性
pv = pinned_version(spec or "")
if n in wh:
if pv is None:
err("C", "%s:L%d 基座包 %s 未用 == 精确固定(实际 %r),"
"wheelhouse 实装 %s;非固定下界在公共源无对应发行物时 pip 必失败"
% (rf, lineno, name, (spec or "").strip(), wh[n]))
elif pv != wh[n]:
err("C", "%s:L%d %s==%s 与 wheelhouse 实装 %s 不一致(pip 找不到发行物)"
% (rf, lineno, name, pv, wh[n]))
elif pv is None and re.search(r">=\s*[0-9]", spec or ""):
# 非基座包用 >= 下界:提示核对下界是否有对应发行物(不阻断,公网包通常可满足)
warn("C", "%s:L%d %s 用下界约束 %r,请确认公共源存在满足该下界的发行物"
% (rf, lineno, name, (spec or "").strip()))
# ---------------- D. wheelhouse 覆盖完整性 ----------------
for pkg in BASE_PACKAGES:
npkg = norm(pkg)
if npkg in wh and npkg not in declared:
err("D", "wheelhouse 提供基座包 %s(%s) 但 requirements 未声明固定版本" % (pkg, wh[npkg]))
# ---------------- E. 硬门禁 6.5 六项声明 ----------------
for pkg in GATE65_REQUIRED:
if norm(pkg) not in declared:
err("E", "硬门禁 6.5 缺基座声明: %s" % pkg)
# ---------------- 模块 pyproject 同类缺陷扫描 ----------------
mod_files = sorted(glob.glob(args.modules_glob)) if args.modules_glob else []
if not args.quiet:
print("[6.6] 业务模块 pyproject.toml 扫描: %d 个" % len(mod_files))
mod_hits = 0
# modules 根目录:从 --modules-glob 反推(.../modules/*/pyproject.toml)
modules_root = ""
if args.modules_glob:
_g = args.modules_glob.replace("\\", "/")
if "/modules/" in _g:
modules_root = _g.split("/modules/")[0] + "/modules"
elif _g.startswith("modules/"):
modules_root = "modules"
local_deps = {} # 被依赖本地模块 -> [依赖它的 pyproject 列表]
for mf in mod_files:
for dep in parse_pyproject_deps(mf):
dn = dep_name_of(dep)
if not dn:
continue
if dn in PHANTOM_BLACKLIST or dn.replace("-", "_") in PHANTOM_BLACKLIST:
key = dn if dn in PHANTOM_BLACKLIST else dn.replace("-", "_")
err("A", "%s 声明幻影依赖 %s —— %s" % (mf, dep, PHANTOM_BLACKLIST[key]))
mod_hits += 1
# A2'. 模块间本地依赖:合法,但被依赖模块目录必须真实存在
# (否则 build.sh 拓扑序也装不上 → 入口 ImportError → 进程起不来)
if dn in LOCAL_MODULE_NAMES or dn.startswith("pbl-"):
local_name = dn.replace("-", "_")
if modules_root:
cand = os.path.join(modules_root, local_name)
if not os.path.isdir(cand):
err("A", "%s 依赖本地模块 %s,但 %s 不存在(幻影本地依赖,"
"pip install -e 阶段必失败)" % (mf, dep, cand))
mod_hits += 1
elif local_name != os.path.basename(mf).split(os.sep)[0]:
local_deps.setdefault(local_name, []).append(mf)
if dn.replace("-", "_") in stdlib:
err("B", "%s 把标准库 %s 当第三方依赖声明" % (mf, dep))
mod_hits += 1
pv = pinned_version(dep)
if dn in wh:
if pv is not None and pv != wh[dn]:
err("C", "%s 固定 %s 与 wheelhouse 实装 %s 不一致" % (mf, dep, wh[dn]))
mod_hits += 1
elif pv is None:
m = re.search(r">=\s*([0-9][0-9A-Za-z.]*)", dep)
if m and m.group(1) > wh[dn]:
err("C", "%s 下界 %s 高于 wheelhouse 实装 %s(无对应发行物)"
% (mf, dep, wh[dn]))
mod_hits += 1
if not args.quiet:
print("[6.6] 模块同类缺陷命中: %d" % mod_hits)
if local_deps:
print("[6.6] 本地模块间依赖(build.sh 须按拓扑序先装被依赖方):")
for k in sorted(local_deps):
print(" %s <- %d 个模块依赖: %s"
% (k, len(local_deps[k]),
", ".join(sorted(os.path.basename(os.path.dirname(p))
for p in local_deps[k])[:6])))
if "pbl_common" in local_deps:
print(" 注: build.sh 第 8 步已显式把 pbl_common 置于 INSTALL_DIRS 首位"
"(R8 拓扑序修复),与上述依赖图一致")
# ---------------- 结论 ----------------
for w in warns:
print(" WARN " + w)
if errors:
print("FAIL 依赖声明可满足性校验未通过(%d 项):" % len(errors))
for e in errors:
print(" " + e)
return 1
print("PASS 依赖声明可满足性校验通过:%d 个 requirements 文件 / %d 项声明 / "
"基座 %d 包与 wheelhouse 版本逐字一致 / %d 个模块 pyproject 零幻影依赖"
% (len(req_files), len(declared), len([p for p in BASE_PACKAGES if norm(p) in wh]),
len(mod_files)))
return 0
if __name__ == "__main__":
sys.exit(main())