pack_dist.py 15 KB

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