pack_dist.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245
  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. 'requirements.txt', 'README_先读我.txt', '测试须知.txt']
  55. EXCLUDE_DIRS = {'.git', '.venv', '.github', 'logs', 'run', '__pycache__'}
  56. # ★ node_modules **不排除**: `release/viewer/node_modules` 里是 esbuild(构建工具)+three(三维库源码), 共 52.5 MB;
  57. # 前端运行期读的是打包好的 viewer.js(three 已 bundle 进去), 理论上可以不发 —— 但**历史交付包是带着它的**
  58. # (实测 guanlan-rudong-v2_0.2.0_test_win64.zip 里就有 1207 条 node_modules 条目),
  59. # 为省 52 MB 去冒"三维页在别人机器上打不开"的风险不划算。宁可多带。
  60. EXCLUDE_GLOBS = ['_products_off*', 'data']
  61. def _xfer():
  62. """复用移植检查器里的安装/起服务核验逻辑 (单一实现, 不抄第二份)。"""
  63. spec = importlib.util.spec_from_file_location('check_transferable', ROOT / 'scripts' / 'check_transferable.py')
  64. m = importlib.util.module_from_spec(spec)
  65. sys.modules['check_transferable'] = m
  66. spec.loader.exec_module(m)
  67. return m
  68. def plan(with_data=False, no_products=False):
  69. """→ (要打包的顶层条目 list, 排除说明 list)
  70. 2026-09-16 用户令: 打包**不含 输入数据 / 产物 / 日志**。
  71. · 输入数据 `data/` —— 一直默认排除 (要用 --with-data 才带);
  72. · 产物 `outputs/` —— 新增 --no-products 排除 (默认仍带: 页面开箱即有数);
  73. · 日志 `logs/` —— 一直在 EXCLUDE_DIRS 里 (连同 run/ 旧 PID)。
  74. 排除产物时, 包内 `dist-manifest.json` 会记 `no_products: true`,
  75. 目标机 `guanlan.py check` 据此把"产物缺失"显示为 `--`(待重算) 而不是 FAIL。
  76. """
  77. inc = []
  78. for d in INCLUDE_DIRS:
  79. p = ROOT / d
  80. if p.is_dir():
  81. inc.append(p)
  82. if not no_products:
  83. p = ROOT / PRODUCTS_DIR
  84. if p.is_dir():
  85. inc.append(p)
  86. if with_data:
  87. inc.append(ROOT / 'data')
  88. for f in INCLUDE_FILES:
  89. p = ROOT / f
  90. if p.is_file():
  91. inc.append(p)
  92. skipped = [('.git/', '版本库'), ('.venv/', '虚拟环境(换机必失效, 目标机安装时重建)')]
  93. _glob_why = {
  94. '_products_off*': '历史遗留的"清除产物"暂存档 (2026-09-16 起清除=真删, 不再产生; 老机器上若有可手工删)',
  95. 'data': '现场原始输入件 (约定"原始件不随包分发"; 要一起交付用 --with-data) —— 用户令: 输入数据不进包',
  96. }
  97. skipped += [(g + '/', _glob_why.get(g, '本机痕迹/超大体量, 见脚本头部说明')) for g in EXCLUDE_GLOBS]
  98. skipped += [('logs/, run/', '本机日志与旧 PID —— 用户令: 日志不进包')]
  99. if no_products and (ROOT / PRODUCTS_DIR).is_dir():
  100. skipped += [('outputs/', '用户令: 产物不进包 (目标机放数据后 rebuild_all.py 重算; '
  101. '或在目标机也用 --no-products 的同一口径)')]
  102. return inc, skipped
  103. def size_of(p: pathlib.Path):
  104. if p.is_file():
  105. return 1, p.stat().st_size
  106. n = s = 0
  107. for f in p.rglob('*'):
  108. if f.is_file():
  109. n += 1; s += f.stat().st_size
  110. return n, s
  111. def build(out: pathlib.Path, with_data=False, progress=True, no_products=False) -> int:
  112. inc, skipped = plan(with_data, no_products)
  113. tot_n = tot_s = 0
  114. print('== 要打进包的内容 ==')
  115. for p in inc:
  116. n, s = size_of(p)
  117. tot_n += n; tot_s += s
  118. print(f' {p.relative_to(ROOT).as_posix():26s} {n:6d} 件 {s/1e6:9.1f} MB')
  119. print(f' {"合计":26s} {tot_n:6d} 件 {tot_s/1e6:9.1f} MB')
  120. print('== 排除 ==')
  121. for name, why in skipped:
  122. print(f' {name:26s} {why}')
  123. out.parent.mkdir(parents=True, exist_ok=True)
  124. man = dict(version=VERSION, built=time.strftime('%Y-%m-%d %H:%M:%S'), root=str(ROOT),
  125. includes=[p.relative_to(ROOT).as_posix() for p in inc],
  126. excluded={n: w for n, w in skipped}, files=tot_n, bytes=tot_s,
  127. no_data=not with_data, no_products=bool(no_products), no_logs=True,
  128. note='本包由 scripts/pack_dist.py 生成; **不含 输入数据(data/) · 产物(outputs/) · 日志(logs/, run/)** '
  129. '(2026-09-16 用户令)。目标机解压后跑 install.bat(Windows) 或 sh install.sh(Linux/macOS), '
  130. '再 check → start。要让页面有数: 把现场包放好后跑 '
  131. '`scripts/place_raw_data.py --src <现场包目录> --scope full` + `scripts/rebuild_all.py`。'
  132. '.venv 不在包内(换机必失效, 安装时重建)。')
  133. t0 = time.time()
  134. with zipfile.ZipFile(out, 'w', zipfile.ZIP_DEFLATED, compresslevel=6) as z:
  135. z.writestr('dist-manifest.json', json.dumps(man, ensure_ascii=False, indent=1))
  136. done = 0
  137. for p in inc:
  138. if p.is_file():
  139. z.write(p, p.relative_to(ROOT).as_posix())
  140. done += 1
  141. else:
  142. for f in p.rglob('*'):
  143. if not f.is_file():
  144. continue
  145. parts = f.relative_to(ROOT).parts
  146. if any(x in EXCLUDE_DIRS for x in parts) or f.suffix == '.pyc':
  147. continue
  148. if any(x.startswith(tuple(g.rstrip('*') for g in EXCLUDE_GLOBS)) for x in parts[:1]):
  149. continue
  150. z.write(f, f.relative_to(ROOT).as_posix())
  151. done += 1
  152. if progress and done % 500 < 1:
  153. print(f' … 已写入 {done} 件', flush=True)
  154. print(f'\n完成: {out} {out.stat().st_size/1e6:.0f} MB (源 {tot_s/1e6:.0f} MB), 条目 {done} 件, 耗时 {time.time()-t0:.0f}s')
  155. print('目标机用法: 解压到无空格无中文路径 → install.bat / sh install.sh → check → start(自动开门户)')
  156. print(f'开箱验证(推荐): python scripts/pack_dist.py --verify "{out}"')
  157. return 0
  158. def verify(zip_path: pathlib.Path, keep=False) -> int:
  159. """解压 → 离线安装 → 起服务核验页面 → 清理。这是"能不能装、能不能跑"的直接证据。"""
  160. tmp = pathlib.Path(tempfile.gettempdir()) / 'guanlan_distcheck'
  161. if tmp.exists():
  162. shutil.rmtree(tmp, ignore_errors=True)
  163. print(f'① 解压到 {tmp}')
  164. t0 = time.time()
  165. with zipfile.ZipFile(zip_path) as z:
  166. z.extractall(tmp)
  167. n = sum(1 for _ in tmp.rglob('*') if _.is_file())
  168. print(f' {n} 件, 耗时 {time.time()-t0:.0f}s')
  169. print('② 离线安装')
  170. xt = _xfer()
  171. py = xt.install_in(tmp)
  172. if py is None:
  173. return 1
  174. print('③ 起服务核验 (副本用独立端口, 不动正在跑的实例)')
  175. ok = xt.run_and_probe(tmp, py)
  176. print('④ 收尾')
  177. if keep:
  178. print(f' 解压目录保留在 {tmp} (--keep)')
  179. else:
  180. shutil.rmtree(tmp, ignore_errors=True)
  181. print(' 已清理')
  182. print(f'\n开箱验证结论: 页面可用 {ok}/5 —— {"可以交付" if ok >= 4 else "有问题, 见上"}')
  183. return 0 if ok >= 4 else 1
  184. def main() -> int:
  185. ap = argparse.ArgumentParser()
  186. ap.add_argument('--out', default=None)
  187. ap.add_argument('--with-data', action='store_true', help='连 data/raw 一起打(约 31 GB, 慎用)')
  188. ap.add_argument('--no-products', action='store_true',
  189. help='**不含产物** (outputs/): 用户令 2026-09-16 "打包不含 输入数据/产物/日志"')
  190. ap.add_argument('--dry-run', action='store_true')
  191. ap.add_argument('--verify', default=None, help='对已打好的 zip 做开箱验证(解压→安装→起服务核验)')
  192. ap.add_argument('--keep', action='store_true', help='--verify 后保留解压目录')
  193. a = ap.parse_args()
  194. if a.verify:
  195. return verify(pathlib.Path(a.verify), a.keep)
  196. out = (pathlib.Path(a.out) if a.out
  197. else ROOT.parent / f'guanlan-v{VERSION}_dist_{time.strftime("%Y%m%d")}.zip')
  198. if a.dry_run:
  199. inc, skipped = plan(a.with_data, a.no_products)
  200. tot_n = tot_s = 0
  201. print(f'== 会打进包 (version {VERSION}) ==')
  202. for p in inc:
  203. n, s = size_of(p); tot_n += n; tot_s += s
  204. print(f' {p.relative_to(ROOT).as_posix():26s} {n:6d} 件 {s/1e6:9.1f} MB')
  205. print(f' {"合计":26s} {tot_n:6d} 件 {tot_s/1e6:9.1f} MB → {out}')
  206. print('== 会排除 ==')
  207. for name, why in skipped:
  208. print(f' {name:26s} {why}')
  209. print('\n(dry-run, 未写文件)')
  210. return 0
  211. return build(out, a.with_data, no_products=a.no_products)
  212. if __name__ == '__main__':
  213. for _s in (sys.stdout, sys.stderr):
  214. try: _s.reconfigure(errors='replace')
  215. except Exception: pass
  216. sys.exit(main())