|
@@ -18,14 +18,21 @@ r"""打一个"拷到别的电脑能装、能跑"的分发包 (2026-09-12)。
|
|
|
**包含**: 程序(`src/` `scripts/` `guanlan.py`) · 配置(`configs/`) · 离线安装件(`wheels/win_amd64/` `vendor/python/`)
|
|
**包含**: 程序(`src/` `scripts/` `guanlan.py`) · 配置(`configs/`) · 离线安装件(`wheels/win_amd64/` `vendor/python/`)
|
|
|
· 页面与交付(`release/` `resources/` `reference/`) · 文档(`docs/` `README_先读我.MD` `测试须知.txt`)
|
|
· 页面与交付(`release/` `resources/` `reference/`) · 文档(`docs/` `README_先读我.MD` `测试须知.txt`)
|
|
|
· 安装/起停/打包脚本(`install.bat` `install.sh` `install.ps1` `check.bat` `start.bat` `stop.bat` `pack.bat`
|
|
· 安装/起停/打包脚本(`install.bat` `install.sh` `install.ps1` `check.bat` `start.bat` `stop.bat` `pack.bat`
|
|
|
-`pack.sh`) · `requirements.txt`。
|
|
|
|
|
|
|
+`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 用户令)。
|
|
**不含**修复记录/临时目录(`_修复记录_*` 之类): 本包已纳入 git 管理, 变更历史由版本库承载 (2026-09-12 用户令)。
|
|
|
**不含 VBScript**: 2026-09-16 用户令"把 VBScript 替换掉" —— 无窗口启动由 `pythonw.exe` +
|
|
**不含 VBScript**: 2026-09-16 用户令"把 VBScript 替换掉" —— 无窗口启动由 `pythonw.exe` +
|
|
|
`scripts/guanlan_start_hidden.py` 承担, 安装时另建 `启动观澜.lnk`; 包里已无 `.vbs`, 也不依赖 Windows 脚本宿主。
|
|
`scripts/guanlan_start_hidden.py` 承担, 安装时另建 `启动观澜.lnk`; 包里已无 `.vbs`, 也不依赖 Windows 脚本宿主。
|
|
|
|
|
|
|
|
**排除**(每条都写了理由; 前四类就是用户令点名的四类):
|
|
**排除**(每条都写了理由; 前四类就是用户令点名的四类):
|
|
|
- data/ ① **输入数据**: 现场原始件(约定"原始件不随包分发"); 要一起交付用 --with-data
|
|
|
|
|
|
|
+ data/ 里的**文件** ① **输入数据**: 现场原始件(约定"原始件不随包分发"); 要一起交付用 --with-data。
|
|
|
|
|
+ ★目录结构随包(空目录, 见上); 用户令 2026-09-19
|
|
|
outputs/ ② **产物(输出数据)**: 页面取数靠它, 但按令不进包 —— 目标机放数据后
|
|
outputs/ ② **产物(输出数据)**: 页面取数靠它, 但按令不进包 —— 目标机放数据后
|
|
|
`scripts/place_raw_data.py` + `scripts/rebuild_all.py` 重算, 或按需从
|
|
`scripts/place_raw_data.py` + `scripts/rebuild_all.py` 重算, 或按需从
|
|
|
无生成端的那批**运行期不从交付包补**(用户令 2026-09-17 #1): 由研发补生成端(docs §13.6)
|
|
无生成端的那批**运行期不从交付包补**(用户令 2026-09-17 #1): 由研发补生成端(docs §13.6)
|
|
@@ -129,6 +136,33 @@ def is_machine_artifact(p: pathlib.Path) -> bool:
|
|
|
or any(fnmatch.fnmatch(p.name, pat) for pat in EXCLUDE_NAMES))
|
|
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/<年>年/<月>月/` 那种数据生成出来的层级
|
|
|
|
|
+ (月目录是收资之后才有的,不该由包来规定)。
|
|
|
|
|
+ """
|
|
|
|
|
+ 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/ 结构')
|
|
|
|
|
+ return out
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
def _xfer():
|
|
def _xfer():
|
|
|
"""复用移植检查器里的安装/起服务核验逻辑 (单一实现, 不抄第二份)。"""
|
|
"""复用移植检查器里的安装/起服务核验逻辑 (单一实现, 不抄第二份)。"""
|
|
|
spec = importlib.util.spec_from_file_location('check_transferable', ROOT / 'scripts' / 'check_transferable.py')
|
|
spec = importlib.util.spec_from_file_location('check_transferable', ROOT / 'scripts' / 'check_transferable.py')
|
|
@@ -184,7 +218,8 @@ def plan(with_data=False, with_products=False):
|
|
|
inc.extend(keepable)
|
|
inc.extend(keepable)
|
|
|
for p in dropped:
|
|
for p in dropped:
|
|
|
print(f' [i] 不进包 (装机产物, 目标机安装时自建): {p.name}')
|
|
print(f' [i] 不进包 (装机产物, 目标机安装时自建): {p.name}')
|
|
|
- skipped = [('data/', '① 输入数据 (现场原始件): 用户令不进包; 要一起交付用 --with-data')]
|
|
|
|
|
|
|
+ skipped = [('data/ 里的文件', '① 输入数据 (现场原始件): 用户令不进包; 要一起交付用 --with-data。'
|
|
|
|
|
+ '★目录结构随包 (空目录, 见"输入数据目录骨架"); 用户令 2026-09-19')]
|
|
|
if (ROOT / PRODUCTS_DIR).is_dir():
|
|
if (ROOT / PRODUCTS_DIR).is_dir():
|
|
|
skipped += [('outputs/', '② 产物/输出数据: 用户令不进包 (目标机放数据后 place_raw_data.py + '
|
|
skipped += [('outputs/', '② 产物/输出数据: 用户令不进包 (目标机放数据后 place_raw_data.py + '
|
|
|
'rebuild_all.py 重算; 无生成端的那批运行期不从交付包补(用户令 2026-09-17 #1), 由研发补生成端 docs §13.6)'
|
|
'rebuild_all.py 重算; 无生成端的那批运行期不从交付包补(用户令 2026-09-17 #1), 由研发补生成端 docs §13.6)'
|
|
@@ -250,6 +285,7 @@ def size_of(p: pathlib.Path, with_data: bool = False, with_products: bool = Fals
|
|
|
|
|
|
|
|
def build(out: pathlib.Path, with_data=False, progress=True, with_products=False) -> int:
|
|
def build(out: pathlib.Path, with_data=False, progress=True, with_products=False) -> int:
|
|
|
inc, skipped = plan(with_data, with_products)
|
|
inc, skipped = plan(with_data, with_products)
|
|
|
|
|
+ skel = [] if with_data else data_skeleton() # --with-data 时 data/ 整个进包, 不需要空骨架
|
|
|
tot_n = tot_s = 0
|
|
tot_n = tot_s = 0
|
|
|
print('== 要打进包的内容 ==')
|
|
print('== 要打进包的内容 ==')
|
|
|
print(' (默认口径: 不含 输入数据 / 产物 / 运行日志 / 临时文件 —— 用户令 2026-09-17)')
|
|
print(' (默认口径: 不含 输入数据 / 产物 / 运行日志 / 临时文件 —— 用户令 2026-09-17)')
|
|
@@ -258,6 +294,8 @@ def build(out: pathlib.Path, with_data=False, progress=True, with_products=False
|
|
|
tot_n += n; tot_s += s
|
|
tot_n += n; tot_s += s
|
|
|
print(f' {p.relative_to(ROOT).as_posix():26s} {n:6d} 件 {s/1e6:9.1f} MB')
|
|
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(f' {"合计":26s} {tot_n:6d} 件 {tot_s/1e6:9.1f} MB')
|
|
|
|
|
+ if skel:
|
|
|
|
|
+ print(f' {"输入数据目录骨架":26s} {len(skel):6d} 个空目录 (用户令 2026-09-19: 带结构不带文件)')
|
|
|
print('== 排除 ==')
|
|
print('== 排除 ==')
|
|
|
for name, why in skipped:
|
|
for name, why in skipped:
|
|
|
print(f' {name:26s} {why}')
|
|
print(f' {name:26s} {why}')
|
|
@@ -266,10 +304,13 @@ def build(out: pathlib.Path, with_data=False, progress=True, with_products=False
|
|
|
man = dict(version=VERSION, built=time.strftime('%Y-%m-%d %H:%M:%S'), root=str(ROOT),
|
|
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],
|
|
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 自身
|
|
excluded={n: w for n, w in skipped}, files=tot_n + 1, # +1 = dist-manifest.json 自身
|
|
|
- bytes=tot_s,
|
|
|
|
|
|
|
+ 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,
|
|
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) · 临时文件(缓存/备份/系统索引)**。'
|
|
|
|
|
|
|
+ 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; '
|
|
'目标机解压后跑 install.bat(Windows) 或 sh install.sh(Linux/macOS), 再 check → start; '
|
|
|
'要让页面有数: 把现场包放好后跑 `scripts/place_raw_data.py --src <现场包目录> --scope full` '
|
|
'要让页面有数: 把现场包放好后跑 `scripts/place_raw_data.py --src <现场包目录> --scope full` '
|
|
|
'+ `scripts/rebuild_all.py` (无生成端的那批用 '
|
|
'+ `scripts/rebuild_all.py` (无生成端的那批用 '
|
|
@@ -291,7 +332,18 @@ def build(out: pathlib.Path, with_data=False, progress=True, with_products=False
|
|
|
done += 1
|
|
done += 1
|
|
|
if progress and done % 500 < 1:
|
|
if progress and done % 500 < 1:
|
|
|
print(f' … 已写入 {done} 件', flush=True)
|
|
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')
|
|
|
|
|
|
|
+ # 输入数据目录骨架: 写成**目录条目**(名以 `/` 结尾 + 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 报错)
|
|
# 入口引用闭合核对 (对着**包内条目**查; 2026-09-16: v0.4.0 第一版漏带 start_hidden.vbs, 目标机 start.bat 报错)
|
|
|
# + 入口编码守则 (install.ps1 的 UTF-8 BOM 曾被编辑工具吃掉 → 目标机 install 1 秒即 ParserError)
|
|
# + 入口编码守则 (install.ps1 的 UTF-8 BOM 曾被编辑工具吃掉 → 目标机 install 1 秒即 ParserError)
|
|
@@ -344,6 +396,23 @@ def verify(zip_path: pathlib.Path, keep=False) -> int:
|
|
|
if miss or enc:
|
|
if miss or enc:
|
|
|
return 1
|
|
return 1
|
|
|
print(' [OK] 引用全部在位, 编码守则通过 (install.ps1 = UTF-8 BOM + CRLF)')
|
|
print(' [OK] 引用全部在位, 编码守则通过 (install.ps1 = UTF-8 BOM + CRLF)')
|
|
|
|
|
+ # ★2026-09-19 用户令: 包里带"输入数据目录结构"(空目录)。解压后必须真的建出来了, 且**一个文件都没有**
|
|
|
|
|
+ # —— 两边都查: 目录少了 ⇒ 人不知道往哪放; 目录里混进文件 ⇒ 包就不是"只带结构"了。
|
|
|
|
|
+ skel = data_skeleton()
|
|
|
|
|
+ miss_d = [r for r in skel if not (tmp / r).is_dir()]
|
|
|
|
|
+ extra_f = []
|
|
|
|
|
+ for r in skel:
|
|
|
|
|
+ d = tmp / r
|
|
|
|
|
+ if d.is_dir():
|
|
|
|
|
+ extra_f += [str(p.relative_to(tmp)) for p in d.rglob('*') if p.is_file()]
|
|
|
|
|
+ if miss_d:
|
|
|
|
|
+ print(f' [X] 输入数据目录骨架缺 {len(miss_d)} 个: {miss_d[:5]}')
|
|
|
|
|
+ if extra_f:
|
|
|
|
|
+ print(f' [X] 骨架目录里混进了 {len(extra_f)} 个文件 (应只有空目录): {extra_f[:5]}')
|
|
|
|
|
+ if miss_d or extra_f:
|
|
|
|
|
+ return 1
|
|
|
|
|
+ print(f' [OK] 输入数据目录骨架: {len(skel)} 个空目录全部在位, 且无任何文件'
|
|
|
|
|
+ f' (用户令 2026-09-19: 带结构不带文件)')
|
|
|
print('② 离线安装')
|
|
print('② 离线安装')
|
|
|
xt = _xfer()
|
|
xt = _xfer()
|
|
|
py = xt.install_in(tmp)
|
|
py = xt.install_in(tmp)
|
|
@@ -423,6 +492,11 @@ def main() -> int:
|
|
|
n, s = size_of(p, a.with_data, a.with_products); tot_n += n; tot_s += s
|
|
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' {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(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('== 会排除 ==')
|
|
print('== 会排除 ==')
|
|
|
for name, why in skipped:
|
|
for name, why in skipped:
|
|
|
print(f' {name:26s} {why}')
|
|
print(f' {name:26s} {why}')
|