Преглед изворни кода

打包器 scripts/pack_dist.py: 显式包含/排除表 + 清单 + 开箱验证(--verify)

用户令: 基于 F:\temp\guanlan-rudong-v2_0.2.0 打包, 确保可在其他电脑安装、运行。

新增 scripts/pack_dist.py:
  · 显式列出**包含**(程序/配置/离线安装件 wheels+vendor/页面与交付/产物 outputs/文档/安装脚本)与
    **排除**(.git .venv _products_off* logs run data/raw __pycache__ *.pyc), 每条都写理由 —— 可复核, 不靠记忆;
  · 包内写 dist-manifest.json(打了什么/排除了什么/件数体积/目标机用法);
  · --dry-run 先看计划; --with-data 可连 31 GB 原始件一起打; --verify <zip> 做**开箱验证**
    (解压 → 离线安装 → 起服务核验页面 → 清理), 复用 check_transferable 里的安装/核验实现(单一实现不抄第二份)。

同时改进 check_transferable.py:
  · 拆出 install_in() 与 run_and_probe() 供打包器复用;
  · **网关端口被占时不再整段跳过**: 副本自动改用空闲端口(实测 28084 被占 → 用 28700), 不打扰正在跑的实例
    (原先主实例在跑时,--simulate 只做了拷贝+安装, 启动核验整段被跳过 —— 实测踩到过)。

实测: 生成 F:\temp\guanlan-rudong-v2_0.2.0_dist_20260912.zip (967.9 MiB, 4319 条目, 157 s);
--verify 开箱验证: 解压 4320 件/23 s → **离线安装 exit 0 / 231 s**(42 个包全部来自 wheels\win_amd64) →
自检全 OK(依赖/产物/门户/三维资产/交付件) → 起服务于备用端口 28700 →
/ 200 (20,225,828 B **与主包同字节**) · /detail/ 200 (229,643 B **同字节**) · /cms/ 200 (414,141 B) ·
/ops 200 · /healthz 200 → **页面可用 5/5, 可以交付**。
zhouyang.xie пре 4 недеља
родитељ
комит
2dad1a2125
2 измењених фајлова са 343 додато и 0 уклоњено
  1. 138 0
      scripts/check_transferable.py
  2. 205 0
      scripts/pack_dist.py

+ 138 - 0
scripts/check_transferable.py

@@ -14,6 +14,9 @@ r"""移植性大扫描 (2026-09-12) —— 换电脑/换盘/换系统之前跑
 用法:
     python scripts/check_transferable.py            # 全量检查 + 结论
     python scripts/check_transferable.py --brief    # 只报问题
+    python scripts/check_transferable.py --simulate # **模拟移植**(推荐换机前跑): 把包拷成一份副本 →
+                                                    # 在副本里跑安装 → 起服务核验页面 → 报结果 → 清理副本。
+                                                    # 默认拷全量(除 data/.git/.venv/暂存区); --keep 保留副本。
 """
 from __future__ import annotations
 
@@ -22,7 +25,13 @@ import json
 import os
 import pathlib
 import re
+import shutil
+import socket
+import subprocess
 import sys
+import tempfile
+import time
+import urllib.request
 
 ROOT = pathlib.Path(__file__).resolve().parents[1]
 sys.path.insert(0, str(ROOT))
@@ -175,11 +184,140 @@ def check_configs():
     print(f'     提到 28084 的文本文件: {n_port} 个 (端口真源是 configs/serve.json, 其余多为文档)')
 
 
+def _free_port(start=28700, tries=40):
+    for p in range(start, start + tries):
+        with socket.socket() as s:
+            try:
+                s.bind(('127.0.0.1', p)); return p
+            except OSError:
+                continue
+    return None
+
+
+def _busy(port, host='127.0.0.1'):
+    with socket.socket() as s:
+        s.settimeout(0.3)
+        return s.connect_ex((host, port)) == 0
+
+
+IGNORE = shutil.ignore_patterns('data', '.git', '.venv', '__pycache__', 'logs', 'run',
+                                '_products_off', '_products_off_prev_*', '*.pyc')
+
+
+def install_in(dst: pathlib.Path) -> pathlib.Path | None:
+    """在 dst 里跑一次离线安装 (Windows: install.ps1; POSIX: install.sh), 返回该副本的解释器路径。"""
+    print(f'   在 {dst} 里跑安装 (离线) …')
+    cmd = (['powershell', '-ExecutionPolicy', 'Bypass', '-File', str(dst / 'install.ps1')]
+           if os.name == 'nt' else ['sh', str(dst / 'install.sh')])
+    t0 = time.time()
+    r = subprocess.run(cmd, cwd=str(dst))
+    print(f'   install 退出码 {r.returncode}, 耗时 {time.time() - t0:.0f}s')
+    py = dst / ('.venv/Scripts/python.exe' if os.name == 'nt' else '.venv/bin/python')
+    if not py.is_file():
+        print(f'   [X] 副本里没有 {py} —— 安装没成')
+        return None
+    v = subprocess.run([str(py), '-c', 'import sys,pandas,pyarrow,polars,yaml;print(sys.version.split()[0])'],
+                       capture_output=True, text=True, errors='replace')
+    print(f'   副本解释器: {v.stdout.strip() or v.stderr.strip()[:120]}')
+    return py
+
+
+def run_and_probe(dst: pathlib.Path, py: pathlib.Path, base_port=28084) -> int:
+    """在副本里起服务并核验页面 —— **不动正在跑的实例**: 网关改用空闲端口, 组件端口若被占则跳过。
+
+    原先网关端口被占就整段跳过(实测踩到: 主实例在跑 → 模拟移植只做了拷贝+安装)。现在:
+      · 组件端口(18033 等)空闲 → 用它们; 网关端口(28084)被占 → 换成空闲端口(如 28700);
+      · 网关 ROUTES 里上游端口是写死的(文档 §三 的已知限制), 所以组件端口**必须**用默认值, 不能随网关一起改。
+    """
+    comp_ports = (18033, 18020, 18791, 18792, 64292)
+    busy_comp = [p for p in comp_ports if _busy(p)]
+    if busy_comp:
+        print(f'   [!] 组件端口被占用 {busy_comp} —— 跳过启动核验(先停掉正在跑的实例再试)')
+        return 0
+    gw = base_port if not _busy(base_port) else _free_port(28700)
+    if gw is None:
+        print('   [!] 找不到空闲网关端口 —— 跳过启动核验'); return 0
+    if gw != base_port:
+        cfg_p = dst / 'configs' / 'serve.json'
+        cfg = json.loads(cfg_p.read_text(encoding='utf-8-sig'))
+        cfg['gateway'] = gw
+        cfg_p.write_text(json.dumps(cfg, ensure_ascii=False, indent=2), encoding='utf-8')
+        print(f'   网关端口 {base_port} 已被占用 → 副本改用 {gw} (不打扰正在跑的实例)')
+    t0 = time.time()
+    rs = subprocess.run([str(py), 'guanlan.py', 'serve'], cwd=str(dst), capture_output=True, text=True, errors='replace')
+    print(f'   serve 退出码 {rs.returncode} (1 = 有模块未就绪, 属正常), 耗时 {time.time() - t0:.0f}s')
+    ok = 0
+    try:
+        urls = [('/', 20225828), ('/detail/', 229643), ('/cms/', None), ('/ops', None), ('/healthz', None)]
+        for path, exp_size in urls:
+            try:
+                with urllib.request.urlopen(f'http://127.0.0.1:{gw}{path}', timeout=30) as rr:
+                    body = rr.read()
+                    tag = '' if exp_size is None else (' 与主包同字节' if len(body) == exp_size else f' ★与主包不同(主包 {exp_size})')
+                    print(f'   {path:10s} HTTP {rr.status}  {len(body):,} B{tag}')
+                    ok += 1
+            except Exception as e:
+                print(f'   {path:10s} 失败: {type(e).__name__} {e}')
+        print(f'   页面可用 {ok}/{len(urls)}')
+    finally:
+        subprocess.run([str(py), 'guanlan.py', 'stop'], cwd=str(dst), capture_output=True)
+    return ok
+
+
+def simulate(into=None, keep=False) -> int:
+    """模拟移植: 拷副本 → 副本内安装 → 起服务核验 → 清理。"""
+    dst = pathlib.Path(into) if into else pathlib.Path(tempfile.gettempdir()) / 'guanlan_portsim'
+    if dst.exists():
+        print(f'  清理旧副本 {dst}'); shutil.rmtree(dst, ignore_errors=True)
+    print(f'① 拷副本 → {dst}   (排除 data/.git/.venv/暂存区/logs/run)')
+    t0 = time.time()
+    shutil.copytree(ROOT, dst, ignore=IGNORE, symlinks=False)
+    n = sum(1 for _ in dst.rglob('*') if _.is_file())
+    mb = sum(_.stat().st_size for _ in dst.rglob('*') if _.is_file()) / 1e6
+    print(f'   完成: {n} 件 / {mb:.0f} MB, 耗时 {time.time() - t0:.0f}s')
+
+    # 产物: 主包当前可能被"清除产物"挪走了 → 从暂存区补进副本, 好让页面有数可验
+    prod_dst = dst / 'outputs' / 'rudong'
+    if not (prod_dst / 'windscada' / 'alarms.parquet').is_file():
+        for src in sorted(ROOT.glob('_products_off*/rudong')):
+            if (src / 'windscada' / 'alarms.parquet').is_file():
+                print(f'② 主包产物不在位 → 从 {src.relative_to(ROOT)} 拷进副本')
+                shutil.copytree(src, prod_dst, dirs_exist_ok=True)
+                break
+        else:
+            print('② 找不到可用产物(主包与暂存区都没有) —— 页面会是"无产物", 属预期')
+    else:
+        print('② 副本已带产物 (从主包拷来)')
+
+    print('③ 在副本里跑安装 (离线)')
+    py = install_in(dst)
+    if py is None:
+        return 1
+
+    print('④ 起服务核验 (副本独占端口; 组件端口被占则跳过这一步)')
+    ok = run_and_probe(dst, py)
+
+    print('⑤ 收尾')
+    if keep:
+        print(f'   副本保留在 {dst} (--keep); 用完手工删掉即可')
+    else:
+        shutil.rmtree(dst, ignore_errors=True)
+        print('   副本已删除')
+    print('\n模拟移植结论: 见上面各步 —— 安装成 + 页面与主包同字节 = 换机可行(记得在目标机也跑一次 install)')
+    return 0
+
+
 def main() -> int:
     ap = argparse.ArgumentParser()
     ap.add_argument('--brief', action='store_true')
+    ap.add_argument('--simulate', action='store_true', help='拷副本→副本内安装→起服务核验→清理')
+    ap.add_argument('--into', default=None, help='--simulate 的副本目录 (默认 %%TEMP%%/guanlan_portsim)')
+    ap.add_argument('--keep', action='store_true', help='--simulate 后保留副本')
     a = ap.parse_args()
     print(f'移植性大扫描 @ {ROOT}\n')
+    if a.simulate:
+        rc = simulate(a.into, a.keep)
+        print()
     hits = scan_paths(verbose=not a.brief)
     big = scan_big_text()
     kv = check_venv()

+ 205 - 0
scripts/pack_dist.py

@@ -0,0 +1,205 @@
+#!/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 <zip>             # **开箱验证**: 解压 → 离线安装 → 起服务核验页面 → 清理
+"""
+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())