#!/usr/bin/env python3 # -*- coding: utf-8 -*- """部署脚本的**虚拟环境契约**:venv 坏了自己修,用错解释器直接拦住。 python tools/tests/test_deploy_shell_env_guard.py **为什么要有这份测试**:客户报的「部署失败」绝大多数不是网络问题,而是 venv 本身坏了或用错了环境(详见 使用手册.md 附录 B)。这些保护全写在四个 分发壳(start.bat / start_lite.bat / start.sh / start_lite.sh)和两个入口 (main.py / main_lite.py)里,而它们**没有任何单元测试覆盖** —— 谁顺手把 那句 `pip install --upgrade pip` 加回来、或者把入口守卫挪到 PyQt5 import 之后,都不会有测试变红。这份测试就是那道闸。 覆盖: [1] .bat 编码、行尾、百分号转义与跳转标签(改错了 cmd 会乱码 / 静默不生效) [2] 静默升级 pip 已绝迹(它是「半装 pip」的唯一来源) [3] venv 体检 + 离线自愈(ensurepip)+ 重建兜底,且体检在装依赖之前 [4] 环境隔离(PYTHONHOME / PYTHONPATH / 用户级 site-packages / conda 提示) [5] 入口守卫:在 PyQt5 之前、规格与分发壳一致 [6] 守卫行为:缺依赖时退出码 2、开关能跳过 [7] 文档同步(使用手册.md 两个语种都写了 pip._internal.cli 这条) [8] lite_package/ 里的副本没掉队(存在才查) [9] run.bat / run.sh 未部署时给得出方向(不教用户手工建 venv) [10] Python 3.10 门(手套 SDK 的加密链按 3.10 ABI 编译,**不是"及以上"**) [11] [4/7] / [4/6] 手套 SDK 段(探针走装配层 / type 打原文 / 陈旧戳自愈) [12] requirements 的 PEP 263 声明(cp936 的 pip 23.0.1 会栽在这) 退出码 0 = 全部通过。 """ from __future__ import annotations import contextlib import io import os import re import sys _BASE = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) sys.path.insert(0, _BASE) BAT_SHELLS = ["start.bat", "start_lite.bat"] SH_SHELLS = ["start.sh", "start_lite.sh"] ENTRIES = {"main.py": "main", "main_lite.py": "lite"} _FAILS: list = [] def check(tag: str, ok: bool, detail: str = "") -> None: if ok: print(f" ok {tag}") else: print(f" FAIL {tag}: {detail}") _FAILS.append(f"{tag}: {detail}") def read(path: str) -> str: """按各自编码读原文(newline='' 保留 CRLF,才能查行尾)。""" enc = "gbk" if path.endswith(".bat") else "utf-8" with open(os.path.join(_BASE, path), "r", encoding=enc, newline="") as fh: return fh.read() def code_lines(text: str, shell: str) -> str: """去掉注释行 —— 注释里出现「曾经有过那句命令」是允许的(也是刻意的)。""" out = [] for line in text.splitlines(): stripped = line.strip() if shell.endswith(".bat") and stripped.lower().startswith("rem"): continue if shell.endswith(".sh") and stripped.startswith("#"): continue out.append(line) return "\n".join(out) def shell_paths() -> list: """四个分发壳,外加 lite_package/ 里的副本(存在才查)。""" paths = BAT_SHELLS + SH_SHELLS for extra in ("lite_package/start_lite.bat", "lite_package/start_lite.sh"): if os.path.exists(os.path.join(_BASE, extra)): paths.append(extra) return paths def test_bat_encoding() -> None: print("[1] .bat 编码、行尾、百分号转义与跳转标签(GBK + CRLF)") bats = BAT_SHELLS + (["lite_package/start_lite.bat"] if os.path.exists( os.path.join(_BASE, "lite_package/start_lite.bat")) else []) + (["run.bat"] if os.path.exists( os.path.join(_BASE, "run.bat")) else []) for name in bats: with open(os.path.join(_BASE, name), "rb") as fh: raw = fh.read() try: raw.decode("gbk") gbk_ok = True except UnicodeDecodeError: gbk_ok = False check(f"{name} 是 GBK", gbk_ok, "按 GBK 解不开(被存成 UTF-8 了?)") check(f"{name} 无 BOM", not raw.startswith(b"\xef\xbb\xbf"), "带 UTF-8 BOM,cmd 会报错") check(f"{name} 全 CRLF", b"\n" not in raw.replace(b"\r\n", b""), "有裸 LF 行尾") # %%VAR%% 只在 for 循环里才是「循环变量」;循环外是两个转义百分号, # 比较的是字面量 "%VAR%" —— 语句能跑、不报错、但**永远为假**。 # 这里踩过一次: PY 清空那句写成了 %%PY%%,reinstall 的修复于是静默失效。 for name in bats: lines = read(name).splitlines() bad = [f"{i}:{ln.strip()}" for i, ln in enumerate(lines, 1) if re.search(r"%%\w+%%", ln) and not ln.strip().lower().startswith("for")] check(f"{name} 无循环外的 %%VAR%%", not bad, f"永远为假的条件: {bad[:1]}") # goto/call 指向不存在的标签时 cmd 会**直接终止脚本**,什么都不说。 # 这里没有 Windows 可跑,静态解析是唯一的网。 labels = {ln.strip()[1:].split()[0].lower() for ln in lines if re.match(r"^\s*:[A-Za-z_]", ln)} dangling = [f"{i}:{m.group(1)}" for i, ln in enumerate(lines, 1) if not ln.strip().lower().startswith("rem") for m in re.finditer(r"(?:goto|call)\s+:(\w+)", ln, re.I) if m.group(1).lower() not in labels] check(f"{name} 跳转标签都存在", not dangling, f"悬空: {dangling[:2]}") # **同名标签**:cmd 的 goto 跳到**第一个**同名标签,后面那些是死代码。 # 比悬空标签更毒 —— 脚本照跑、不报错,只是跳去了旧那段。这里踩过 # 一次:删旧的手套工具包段时只删了一半,:toolkit_missing 剩两份, # goto 落在旧那份上,而旧那份的条件恰好把流程送回自己 ⇒ 死循环。 seen, dups = {}, [] for i, ln in enumerate(lines, 1): m = re.match(r"^\s*:([A-Za-z_]\w*)", ln) if not m: continue key = m.group(1).lower() if key in seen: dups.append(f"{key}({seen[key]},{i})") else: seen[key] = i check(f"{name} 无同名标签", not dups, f"goto 只会跳到第一个,其余是死代码/死循环: {dups[:3]}") def test_no_silent_pip_upgrade() -> None: print("[2] 静默升级 pip 已绝迹") for name in shell_paths(): body = code_lines(read(name), name) hits = [ln.strip() for ln in body.splitlines() if re.search(r"pip\s+install\s+--upgrade\s+pip", ln)] check(f"{name} 无 `pip install --upgrade pip`", not hits, f"又在自动升级 pip(半装 pip 的来源): {hits[:1]}") def test_venv_health_and_heal() -> None: print("[3] venv 体检 + 离线自愈 + 重建兜底") for name in shell_paths(): text = code_lines(read(name), name) lite = "lite" in name venv = "venv_lite" if lite else "venv" check(f"{name} 体检 pip", "-m pip --version" in text, "没有 pip 健康探测") check(f"{name} 离线自愈", "ensurepip --upgrade" in text, "没有 ensurepip 自救") rebuild = ("rmdir /s /q" if name.endswith(".bat") else "rm -rf") check(f"{name} 重建兜底", rebuild in text, f"没有 {rebuild} 重建路径") check(f"{name} 指向 {venv}", f'"{venv}"' in text or f" {venv}" in text, f"没提到 {venv}") # ensurepip 只在「没装 pip」时才装: 半装 pip 的 dist-info 还在、版本又恰好等于 # Python 自带的那只 wheel → 它判「已满足」直接跳过,根本修不动(2026-09-21 在 # Wine 的真 cmd 里实测到:修完 pip --version 照旧报错)。所以每一处 ensurepip # 的前一行必须是清残缺 pip 的那步 —— 这不是风格,是「修不修得动」的分界。 purge = "call :purge_pip" if name.endswith(".bat") else "purge_pip" lines_ = [ln.strip() for ln in text.splitlines()] naked = [] for i, ln in enumerate(lines_): if "ensurepip --upgrade" not in ln: continue before = [x for x in lines_[:i] if x] if not before or before[-1] != purge: naked.append(f"{i + 1}行前是 {before[-1][:28]!r}") check(f"{name} ensurepip 前先清残缺 pip", not naked, f"没清就直接 ensurepip(判「已满足」修不动): {naked[:1]}") # 体检必须发生在装依赖之前 —— 顺序反了就成了「用坏 pip 去装依赖」 probe = text.find("-m pip --version") install = text.find(":install_deps") if name.endswith(".bat") else \ text.find("install_req requirements") check(f"{name} 体检先于装依赖", -1 < probe < install, f"probe@{probe} install@{install}") def test_env_isolation() -> None: print("[4] 环境隔离(外部环境串味)") for name in shell_paths(): text = read(name) win = name.endswith(".bat") check(f"{name} 清 PYTHONHOME", ('set "PYTHONHOME="' in text) if win else ("unset PYTHONHOME" in text), "没有清 PYTHONHOME") check(f"{name} 清 PYTHONPATH", ('set "PYTHONPATH="' in text) if win else ("PYTHONPATH" in text), "没有清 PYTHONPATH") check(f"{name} 屏蔽用户级 site-packages", "PYTHONNOUSERSITE" in text, "没有 PYTHONNOUSERSITE=1") check(f"{name} 提示外部环境", "VIRTUAL_ENV" in text and "CONDA_PREFIX" in text, "没有 conda/venv 提示") def test_entry_guard() -> None: print("[5] 入口守卫(在 PyQt5 之前 + 规格一致)") from core.startup_guard import SPECS for entry, app in ENTRIES.items(): text = read(entry) guard_at = text.find("_enforce_env(") pyqt_at = text.find("from PyQt5") check(f"{entry} 调用守卫", guard_at != -1, "没有调用 startup_guard.enforce") check(f"{entry} 守卫在 PyQt5 之前", -1 < guard_at < pyqt_at, f"guard@{guard_at} pyqt@{pyqt_at}") check(f"{entry} 传入 app={app!r}", f'"{app}"' in text, "app key 不对") # 规格里的 venv 名与分发脚本名必须和分发壳实际使用的一致 for app, spec in SPECS.items(): for shell, key in (("start.bat" if app == "main" else "start_lite.bat", "launcher_win"), ("start.sh" if app == "main" else "start_lite.sh", "launcher_unix")): check(f"SPECS[{app}][{key}] 与壳一致", spec[key].lstrip("./") == shell, f"{spec[key]} != {shell}") shell_text = read("start.bat" if app == "main" else "start_lite.bat") vpy_marker = 'set "VPY=' + spec["venv"] check(f"SPECS[{app}][venv] 与壳一致", vpy_marker in shell_text, f"壳里的 VPY 不是 {spec['venv']}") def test_guard_behaviour() -> None: print("[6] 守卫行为(缺依赖退出码 2 / 开关能跳过)") from core import startup_guard fake = {"venv": "venv", "modules": ("no_such_module_for_test_xyz",), "entry": "main.py", "launcher_win": "start.bat", "launcher_unix": "./start.sh"} startup_guard.SPECS["__test__"] = fake try: buf = io.StringIO() with contextlib.redirect_stdout(buf): try: startup_guard.enforce(_BASE, "__test__") code = None except SystemExit as exc: code = exc.code check("缺依赖 → 退出码 2", code == 2, f"实际 {code!r}") out = buf.getvalue() check("提示里给出启动命令", "start.bat" in out and "./start.sh" in out, "没有可照抄的命令") check("提示里点了缺什么", "no_such_module_for_test_xyz" in out, "没报缺哪个") # 解释器**就是**项目 venv、只是依赖没装完时,文案必须换一套 —— # 否则会告诉用户「你的 Python 不对、换个方式启动」,而他换哪种方式 # 都是在用同一只 venv,问题一点没解决(客户就是这么被绕晕的)。 orig = startup_guard.in_project_venv startup_guard.in_project_venv = lambda *a, **k: True try: buf = io.StringIO() with contextlib.redirect_stdout(buf): try: startup_guard.enforce(_BASE, "__test__") except SystemExit: pass out = buf.getvalue() check("venv 内缺依赖 → 换文案", "启动环境不完整" in out, "还在说「不是本项目的运行环境」") check("venv 内缺依赖 → 不误导", "不是本项目的运行环境" not in out, "两套文案混在一起了") finally: startup_guard.in_project_venv = orig os.environ["DAQ_SKIP_VENV_GUARD"] = "1" try: buf = io.StringIO() with contextlib.redirect_stdout(buf): startup_guard.enforce(_BASE, "__test__") check("开关能跳过", buf.getvalue() == "", "开关没生效") finally: del os.environ["DAQ_SKIP_VENV_GUARD"] finally: startup_guard.SPECS.pop("__test__", None) # 依赖齐全时不该拦(开发机用自己的环境跑) buf = io.StringIO() with contextlib.redirect_stdout(buf): startup_guard.SPECS["__test2__"] = dict(fake, modules=("os", "sys")) try: startup_guard.enforce(_BASE, "__test2__") finally: startup_guard.SPECS.pop("__test2__", None) check("依赖齐全不拦", "WRONG PYTHON" not in buf.getvalue(), "被误拦了") def test_docs() -> None: print("[7] 文档同步") manual = read("使用手册.md") check("手册写了 pip 损坏这条", manual.count("pip._internal.cli") >= 2, f"只出现 {manual.count('pip._internal.cli')} 次(中英各一)") for name in (BAT_SHELLS + SH_SHELLS): check(f"{name} 错误 D 提到 pip 损坏", "pip._internal.cli" in read(name), "错误 D 没讲 pip 损坏这条") def test_run_launchers() -> None: """run.bat / run.sh 是手册里写明的「日常启动」方式,也必须给得出方向。 它们跳过全部部署检查(这是它们的卖点),但「压根没部署」这一种情况得 拦住: 否则 cmd 只甩一句「系统找不到指定的路径。」。**尤其不能教用户 手工 python -m venv** —— 那条路绕过离线 wheels/ 与依赖签名,装完照样 缺包,客户就会再来一轮「装了还是报错」。 """ print("[9] run 启动器(缺 venv 时指向 start 脚本)") for name, launcher in (("run.bat", "start.bat"), ("run.sh", "./start.sh")): if not os.path.exists(os.path.join(_BASE, name)): continue text = code_lines(read(name), name) check(f"{name} 未部署时指向 {launcher}", launcher in text, f"没告诉用户去跑 {launcher}") # 反模式是**让用户照着抄**那句手工部署命令(建 venv + 装 requirements): # 绕过离线 wheels/、依赖签名与工具包展开,装完照样缺包。 # 「不要手工建 venv」这类**劝阻**文案不算 —— 所以看的是组合,不是词。 hand_rolled = re.search(r"python -m venv\s+venv", text) and \ re.search(r"pip install\s+-r\s+requirements", text) check(f"{name} 不教手工建 venv", not hand_rolled, "让用户手工建 venv 再装 requirements(会绕过 wheels/ 与依赖签名)") check(f"{name} 提示会停住窗口", "pause" in text if name.endswith(".bat") else True, "双击时窗口一闪而过,看不到提示") def test_lite_package_sync() -> None: """lite_package/ 里的分发壳副本必须与仓库根的逐字节一致。 这两份是 pack_lite.py 的**回落源**(仓库根优先,找不到才用它们),平时 没人跑、也没人看 —— 上一次性命攸关的修复(reinstall 用已删除的解释器建 venv)就只落在了仓库根,副本悄悄落后了一整轮。存在就逐字节比对。 """ print("[8] lite_package/ 副本没掉队") for name in ("start_lite.sh", "start_lite.bat"): dst = os.path.join(_BASE, "lite_package", name) if not os.path.exists(dst): continue with open(os.path.join(_BASE, name), "rb") as fh: root = fh.read() with open(dst, "rb") as fh: copy = fh.read() check(f"lite_package/{name} 与根一致", root == copy, "副本落后了(pack_lite.py 的回落源,会把老 bug 一起发出去)") def test_python310_gate() -> None: """四个分发壳的解释器门必须是**恰好 3.10**,不是「3.10 及以上」。 手套 SDK 的 `algorithm/` 是 PyArmor 按 3.10 ABI 加密的 (`pyarmor_runtime.so` 引用了 3.11 起移除的 `_PyFloat_Pack8`),而 `sdk.api` **顶层**就 import 解算链 ⇒ 3.12 下不是「少个骨架」,是 **整个 SDK 不可用**。三个后果各一条断言: · 判据写成 `>= (3, 10)` → 3.12 的机器被放行 → 静默坏 · 候选顺序把 3.12 排前面 → 有 3.10 的机器反而挑中 3.12 · 已存在的 venv 不校验版本 → 老客户机(3.12 venv)升级后直接坏 """ print("[10] Python 3.10 门(SDK 加密链的 ABI 要求)") for name in shell_paths(): text = read(name) code = code_lines(text, name) # 判据:两处 `sys.version_info[:2] == (3, 10)` 字面量 —— 定位解释器与 # venv 体检各一处(`.bat` 的定位走 :try_py,所以也是两处)。 exact = len(re.findall(r"version_info\[:2\]\s*==\s*\(3,\s*10\)", code)) loose = re.findall(r"version_info\[:2\]\s*>=?\s*\(3,\s*10\)", code) check(f"{name} 版本判据是「恰好 3.10」", exact >= 2 and not loose, f"恰好判据 {exact} 处(要 ≥2: 定位 + 体检)," f"宽松判据 {len(loose)} 处(≥(3,10) 会放行 3.12)") # 光有判据不够:判据必须接到「自动重建」那条路上 —— 老客户机上是 # 3.12 的 venv,只报错不重建 = 用户升个级就永久失去手套功能。 check(f"{name} 版本不符时自动重建", re.search(r"不是 Python 3\.10|不是 3\.10", text) is not None and re.search(r"reinstall|重建", code) is not None, "体检发现版本不符但没有重建分支") # 注释与用户提示里出现 3.12 是**对的**(要告诉用户 3.12 不行); # 这里只钉「会真去装/真去找的那个版本号」。 if name.endswith(".bat"): check(f"{name} 装的 Python 是 3.10.11", 'set "PY_VER=3.10.11"' in code and not re.search(r"PY_VER=3\.(1[12]|[0-9])\b(?!\.)", code, re.I) and "%PY_VER%" in code, "PY_VER 还指着别的版本(老客户机会被静默装上 3.12)") check(f"{name} 先试 py -3.10", re.search(r"call :try_py py -3\.10", code) is not None, "候选顺序里没有 py -3.10 打头") else: m = re.search(r"for c in ([^;]+); do", code) check(f"{name} 候选顺序 3.10 打头", bool(m) and m.group(1).split()[0] == "python3.10" and not re.search(r"python3\.1[12]", m.group(1)), f"候选 = {m.group(1).strip() if m else '<找不到>'}") def test_glove_sdk_section() -> None: """[4/7] / [4/6] 手套 SDK 段的契约(本次部署最容易翻车处)。 这一段有三个**静默**失败形态,全都不会让脚本报错: · 探针手写 `sys.path` + import(而不是走 `core.glove_sdk_boot`)→ 探针过了,主程序照样连不上(装配层还有 config 撞名等自检) · 错误文本用 `for /f` 读 → cmd 按控制台代码页转一次,中文全变 `?` · 陈旧戳(zip 在、目录被删)→ 永远不解压,还叫用户去开发机重打包 """ print("[11] 手套 SDK 段([4/7] / [4/6])") for name in shell_paths(): text = read(name) code = code_lines(text, name) is_lite = "lite" in name step = "[4/6]" if is_lite else "[4/7]" # 旧工具包的名字一个都不该剩(注释里也不该,那会误导排障) check(f"{name} 无旧工具包残留", "stouch_glove_toolkit" not in text and "glove_toolkit.zip" not in text, "还印着旧工具包的名字") check(f"{name} 有 {step} 段", step in code, f"没有 {step} 段") check(f"{name} 探针走装配层", "core.glove_sdk_boot" in code and "core\\glove_sdk_boot" in code or "core.glove_sdk_boot" in code, "探针没走 core.glove_sdk_boot(手写 sys.path 能过而主程序仍失败)") # 极简版不能查解算链:它的载荷本来就没有 algorithm/,查了会把 # 「极简版一切正常」误判成「SDK 不可用」 check(f"{name} --no-solver 用法正确", ("--no-solver" in code) == is_lite, "极简版漏了 --no-solver(会误报 SDK 不可用)" if is_lite else "普通版带了 --no-solver(解算链就不查了)") # 陈旧戳自愈:zip 换了 **或** 目录不在,都要重新解压 check(f"{name} 陈旧戳会重新解压", "TK_NEED" in code and re.search(r"TK_NEED=1", code) and len(re.findall(r"TK_NEED=1", code)) >= 2, "只按戳判断(戳说已展开就跳过),目录被删后永不自愈") # 只在真解出目录时才落戳,否则下次启动会自动重试 check(f"{name} 解压成功才落戳", re.search(r"if not defined TOOLKIT goto|\[ -z \"\$TOOLKIT\" \]|\n\s*\[ -n \"\$TOOLKIT\" \]", code) is not None, "无条件落戳(解压失败也会写「已完成」)") # .bat 的错误文本必须用 type 原样打:cmd 的 for /f 会按控制台代码页 # 转换内容,中文全变成 ?(Wine 实测,chcp 936 也一样)。 for name in ("start.bat", "start_lite.bat"): if not os.path.exists(os.path.join(_BASE, name)): continue code = code_lines(read(name), name) check(f"{name} 用 type 打错误原文", re.search(r'type\s+"%TK_ERR%"', code) is not None, "没用 type 原样输出(for /f 会把中文转成 ?)") check(f"{name} 不用 for /f 读错误文件", not re.search(r'for\s+/f.*TK_ERR', code, re.I), "for /f 读错误文件会按控制台代码页转换内容") def test_requirements_encoding() -> None: """三份 requirements 必须带 PEP 263 编码声明(第 1 或第 2 行)。 这是 2026-09-21 真机上炸过的一条:Python 3.10 的 ensurepip 自带 pip **23.0.1**,它的 `auto_decode` 在没有编码声明时回落到 `locale.getpreferredencoding(False)`;中文 Windows 上那是 **cp936**, 于是一读到 UTF-8 的中文注释就 `UnicodeDecodeError: 'gbk' codec ...` —— 部署卡在 [3/7],报的还是 pip 的内部错。3.12 自带 pip 25.0.1 (默认 UTF-8)所以从没暴露过;这是**迁 3.10 引入的回归**。 """ print("[12] requirements 的编码声明(中文 Windows 的 pip 23.0.1)") paths = ["requirements.txt", "requirements-lite.txt"] if os.path.exists(os.path.join(_BASE, "lite_package/requirements-lite.txt")): paths.append("lite_package/requirements-lite.txt") for name in paths: with open(os.path.join(_BASE, name), "rb") as fh: raw = fh.read() head = raw.split(b"\n")[:2] cookie = any(re.match(rb"^#.*coding[:=]\s*utf-8", ln, re.I) for ln in head) check(f"{name} 有 PEP 263 声明", cookie, "缺编码声明 → cp936 的 pip 23.0.1 读中文注释即 UnicodeDecodeError") # 声明存在还不够:pip ≤23.x 用 auto_decode 时**先看声明**,所以 # 声明与内容必须真的一致,否则只是把报错挪了个位置 try: raw.decode("utf-8") check(f"{name} 是 UTF-8", True) except UnicodeDecodeError as exc: check(f"{name} 是 UTF-8", False, f"解不开: {exc}") def main() -> int: print(f"项目根: {_BASE}\n") test_bat_encoding() test_no_silent_pip_upgrade() test_venv_health_and_heal() test_env_isolation() test_entry_guard() test_guard_behaviour() test_docs() test_lite_package_sync() test_run_launchers() test_python310_gate() test_glove_sdk_section() test_requirements_encoding() print() if _FAILS: print(f"FAILED: {len(_FAILS)} 项") for item in _FAILS: print(" -", item) return 1 print("ALL PASS") return 0 if __name__ == "__main__": sys.exit(main())