|
@@ -1,343 +1,346 @@
|
|
|
-#!/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/`, 页面取数靠它; 加 `--no-products`
|
|
|
|
|
-则不含) · 文档(`docs/` `README_先读我.txt` `测试须知.txt`) · 安装/起停脚本(`install.bat` `install.sh`
|
|
|
|
|
-`install.ps1` `check.bat` `start.bat` `stop.bat`) · `requirements.txt`。
|
|
|
|
|
-
|
|
|
|
|
-**不含**修复记录/临时目录(`_修复记录_*` 之类): 本包已纳入 git 管理, 变更历史由版本库承载 (2026-09-12 用户令)。
|
|
|
|
|
-**不含 VBScript**: 2026-09-16 用户令"把 VBScript 替换掉" —— 无窗口启动由 `pythonw.exe` +
|
|
|
|
|
-`scripts/guanlan_start_hidden.py` 承担, 安装时另建 `启动观澜.lnk`; 包里已无 `.vbs`, 也不依赖 Windows 脚本宿主。
|
|
|
|
|
-
|
|
|
|
|
-**排除**(每条都写了理由):
|
|
|
|
|
- .git/ .venv/ .github/ 版本库与虚拟环境 —— venv 换机必失效, 目标机安装时重建
|
|
|
|
|
- _products_off*/ 历史遗留的"清除产物"暂存档(2026-09-16 起清除=真删, 不再产生此类目录)
|
|
|
|
|
- logs/ run/ 本机日志与 run/pids.json(旧 PID, 到新机器上是无效引用)
|
|
|
|
|
- data/raw/ 现场原始件(约定"原始件不随包分发"); 要一起交付用 --with-data
|
|
|
|
|
- __pycache__/ *.pyc 解释器缓存
|
|
|
|
|
- *.lnk 安装时建的快捷方式(内含绝对路径, 换机必失效; 目标机由 install.ps1 重建)
|
|
|
|
|
- *.zip(顶层) 旧的交付压缩包; 避免包中包
|
|
|
|
|
-
|
|
|
|
|
-## 用法
|
|
|
|
|
-
|
|
|
|
|
- python scripts/pack_dist.py --dry-run # 只报会打什么/多大, 不写文件
|
|
|
|
|
- python scripts/pack_dist.py # 打包 → <父目录>/guanlan-v<VERSION>_dist_<日期>.zip
|
|
|
|
|
- python scripts/pack_dist.py --no-products # **不含产物**(用户令 2026-09-16): 开箱页面显示"无产物"
|
|
|
|
|
- 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 <zip> # **开箱验证**: 解压 → 离线安装 → 起服务核验页面 → 清理
|
|
|
|
|
-
|
|
|
|
|
-## 排除结果写进包
|
|
|
|
|
-
|
|
|
|
|
-包里 `dist-manifest.json` 记下 version / no_data / no_products / no_logs 与逐条排除理由;
|
|
|
|
|
-`guanlan.py check` 读它 —— 打了"不含产物"的包, 产物那几行检查按 `[--]` 说明而不判 FAIL。
|
|
|
|
|
-"""
|
|
|
|
|
-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' # 产物仓: 默认打进包 (页面开箱有数); --no-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_先读我.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']
|
|
|
|
|
-# 进包**只**看目录/名的顶部前缀, 这些则按后缀排除 —— 都是"本机/装机时才产生"的东西:
|
|
|
|
|
-# .pyc 解释器缓存; .lnk 安装时建的快捷方式(里面是绝对路径, 换机必失效, 由 install.ps1 重建);
|
|
|
|
|
-# .zip 顶层旧交付包(避免包中包; 原先只写在文档里, 2026-09-16 补上实现)
|
|
|
|
|
-EXCLUDE_SUFFIX = {'.pyc', '.lnk'}
|
|
|
|
|
-
|
|
|
|
|
-
|
|
|
|
|
-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, no_products=False):
|
|
|
|
|
- """→ (要打包的顶层条目 list, 排除说明 list)
|
|
|
|
|
-
|
|
|
|
|
- 2026-09-16 用户令: 打包**不含 输入数据 / 产物 / 日志**。
|
|
|
|
|
- · 输入数据 `data/` —— 一直默认排除 (要用 --with-data 才带);
|
|
|
|
|
- · 产物 `outputs/` —— 新增 --no-products 排除 (默认仍带: 页面开箱即有数);
|
|
|
|
|
- · 日志 `logs/` —— 一直在 EXCLUDE_DIRS 里 (连同 run/ 旧 PID)。
|
|
|
|
|
- 排除产物时, 包内 `dist-manifest.json` 会记 `no_products: true`,
|
|
|
|
|
- 目标机 `guanlan.py check` 据此把"产物缺失"显示为 `--`(待重算) 而不是 FAIL。
|
|
|
|
|
- """
|
|
|
|
|
- inc = []
|
|
|
|
|
- for d in INCLUDE_DIRS:
|
|
|
|
|
- p = ROOT / d
|
|
|
|
|
- if p.is_dir():
|
|
|
|
|
- inc.append(p)
|
|
|
|
|
- if not no_products:
|
|
|
|
|
- p = ROOT / PRODUCTS_DIR
|
|
|
|
|
- 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)
|
|
|
|
|
- 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 = [('.git/', '版本库'), ('.venv/', '虚拟环境(换机必失效, 目标机安装时重建)')]
|
|
|
|
|
- _glob_why = {
|
|
|
|
|
- '_products_off*': '历史遗留的"清除产物"暂存档 (2026-09-16 起清除=真删, 不再产生; 老机器上若有可手工删)',
|
|
|
|
|
- 'data': '现场原始输入件 (约定"原始件不随包分发"; 要一起交付用 --with-data) —— 用户令: 输入数据不进包',
|
|
|
|
|
- }
|
|
|
|
|
- skipped += [(g + '/', _glob_why.get(g, '本机痕迹/超大体量, 见脚本头部说明')) for g in EXCLUDE_GLOBS]
|
|
|
|
|
- skipped += [('logs/, run/', '本机日志与旧 PID —— 用户令: 日志不进包')]
|
|
|
|
|
- skipped += [('__pycache__/ *.pyc', '解释器缓存')]
|
|
|
|
|
- skipped += [('*.lnk', '安装时建的快捷方式 (内含绝对路径, 换机必失效; 目标机由 install.ps1 重建)')]
|
|
|
|
|
- skipped += [('*.zip (顶层)', '旧的交付压缩包; 避免包中包')]
|
|
|
|
|
- if no_products and (ROOT / PRODUCTS_DIR).is_dir():
|
|
|
|
|
- skipped += [('outputs/', '用户令: 产物不进包 (目标机放数据后 rebuild_all.py 重算; '
|
|
|
|
|
- '或在目标机也用 --no-products 的同一口径)')]
|
|
|
|
|
- return inc, skipped
|
|
|
|
|
-
|
|
|
|
|
-
|
|
|
|
|
-def keep(f: pathlib.Path) -> bool:
|
|
|
|
|
- """这个文件真会被写进包吗 —— 打包循环与"预演件数"共用同一条判据。
|
|
|
|
|
-
|
|
|
|
|
- ★ 2026-09-16 修正: 原实现里 `size_of()` 数的是**目录下所有文件**(连 `__pycache__/*.pyc` 一起数),
|
|
|
|
|
- 而实际写入时又跳过它们 ⇒ 预演报的"件数/体积"比包里真的多 (src 少报 100+ 件)。
|
|
|
|
|
- 现在两处都走 `keep()`: 预演数字 = 包里条目数, 对不上就是 bug。
|
|
|
|
|
- """
|
|
|
|
|
- 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 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, no_products=False) -> int:
|
|
|
|
|
- inc, skipped = plan(with_data, no_products)
|
|
|
|
|
- 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(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=bool(no_products), no_logs=True,
|
|
|
|
|
- note='本包由 scripts/pack_dist.py 生成; **不含 输入数据(data/) · 产物(outputs/) · 日志(logs/, run/)** '
|
|
|
|
|
- '(2026-09-16 用户令)。目标机解压后跑 install.bat(Windows) 或 sh install.sh(Linux/macOS), '
|
|
|
|
|
- '再 check → start。要让页面有数: 把现场包放好后跑 '
|
|
|
|
|
- '`scripts/place_raw_data.py --src <现场包目录> --scope full` + `scripts/rebuild_all.py`。'
|
|
|
|
|
- '.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() 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':
|
|
|
|
|
- lnk = tmp / '启动观澜.lnk'
|
|
|
|
|
- if lnk.is_file() and lnk.stat().st_size > 0:
|
|
|
|
|
- print(f' [OK] 安装时已生成快捷方式: {lnk.name} ({lnk.stat().st_size} B)')
|
|
|
|
|
- else:
|
|
|
|
|
- bad.append('安装后没有 启动观澜.lnk (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('--no-products', action='store_true',
|
|
|
|
|
- help='**不含产物** (outputs/): 用户令 2026-09-16 "打包不含 输入数据/产物/日志"')
|
|
|
|
|
- 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.no_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, no_products=a.no_products)
|
|
|
|
|
-
|
|
|
|
|
-
|
|
|
|
|
-if __name__ == '__main__':
|
|
|
|
|
- for _s in (sys.stdout, sys.stderr):
|
|
|
|
|
- try: _s.reconfigure(errors='replace')
|
|
|
|
|
- except Exception: pass
|
|
|
|
|
- sys.exit(main())
|
|
|
|
|
|
|
+#!/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/`, 页面取数靠它; 加 `--no-products`
|
|
|
|
|
+则不含) · 文档(`docs/` `README_先读我.txt` `测试须知.txt`) · 安装/起停脚本(`install.bat` `install.sh`
|
|
|
|
|
+`install.ps1` `check.bat` `start.bat` `stop.bat`) · `requirements.txt`。
|
|
|
|
|
+
|
|
|
|
|
+**不含**修复记录/临时目录(`_修复记录_*` 之类): 本包已纳入 git 管理, 变更历史由版本库承载 (2026-09-12 用户令)。
|
|
|
|
|
+**不含 VBScript**: 2026-09-16 用户令"把 VBScript 替换掉" —— 无窗口启动由 `pythonw.exe` +
|
|
|
|
|
+`scripts/guanlan_start_hidden.py` 承担, 安装时另建 `启动观澜.lnk`; 包里已无 `.vbs`, 也不依赖 Windows 脚本宿主。
|
|
|
|
|
+
|
|
|
|
|
+**排除**(每条都写了理由):
|
|
|
|
|
+ .git/ .venv/ .github/ 版本库与虚拟环境 —— venv 换机必失效, 目标机安装时重建
|
|
|
|
|
+ _products_off*/ 历史遗留的"清除产物"暂存档(2026-09-16 起清除=真删, 不再产生此类目录)
|
|
|
|
|
+ logs/ run/ 本机日志与 run/pids.json(旧 PID, 到新机器上是无效引用)
|
|
|
|
|
+ data/raw/ 现场原始件(约定"原始件不随包分发"); 要一起交付用 --with-data
|
|
|
|
|
+ __pycache__/ *.pyc 解释器缓存
|
|
|
|
|
+ *.lnk 安装时建的快捷方式(内含绝对路径, 换机必失效; 目标机由 install.ps1 重建)
|
|
|
|
|
+ *.zip(顶层) 旧的交付压缩包; 避免包中包
|
|
|
|
|
+
|
|
|
|
|
+## 用法
|
|
|
|
|
+
|
|
|
|
|
+ python scripts/pack_dist.py --dry-run # 只报会打什么/多大, 不写文件
|
|
|
|
|
+ python scripts/pack_dist.py # 打包 → <父目录>/guanlan-v<VERSION>_dist_<日期>.zip
|
|
|
|
|
+ python scripts/pack_dist.py --no-products # **不含产物**(用户令 2026-09-16): 开箱页面显示"无产物"
|
|
|
|
|
+ python scripts/pack_dist.py --with-data # 连 data/raw 一起打 (30 GB, 慎用)
|
|
|
|
|
+ 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 与逐条排除理由;
|
|
|
|
|
+`guanlan.py check` 读它 —— 打了"不含产物"的包, 产物那几行检查按 `[--]` 说明而不判 FAIL。
|
|
|
|
|
+"""
|
|
|
|
|
+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' # 产物仓: 默认打进包 (页面开箱有数); --no-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_先读我.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']
|
|
|
|
|
+# 进包**只**看目录/名的顶部前缀, 这些则按后缀排除 —— 都是"本机/装机时才产生"的东西:
|
|
|
|
|
+# .pyc 解释器缓存; .lnk 安装时建的快捷方式(里面是绝对路径, 换机必失效, 由 install.ps1 重建);
|
|
|
|
|
+# .zip 顶层旧交付包(避免包中包; 原先只写在文档里, 2026-09-16 补上实现)
|
|
|
|
|
+EXCLUDE_SUFFIX = {'.pyc', '.lnk'}
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+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, no_products=False):
|
|
|
|
|
+ """→ (要打包的顶层条目 list, 排除说明 list)
|
|
|
|
|
+
|
|
|
|
|
+ 2026-09-16 用户令: 打包**不含 输入数据 / 产物 / 日志**。
|
|
|
|
|
+ · 输入数据 `data/` —— 一直默认排除 (要用 --with-data 才带);
|
|
|
|
|
+ · 产物 `outputs/` —— 新增 --no-products 排除 (默认仍带: 页面开箱即有数);
|
|
|
|
|
+ · 日志 `logs/` —— 一直在 EXCLUDE_DIRS 里 (连同 run/ 旧 PID)。
|
|
|
|
|
+ 排除产物时, 包内 `dist-manifest.json` 会记 `no_products: true`,
|
|
|
|
|
+ 目标机 `guanlan.py check` 据此把"产物缺失"显示为 `--`(待重算) 而不是 FAIL。
|
|
|
|
|
+ """
|
|
|
|
|
+ inc = []
|
|
|
|
|
+ for d in INCLUDE_DIRS:
|
|
|
|
|
+ p = ROOT / d
|
|
|
|
|
+ if p.is_dir():
|
|
|
|
|
+ inc.append(p)
|
|
|
|
|
+ if not no_products:
|
|
|
|
|
+ p = ROOT / PRODUCTS_DIR
|
|
|
|
|
+ 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)
|
|
|
|
|
+ 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 = [('.git/', '版本库'), ('.venv/', '虚拟环境(换机必失效, 目标机安装时重建)')]
|
|
|
|
|
+ _glob_why = {
|
|
|
|
|
+ '_products_off*': '历史遗留的"清除产物"暂存档 (2026-09-16 起清除=真删, 不再产生; 老机器上若有可手工删)',
|
|
|
|
|
+ 'data': '现场原始输入件 (约定"原始件不随包分发"; 要一起交付用 --with-data) —— 用户令: 输入数据不进包',
|
|
|
|
|
+ }
|
|
|
|
|
+ skipped += [(g + '/', _glob_why.get(g, '本机痕迹/超大体量, 见脚本头部说明')) for g in EXCLUDE_GLOBS]
|
|
|
|
|
+ skipped += [('logs/, run/', '本机日志与旧 PID —— 用户令: 日志不进包')]
|
|
|
|
|
+ skipped += [('__pycache__/ *.pyc', '解释器缓存')]
|
|
|
|
|
+ skipped += [('*.lnk', '安装时建的快捷方式 (内含绝对路径, 换机必失效; 目标机由 install.ps1 重建)')]
|
|
|
|
|
+ skipped += [('*.zip (顶层)', '旧的交付压缩包; 避免包中包')]
|
|
|
|
|
+ if no_products and (ROOT / PRODUCTS_DIR).is_dir():
|
|
|
|
|
+ skipped += [('outputs/', '用户令: 产物不进包 (目标机放数据后 rebuild_all.py 重算; '
|
|
|
|
|
+ '或在目标机也用 --no-products 的同一口径)')]
|
|
|
|
|
+ return inc, skipped
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def keep(f: pathlib.Path) -> bool:
|
|
|
|
|
+ """这个文件真会被写进包吗 —— 打包循环与"预演件数"共用同一条判据。
|
|
|
|
|
+
|
|
|
|
|
+ ★ 2026-09-16 修正: 原实现里 `size_of()` 数的是**目录下所有文件**(连 `__pycache__/*.pyc` 一起数),
|
|
|
|
|
+ 而实际写入时又跳过它们 ⇒ 预演报的"件数/体积"比包里真的多 (src 少报 100+ 件)。
|
|
|
|
|
+ 现在两处都走 `keep()`: 预演数字 = 包里条目数, 对不上就是 bug。
|
|
|
|
|
+ """
|
|
|
|
|
+ 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 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, no_products=False) -> int:
|
|
|
|
|
+ inc, skipped = plan(with_data, no_products)
|
|
|
|
|
+ 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(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=bool(no_products), no_logs=True,
|
|
|
|
|
+ note='本包由 scripts/pack_dist.py 生成; **不含 输入数据(data/) · 产物(outputs/) · 日志(logs/, run/)** '
|
|
|
|
|
+ '(2026-09-16 用户令)。目标机解压后跑 install.bat(Windows) 或 sh install.sh(Linux/macOS), '
|
|
|
|
|
+ '再 check → start。要让页面有数: 把现场包放好后跑 '
|
|
|
|
|
+ '`scripts/place_raw_data.py --src <现场包目录> --scope full` + `scripts/rebuild_all.py`。'
|
|
|
|
|
+ '.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() 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('--no-products', action='store_true',
|
|
|
|
|
+ help='**不含产物** (outputs/): 用户令 2026-09-16 "打包不含 输入数据/产物/日志"')
|
|
|
|
|
+ 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.no_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, no_products=a.no_products)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+if __name__ == '__main__':
|
|
|
|
|
+ for _s in (sys.stdout, sys.stderr):
|
|
|
|
|
+ try: _s.reconfigure(errors='replace')
|
|
|
|
|
+ except Exception: pass
|
|
|
|
|
+ sys.exit(main())
|