#!/usr/bin/env python3 # -*- coding: utf-8 -*- r"""打一个"拷到别的电脑能装、能跑"的分发包 (2026-09-12)。 ## 为什么会需要单独一个打包器 直接 `右键压缩整个目录` 会把**本机痕迹**一起打进去, 目标机上要么装不了、要么体积翻倍: `.venv`(688 MB, 换机必失效) · `.git`(55 MB) · `_products_off*`(670 MB 本机产物暂存) · `logs` `run`(本机日志与旧 PID) · `data/raw`(31 GB 现场原始件, 按约定不随包分发)。 本器把这些**显式列出来**(含理由), 照单排除 —— "打了什么/没打什么"可复核, 而不是靠记忆。 ## 包含 / 排除 (规则即文档) **包含**: 程序(`src/` `scripts/` `guanlan.py`) · 配置(`configs/`) · 离线安装件(`wheels/win_amd64/` `vendor/python/`) · 页面与交付(`release/` `resources/` `reference/`) · **产物**(`outputs/`, 页面取数靠它) · 文档(`docs/` `README_先读我.txt` `测试须知.txt` `_修复记录_20260911/`) · 安装/起停脚本(`install.bat` `install.sh` `install.ps1` `check.bat` `start.bat` `stop.bat`) · `requirements.txt`。 **排除**(每条都写了理由): .git/ .venv/ .github/ 版本库与虚拟环境 —— venv 换机必失效, 目标机安装时重建 _products_off*/ 本机"清除产物"的暂存与存档(每份含整份产物); 目标机用不到 logs/ run/ 本机日志与 run/pids.json(旧 PID, 到新机器上是无效引用) data/raw/ 31 GB 现场原始件(约定"原始件不随包分发"); 要一起交付用 --with-data __pycache__/ *.pyc 解释器缓存 *.zip(顶层) 旧的交付压缩包; 避免包中包 ## 用法 python scripts/pack_dist.py --dry-run # 只报会打什么/多大, 不写文件 python scripts/pack_dist.py # 打包 → <父目录>/guanlan-rudong-v2_0.2.0_dist_<日期>.zip python scripts/pack_dist.py --with-data # 连 data/raw 一起打 (30 GB, 慎用) python scripts/pack_dist.py --out D:\out\x.zip # 指定输出 python scripts/pack_dist.py --verify # **开箱验证**: 解压 → 离线安装 → 起服务核验页面 → 清理 """ from __future__ import annotations import argparse import importlib.util import json import os import pathlib import shutil import sys import tempfile import time import zipfile ROOT = pathlib.Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT)) INCLUDE_DIRS = ['src', 'scripts', 'configs', 'release', 'resources', 'reference', 'outputs', 'docs', 'wheels', 'vendor', '_修复记录_20260911'] INCLUDE_FILES = ['guanlan.py', 'install.bat', 'install.ps1', 'install.sh', 'check.bat', 'start.bat', 'stop.bat', 'requirements.txt', 'README_先读我.txt', '测试须知.txt'] EXCLUDE_DIRS = {'.git', '.venv', '.github', 'logs', 'run', '__pycache__'} # ★ node_modules **不排除**: `release/viewer/node_modules` 里是 esbuild(构建工具)+three(三维库源码), 共 52.5 MB; # 前端运行期读的是打包好的 viewer.js(three 已 bundle 进去), 理论上可以不发 —— 但**历史交付包是带着它的** # (实测 guanlan-rudong-v2_0.2.0_test_win64.zip 里就有 1207 条 node_modules 条目), # 为省 52 MB 去冒"三维页在别人机器上打不开"的风险不划算。宁可多带。 EXCLUDE_GLOBS = ['_products_off*', 'data'] def _xfer(): """复用移植检查器里的安装/起服务核验逻辑 (单一实现, 不抄第二份)。""" spec = importlib.util.spec_from_file_location('check_transferable', ROOT / 'scripts' / 'check_transferable.py') m = importlib.util.module_from_spec(spec) sys.modules['check_transferable'] = m spec.loader.exec_module(m) return m def plan(with_data=False): """→ (要打包的顶层条目 list, 排除说明 list)""" inc = [] for d in INCLUDE_DIRS: p = ROOT / d if p.is_dir(): inc.append(p) if with_data: inc.append(ROOT / 'data') for f in INCLUDE_FILES: p = ROOT / f if p.is_file(): inc.append(p) skipped = [('.git/', '版本库'), ('.venv/', '虚拟环境(换机必失效, 目标机安装时重建)')] skipped += [(g + '/', '本机痕迹/超大体量, 见脚本头部说明') for g in EXCLUDE_GLOBS] skipped += [('logs/, run/', '本机日志与旧 PID')] return inc, skipped def size_of(p: pathlib.Path): if p.is_file(): return 1, p.stat().st_size n = s = 0 for f in p.rglob('*'): if f.is_file(): n += 1; s += f.stat().st_size return n, s def build(out: pathlib.Path, with_data=False, progress=True) -> int: inc, skipped = plan(with_data) tot_n = tot_s = 0 print('== 要打进包的内容 ==') for p in inc: n, s = size_of(p) tot_n += n; tot_s += s print(f' {p.relative_to(ROOT).as_posix():26s} {n:6d} 件 {s/1e6:9.1f} MB') print(f' {"合计":26s} {tot_n:6d} 件 {tot_s/1e6:9.1f} MB') print('== 排除 ==') for name, why in skipped: print(f' {name:26s} {why}') out.parent.mkdir(parents=True, exist_ok=True) 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], excluded={n: w for n, w in skipped}, files=tot_n, bytes=tot_s, note='本包由 scripts/pack_dist.py 生成; 目标机解压后跑 install.bat(Windows) 或 sh install.sh(Linux/macOS), ' '再 check → start。.venv 不在包内(换机必失效, 安装时重建)。') t0 = time.time() with zipfile.ZipFile(out, 'w', zipfile.ZIP_DEFLATED, compresslevel=6) as z: z.writestr('dist-manifest.json', json.dumps(man, ensure_ascii=False, indent=1)) done = 0 for p in inc: if p.is_file(): z.write(p, p.relative_to(ROOT).as_posix()) done += 1 else: for f in p.rglob('*'): if not f.is_file(): continue parts = f.relative_to(ROOT).parts if any(x in EXCLUDE_DIRS for x in parts) or f.suffix == '.pyc': continue if any(x.startswith(tuple(g.rstrip('*') for g in EXCLUDE_GLOBS)) for x in parts[:1]): continue z.write(f, f.relative_to(ROOT).as_posix()) done += 1 if progress and done % 500 < 1: print(f' … 已写入 {done} 件', flush=True) print(f'\n完成: {out} {out.stat().st_size/1e6:.0f} MB (源 {tot_s/1e6:.0f} MB), 条目 {done} 件, 耗时 {time.time()-t0:.0f}s') print('目标机用法: 解压到无空格无中文路径 → install.bat / sh install.sh → check → start(自动开门户)') print(f'开箱验证(推荐): python scripts/pack_dist.py --verify "{out}"') return 0 def verify(zip_path: pathlib.Path, keep=False) -> int: """解压 → 离线安装 → 起服务核验页面 → 清理。这是"能不能装、能不能跑"的直接证据。""" tmp = pathlib.Path(tempfile.gettempdir()) / 'guanlan_distcheck' if tmp.exists(): shutil.rmtree(tmp, ignore_errors=True) print(f'① 解压到 {tmp}') t0 = time.time() with zipfile.ZipFile(zip_path) as z: z.extractall(tmp) n = sum(1 for _ in tmp.rglob('*') if _.is_file()) print(f' {n} 件, 耗时 {time.time()-t0:.0f}s') print('② 离线安装') xt = _xfer() py = xt.install_in(tmp) if py is None: return 1 print('③ 起服务核验 (副本用独立端口, 不动正在跑的实例)') ok = xt.run_and_probe(tmp, py) print('④ 收尾') if keep: print(f' 解压目录保留在 {tmp} (--keep)') else: shutil.rmtree(tmp, ignore_errors=True) print(' 已清理') print(f'\n开箱验证结论: 页面可用 {ok}/5 —— {"可以交付" if ok >= 4 else "有问题, 见上"}') return 0 if ok >= 4 else 1 def main() -> int: ap = argparse.ArgumentParser() ap.add_argument('--out', default=None) ap.add_argument('--with-data', action='store_true', help='连 data/raw 一起打(约 31 GB, 慎用)') ap.add_argument('--dry-run', action='store_true') ap.add_argument('--verify', default=None, help='对已打好的 zip 做开箱验证(解压→安装→起服务核验)') ap.add_argument('--keep', action='store_true', help='--verify 后保留解压目录') a = ap.parse_args() if a.verify: return verify(pathlib.Path(a.verify), a.keep) 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' if a.dry_run: inc, skipped = plan(a.with_data) tot_n = tot_s = 0 print('== 会打进包 ==') for p in inc: n, s = size_of(p); tot_n += n; tot_s += s print(f' {p.relative_to(ROOT).as_posix():26s} {n:6d} 件 {s/1e6:9.1f} MB') print(f' {"合计":26s} {tot_n:6d} 件 {tot_s/1e6:9.1f} MB → {out}') print('== 会排除 ==') for name, why in skipped: print(f' {name:26s} {why}') print('\n(dry-run, 未写文件)') return 0 return build(out, a.with_data) if __name__ == '__main__': for _s in (sys.stdout, sys.stderr): try: _s.reconfigure(errors='replace') except Exception: pass sys.exit(main())