pack_dist.py 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438
  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. r"""打一个"拷到别的电脑能装、能跑"的分发包 (2026-09-12)。
  4. ## 为什么会需要单独一个打包器
  5. 直接 `右键压缩整个目录` 会把**本机痕迹**一起打进去, 目标机上要么装不了、要么体积翻倍:
  6. `.venv`(688 MB, 换机必失效) · `.git`(55 MB) · `_products_off*`(670 MB 本机产物暂存) · `logs` `run`(本机日志与旧 PID)
  7. · `data/raw`(31 GB 现场原始件, 按约定不随包分发)。
  8. 本器把这些**显式列出来**(含理由), 照单排除 —— "打了什么/没打什么"可复核, 而不是靠记忆。
  9. ## 包含 / 排除 (规则即文档)
  10. ★ 2026-09-17 用户令: **交付包默认不含 输入数据 / 产物(输出数据) / 运行日志 / 临时文件** ——
  11. 即"开箱是一套干净的程序", 数据与产物到目标机自己放、自己算。默认执行
  12. `python scripts/pack_dist.py` 就是这四类全不含, 不需要任何开关 (要破例才加 `--with-data` / `--with-products`)。
  13. **包含**: 程序(`src/` `scripts/` `guanlan.py`) · 配置(`configs/`) · 离线安装件(`wheels/win_amd64/` `vendor/python/`)
  14. · 页面与交付(`release/` `resources/` `reference/`) · 文档(`docs/` `README_先读我.MD` `测试须知.txt`)
  15. · 安装/起停/打包脚本(`install.bat` `install.sh` `install.ps1` `check.bat` `start.bat` `stop.bat` `pack.bat`
  16. `pack.sh`) · `requirements.txt`。
  17. **不含**修复记录/临时目录(`_修复记录_*` 之类): 本包已纳入 git 管理, 变更历史由版本库承载 (2026-09-12 用户令)。
  18. **不含 VBScript**: 2026-09-16 用户令"把 VBScript 替换掉" —— 无窗口启动由 `pythonw.exe` +
  19. `scripts/guanlan_start_hidden.py` 承担, 安装时另建 `启动观澜.lnk`; 包里已无 `.vbs`, 也不依赖 Windows 脚本宿主。
  20. **排除**(每条都写了理由; 前四类就是用户令点名的四类):
  21. data/ ① **输入数据**: 现场原始件(约定"原始件不随包分发"); 要一起交付用 --with-data
  22. outputs/ ② **产物(输出数据)**: 页面取数靠它, 但按令不进包 —— 目标机放数据后
  23. `scripts/place_raw_data.py` + `scripts/rebuild_all.py` 重算, 或按需从
  24. 交付包补齐无生成端的那批(products_restore_missing.py --stash <zip>)
  25. logs/ run/ ③ **运行日志与运行态**: 本机日志(含 logs/build、logs/legacy)、run/pids.json
  26. (旧 PID 到新机器上是无效引用); 要连产物一起发用 --with-products
  27. *.log *.jsonl(任何位置) ③ 运行日志/审计流水的兜底规则: 不管躺在哪都不进包
  28. *_cache/ .*_cache/ __pycache__/ ④ **临时文件与缓存**: 解释器缓存、第三方工具缓存
  29. .pytest_cache .mypy_cache .ruff_cache .ipynb_checkpoints .npm-cache .qa-profile
  30. *.tmp *.temp *.bak* *.old *.orig *.rej *.swp *.swo *~ ~$*
  31. ④ 编辑器/工具留下的临时与备份文件
  32. .git/ .venv/ .github/ 版本库与虚拟环境 —— venv 换机必失效, 目标机安装时重建
  33. _products_off*/ 历史遗留的"清除产物"暂存档(2026-09-16 起清除=真删, 不再产生此类目录)
  34. *.pyc *.pyo 解释器缓存(字节码)
  35. *.lnk 安装时建的快捷方式(内含绝对路径, 换机必失效; 目标机由 install.ps1 重建)
  36. *.zip(顶层) 旧的交付压缩包; 避免包中包
  37. .DS_Store Thumbs.db desktop.ini 其它系统在目录里留下的临时索引
  38. ## 用法
  39. python scripts/pack_dist.py --dry-run # 只报会打什么/多大, 不写文件
  40. python scripts/pack_dist.py # 打包(默认不含 数据/产物/日志/临时文件)
  41. python scripts/pack_dist.py --out <目标 zip> # 指定输出路径
  42. python scripts/pack_dist.py --with-products # 例外: 连产物一起打 (页面开箱即有数)
  43. python scripts/pack_dist.py --with-data # 例外: 连 data/raw 一起打 (30 GB, 慎用)
  44. python scripts/pack_dist.py --no-products # (旧开关, 现在默认就是这个; 保留兼容不报错)
  45. python scripts/pack_dist.py --out D:\out\x.zip # 指定输出 # portability-allow: 只是用法示例里的 Windows 路径写法, 不是本机路径
  46. python scripts/pack_dist.py --verify <zip> # **开箱验证**: 解压 → 离线安装 → 起服务核验页面 → 清理
  47. ## 排除结果写进包
  48. 包里 `dist-manifest.json` 记下 version / no_data / no_products / no_logs / no_temp 与**逐条**排除理由;
  49. `guanlan.py check` 读它 —— 没带产物的包, 产物那几行检查按 `[--]` 说明而不判 FAIL。
  50. 一键入口(给不敲命令行的人): Windows 双击 `pack.bat`(或 `pack.bat dry` 只看计划)、Linux/macOS `sh pack.sh`。
  51. """
  52. from __future__ import annotations
  53. import argparse
  54. import importlib.util
  55. import json
  56. import os
  57. import pathlib
  58. import shutil
  59. import sys
  60. import tempfile
  61. import time
  62. import zipfile
  63. ROOT = pathlib.Path(__file__).resolve().parents[1]
  64. sys.path.insert(0, str(ROOT))
  65. from src import version as V # noqa: E402 ★ 版本号与包名的**唯一真源** (用户令 2026-09-17)
  66. INCLUDE_DIRS = ['src', 'scripts', 'configs', 'release', 'resources', 'reference', 'docs',
  67. 'wheels', 'vendor']
  68. PRODUCTS_DIR = 'outputs' # 产物仓: **默认不进包** (用户令 2026-09-17); 要带用 --with-products
  69. # ★ 不打包"修复记录/临时/脚手架"类目录 (2026-09-12 用户令): 本包已纳入 git 管理, 变更历史由版本库承载,
  70. # 不必在交付物里再带一份 `_修复记录_<日期>\`。原先这里含 `_修复记录_20260911` (该目录的**内容**已并入
  71. # `docs\振动数据接入_v0.1.md` 与 `docs\数据目录结构与落位约定_v0.2.md`, 目录本身按用户令删除)。
  72. # ★ 版本号不再在这里写死 (2026-09-17 用户令"打包版本管理"): 改版本只改 `src/version.py` 一行,
  73. # 本器读它写进 dist-manifest.json 并用它拼默认包名 `app_guanlang_v<版本号>.zip`。
  74. VERSION = V.VERSION # 包版本 = src/version.py 的 VERSION(单一真源)
  75. INCLUDE_FILES = ['guanlan.py', 'install.bat', 'install.ps1', 'install.sh', 'check.bat', 'start.bat', 'stop.bat',
  76. # ★ 无窗口启动: v0.4.0 第一版靠 start_hidden.vbs, 漏打进包 → 目标机 start.bat 报
  77. # "无法找到脚本文件 start_hidden.vbs" (2026-09-16 现场反馈)。现已按用户令**把 VBScript
  78. # 整个去掉**: 无窗口启动 = `pythonw.exe` + `scripts/guanlan_start_hidden.py`,
  79. # 安装时另建 `启动观澜.lnk`。这条手工清单仍由 src/entry_refs.py 从引用反推兜底(漏不掉)。
  80. 'requirements.txt', 'README_先读我.MD', '测试须知.txt',
  81. # 打包入口 (用户令 2026-09-17: 把"打包功能"做成双击可用)
  82. 'pack.bat', 'pack.sh',
  83. # 卸载入口 (用户令 2026-09-17: 增加卸载脚本; 它引用的 scripts\guanlan_uninstall.py
  84. # 由 INCLUDE_DIRS 的 scripts/ 覆盖, 入口本身必须在这里点名)
  85. 'uninstall.bat', 'uninstall.sh']
  86. # 目录排除: 版本库/虚拟环境/日志/运行态/缓存 (名字出现在路径的任何一层都算)
  87. EXCLUDE_DIRS = {'.git', '.venv', '.github', '.git-*', 'logs', 'run', '__pycache__',
  88. '.pytest_cache', '.mypy_cache', '.ruff_cache', '.ipynb_checkpoints',
  89. '.cache', '.npm-cache', '.qa-profile', '.Trash', '$RECYCLE.BIN', '.venv.bak'}
  90. # ★ node_modules **不排除**: `release/viewer/node_modules` 里是 esbuild(构建工具)+three(三维库源码), 共 52.5 MB;
  91. # 前端运行期读的是打包好的 viewer.js(three 已 bundle 进去), 理论上可以不发 —— 但**历史交付包是带着它的**
  92. # (实测 guanlan-rudong-v2_0.2.0_test_win64.zip 里就有 1207 条 node_modules 条目),
  93. # 为省 52 MB 去冒"三维页在别人机器上打不开"的风险不划算。宁可多带。
  94. EXCLUDE_GLOBS = ['_products_off*', 'data', 'outputs']
  95. # 进包**只**看目录/名的顶部前缀, 这些则按后缀/文件名排除 —— 都是"本机/装机时/编辑时"才产生的东西
  96. # (用户令 2026-09-17 的"临时文件"与"运行日志"两条就落在这里):
  97. # .pyc/.pyo 解释器缓存; .lnk 安装时建的快捷方式(绝对路径, 换机失效, 由 install.ps1 重建);
  98. # .log/.jsonl 运行日志与审计流水 —— **不管躺在哪都不进包**; .tmp/.bak/.old/.orig/.rej/.swp 编辑器临时与备份
  99. EXCLUDE_SUFFIX = {'.pyc', '.pyo', '.lnk', '.log', '.jsonl',
  100. '.tmp', '.temp', '.bak', '.old', '.orig', '.rej', '.swp', '.swo'}
  101. # 文件名/尾部模式 (glob): 临时与备份的常见写法 + 系统索引文件 + **本机安装记录**
  102. # ★ `install-info.json` 必须排除 (2026-09-17 实逮: 它被原样打进了交付包): 那是**本机装没装、装的哪一版**
  103. # 的凭据, 里面写着安装时间与本机绝对路径; 随包发出去, 目标机第一次装就会读到它, 于是安装前检查
  104. # 报"已安装 v2.5.0(装于 <打包机时间>)", 明明是新机器却提示"版本相同, 是否重装" —— 用户令 1
  105. # 要的版本检查当场失灵。它是装机产物, 不是交付件。
  106. EXCLUDE_NAMES = ('install-info.json', '*~', '~$*', '*.bak-*', '.DS_Store', 'Thumbs.db', 'desktop.ini',
  107. '*.crdownload', '*.partial')
  108. def is_machine_artifact(p: pathlib.Path) -> bool:
  109. """装机产物(本机装没装 / 本机快捷方式)—— 只在本机有效, 不随包、不入库 (2026-09-17)。"""
  110. import fnmatch
  111. return (p.name == 'install-info.json' or p.suffix.lower() in EXCLUDE_SUFFIX
  112. or any(fnmatch.fnmatch(p.name, pat) for pat in EXCLUDE_NAMES))
  113. def _xfer():
  114. """复用移植检查器里的安装/起服务核验逻辑 (单一实现, 不抄第二份)。"""
  115. spec = importlib.util.spec_from_file_location('check_transferable', ROOT / 'scripts' / 'check_transferable.py')
  116. m = importlib.util.module_from_spec(spec)
  117. sys.modules['check_transferable'] = m
  118. spec.loader.exec_module(m)
  119. return m
  120. def plan(with_data=False, with_products=False):
  121. """→ (要打包的顶层条目 list, 排除说明 list)
  122. ★ 2026-09-17 用户令: 交付包**默认不含 输入数据 / 产物(输出数据) / 运行日志 / 临时文件**。
  123. · 输入数据 `data/` —— 默认排除 (--with-data 才带);
  124. · 产物 `outputs/` —— 默认排除 (--with-products 才带);
  125. · 运行日志 `logs/ run/` 与任何位置的 `*.log`/`*.jsonl` —— 默认排除;
  126. · 临时文件 `*_cache/ __pycache__/ *.tmp *.bak* *~ ~$* .DS_Store …` —— 默认排除。
  127. 于是"开箱是一套干净程序": 页面显示"无产物", 目标机自己放数据、自己重算 (docs §9 / §12)。
  128. 包内 `dist-manifest.json` 记 `no_data/no_products/no_logs/no_temp`, 目标机 `guanlan.py check`
  129. 据此把"产物缺失"显示为 `--`(待重算) 而不是 FAIL。
  130. """
  131. inc = []
  132. for d in INCLUDE_DIRS:
  133. p = ROOT / d
  134. if p.is_dir():
  135. inc.append(p)
  136. if with_products and (ROOT / PRODUCTS_DIR).is_dir():
  137. inc.append(ROOT / PRODUCTS_DIR)
  138. if with_data:
  139. inc.append(ROOT / 'data')
  140. for f in INCLUDE_FILES:
  141. p = ROOT / f
  142. if p.is_file():
  143. inc.append(p)
  144. else:
  145. print(f' [!] 清单里的 {f} 不在源码树里 —— 交付包会少一个入口文件, 请查')
  146. # ★ 引用闭合兜底 (2026-09-16 现场实炸后加): 凡是入口脚本 (.bat/.ps1/.sh) 引用到的、本机真实存在的
  147. # 根目录文件, 一律自动补进包。手工清单 `INCLUDE_FILES` 漏一个文件, 代价是目标机双击 start.bat 直接报错;
  148. # 这条兜底让"漏"变得不可能 —— 上面那个 [X] 才是靠人盯的。
  149. from src.entry_refs import auto_include, referenced_tree_files
  150. got = {p.relative_to(ROOT).as_posix().lower() for p in inc}
  151. extra = auto_include(ROOT, got)
  152. # ★ 装机产物不许被"引用闭合"兜底补进包 (2026-09-17 实逮): `install.ps1`/`install.sh` 的版本检查那步
  153. # 会读 `install-info.json`, 于是自动补入把它当"入口引用的文件"补了进来 —— 而它是**本机装没装**的
  154. # 凭据(含安装时间与本机路径), 目标机第一次装就会误报"已安装、版本相同"。引用闭合管的是"入口点名的
  155. # 文件必须在位", 不管"这个文件该不该随包", 所以这里按排除规则再滤一遍并如实说一句。
  156. keepable, dropped = [], []
  157. for p in extra:
  158. (dropped if is_machine_artifact(p) else keepable).append(p)
  159. if keepable:
  160. for p in keepable:
  161. print(f' [+] 自动补入 (被入口脚本引用): {p.name}')
  162. inc.extend(keepable)
  163. for p in dropped:
  164. print(f' [i] 不进包 (装机产物, 目标机安装时自建): {p.name}')
  165. skipped = [('data/', '① 输入数据 (现场原始件): 用户令不进包; 要一起交付用 --with-data')]
  166. if (ROOT / PRODUCTS_DIR).is_dir():
  167. skipped += [('outputs/', '② 产物/输出数据: 用户令不进包 (目标机放数据后 place_raw_data.py + '
  168. 'rebuild_all.py 重算; 无生成端的那批用 products_restore_missing.py --stash <交付包.zip>)'
  169. + ('' if not with_products else ' ※本次给了 --with-products, 例外打进包'))]
  170. skipped += [('logs/, run/', '③ 运行日志与运行态 (logs/build、logs/legacy、run/pids.json 旧 PID)')]
  171. skipped += [('*.log, *.jsonl (任何位置)', '③ 运行日志/审计流水兜底: 不管躺在哪都不进包')]
  172. _glob_why = {
  173. '_products_off*': '历史遗留的"清除产物"暂存档 (2026-09-16 起清除=真删, 不再产生; 老机器上若有可手工删)',
  174. 'data': '① 输入数据: 现场原始件, 用户令不进包 (--with-data 才带)',
  175. 'outputs': '② 产物/输出数据: 用户令不进包 (--with-products 才带)',
  176. }
  177. skipped += [(g + '/', _glob_why.get(g, '本机痕迹/超大体量, 见脚本头部说明')) for g in EXCLUDE_GLOBS]
  178. skipped += [('*_cache/ __pycache__/ 等', '④ 临时文件与缓存 (解释器/工具缓存: .pytest_cache .npm-cache .qa-profile …)')]
  179. skipped += [('*.tmp *.bak* *.old *.orig *.rej *.swp *~ ~$*', '④ 编辑器/工具留下的临时与备份文件')]
  180. skipped += [('.DS_Store Thumbs.db desktop.ini', '④ 系统临时索引文件')]
  181. skipped += [('.git/ .venv/ .github/', '版本库与虚拟环境 (venv 换机必失效, 目标机安装时重建)')]
  182. skipped += [('__pycache__/ *.pyc *.pyo', '字节码缓存')]
  183. skipped += [('*.lnk', '安装时建的快捷方式 (内含绝对路径, 换机必失效; 目标机由 install.ps1 重建)')]
  184. skipped += [('*.zip (顶层)', '旧的交付压缩包; 避免包中包')]
  185. return inc, skipped
  186. def keep(f: pathlib.Path, with_data: bool = False, with_products: bool = False) -> bool:
  187. """这个文件真会被写进包吗 —— 打包循环与"预演件数"共用同一条判据。
  188. ★ 2026-09-16 修正: 原实现里 `size_of()` 数的是**目录下所有文件**(连 `__pycache__/*.pyc` 一起数),
  189. 而实际写入时又跳过它们 ⇒ 预演报的"件数/体积"比包里真的多 (src 少报 100+ 件)。
  190. 现在两处都走 `keep()`: 预演数字 = 包里条目数, 对不上就是 bug。
  191. ★ 2026-09-17: 加"运行日志(*.log/*.jsonl 任何位置)"与"临时/备份文件"两类排除 (用户令)。
  192. ★ 2026-09-17 修: `--with-products` / `--with-data` 原先**是个摆设** —— `keep()` 无条件套用
  193. `EXCLUDE_GLOBS`(里面有 `data`/`outputs`), 于是 `plan()` 把 outputs/ 加进要打清单、打印
  194. "本次给了 --with-products, 例外打进包", 而写盘循环又把它们逐件滤掉: 实测 `--with-products`
  195. 打出来的包与默认包**同样 3441 件 / 879 MB**, 里面一件产物都没有。后果不是"少带东西",
  196. 而是"以为有存档、其实没有" —— 要补随包件时才发现在包里找不到。现在两个开关**真的**生效:
  197. 例外目录一旦显式要带, 就不再被 EXCLUDE_GLOBS 拦下。
  198. """
  199. import fnmatch
  200. parts = f.relative_to(ROOT).parts
  201. if any(x in EXCLUDE_DIRS for x in parts) or f.suffix.lower() in EXCLUDE_SUFFIX:
  202. return False
  203. if any(fnmatch.fnmatch(f.name, pat) for pat in EXCLUDE_NAMES):
  204. return False
  205. if len(parts) == 1 and f.suffix.lower() == '.zip': # 顶层旧交付包, 避免包中包
  206. return False
  207. allow = set()
  208. if with_data:
  209. allow.add('data')
  210. if with_products:
  211. allow.add(PRODUCTS_DIR)
  212. globs = tuple(g.rstrip('*') for g in EXCLUDE_GLOBS if g.rstrip('*') not in allow)
  213. return not any(x.startswith(globs) for x in parts[:1])
  214. def size_of(p: pathlib.Path, with_data: bool = False, with_products: bool = False):
  215. if p.is_file():
  216. return 1, p.stat().st_size
  217. n = s = 0
  218. for f in p.rglob('*'):
  219. if f.is_file() and keep(f, with_data, with_products):
  220. n += 1; s = s + f.stat().st_size
  221. return n, s
  222. def build(out: pathlib.Path, with_data=False, progress=True, with_products=False) -> int:
  223. inc, skipped = plan(with_data, with_products)
  224. tot_n = tot_s = 0
  225. print('== 要打进包的内容 ==')
  226. print(' (默认口径: 不含 输入数据 / 产物 / 运行日志 / 临时文件 —— 用户令 2026-09-17)')
  227. for p in inc:
  228. n, s = size_of(p, with_data, with_products)
  229. tot_n += n; tot_s += s
  230. print(f' {p.relative_to(ROOT).as_posix():26s} {n:6d} 件 {s/1e6:9.1f} MB')
  231. print(f' {"合计":26s} {tot_n:6d} 件 {tot_s/1e6:9.1f} MB')
  232. print('== 排除 ==')
  233. for name, why in skipped:
  234. print(f' {name:26s} {why}')
  235. out.parent.mkdir(parents=True, exist_ok=True)
  236. man = dict(version=VERSION, built=time.strftime('%Y-%m-%d %H:%M:%S'), root=str(ROOT),
  237. includes=[p.relative_to(ROOT).as_posix() for p in inc],
  238. excluded={n: w for n, w in skipped}, files=tot_n + 1, # +1 = dist-manifest.json 自身
  239. bytes=tot_s,
  240. no_data=not with_data, no_products=not with_products, no_logs=True, no_temp=True,
  241. note='本包由 scripts/pack_dist.py 生成 (用户令 2026-09-17): **不含 输入数据(data/) · '
  242. '产物(outputs/) · 运行日志(logs/, run/, *.log, *.jsonl) · 临时文件(缓存/备份/系统索引)**。'
  243. '目标机解压后跑 install.bat(Windows) 或 sh install.sh(Linux/macOS), 再 check → start; '
  244. '要让页面有数: 把现场包放好后跑 `scripts/place_raw_data.py --src <现场包目录> --scope full` '
  245. '+ `scripts/rebuild_all.py` (无生成端的那批用 '
  246. '`scripts/products_restore_missing.py --stash <交付包.zip>` 补齐)。'
  247. '.venv 不在包内(换机必失效, 安装时重建); 打包方法见包内 README_先读我.MD 的「打包」一节。')
  248. t0 = time.time()
  249. with zipfile.ZipFile(out, 'w', zipfile.ZIP_DEFLATED, compresslevel=6) as z:
  250. z.writestr('dist-manifest.json', json.dumps(man, ensure_ascii=False, indent=1))
  251. done = 0
  252. for p in inc:
  253. if p.is_file():
  254. z.write(p, p.relative_to(ROOT).as_posix())
  255. done += 1
  256. else:
  257. for f in p.rglob('*'):
  258. if not f.is_file() or not keep(f, with_data, with_products):
  259. continue
  260. z.write(f, f.relative_to(ROOT).as_posix())
  261. done += 1
  262. if progress and done % 500 < 1:
  263. print(f' … 已写入 {done} 件', flush=True)
  264. print(f'\n完成: {out} {out.stat().st_size/1e6:.0f} MB (源 {tot_s/1e6:.0f} MB), 条目 {done} 件, 耗时 {time.time()-t0:.0f}s')
  265. # 入口引用闭合核对 (对着**包内条目**查; 2026-09-16: v0.4.0 第一版漏带 start_hidden.vbs, 目标机 start.bat 报错)
  266. # + 入口编码守则 (install.ps1 的 UTF-8 BOM 曾被编辑工具吃掉 → 目标机 install 1 秒即 ParserError)
  267. from src.entry_refs import encoding_problems, missing_refs
  268. with zipfile.ZipFile(out) as z:
  269. names = z.namelist()
  270. enc = encoding_problems(ROOT)
  271. miss = missing_refs(ROOT, names)
  272. if miss:
  273. for e, rel in miss:
  274. print(f' [X] 包内 {e} 引用了 {rel}, 但包里没有 —— 这个包不能交付')
  275. for m in enc:
  276. print(f' [X] 入口编码守则: {m}')
  277. # 装机产物不得进包 (2026-09-17 实逮: 本机的 install-info.json 被原样打进包 ⇒ 目标机安装前检查
  278. # 会误报"已安装 vx.y.z, 版本相同" —— 版本检查当场失灵)。这类"看着无害、后果是静默误报"的东西
  279. # 必须由机器守: 包里出现一件就删包。
  280. machine = [n for n in names if n.split('/')[-1] in ('install-info.json',) or n.endswith('.lnk')]
  281. for n in machine[:5]:
  282. print(f' [X] 包里有"装机产物" {n} —— 它只在本机有效 (安装记录/快捷方式), 不该随包分发')
  283. if miss or enc or machine:
  284. out.unlink()
  285. print(f' [X] 已删除不合格的包 {out.name} (留着会被人当成能用的包发出去)')
  286. return 1
  287. print(f' [OK] 入口引用闭合 + 编码守则 + 无装机产物: 包内每个入口脚本引用的文件都在包里 (条目 {len(names)} 件)')
  288. print('目标机用法: 解压到无空格无中文路径 → install.bat / sh install.sh → check → start(自动开门户)')
  289. print(f'开箱验证(推荐): python scripts/pack_dist.py --verify "{out}"')
  290. return 0
  291. def verify(zip_path: pathlib.Path, keep=False) -> int:
  292. """解压 → 离线安装 → 起服务核验页面 → 清理。这是"能不能装、能不能跑"的直接证据。"""
  293. tmp = pathlib.Path(tempfile.gettempdir()) / 'guanlan_distcheck'
  294. if tmp.exists():
  295. shutil.rmtree(tmp, ignore_errors=True)
  296. print(f'① 解压到 {tmp}')
  297. t0 = time.time()
  298. with zipfile.ZipFile(zip_path) as z:
  299. z.extractall(tmp)
  300. n = sum(1 for _ in tmp.rglob('*') if _.is_file())
  301. print(f' {n} 件, 耗时 {time.time()-t0:.0f}s')
  302. print('①b 入口引用闭合核对 (包内 start.bat → scripts\\guanlan_start_hidden.py 之类)')
  303. from src.entry_refs import encoding_problems, missing_refs
  304. miss = missing_refs(tmp)
  305. enc = encoding_problems(tmp)
  306. if miss:
  307. for e, rel in miss:
  308. print(f' [X] {e} 引用了 {rel}, 但解压目录里没有 —— 目标机会双击即报错')
  309. for m in enc:
  310. print(f' [X] 入口编码守则: {m}')
  311. if miss or enc:
  312. return 1
  313. print(' [OK] 引用全部在位, 编码守则通过 (install.ps1 = UTF-8 BOM + CRLF)')
  314. print('② 离线安装')
  315. xt = _xfer()
  316. py = xt.install_in(tmp)
  317. if py is None:
  318. return 1
  319. print('②b 无窗口启动核验 (VBScript 已按用户令移除, 现在靠 pythonw.exe + 启动器 + 快捷方式)')
  320. bad = []
  321. if os.name == 'nt':
  322. # 2026-09-17: 安装脚本现在建**两份**快捷方式 —— 中文名给人双击, ASCII 名 (start_guanlan.lnk)
  323. # 给控制台/脚本引用 (纯 ASCII 的 start.bat 只能引用 ASCII 名)。两份都要在。
  324. for nm in ('启动观澜.lnk', 'start_guanlan.lnk'):
  325. lnk = tmp / nm
  326. if lnk.is_file() and lnk.stat().st_size > 0:
  327. print(f' [OK] 安装时已生成快捷方式: {nm} ({lnk.stat().st_size} B)')
  328. else:
  329. bad.append(f'安装后没有 {nm} (install.ps1 第 5/6 步没生效)')
  330. import json as _json
  331. import subprocess as _sp
  332. pyw = tmp / '.venv/Scripts/pythonw.exe'
  333. r = _sp.run([str(pyw), str(tmp / 'scripts/guanlan_start_hidden.py'), '--selftest'],
  334. capture_output=True, text=True, errors='replace', timeout=120)
  335. try:
  336. info = _json.loads((r.stdout or '').strip().splitlines()[-1])
  337. except Exception:
  338. info = {}
  339. if info.get('windowless') is True:
  340. print(f' [OK] pythonw.exe 跑启动器: console_hwnd={info.get("console_hwnd")} → 无窗口成立')
  341. else:
  342. bad.append(f'pythonw --selftest 没报无窗口: stdout={ (r.stdout or "").strip()[:120] }')
  343. if list(tmp.glob('*.vbs')):
  344. bad.append(f'包里还有 .vbs: {[p.name for p in tmp.glob("*.vbs")]}')
  345. else:
  346. print(' [OK] 包里无 .vbs (不依赖 Windows 脚本宿主/WSH)')
  347. else:
  348. print(' (非 Windows: 无窗口/快捷方式不适用)')
  349. for b in bad:
  350. print(f' [X] {b}')
  351. if bad:
  352. return 1
  353. print('③ 起服务核验 (副本用独立端口, 不动正在跑的实例)')
  354. ok = xt.run_and_probe(tmp, py)
  355. print('③b 卸载核验 (用户令 2026-09-17: 装得上, 也要卸得掉)')
  356. ubad = xt.uninstall_probe(tmp, py, gw=xt.LAST_GATEWAY_PORT)
  357. print('④ 收尾')
  358. if keep:
  359. print(f' 解压目录保留在 {tmp} (--keep)')
  360. else:
  361. shutil.rmtree(tmp, ignore_errors=True)
  362. print(' 已清理')
  363. print(f'\n开箱验证结论: 页面可用 {ok}/5 —— {"可以交付" if ok >= 4 else "有问题, 见上"}'
  364. + (f';卸载核验 {"通过" if ubad == 0 else f"{ubad} 项未过"}'))
  365. return 0 if (ok >= 4 and ubad == 0) else 1
  366. def main() -> int:
  367. ap = argparse.ArgumentParser()
  368. ap.add_argument('--out', default=None)
  369. ap.add_argument('--with-data', action='store_true',
  370. help='例外: 连输入数据 data/raw 一起打 (约 31 GB, 慎用; 默认不含)')
  371. ap.add_argument('--with-products', action='store_true',
  372. help='例外: 连产物 outputs/ 一起打 (页面开箱即有数; 默认不含 —— 用户令 2026-09-17)')
  373. ap.add_argument('--no-products', action='store_true',
  374. help='(旧开关) 不含产物 —— 现在**默认就是这个**, 保留只为兼容旧命令')
  375. ap.add_argument('--dry-run', action='store_true')
  376. ap.add_argument('--verify', default=None, help='对已打好的 zip 做开箱验证(解压→安装→起服务核验)')
  377. ap.add_argument('--keep', action='store_true', help='--verify 后保留解压目录')
  378. a = ap.parse_args()
  379. if a.verify:
  380. return verify(pathlib.Path(a.verify), a.keep)
  381. out = (pathlib.Path(a.out) if a.out
  382. else ROOT.parent / V.package_name())
  383. if a.dry_run:
  384. inc, skipped = plan(a.with_data, a.with_products)
  385. tot_n = tot_s = 0
  386. print(f'== 会打进包 (version {VERSION}) ==')
  387. for p in inc:
  388. n, s = size_of(p, a.with_data, a.with_products); tot_n += n; tot_s += s
  389. print(f' {p.relative_to(ROOT).as_posix():26s} {n:6d} 件 {s/1e6:9.1f} MB')
  390. print(f' {"合计":26s} {tot_n:6d} 件 {tot_s/1e6:9.1f} MB → {out}')
  391. print('== 会排除 ==')
  392. for name, why in skipped:
  393. print(f' {name:26s} {why}')
  394. print('\n(dry-run, 未写文件)')
  395. return 0
  396. return build(out, a.with_data, with_products=a.with_products)
  397. if __name__ == '__main__':
  398. for _s in (sys.stdout, sys.stderr):
  399. try: _s.reconfigure(errors='replace')
  400. except Exception: pass
  401. sys.exit(main())