pack_dist.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343
  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. **包含**: 程序(`src/` `scripts/` `guanlan.py`) · 配置(`configs/`) · 离线安装件(`wheels/win_amd64/` `vendor/python/`)
  11. · 页面与交付(`release/` `resources/` `reference/`) · **产物**(`outputs/`, 页面取数靠它; 加 `--no-products`
  12. 则不含) · 文档(`docs/` `README_先读我.txt` `测试须知.txt`) · 安装/起停脚本(`install.bat` `install.sh`
  13. `install.ps1` `check.bat` `start.bat` `stop.bat`) · `requirements.txt`。
  14. **不含**修复记录/临时目录(`_修复记录_*` 之类): 本包已纳入 git 管理, 变更历史由版本库承载 (2026-09-12 用户令)。
  15. **不含 VBScript**: 2026-09-16 用户令"把 VBScript 替换掉" —— 无窗口启动由 `pythonw.exe` +
  16. `scripts/guanlan_start_hidden.py` 承担, 安装时另建 `启动观澜.lnk`; 包里已无 `.vbs`, 也不依赖 Windows 脚本宿主。
  17. **排除**(每条都写了理由):
  18. .git/ .venv/ .github/ 版本库与虚拟环境 —— venv 换机必失效, 目标机安装时重建
  19. _products_off*/ 历史遗留的"清除产物"暂存档(2026-09-16 起清除=真删, 不再产生此类目录)
  20. logs/ run/ 本机日志与 run/pids.json(旧 PID, 到新机器上是无效引用)
  21. data/raw/ 现场原始件(约定"原始件不随包分发"); 要一起交付用 --with-data
  22. __pycache__/ *.pyc 解释器缓存
  23. *.lnk 安装时建的快捷方式(内含绝对路径, 换机必失效; 目标机由 install.ps1 重建)
  24. *.zip(顶层) 旧的交付压缩包; 避免包中包
  25. ## 用法
  26. python scripts/pack_dist.py --dry-run # 只报会打什么/多大, 不写文件
  27. python scripts/pack_dist.py # 打包 → <父目录>/guanlan-v<VERSION>_dist_<日期>.zip
  28. python scripts/pack_dist.py --no-products # **不含产物**(用户令 2026-09-16): 开箱页面显示"无产物"
  29. python scripts/pack_dist.py --with-data # 连 data/raw 一起打 (30 GB, 慎用)
  30. python scripts/pack_dist.py --out D:\out\x.zip # 指定输出
  31. python scripts/pack_dist.py --verify <zip> # **开箱验证**: 解压 → 离线安装 → 起服务核验页面 → 清理
  32. ## 排除结果写进包
  33. 包里 `dist-manifest.json` 记下 version / no_data / no_products / no_logs 与逐条排除理由;
  34. `guanlan.py check` 读它 —— 打了"不含产物"的包, 产物那几行检查按 `[--]` 说明而不判 FAIL。
  35. """
  36. from __future__ import annotations
  37. import argparse
  38. import importlib.util
  39. import json
  40. import os
  41. import pathlib
  42. import shutil
  43. import sys
  44. import tempfile
  45. import time
  46. import zipfile
  47. ROOT = pathlib.Path(__file__).resolve().parents[1]
  48. sys.path.insert(0, str(ROOT))
  49. INCLUDE_DIRS = ['src', 'scripts', 'configs', 'release', 'resources', 'reference', 'docs',
  50. 'wheels', 'vendor']
  51. PRODUCTS_DIR = 'outputs' # 产物仓: 默认打进包 (页面开箱有数); --no-products 时排除
  52. # ★ 不打包"修复记录/临时/脚手架"类目录 (2026-09-12 用户令): 本包已纳入 git 管理, 变更历史由版本库承载,
  53. # 不必在交付物里再带一份 `_修复记录_<日期>\`。原先这里含 `_修复记录_20260911` (该目录的**内容**已并入
  54. # `docs\振动数据接入_v0.1.md` 与 `docs\数据目录结构与落位约定_v0.2.md`, 目录本身按用户令删除)。
  55. VERSION = '0.4.0' # 包版本 (2026-09-16): 写进 dist-manifest.json 与默认文件名
  56. INCLUDE_FILES = ['guanlan.py', 'install.bat', 'install.ps1', 'install.sh', 'check.bat', 'start.bat', 'stop.bat',
  57. # ★ 无窗口启动: v0.4.0 第一版靠 start_hidden.vbs, 漏打进包 → 目标机 start.bat 报
  58. # "无法找到脚本文件 start_hidden.vbs" (2026-09-16 现场反馈)。现已按用户令**把 VBScript
  59. # 整个去掉**: 无窗口启动 = `pythonw.exe` + `scripts/guanlan_start_hidden.py`,
  60. # 安装时另建 `启动观澜.lnk`。这条手工清单仍由 src/entry_refs.py 从引用反推兜底(漏不掉)。
  61. 'requirements.txt', 'README_先读我.txt', '测试须知.txt']
  62. EXCLUDE_DIRS = {'.git', '.venv', '.github', 'logs', 'run', '__pycache__'}
  63. # ★ node_modules **不排除**: `release/viewer/node_modules` 里是 esbuild(构建工具)+three(三维库源码), 共 52.5 MB;
  64. # 前端运行期读的是打包好的 viewer.js(three 已 bundle 进去), 理论上可以不发 —— 但**历史交付包是带着它的**
  65. # (实测 guanlan-rudong-v2_0.2.0_test_win64.zip 里就有 1207 条 node_modules 条目),
  66. # 为省 52 MB 去冒"三维页在别人机器上打不开"的风险不划算。宁可多带。
  67. EXCLUDE_GLOBS = ['_products_off*', 'data']
  68. # 进包**只**看目录/名的顶部前缀, 这些则按后缀排除 —— 都是"本机/装机时才产生"的东西:
  69. # .pyc 解释器缓存; .lnk 安装时建的快捷方式(里面是绝对路径, 换机必失效, 由 install.ps1 重建);
  70. # .zip 顶层旧交付包(避免包中包; 原先只写在文档里, 2026-09-16 补上实现)
  71. EXCLUDE_SUFFIX = {'.pyc', '.lnk'}
  72. def _xfer():
  73. """复用移植检查器里的安装/起服务核验逻辑 (单一实现, 不抄第二份)。"""
  74. spec = importlib.util.spec_from_file_location('check_transferable', ROOT / 'scripts' / 'check_transferable.py')
  75. m = importlib.util.module_from_spec(spec)
  76. sys.modules['check_transferable'] = m
  77. spec.loader.exec_module(m)
  78. return m
  79. def plan(with_data=False, no_products=False):
  80. """→ (要打包的顶层条目 list, 排除说明 list)
  81. 2026-09-16 用户令: 打包**不含 输入数据 / 产物 / 日志**。
  82. · 输入数据 `data/` —— 一直默认排除 (要用 --with-data 才带);
  83. · 产物 `outputs/` —— 新增 --no-products 排除 (默认仍带: 页面开箱即有数);
  84. · 日志 `logs/` —— 一直在 EXCLUDE_DIRS 里 (连同 run/ 旧 PID)。
  85. 排除产物时, 包内 `dist-manifest.json` 会记 `no_products: true`,
  86. 目标机 `guanlan.py check` 据此把"产物缺失"显示为 `--`(待重算) 而不是 FAIL。
  87. """
  88. inc = []
  89. for d in INCLUDE_DIRS:
  90. p = ROOT / d
  91. if p.is_dir():
  92. inc.append(p)
  93. if not no_products:
  94. p = ROOT / PRODUCTS_DIR
  95. if p.is_dir():
  96. inc.append(p)
  97. if with_data:
  98. inc.append(ROOT / 'data')
  99. for f in INCLUDE_FILES:
  100. p = ROOT / f
  101. if p.is_file():
  102. inc.append(p)
  103. else:
  104. print(f' [!] 清单里的 {f} 不在源码树里 —— 交付包会少一个入口文件, 请查')
  105. # ★ 引用闭合兜底 (2026-09-16 现场实炸后加): 凡是入口脚本 (.bat/.ps1/.sh) 引用到的、本机真实存在的
  106. # 根目录文件, 一律自动补进包。手工清单 `INCLUDE_FILES` 漏一个文件, 代价是目标机双击 start.bat 直接报错;
  107. # 这条兜底让"漏"变得不可能 —— 上面那个 [X] 才是靠人盯的。
  108. from src.entry_refs import auto_include, referenced_tree_files
  109. got = {p.relative_to(ROOT).as_posix().lower() for p in inc}
  110. extra = auto_include(ROOT, got)
  111. if extra:
  112. for p in extra:
  113. print(f' [+] 自动补入 (被入口脚本引用): {p.name}')
  114. inc.extend(extra)
  115. skipped = [('.git/', '版本库'), ('.venv/', '虚拟环境(换机必失效, 目标机安装时重建)')]
  116. _glob_why = {
  117. '_products_off*': '历史遗留的"清除产物"暂存档 (2026-09-16 起清除=真删, 不再产生; 老机器上若有可手工删)',
  118. 'data': '现场原始输入件 (约定"原始件不随包分发"; 要一起交付用 --with-data) —— 用户令: 输入数据不进包',
  119. }
  120. skipped += [(g + '/', _glob_why.get(g, '本机痕迹/超大体量, 见脚本头部说明')) for g in EXCLUDE_GLOBS]
  121. skipped += [('logs/, run/', '本机日志与旧 PID —— 用户令: 日志不进包')]
  122. skipped += [('__pycache__/ *.pyc', '解释器缓存')]
  123. skipped += [('*.lnk', '安装时建的快捷方式 (内含绝对路径, 换机必失效; 目标机由 install.ps1 重建)')]
  124. skipped += [('*.zip (顶层)', '旧的交付压缩包; 避免包中包')]
  125. if no_products and (ROOT / PRODUCTS_DIR).is_dir():
  126. skipped += [('outputs/', '用户令: 产物不进包 (目标机放数据后 rebuild_all.py 重算; '
  127. '或在目标机也用 --no-products 的同一口径)')]
  128. return inc, skipped
  129. def keep(f: pathlib.Path) -> bool:
  130. """这个文件真会被写进包吗 —— 打包循环与"预演件数"共用同一条判据。
  131. ★ 2026-09-16 修正: 原实现里 `size_of()` 数的是**目录下所有文件**(连 `__pycache__/*.pyc` 一起数),
  132. 而实际写入时又跳过它们 ⇒ 预演报的"件数/体积"比包里真的多 (src 少报 100+ 件)。
  133. 现在两处都走 `keep()`: 预演数字 = 包里条目数, 对不上就是 bug。
  134. """
  135. parts = f.relative_to(ROOT).parts
  136. if any(x in EXCLUDE_DIRS for x in parts) or f.suffix.lower() in EXCLUDE_SUFFIX:
  137. return False
  138. if len(parts) == 1 and f.suffix.lower() == '.zip': # 顶层旧交付包, 避免包中包
  139. return False
  140. return not any(x.startswith(tuple(g.rstrip('*') for g in EXCLUDE_GLOBS)) for x in parts[:1])
  141. def size_of(p: pathlib.Path):
  142. if p.is_file():
  143. return 1, p.stat().st_size
  144. n = s = 0
  145. for f in p.rglob('*'):
  146. if f.is_file() and keep(f):
  147. n += 1; s += f.stat().st_size
  148. return n, s
  149. def build(out: pathlib.Path, with_data=False, progress=True, no_products=False) -> int:
  150. inc, skipped = plan(with_data, no_products)
  151. tot_n = tot_s = 0
  152. print('== 要打进包的内容 ==')
  153. for p in inc:
  154. n, s = size_of(p)
  155. tot_n += n; tot_s += s
  156. print(f' {p.relative_to(ROOT).as_posix():26s} {n:6d} 件 {s/1e6:9.1f} MB')
  157. print(f' {"合计":26s} {tot_n:6d} 件 {tot_s/1e6:9.1f} MB')
  158. print('== 排除 ==')
  159. for name, why in skipped:
  160. print(f' {name:26s} {why}')
  161. out.parent.mkdir(parents=True, exist_ok=True)
  162. man = dict(version=VERSION, built=time.strftime('%Y-%m-%d %H:%M:%S'), root=str(ROOT),
  163. includes=[p.relative_to(ROOT).as_posix() for p in inc],
  164. excluded={n: w for n, w in skipped}, files=tot_n + 1, # +1 = dist-manifest.json 自身
  165. bytes=tot_s,
  166. no_data=not with_data, no_products=bool(no_products), no_logs=True,
  167. note='本包由 scripts/pack_dist.py 生成; **不含 输入数据(data/) · 产物(outputs/) · 日志(logs/, run/)** '
  168. '(2026-09-16 用户令)。目标机解压后跑 install.bat(Windows) 或 sh install.sh(Linux/macOS), '
  169. '再 check → start。要让页面有数: 把现场包放好后跑 '
  170. '`scripts/place_raw_data.py --src <现场包目录> --scope full` + `scripts/rebuild_all.py`。'
  171. '.venv 不在包内(换机必失效, 安装时重建)。')
  172. t0 = time.time()
  173. with zipfile.ZipFile(out, 'w', zipfile.ZIP_DEFLATED, compresslevel=6) as z:
  174. z.writestr('dist-manifest.json', json.dumps(man, ensure_ascii=False, indent=1))
  175. done = 0
  176. for p in inc:
  177. if p.is_file():
  178. z.write(p, p.relative_to(ROOT).as_posix())
  179. done += 1
  180. else:
  181. for f in p.rglob('*'):
  182. if not f.is_file() or not keep(f):
  183. continue
  184. z.write(f, f.relative_to(ROOT).as_posix())
  185. done += 1
  186. if progress and done % 500 < 1:
  187. print(f' … 已写入 {done} 件', flush=True)
  188. print(f'\n完成: {out} {out.stat().st_size/1e6:.0f} MB (源 {tot_s/1e6:.0f} MB), 条目 {done} 件, 耗时 {time.time()-t0:.0f}s')
  189. # 入口引用闭合核对 (对着**包内条目**查; 2026-09-16: v0.4.0 第一版漏带 start_hidden.vbs, 目标机 start.bat 报错)
  190. # + 入口编码守则 (install.ps1 的 UTF-8 BOM 曾被编辑工具吃掉 → 目标机 install 1 秒即 ParserError)
  191. from src.entry_refs import encoding_problems, missing_refs
  192. with zipfile.ZipFile(out) as z:
  193. names = z.namelist()
  194. enc = encoding_problems(ROOT)
  195. miss = missing_refs(ROOT, names)
  196. if miss:
  197. for e, rel in miss:
  198. print(f' [X] 包内 {e} 引用了 {rel}, 但包里没有 —— 这个包不能交付')
  199. for m in enc:
  200. print(f' [X] 入口编码守则: {m}')
  201. if miss or enc:
  202. out.unlink()
  203. print(f' [X] 已删除不合格的包 {out.name} (留着会被人当成能用的包发出去)')
  204. return 1
  205. print(f' [OK] 入口引用闭合 + 编码守则: 包内每个入口脚本引用的文件都在包里 (条目 {len(names)} 件)')
  206. print('目标机用法: 解压到无空格无中文路径 → install.bat / sh install.sh → check → start(自动开门户)')
  207. print(f'开箱验证(推荐): python scripts/pack_dist.py --verify "{out}"')
  208. return 0
  209. def verify(zip_path: pathlib.Path, keep=False) -> int:
  210. """解压 → 离线安装 → 起服务核验页面 → 清理。这是"能不能装、能不能跑"的直接证据。"""
  211. tmp = pathlib.Path(tempfile.gettempdir()) / 'guanlan_distcheck'
  212. if tmp.exists():
  213. shutil.rmtree(tmp, ignore_errors=True)
  214. print(f'① 解压到 {tmp}')
  215. t0 = time.time()
  216. with zipfile.ZipFile(zip_path) as z:
  217. z.extractall(tmp)
  218. n = sum(1 for _ in tmp.rglob('*') if _.is_file())
  219. print(f' {n} 件, 耗时 {time.time()-t0:.0f}s')
  220. print('①b 入口引用闭合核对 (包内 start.bat → scripts\\guanlan_start_hidden.py 之类)')
  221. from src.entry_refs import encoding_problems, missing_refs
  222. miss = missing_refs(tmp)
  223. enc = encoding_problems(tmp)
  224. if miss:
  225. for e, rel in miss:
  226. print(f' [X] {e} 引用了 {rel}, 但解压目录里没有 —— 目标机会双击即报错')
  227. for m in enc:
  228. print(f' [X] 入口编码守则: {m}')
  229. if miss or enc:
  230. return 1
  231. print(' [OK] 引用全部在位, 编码守则通过 (install.ps1 = UTF-8 BOM + CRLF)')
  232. print('② 离线安装')
  233. xt = _xfer()
  234. py = xt.install_in(tmp)
  235. if py is None:
  236. return 1
  237. print('②b 无窗口启动核验 (VBScript 已按用户令移除, 现在靠 pythonw.exe + 启动器 + 快捷方式)')
  238. bad = []
  239. if os.name == 'nt':
  240. lnk = tmp / '启动观澜.lnk'
  241. if lnk.is_file() and lnk.stat().st_size > 0:
  242. print(f' [OK] 安装时已生成快捷方式: {lnk.name} ({lnk.stat().st_size} B)')
  243. else:
  244. bad.append('安装后没有 启动观澜.lnk (install.ps1 第 5/6 步没生效)')
  245. import json as _json
  246. import subprocess as _sp
  247. pyw = tmp / '.venv/Scripts/pythonw.exe'
  248. r = _sp.run([str(pyw), str(tmp / 'scripts/guanlan_start_hidden.py'), '--selftest'],
  249. capture_output=True, text=True, errors='replace', timeout=120)
  250. try:
  251. info = _json.loads((r.stdout or '').strip().splitlines()[-1])
  252. except Exception:
  253. info = {}
  254. if info.get('windowless') is True:
  255. print(f' [OK] pythonw.exe 跑启动器: console_hwnd={info.get("console_hwnd")} → 无窗口成立')
  256. else:
  257. bad.append(f'pythonw --selftest 没报无窗口: stdout={ (r.stdout or "").strip()[:120] }')
  258. if list(tmp.glob('*.vbs')):
  259. bad.append(f'包里还有 .vbs: {[p.name for p in tmp.glob("*.vbs")]}')
  260. else:
  261. print(' [OK] 包里无 .vbs (不依赖 Windows 脚本宿主/WSH)')
  262. else:
  263. print(' (非 Windows: 无窗口/快捷方式不适用)')
  264. for b in bad:
  265. print(f' [X] {b}')
  266. if bad:
  267. return 1
  268. print('③ 起服务核验 (副本用独立端口, 不动正在跑的实例)')
  269. ok = xt.run_and_probe(tmp, py)
  270. print('④ 收尾')
  271. if keep:
  272. print(f' 解压目录保留在 {tmp} (--keep)')
  273. else:
  274. shutil.rmtree(tmp, ignore_errors=True)
  275. print(' 已清理')
  276. print(f'\n开箱验证结论: 页面可用 {ok}/5 —— {"可以交付" if ok >= 4 else "有问题, 见上"}')
  277. return 0 if ok >= 4 else 1
  278. def main() -> int:
  279. ap = argparse.ArgumentParser()
  280. ap.add_argument('--out', default=None)
  281. ap.add_argument('--with-data', action='store_true', help='连 data/raw 一起打(约 31 GB, 慎用)')
  282. ap.add_argument('--no-products', action='store_true',
  283. help='**不含产物** (outputs/): 用户令 2026-09-16 "打包不含 输入数据/产物/日志"')
  284. ap.add_argument('--dry-run', action='store_true')
  285. ap.add_argument('--verify', default=None, help='对已打好的 zip 做开箱验证(解压→安装→起服务核验)')
  286. ap.add_argument('--keep', action='store_true', help='--verify 后保留解压目录')
  287. a = ap.parse_args()
  288. if a.verify:
  289. return verify(pathlib.Path(a.verify), a.keep)
  290. out = (pathlib.Path(a.out) if a.out
  291. else ROOT.parent / f'guanlan-v{VERSION}_dist_{time.strftime("%Y%m%d")}.zip')
  292. if a.dry_run:
  293. inc, skipped = plan(a.with_data, a.no_products)
  294. tot_n = tot_s = 0
  295. print(f'== 会打进包 (version {VERSION}) ==')
  296. for p in inc:
  297. n, s = size_of(p); tot_n += n; tot_s += s
  298. print(f' {p.relative_to(ROOT).as_posix():26s} {n:6d} 件 {s/1e6:9.1f} MB')
  299. print(f' {"合计":26s} {tot_n:6d} 件 {tot_s/1e6:9.1f} MB → {out}')
  300. print('== 会排除 ==')
  301. for name, why in skipped:
  302. print(f' {name:26s} {why}')
  303. print('\n(dry-run, 未写文件)')
  304. return 0
  305. return build(out, a.with_data, no_products=a.no_products)
  306. if __name__ == '__main__':
  307. for _s in (sys.stdout, sys.stderr):
  308. try: _s.reconfigure(errors='replace')
  309. except Exception: pass
  310. sys.exit(main())