#!/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 现场原始件, 按约定不随包分发)。 本器把这些**显式列出来**(含理由), 照单排除 —— "打了什么/没打什么"可复核, 而不是靠记忆。 ## 包含 / 排除 (规则即文档) ★ 2026-09-17 用户令: **交付包默认不含 输入数据 / 产物(输出数据) / 运行日志 / 临时文件** —— 即"开箱是一套干净的程序", 数据与产物到目标机自己放、自己算。默认执行 `python scripts/pack_dist.py` 就是这四类全不含, 不需要任何开关 (要破例才加 `--with-data` / `--with-products`)。 **包含**: 程序(`src/` `scripts/` `guanlan.py`) · 配置(`configs/`) · 离线安装件(`wheels/win_amd64/` `vendor/python/`) · 页面与交付(`release/` `resources/` `reference/`) · 文档(`docs/` `README_先读我.MD` `测试须知.txt`) · 安装/起停/打包脚本(`install.bat` `install.sh` `install.ps1` `check.bat` `start.bat` `stop.bat` `pack.bat` `pack.sh`) · `requirements.txt`。 **不含**修复记录/临时目录(`_修复记录_*` 之类): 本包已纳入 git 管理, 变更历史由版本库承载 (2026-09-12 用户令)。 **不含 VBScript**: 2026-09-16 用户令"把 VBScript 替换掉" —— 无窗口启动由 `pythonw.exe` + `scripts/guanlan_start_hidden.py` 承担, 安装时另建 `启动观澜.lnk`; 包里已无 `.vbs`, 也不依赖 Windows 脚本宿主。 **排除**(每条都写了理由; 前四类就是用户令点名的四类): data/ ① **输入数据**: 现场原始件(约定"原始件不随包分发"); 要一起交付用 --with-data outputs/ ② **产物(输出数据)**: 页面取数靠它, 但按令不进包 —— 目标机放数据后 `scripts/place_raw_data.py` + `scripts/rebuild_all.py` 重算, 或按需从 交付包补齐无生成端的那批(products_restore_missing.py --stash ) logs/ run/ ③ **运行日志与运行态**: 本机日志(含 logs/build、logs/legacy)、run/pids.json (旧 PID 到新机器上是无效引用); 要连产物一起发用 --with-products *.log *.jsonl(任何位置) ③ 运行日志/审计流水的兜底规则: 不管躺在哪都不进包 *_cache/ .*_cache/ __pycache__/ ④ **临时文件与缓存**: 解释器缓存、第三方工具缓存 .pytest_cache .mypy_cache .ruff_cache .ipynb_checkpoints .npm-cache .qa-profile *.tmp *.temp *.bak* *.old *.orig *.rej *.swp *.swo *~ ~$* ④ 编辑器/工具留下的临时与备份文件 .git/ .venv/ .github/ 版本库与虚拟环境 —— venv 换机必失效, 目标机安装时重建 _products_off*/ 历史遗留的"清除产物"暂存档(2026-09-16 起清除=真删, 不再产生此类目录) *.pyc *.pyo 解释器缓存(字节码) *.lnk 安装时建的快捷方式(内含绝对路径, 换机必失效; 目标机由 install.ps1 重建) *.zip(顶层) 旧的交付压缩包; 避免包中包 .DS_Store Thumbs.db desktop.ini 其它系统在目录里留下的临时索引 ## 用法 python scripts/pack_dist.py --dry-run # 只报会打什么/多大, 不写文件 python scripts/pack_dist.py # 打包(默认不含 数据/产物/日志/临时文件) python scripts/pack_dist.py --out <目标 zip> # 指定输出路径 python scripts/pack_dist.py --with-products # 例外: 连产物一起打 (页面开箱即有数) python scripts/pack_dist.py --with-data # 例外: 连 data/raw 一起打 (30 GB, 慎用) python scripts/pack_dist.py --no-products # (旧开关, 现在默认就是这个; 保留兼容不报错) python scripts/pack_dist.py --out D:\out\x.zip # 指定输出 # portability-allow: 只是用法示例里的 Windows 路径写法, 不是本机路径 python scripts/pack_dist.py --verify # **开箱验证**: 解压 → 离线安装 → 起服务核验页面 → 清理 ## 排除结果写进包 包里 `dist-manifest.json` 记下 version / no_data / no_products / no_logs / no_temp 与**逐条**排除理由; `guanlan.py check` 读它 —— 没带产物的包, 产物那几行检查按 `[--]` 说明而不判 FAIL。 一键入口(给不敲命令行的人): Windows 双击 `pack.bat`(或 `pack.bat dry` 只看计划)、Linux/macOS `sh pack.sh`。 """ 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', 'docs', 'wheels', 'vendor'] PRODUCTS_DIR = 'outputs' # 产物仓: **默认不进包** (用户令 2026-09-17); 要带用 --with-products # ★ 不打包"修复记录/临时/脚手架"类目录 (2026-09-12 用户令): 本包已纳入 git 管理, 变更历史由版本库承载, # 不必在交付物里再带一份 `_修复记录_<日期>\`。原先这里含 `_修复记录_20260911` (该目录的**内容**已并入 # `docs\振动数据接入_v0.1.md` 与 `docs\数据目录结构与落位约定_v0.2.md`, 目录本身按用户令删除)。 VERSION = '0.4.0' # 包版本 (2026-09-16): 写进 dist-manifest.json 与默认文件名 INCLUDE_FILES = ['guanlan.py', 'install.bat', 'install.ps1', 'install.sh', 'check.bat', 'start.bat', 'stop.bat', # ★ 无窗口启动: v0.4.0 第一版靠 start_hidden.vbs, 漏打进包 → 目标机 start.bat 报 # "无法找到脚本文件 start_hidden.vbs" (2026-09-16 现场反馈)。现已按用户令**把 VBScript # 整个去掉**: 无窗口启动 = `pythonw.exe` + `scripts/guanlan_start_hidden.py`, # 安装时另建 `启动观澜.lnk`。这条手工清单仍由 src/entry_refs.py 从引用反推兜底(漏不掉)。 'requirements.txt', 'README_先读我.MD', '测试须知.txt', # 打包入口 (用户令 2026-09-17: 把"打包功能"做成双击可用) 'pack.bat', 'pack.sh'] # 目录排除: 版本库/虚拟环境/日志/运行态/缓存 (名字出现在路径的任何一层都算) EXCLUDE_DIRS = {'.git', '.venv', '.github', '.git-*', 'logs', 'run', '__pycache__', '.pytest_cache', '.mypy_cache', '.ruff_cache', '.ipynb_checkpoints', '.cache', '.npm-cache', '.qa-profile', '.Trash', '$RECYCLE.BIN', '.venv.bak'} # ★ 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', 'outputs'] # 进包**只**看目录/名的顶部前缀, 这些则按后缀/文件名排除 —— 都是"本机/装机时/编辑时"才产生的东西 # (用户令 2026-09-17 的"临时文件"与"运行日志"两条就落在这里): # .pyc/.pyo 解释器缓存; .lnk 安装时建的快捷方式(绝对路径, 换机失效, 由 install.ps1 重建); # .log/.jsonl 运行日志与审计流水 —— **不管躺在哪都不进包**; .tmp/.bak/.old/.orig/.rej/.swp 编辑器临时与备份 EXCLUDE_SUFFIX = {'.pyc', '.pyo', '.lnk', '.log', '.jsonl', '.tmp', '.temp', '.bak', '.old', '.orig', '.rej', '.swp', '.swo'} # 文件名/尾部模式 (glob): 临时与备份的常见写法 + 系统索引文件 EXCLUDE_NAMES = ('*~', '~$*', '*.bak-*', '.DS_Store', 'Thumbs.db', 'desktop.ini', '*.crdownload', '*.partial') 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, with_products=False): """→ (要打包的顶层条目 list, 排除说明 list) ★ 2026-09-17 用户令: 交付包**默认不含 输入数据 / 产物(输出数据) / 运行日志 / 临时文件**。 · 输入数据 `data/` —— 默认排除 (--with-data 才带); · 产物 `outputs/` —— 默认排除 (--with-products 才带); · 运行日志 `logs/ run/` 与任何位置的 `*.log`/`*.jsonl` —— 默认排除; · 临时文件 `*_cache/ __pycache__/ *.tmp *.bak* *~ ~$* .DS_Store …` —— 默认排除。 于是"开箱是一套干净程序": 页面显示"无产物", 目标机自己放数据、自己重算 (docs §9 / §12)。 包内 `dist-manifest.json` 记 `no_data/no_products/no_logs/no_temp`, 目标机 `guanlan.py check` 据此把"产物缺失"显示为 `--`(待重算) 而不是 FAIL。 """ inc = [] for d in INCLUDE_DIRS: p = ROOT / d if p.is_dir(): inc.append(p) if with_products and (ROOT / PRODUCTS_DIR).is_dir(): inc.append(ROOT / PRODUCTS_DIR) if with_data: inc.append(ROOT / 'data') for f in INCLUDE_FILES: p = ROOT / f if p.is_file(): inc.append(p) else: print(f' [!] 清单里的 {f} 不在源码树里 —— 交付包会少一个入口文件, 请查') # ★ 引用闭合兜底 (2026-09-16 现场实炸后加): 凡是入口脚本 (.bat/.ps1/.sh) 引用到的、本机真实存在的 # 根目录文件, 一律自动补进包。手工清单 `INCLUDE_FILES` 漏一个文件, 代价是目标机双击 start.bat 直接报错; # 这条兜底让"漏"变得不可能 —— 上面那个 [X] 才是靠人盯的。 from src.entry_refs import auto_include, referenced_tree_files got = {p.relative_to(ROOT).as_posix().lower() for p in inc} extra = auto_include(ROOT, got) if extra: for p in extra: print(f' [+] 自动补入 (被入口脚本引用): {p.name}') inc.extend(extra) skipped = [('data/', '① 输入数据 (现场原始件): 用户令不进包; 要一起交付用 --with-data')] if (ROOT / PRODUCTS_DIR).is_dir(): skipped += [('outputs/', '② 产物/输出数据: 用户令不进包 (目标机放数据后 place_raw_data.py + ' 'rebuild_all.py 重算; 无生成端的那批用 products_restore_missing.py --stash <交付包.zip>)' + ('' if not with_products else ' ※本次给了 --with-products, 例外打进包'))] skipped += [('logs/, run/', '③ 运行日志与运行态 (logs/build、logs/legacy、run/pids.json 旧 PID)')] skipped += [('*.log, *.jsonl (任何位置)', '③ 运行日志/审计流水兜底: 不管躺在哪都不进包')] _glob_why = { '_products_off*': '历史遗留的"清除产物"暂存档 (2026-09-16 起清除=真删, 不再产生; 老机器上若有可手工删)', 'data': '① 输入数据: 现场原始件, 用户令不进包 (--with-data 才带)', 'outputs': '② 产物/输出数据: 用户令不进包 (--with-products 才带)', } skipped += [(g + '/', _glob_why.get(g, '本机痕迹/超大体量, 见脚本头部说明')) for g in EXCLUDE_GLOBS] skipped += [('*_cache/ __pycache__/ 等', '④ 临时文件与缓存 (解释器/工具缓存: .pytest_cache .npm-cache .qa-profile …)')] skipped += [('*.tmp *.bak* *.old *.orig *.rej *.swp *~ ~$*', '④ 编辑器/工具留下的临时与备份文件')] skipped += [('.DS_Store Thumbs.db desktop.ini', '④ 系统临时索引文件')] skipped += [('.git/ .venv/ .github/', '版本库与虚拟环境 (venv 换机必失效, 目标机安装时重建)')] skipped += [('__pycache__/ *.pyc *.pyo', '字节码缓存')] skipped += [('*.lnk', '安装时建的快捷方式 (内含绝对路径, 换机必失效; 目标机由 install.ps1 重建)')] skipped += [('*.zip (顶层)', '旧的交付压缩包; 避免包中包')] return inc, skipped def keep(f: pathlib.Path) -> bool: """这个文件真会被写进包吗 —— 打包循环与"预演件数"共用同一条判据。 ★ 2026-09-16 修正: 原实现里 `size_of()` 数的是**目录下所有文件**(连 `__pycache__/*.pyc` 一起数), 而实际写入时又跳过它们 ⇒ 预演报的"件数/体积"比包里真的多 (src 少报 100+ 件)。 现在两处都走 `keep()`: 预演数字 = 包里条目数, 对不上就是 bug。 ★ 2026-09-17: 加"运行日志(*.log/*.jsonl 任何位置)"与"临时/备份文件"两类排除 (用户令)。 """ import fnmatch parts = f.relative_to(ROOT).parts if any(x in EXCLUDE_DIRS for x in parts) or f.suffix.lower() in EXCLUDE_SUFFIX: return False if any(fnmatch.fnmatch(f.name, pat) for pat in EXCLUDE_NAMES): return False if len(parts) == 1 and f.suffix.lower() == '.zip': # 顶层旧交付包, 避免包中包 return False return not any(x.startswith(tuple(g.rstrip('*') for g in EXCLUDE_GLOBS)) for x in parts[:1]) 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() and keep(f): n += 1; s += f.stat().st_size return n, s def build(out: pathlib.Path, with_data=False, progress=True, with_products=False) -> int: inc, skipped = plan(with_data, with_products) tot_n = tot_s = 0 print('== 要打进包的内容 ==') print(' (默认口径: 不含 输入数据 / 产物 / 运行日志 / 临时文件 —— 用户令 2026-09-17)') 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(version=VERSION, 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 + 1, # +1 = dist-manifest.json 自身 bytes=tot_s, no_data=not with_data, no_products=not with_products, no_logs=True, no_temp=True, note='本包由 scripts/pack_dist.py 生成 (用户令 2026-09-17): **不含 输入数据(data/) · ' '产物(outputs/) · 运行日志(logs/, run/, *.log, *.jsonl) · 临时文件(缓存/备份/系统索引)**。' '目标机解压后跑 install.bat(Windows) 或 sh install.sh(Linux/macOS), 再 check → start; ' '要让页面有数: 把现场包放好后跑 `scripts/place_raw_data.py --src <现场包目录> --scope full` ' '+ `scripts/rebuild_all.py` (无生成端的那批用 ' '`scripts/products_restore_missing.py --stash <交付包.zip>` 补齐)。' '.venv 不在包内(换机必失效, 安装时重建); 打包方法见包内 README_先读我.MD 的「打包」一节。') 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() or not keep(f): 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') # 入口引用闭合核对 (对着**包内条目**查; 2026-09-16: v0.4.0 第一版漏带 start_hidden.vbs, 目标机 start.bat 报错) # + 入口编码守则 (install.ps1 的 UTF-8 BOM 曾被编辑工具吃掉 → 目标机 install 1 秒即 ParserError) from src.entry_refs import encoding_problems, missing_refs with zipfile.ZipFile(out) as z: names = z.namelist() enc = encoding_problems(ROOT) miss = missing_refs(ROOT, names) if miss: for e, rel in miss: print(f' [X] 包内 {e} 引用了 {rel}, 但包里没有 —— 这个包不能交付') for m in enc: print(f' [X] 入口编码守则: {m}') if miss or enc: out.unlink() print(f' [X] 已删除不合格的包 {out.name} (留着会被人当成能用的包发出去)') return 1 print(f' [OK] 入口引用闭合 + 编码守则: 包内每个入口脚本引用的文件都在包里 (条目 {len(names)} 件)') 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('①b 入口引用闭合核对 (包内 start.bat → scripts\\guanlan_start_hidden.py 之类)') from src.entry_refs import encoding_problems, missing_refs miss = missing_refs(tmp) enc = encoding_problems(tmp) if miss: for e, rel in miss: print(f' [X] {e} 引用了 {rel}, 但解压目录里没有 —— 目标机会双击即报错') for m in enc: print(f' [X] 入口编码守则: {m}') if miss or enc: return 1 print(' [OK] 引用全部在位, 编码守则通过 (install.ps1 = UTF-8 BOM + CRLF)') print('② 离线安装') xt = _xfer() py = xt.install_in(tmp) if py is None: return 1 print('②b 无窗口启动核验 (VBScript 已按用户令移除, 现在靠 pythonw.exe + 启动器 + 快捷方式)') bad = [] if os.name == 'nt': # 2026-09-17: 安装脚本现在建**两份**快捷方式 —— 中文名给人双击, ASCII 名 (start_guanlan.lnk) # 给控制台/脚本引用 (纯 ASCII 的 start.bat 只能引用 ASCII 名)。两份都要在。 for nm in ('启动观澜.lnk', 'start_guanlan.lnk'): lnk = tmp / nm if lnk.is_file() and lnk.stat().st_size > 0: print(f' [OK] 安装时已生成快捷方式: {nm} ({lnk.stat().st_size} B)') else: bad.append(f'安装后没有 {nm} (install.ps1 第 5/6 步没生效)') import json as _json import subprocess as _sp pyw = tmp / '.venv/Scripts/pythonw.exe' r = _sp.run([str(pyw), str(tmp / 'scripts/guanlan_start_hidden.py'), '--selftest'], capture_output=True, text=True, errors='replace', timeout=120) try: info = _json.loads((r.stdout or '').strip().splitlines()[-1]) except Exception: info = {} if info.get('windowless') is True: print(f' [OK] pythonw.exe 跑启动器: console_hwnd={info.get("console_hwnd")} → 无窗口成立') else: bad.append(f'pythonw --selftest 没报无窗口: stdout={ (r.stdout or "").strip()[:120] }') if list(tmp.glob('*.vbs')): bad.append(f'包里还有 .vbs: {[p.name for p in tmp.glob("*.vbs")]}') else: print(' [OK] 包里无 .vbs (不依赖 Windows 脚本宿主/WSH)') else: print(' (非 Windows: 无窗口/快捷方式不适用)') for b in bad: print(f' [X] {b}') if bad: 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('--with-products', action='store_true', help='例外: 连产物 outputs/ 一起打 (页面开箱即有数; 默认不含 —— 用户令 2026-09-17)') ap.add_argument('--no-products', action='store_true', help='(旧开关) 不含产物 —— 现在**默认就是这个**, 保留只为兼容旧命令') 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-v{VERSION}_dist_{time.strftime("%Y%m%d")}.zip') if a.dry_run: inc, skipped = plan(a.with_data, a.with_products) tot_n = tot_s = 0 print(f'== 会打进包 (version {VERSION}) ==') 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, with_products=a.with_products) if __name__ == '__main__': for _s in (sys.stdout, sys.stderr): try: _s.reconfigure(errors='replace') except Exception: pass sys.exit(main())