pack_dist.py 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205
  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/`, 页面取数靠它) · 文档(`docs/` `README_先读我.txt`
  12. `测试须知.txt` `_修复记录_20260911/`) · 安装/起停脚本(`install.bat` `install.sh` `install.ps1` `check.bat` `start.bat`
  13. `stop.bat`) · `requirements.txt`。
  14. **排除**(每条都写了理由):
  15. .git/ .venv/ .github/ 版本库与虚拟环境 —— venv 换机必失效, 目标机安装时重建
  16. _products_off*/ 本机"清除产物"的暂存与存档(每份含整份产物); 目标机用不到
  17. logs/ run/ 本机日志与 run/pids.json(旧 PID, 到新机器上是无效引用)
  18. data/raw/ 31 GB 现场原始件(约定"原始件不随包分发"); 要一起交付用 --with-data
  19. __pycache__/ *.pyc 解释器缓存
  20. *.zip(顶层) 旧的交付压缩包; 避免包中包
  21. ## 用法
  22. python scripts/pack_dist.py --dry-run # 只报会打什么/多大, 不写文件
  23. python scripts/pack_dist.py # 打包 → <父目录>/guanlan-rudong-v2_0.2.0_dist_<日期>.zip
  24. python scripts/pack_dist.py --with-data # 连 data/raw 一起打 (30 GB, 慎用)
  25. python scripts/pack_dist.py --out D:\out\x.zip # 指定输出
  26. python scripts/pack_dist.py --verify <zip> # **开箱验证**: 解压 → 离线安装 → 起服务核验页面 → 清理
  27. """
  28. from __future__ import annotations
  29. import argparse
  30. import importlib.util
  31. import json
  32. import os
  33. import pathlib
  34. import shutil
  35. import sys
  36. import tempfile
  37. import time
  38. import zipfile
  39. ROOT = pathlib.Path(__file__).resolve().parents[1]
  40. sys.path.insert(0, str(ROOT))
  41. INCLUDE_DIRS = ['src', 'scripts', 'configs', 'release', 'resources', 'reference', 'outputs', 'docs',
  42. 'wheels', 'vendor', '_修复记录_20260911']
  43. INCLUDE_FILES = ['guanlan.py', 'install.bat', 'install.ps1', 'install.sh', 'check.bat', 'start.bat', 'stop.bat',
  44. 'requirements.txt', 'README_先读我.txt', '测试须知.txt']
  45. EXCLUDE_DIRS = {'.git', '.venv', '.github', 'logs', 'run', '__pycache__'}
  46. # ★ node_modules **不排除**: `release/viewer/node_modules` 里是 esbuild(构建工具)+three(三维库源码), 共 52.5 MB;
  47. # 前端运行期读的是打包好的 viewer.js(three 已 bundle 进去), 理论上可以不发 —— 但**历史交付包是带着它的**
  48. # (实测 guanlan-rudong-v2_0.2.0_test_win64.zip 里就有 1207 条 node_modules 条目),
  49. # 为省 52 MB 去冒"三维页在别人机器上打不开"的风险不划算。宁可多带。
  50. EXCLUDE_GLOBS = ['_products_off*', 'data']
  51. def _xfer():
  52. """复用移植检查器里的安装/起服务核验逻辑 (单一实现, 不抄第二份)。"""
  53. spec = importlib.util.spec_from_file_location('check_transferable', ROOT / 'scripts' / 'check_transferable.py')
  54. m = importlib.util.module_from_spec(spec)
  55. sys.modules['check_transferable'] = m
  56. spec.loader.exec_module(m)
  57. return m
  58. def plan(with_data=False):
  59. """→ (要打包的顶层条目 list, 排除说明 list)"""
  60. inc = []
  61. for d in INCLUDE_DIRS:
  62. p = ROOT / d
  63. if p.is_dir():
  64. inc.append(p)
  65. if with_data:
  66. inc.append(ROOT / 'data')
  67. for f in INCLUDE_FILES:
  68. p = ROOT / f
  69. if p.is_file():
  70. inc.append(p)
  71. skipped = [('.git/', '版本库'), ('.venv/', '虚拟环境(换机必失效, 目标机安装时重建)')]
  72. skipped += [(g + '/', '本机痕迹/超大体量, 见脚本头部说明') for g in EXCLUDE_GLOBS]
  73. skipped += [('logs/, run/', '本机日志与旧 PID')]
  74. return inc, skipped
  75. def size_of(p: pathlib.Path):
  76. if p.is_file():
  77. return 1, p.stat().st_size
  78. n = s = 0
  79. for f in p.rglob('*'):
  80. if f.is_file():
  81. n += 1; s += f.stat().st_size
  82. return n, s
  83. def build(out: pathlib.Path, with_data=False, progress=True) -> int:
  84. inc, skipped = plan(with_data)
  85. tot_n = tot_s = 0
  86. print('== 要打进包的内容 ==')
  87. for p in inc:
  88. n, s = size_of(p)
  89. tot_n += n; tot_s += s
  90. print(f' {p.relative_to(ROOT).as_posix():26s} {n:6d} 件 {s/1e6:9.1f} MB')
  91. print(f' {"合计":26s} {tot_n:6d} 件 {tot_s/1e6:9.1f} MB')
  92. print('== 排除 ==')
  93. for name, why in skipped:
  94. print(f' {name:26s} {why}')
  95. out.parent.mkdir(parents=True, exist_ok=True)
  96. man = dict(built=time.strftime('%Y-%m-%d %H:%M:%S'), root=str(ROOT), includes=[p.relative_to(ROOT).as_posix() for p in inc],
  97. excluded={n: w for n, w in skipped}, files=tot_n, bytes=tot_s,
  98. note='本包由 scripts/pack_dist.py 生成; 目标机解压后跑 install.bat(Windows) 或 sh install.sh(Linux/macOS), '
  99. '再 check → start。.venv 不在包内(换机必失效, 安装时重建)。')
  100. t0 = time.time()
  101. with zipfile.ZipFile(out, 'w', zipfile.ZIP_DEFLATED, compresslevel=6) as z:
  102. z.writestr('dist-manifest.json', json.dumps(man, ensure_ascii=False, indent=1))
  103. done = 0
  104. for p in inc:
  105. if p.is_file():
  106. z.write(p, p.relative_to(ROOT).as_posix())
  107. done += 1
  108. else:
  109. for f in p.rglob('*'):
  110. if not f.is_file():
  111. continue
  112. parts = f.relative_to(ROOT).parts
  113. if any(x in EXCLUDE_DIRS for x in parts) or f.suffix == '.pyc':
  114. continue
  115. if any(x.startswith(tuple(g.rstrip('*') for g in EXCLUDE_GLOBS)) for x in parts[:1]):
  116. continue
  117. z.write(f, f.relative_to(ROOT).as_posix())
  118. done += 1
  119. if progress and done % 500 < 1:
  120. print(f' … 已写入 {done} 件', flush=True)
  121. print(f'\n完成: {out} {out.stat().st_size/1e6:.0f} MB (源 {tot_s/1e6:.0f} MB), 条目 {done} 件, 耗时 {time.time()-t0:.0f}s')
  122. print('目标机用法: 解压到无空格无中文路径 → install.bat / sh install.sh → check → start(自动开门户)')
  123. print(f'开箱验证(推荐): python scripts/pack_dist.py --verify "{out}"')
  124. return 0
  125. def verify(zip_path: pathlib.Path, keep=False) -> int:
  126. """解压 → 离线安装 → 起服务核验页面 → 清理。这是"能不能装、能不能跑"的直接证据。"""
  127. tmp = pathlib.Path(tempfile.gettempdir()) / 'guanlan_distcheck'
  128. if tmp.exists():
  129. shutil.rmtree(tmp, ignore_errors=True)
  130. print(f'① 解压到 {tmp}')
  131. t0 = time.time()
  132. with zipfile.ZipFile(zip_path) as z:
  133. z.extractall(tmp)
  134. n = sum(1 for _ in tmp.rglob('*') if _.is_file())
  135. print(f' {n} 件, 耗时 {time.time()-t0:.0f}s')
  136. print('② 离线安装')
  137. xt = _xfer()
  138. py = xt.install_in(tmp)
  139. if py is None:
  140. return 1
  141. print('③ 起服务核验 (副本用独立端口, 不动正在跑的实例)')
  142. ok = xt.run_and_probe(tmp, py)
  143. print('④ 收尾')
  144. if keep:
  145. print(f' 解压目录保留在 {tmp} (--keep)')
  146. else:
  147. shutil.rmtree(tmp, ignore_errors=True)
  148. print(' 已清理')
  149. print(f'\n开箱验证结论: 页面可用 {ok}/5 —— {"可以交付" if ok >= 4 else "有问题, 见上"}')
  150. return 0 if ok >= 4 else 1
  151. def main() -> int:
  152. ap = argparse.ArgumentParser()
  153. ap.add_argument('--out', default=None)
  154. ap.add_argument('--with-data', action='store_true', help='连 data/raw 一起打(约 31 GB, 慎用)')
  155. ap.add_argument('--dry-run', action='store_true')
  156. ap.add_argument('--verify', default=None, help='对已打好的 zip 做开箱验证(解压→安装→起服务核验)')
  157. ap.add_argument('--keep', action='store_true', help='--verify 后保留解压目录')
  158. a = ap.parse_args()
  159. if a.verify:
  160. return verify(pathlib.Path(a.verify), a.keep)
  161. out = pathlib.Path(a.out) if a.out else ROOT.parent / f'guanlan-rudong-v2_0.2.0_dist_{time.strftime("%Y%m%d")}.zip'
  162. if a.dry_run:
  163. inc, skipped = plan(a.with_data)
  164. tot_n = tot_s = 0
  165. print('== 会打进包 ==')
  166. for p in inc:
  167. n, s = size_of(p); tot_n += n; tot_s += s
  168. print(f' {p.relative_to(ROOT).as_posix():26s} {n:6d} 件 {s/1e6:9.1f} MB')
  169. print(f' {"合计":26s} {tot_n:6d} 件 {tot_s/1e6:9.1f} MB → {out}')
  170. print('== 会排除 ==')
  171. for name, why in skipped:
  172. print(f' {name:26s} {why}')
  173. print('\n(dry-run, 未写文件)')
  174. return 0
  175. return build(out, a.with_data)
  176. if __name__ == '__main__':
  177. for _s in (sys.stdout, sys.stderr):
  178. try: _s.reconfigure(errors='replace')
  179. except Exception: pass
  180. sys.exit(main())