| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541 |
- #!/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`
- · ★**输入数据目录结构**(空目录, 2026-09-19 用户令「包中增加输入数据目录结构,但不包含文件」): `data/` `data/raw/`
- `data/_incoming/` 与 `data/raw/<场站>/{scada_10min, scada_1min, 故障报警, 风机故障记录, 油样报告, windcms,
- m5_cms_tcm, scada_mdb}` —— 共 12 个空目录, **里面一个文件都没有**。结构取自单一真源
- `src/windscada/config.py::STATION_SUBDIRS`, 场站目录名取解析出来的那一个(现场是「如东」)。
- 目的: 目标机解压后开箱就能看见"每个约定位置在哪、该往哪个抽屉放原始件", 不必翻文档猜。
- 开箱验证会同时查两件事: 12 个目录都在位 + 目录里没混进任何文件。
- **不含**修复记录/临时目录(`_修复记录_*` 之类): 本包已纳入 git 管理, 变更历史由版本库承载 (2026-09-12 用户令)。
- **不含 VBScript**: 2026-09-16 用户令"把 VBScript 替换掉" —— 无窗口启动由 `pythonw.exe` +
- `scripts/guanlan_start_hidden.py` 承担, 安装时另建 `启动观澜.lnk`; 包里已无 `.vbs`, 也不依赖 Windows 脚本宿主。
- **排除**(每条都写了理由; 前四类就是用户令点名的四类):
- data/ 里的**文件** ① **输入数据**: 现场原始件(约定"原始件不随包分发"); 要一起交付用 --with-data。
- ★目录结构随包(空目录, 见上); 用户令 2026-09-19
- outputs/ ② **产物(输出数据)**: 页面取数靠它, 但按令不进包 —— 目标机放数据后
- `scripts/place_raw_data.py` + `scripts/rebuild_all.py` 重算, 或按需从
- 无生成端的那批**运行期不从交付包补**(用户令 2026-09-17 #1): 由研发补生成端(docs §13.6)
- 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 <zip> # **开箱验证**: 解压 → 离线安装 → 起服务核验页面 → 清理
- ## 排除结果写进包
- 包里 `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))
- from src import version as V # noqa: E402 ★ 版本号与包名的**唯一真源** (用户令 2026-09-17)
- 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`, 目录本身按用户令删除)。
- # ★ 版本号不再在这里写死 (2026-09-17 用户令"打包版本管理"): 改版本只改 `src/version.py` 一行,
- # 本器读它写进 dist-manifest.json 并用它拼默认包名 `app_guanlang_v<版本号>.zip`。
- VERSION = V.VERSION # 包版本 = src/version.py 的 VERSION(单一真源)
- 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',
- # 卸载入口 (用户令 2026-09-17: 增加卸载脚本; 它引用的 scripts\guanlan_uninstall.py
- # 由 INCLUDE_DIRS 的 scripts/ 覆盖, 入口本身必须在这里点名)
- 'uninstall.bat', 'uninstall.sh',
- # ★2026-09-19: 输入数据目录里的**说明文件**随包 —— 它是仓库里的说明(git 已跟踪), 不是现场数据。
- # 作用: 目标机解压后每个抽屉放什么一目了然(含"机理层资料在 data/raw/西门子4.0技术资料/")。
- # 注意 data/ 的**数据文件**照旧不进包。
- 'data/raw/README_把原始数据放这里.txt']
- # 目录排除: 版本库/虚拟环境/日志/运行态/缓存 (名字出现在路径的任何一层都算)
- 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): 临时与备份的常见写法 + 系统索引文件 + **本机安装记录**
- # ★ `install-info.json` 必须排除 (2026-09-17 实逮: 它被原样打进了交付包): 那是**本机装没装、装的哪一版**
- # 的凭据, 里面写着安装时间与本机绝对路径; 随包发出去, 目标机第一次装就会读到它, 于是安装前检查
- # 报"已安装 v2.5.0(装于 <打包机时间>)", 明明是新机器却提示"版本相同, 是否重装" —— 用户令 1
- # 要的版本检查当场失灵。它是装机产物, 不是交付件。
- EXCLUDE_NAMES = ('install-info.json', '*~', '~$*', '*.bak-*', '.DS_Store', 'Thumbs.db', 'desktop.ini',
- '*.crdownload', '*.partial')
- def is_machine_artifact(p: pathlib.Path) -> bool:
- """装机产物(本机装没装 / 本机快捷方式)—— 只在本机有效, 不随包、不入库 (2026-09-17)。"""
- import fnmatch
- return (p.name == 'install-info.json' or p.suffix.lower() in EXCLUDE_SUFFIX
- or any(fnmatch.fnmatch(p.name, pat) for pat in EXCLUDE_NAMES))
- def data_skeleton() -> list[str]:
- r"""`data/` 下要随包发的**空目录骨架**(用户令 2026-09-19: 「包中增加输入数据目录结构,但不包含文件」)。
- 为什么要有它: 交付包默认不含输入数据(现场原始件 30 GB 级),可目标机解压后是一套**没有"放哪儿"的**
- 程序 —— 人得去翻文档才知道 `data/raw/<场站>/{scada_10min,故障报警,…}` 这个约定目录结构。
- 把**目录结构**随包发出去(**不带任何文件**),开箱就能看见每个约定位置在哪、该往哪个抽屉里放。
- 结构来源是**单一真源** `src/windscada/config.py::STATION_SUBDIRS`(摄入侧的约定子目录名 —— 改名等于
- 换接口),场站目录名取 `raw_station_dir()` 解析出来的那一个(现场是「如东」,不是 farm key「rudong」)。
- 另外补两个入口目录: `data/` 与 `data/raw/` 本身,以及 `data/_incoming/`(`place_raw_data.py` 的默认
- 投放口)。**不含任何文件**,也不含 `scada_mdb/<年>年/<月>月/` 那种数据生成出来的层级
- (月目录是收资之后才有的,不该由包来规定)。
- ★2026-09-19 用户逮「`data\raw\西门子4.0技术资料` 目录没有打包进去」后补: 机理层厂商资料按 A2 约定
- **不在场站目录下、而在 `data/raw/` 根下**(`scripts/raw_data_check.py::TOP_OK_DIRS`),并且它是本体链
- 的输入(`src/ontology/kb_ingest.py`: `TECH = RAW_ROOT/'西门子4.0技术资料'`)⇒ 现在连它**与其一级子目录**
- 一起发空骨架(本机共 30 个空目录)。另外把仓库里那份放置说明 `data/raw/README_把原始数据放这里.txt`
- 作为**说明文件**随包(它 git 已跟踪,不是现场数据)。
- """
- out = ['data', 'data/raw', 'data/_incoming']
- try:
- from src.windscada.config import STATION_SUBDIRS, raw_station_dir
- # ★只发**解析出来的那一个场站目录名**(现场是「如东」) —— 不把 farm key(`rudong`) 也发一份:
- # 两个空场站目录会让 `station_scan` 报"2 个场站目录", 目标机上看起来像"多了一个不知哪来的场"。
- station = pathlib.Path(str(raw_station_dir()))
- out.append(f'data/raw/{station.name}')
- for sub in STATION_SUBDIRS:
- out.append(f'data/raw/{station.name}/{sub}')
- except Exception as e: # 配不出骨架时如实说, 不静默少发
- print(f' [!] 输入数据目录骨架没配出来 ({type(e).__name__}: {e}) —— 包里不会有 data/ 结构')
- # ★2026-09-19 补 (用户逮: 「data\raw\西门子4.0技术资料 目录没有打包进去」):
- # 机理层厂商资料按 A2 约定**不在场站目录下**, 而在 `data/raw/` 根下
- # (`src/windscada/config.py` 的说明 + `scripts/raw_data_check.py::TOP_OK_DIRS`),
- # 它是本体链的输入 (`src/ontology/kb_ingest.py`: TECH = RAW_ROOT/'西门子4.0技术资料')。
- # 骨架只发**空目录**(文件仍不随包, 由现场收资包经 place_raw_data.py 落位);
- # 本机若有该目录, 连它的一级子目录一起镜像出来(让人看见该往哪一层放哪类资料)。
- tech = ROOT / 'data' / 'raw' / '西门子4.0技术资料'
- out.append('data/raw/西门子4.0技术资料')
- if tech.is_dir():
- for sub in sorted(p for p in tech.iterdir() if p.is_dir()):
- out.append(f'data/raw/西门子4.0技术资料/{sub.name}')
- return out
- 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)
- # ★ 装机产物不许被"引用闭合"兜底补进包 (2026-09-17 实逮): `install.ps1`/`install.sh` 的版本检查那步
- # 会读 `install-info.json`, 于是自动补入把它当"入口引用的文件"补了进来 —— 而它是**本机装没装**的
- # 凭据(含安装时间与本机路径), 目标机第一次装就会误报"已安装、版本相同"。引用闭合管的是"入口点名的
- # 文件必须在位", 不管"这个文件该不该随包", 所以这里按排除规则再滤一遍并如实说一句。
- keepable, dropped = [], []
- for p in extra:
- (dropped if is_machine_artifact(p) else keepable).append(p)
- if keepable:
- for p in keepable:
- print(f' [+] 自动补入 (被入口脚本引用): {p.name}')
- inc.extend(keepable)
- for p in dropped:
- print(f' [i] 不进包 (装机产物, 目标机安装时自建): {p.name}')
- skipped = [('data/ 里的文件', '① 输入数据 (现场原始件): 用户令不进包; 要一起交付用 --with-data。'
- '★目录结构随包 (空目录, 见"输入数据目录骨架"); 用户令 2026-09-19')]
- if (ROOT / PRODUCTS_DIR).is_dir():
- skipped += [('outputs/', '② 产物/输出数据: 用户令不进包 (目标机放数据后 place_raw_data.py + '
- 'rebuild_all.py 重算; 无生成端的那批运行期不从交付包补(用户令 2026-09-17 #1), 由研发补生成端 docs §13.6)'
- + ('' 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, with_data: bool = False, with_products: bool = False) -> bool:
- """这个文件真会被写进包吗 —— 打包循环与"预演件数"共用同一条判据。
- ★ 2026-09-16 修正: 原实现里 `size_of()` 数的是**目录下所有文件**(连 `__pycache__/*.pyc` 一起数),
- 而实际写入时又跳过它们 ⇒ 预演报的"件数/体积"比包里真的多 (src 少报 100+ 件)。
- 现在两处都走 `keep()`: 预演数字 = 包里条目数, 对不上就是 bug。
- ★ 2026-09-17: 加"运行日志(*.log/*.jsonl 任何位置)"与"临时/备份文件"两类排除 (用户令)。
- ★ 2026-09-17 修: `--with-products` / `--with-data` 原先**是个摆设** —— `keep()` 无条件套用
- `EXCLUDE_GLOBS`(里面有 `data`/`outputs`), 于是 `plan()` 把 outputs/ 加进要打清单、打印
- "本次给了 --with-products, 例外打进包", 而写盘循环又把它们逐件滤掉: 实测 `--with-products`
- 打出来的包与默认包**同样 3441 件 / 879 MB**, 里面一件产物都没有。后果不是"少带东西",
- 而是"以为有存档、其实没有" —— 要补随包件时才发现在包里找不到。现在两个开关**真的**生效:
- 例外目录一旦显式要带, 就不再被 EXCLUDE_GLOBS 拦下。
- """
- 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
- allow = set()
- if with_data:
- allow.add('data')
- if with_products:
- allow.add(PRODUCTS_DIR)
- globs = tuple(g.rstrip('*') for g in EXCLUDE_GLOBS if g.rstrip('*') not in allow)
- return not any(x.startswith(globs) for x in parts[:1])
- def size_of(p: pathlib.Path, with_data: bool = False, with_products: bool = False):
- 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, with_data, with_products):
- n += 1; s = 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)
- skel = [] if with_data else data_skeleton() # --with-data 时 data/ 整个进包, 不需要空骨架
- tot_n = tot_s = 0
- print('== 要打进包的内容 ==')
- print(' (默认口径: 不含 输入数据 / 产物 / 运行日志 / 临时文件 —— 用户令 2026-09-17)')
- for p in inc:
- n, s = size_of(p, with_data, with_products)
- 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')
- if skel:
- print(f' {"输入数据目录骨架":26s} {len(skel):6d} 个空目录 (用户令 2026-09-19: 带结构不带文件)')
- 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, data_skeleton=skel or None,
- data_skeleton_note=('用户令 2026-09-19: 包里带**输入数据目录结构**(空目录), 不带任何文件; '
- '目标机解压后照这个结构往 data/raw/<场站>/ 各抽屉里放原始件'),
- 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) · 临时文件(缓存/备份/系统索引)**; '
- '**含**输入数据目录结构(空目录, 用户令 2026-09-19)。'
- '目标机解压后跑 install.bat(Windows) 或 sh install.sh(Linux/macOS), 再 check → start; '
- '要让页面有数: 把现场包放好后跑 `scripts/place_raw_data.py --src <现场包目录> --scope full` '
- '+ `scripts/rebuild_all.py` (无生成端的那批用 '
- '没有生成端的族由研发补生成端(运行期不从交付包补齐, 用户令 2026-09-17 #1))。'
- '.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, with_data, with_products):
- continue
- z.write(f, f.relative_to(ROOT).as_posix())
- done += 1
- if progress and done % 500 < 1:
- print(f' … 已写入 {done} 件', flush=True)
- # 输入数据目录骨架: 写成**目录条目**(名以 `/` 结尾 + MS-DOS 目录位), 解压即建出空目录。
- # ★必须显式写条目: 空目录在 zip 里不会因为"里面没文件"而自动存在; 只写父目录也不够
- # (解压工具只按条目建目录)。
- for rel in skel:
- zi = zipfile.ZipInfo(rel.rstrip('/') + '/')
- zi.external_attr = (0o40775 << 16) | 0x10
- zi.compress_type = zipfile.ZIP_STORED
- z.writestr(zi, b'')
- if skel:
- print(f' … 目录骨架 {len(skel)} 个空目录已写入', flush=True)
- print(f'\n完成: {out} {out.stat().st_size/1e6:.0f} MB (源 {tot_s/1e6:.0f} MB), 条目 {done} 件'
- + (f' + 目录 {len(skel)} 个' if skel else '') + f', 耗时 {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}')
- # 装机产物不得进包 (2026-09-17 实逮: 本机的 install-info.json 被原样打进包 ⇒ 目标机安装前检查
- # 会误报"已安装 vx.y.z, 版本相同" —— 版本检查当场失灵)。这类"看着无害、后果是静默误报"的东西
- # 必须由机器守: 包里出现一件就删包。
- machine = [n for n in names if n.split('/')[-1] in ('install-info.json',) or n.endswith('.lnk')]
- for n in machine[:5]:
- print(f' [X] 包里有"装机产物" {n} —— 它只在本机有效 (安装记录/快捷方式), 不该随包分发')
- if miss or enc or machine:
- 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)')
- # ★2026-09-19 用户令: 包里带"输入数据目录结构"(空目录)。解压后必须真的建出来了, 且**除说明文件外一个
- # 文件都没有** —— 两边都查: 目录少了 ⇒ 人不知道往哪放; 目录里混进数据文件 ⇒ 包就不是"只带结构"了。
- # ★2026-09-19 实逮 (第一次开箱验证就卡在这): ① 原先用 `rglob` 逐目录收集, 同一个文件会被父目录
- # 重复计入 (`data` 与 `data/raw` 各算一次) ⇒ 报"混进 2 个文件"; ② 按清单随包发的**放置说明**
- # (`data/raw/README_把原始数据放这里.txt`, 在 `INCLUDE_FILES` 里) 被当成"混进的文件"。
- # 现在: 允许清单 = INCLUDE_FILES 里落在骨架目录内的文件; 收集用集合(去重)。
- skel = data_skeleton()
- allowed = {f.replace('\\', '/') for f in INCLUDE_FILES if any(f.replace('\\', '/').startswith(r.rstrip('/') + '/')
- for r in skel)}
- miss_d = [r for r in skel if not (tmp / r).is_dir()]
- extra_f = set()
- for r in skel:
- d = tmp / r
- if d.is_dir():
- extra_f |= {p.relative_to(tmp).as_posix() for p in d.rglob('*') if p.is_file()}
- extra_f -= allowed
- if miss_d:
- print(f' [X] 输入数据目录骨架缺 {len(miss_d)} 个: {miss_d[:5]}')
- if extra_f:
- print(f' [X] 骨架目录里混进了 {len(extra_f)} 个文件 (除说明文件外应只有空目录): {sorted(extra_f)[:5]}')
- if miss_d or extra_f:
- return 1
- print(f' [OK] 输入数据目录骨架: {len(skel)} 个空目录全部在位'
- f'{f" + 放置说明 {len(allowed)} 件" if allowed else ""}, 无其他文件'
- f' (用户令 2026-09-19: 带结构不带数据文件)')
- 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('③b 卸载核验 (用户令 2026-09-17: 装得上, 也要卸得掉)')
- ubad = xt.uninstall_probe(tmp, py, gw=xt.LAST_GATEWAY_PORT)
- print('④ 收尾')
- if keep:
- print(f' 解压目录保留在 {tmp} (--keep)')
- else:
- shutil.rmtree(tmp, ignore_errors=True)
- print(' 已清理')
- print(f'\n开箱验证结论: 页面可用 {ok}/5 —— {"可以交付" if ok >= 4 else "有问题, 见上"}'
- + (f';卸载核验 {"通过" if ubad == 0 else f"{ubad} 项未过"}'))
- return 0 if (ok >= 4 and ubad == 0) 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 / V.package_name())
- 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, a.with_data, a.with_products); 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}')
- skel = [] if a.with_data else data_skeleton()
- if skel:
- print(f' {"输入数据目录骨架":26s} {len(skel):6d} 个空目录 (不带文件; 用户令 2026-09-19):')
- for r in skel:
- print(f' {r}/')
- 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())
|