products_state.py 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190
  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. r"""产物开关 —— 把 outputs\<场>\ 下的产物整体挪走 / 挪回 (人工检查页面空状态用)。
  4. ## 为什么做成开关而不是删除
  5. 产物是"上一次算出来的结果", 人工检查"没数据时页面长什么样"必须把它们清掉; 但清掉之后一定要能**一条命令还原**,
  6. 否则就得重跑整条摄入链(而且像油样那 102 行华创合并报告根本重算不出来)。所以: **移动**(同盘瞬间完成) + 清单留痕。
  7. ## 保留不动的
  8. - `data\raw\` 现场数据 (检查的是"产物没了会怎样", 不是"数据没了会怎样")
  9. - `outputs\<场>\windscada\_pre_rebuild_20260911\` 随包基线备份 (它是备份, 不是产物; `rebuild_from_raw.py --verify` 要用)
  10. - `release\` 门户与三维/交付件 (那是外壳, 不是数据产物)
  11. - `logs\` `run\` 运行期文件
  12. 用法:
  13. python scripts/products_state.py --off # 挪走全部产物, 打印清单
  14. python scripts/products_state.py --status # 看当前是开还是关
  15. python scripts/products_state.py --on # 全部挪回
  16. """
  17. from __future__ import annotations
  18. import argparse
  19. import json
  20. import pathlib
  21. import shutil
  22. import sys
  23. import time
  24. ROOT = pathlib.Path(__file__).resolve().parents[1]
  25. sys.path.insert(0, str(ROOT))
  26. from src import paths as P # noqa: E402
  27. OFF = ROOT / '_products_off' # 挪走后的存放处 (仓库外无需, 放安装目录下便于还原)
  28. MANIFEST = OFF / 'manifest.json'
  29. KEEP = ('_pre_rebuild_20260911',) # 随包基线备份, 不算产物
  30. def farms() -> list[pathlib.Path]:
  31. out = ROOT / 'outputs'
  32. return sorted(p for p in out.iterdir() if p.is_dir()) if out.is_dir() else []
  33. def plan() -> list[tuple[pathlib.Path, pathlib.Path]]:
  34. """→ [(原位置, 挪走后的位置)]"""
  35. items = []
  36. for fam in farms():
  37. for d in sorted(fam.iterdir()):
  38. if d.name in KEEP:
  39. continue
  40. items.append((d, OFF / fam.name / d.name))
  41. return items
  42. def count(p: pathlib.Path) -> tuple[int, float]:
  43. if p.is_file():
  44. return 1, p.stat().st_size / 1024 / 1024
  45. fs = [f for f in p.rglob('*') if f.is_file()]
  46. return len(fs), sum(f.stat().st_size for f in fs) / 1024 / 1024
  47. def archive_old() -> bool:
  48. """把**上一代**暂存区改名存档, 给这一次挪走腾出干净的落点。
  49. 为什么必须做: 挪走的落点是 `_products_off/<场>/<目录>`; 若该目录已存在(shutil.move 的语义是
  50. "目的地是已存在目录 → 把源挪进去"), 会嵌套成 `_products_off/rudong/windscada/windscada/…`,
  51. 随后 `--on` 就会把新旧两代混在一起还原 —— 2026-09-12 实逮这个隐患。
  52. 存档时**保留** `_baseline_kept/`(随包基线, `rebuild_from_raw.py --verify` 要用), 只动场站目录与清单。
  53. """
  54. arch = ROOT / f'_products_off_prev_{time.strftime("%Y%m%d_%H%M%S")}'
  55. moved = []
  56. for fam in farms():
  57. src = OFF / fam.name
  58. if src.exists():
  59. arch.mkdir(parents=True, exist_ok=True)
  60. shutil.move(str(src), str(arch / fam.name))
  61. moved.append(fam.name)
  62. if MANIFEST.exists():
  63. arch.mkdir(parents=True, exist_ok=True)
  64. shutil.move(str(MANIFEST), str(arch / 'manifest.json'))
  65. if not moved and not arch.exists():
  66. return False
  67. print(f' 上一代暂存区已存档 → {P.rel(arch)}/ (场站: {", ".join(moved) or "无"}; 基线 _baseline_kept 留在原地)')
  68. return True
  69. def do_off(archive=False) -> int:
  70. items = plan()
  71. if not items:
  72. print('产物已经全部挪走了 (用 --status 看清单)')
  73. return 0
  74. OFF.mkdir(parents=True, exist_ok=True)
  75. conflicts = [dst for _, dst in items if dst.exists()]
  76. if conflicts:
  77. if not archive:
  78. print(f'[X] 暂存区里已经有同名产物 ({len(conflicts)} 个, 例: {P.rel(conflicts[0])}) —— 直接挪会嵌套成'
  79. f' <目录>/<目录>/, 之后 --on 会把两代混在一起。\n'
  80. f' 二选一: ① 加 --archive-old 把上一代存档后重跑 (推荐);\n'
  81. f' ② 手工把整个 {P.rel(OFF)} 改名后再跑 (例: Move-Item _products_off _products_off_旧)。')
  82. return 1
  83. archive_old()
  84. man = []
  85. # 随包基线备份藏在 windscada 里面: 先摘出来, 它不是产物 (rebuild_from_raw.py --verify 要用)
  86. baseline = P.store() / KEEP[0]
  87. baseline_kept = False
  88. kept_at = OFF / '_baseline_kept' / KEEP[0]
  89. if baseline.exists():
  90. if not kept_at.exists():
  91. kept_at.parent.mkdir(parents=True, exist_ok=True)
  92. n, mb = count(baseline)
  93. shutil.move(str(baseline), str(kept_at))
  94. print(f' 保留 {P.rel(baseline):34s} {n:5d} 件 {mb:8.1f} MB (随包基线, 不算产物)')
  95. kept_at = OFF / '_baseline_kept' / KEEP[0]
  96. baseline_kept = kept_at.exists() # ★必须在 move **之后**判 (2026-09-11 曾写成之前 → 恒 False, --on 就不还原基线)
  97. for src, dst in items:
  98. if not src.exists():
  99. continue
  100. n, mb = count(src)
  101. dst.parent.mkdir(parents=True, exist_ok=True)
  102. shutil.move(str(src), str(dst))
  103. man.append(dict(rel=P.rel(src), to=P.rel(dst), files=n, mb=round(mb, 1)))
  104. print(f' 挪走 {P.rel(src):34s} {n:5d} 件 {mb:8.1f} MB')
  105. MANIFEST.write_text(json.dumps(dict(at=time.strftime('%Y-%m-%d %H:%M:%S'),
  106. baseline_kept=baseline_kept, items=man),
  107. ensure_ascii=False, indent=1), encoding='utf-8')
  108. print(f'\n共 {len(man)} 项已挪到 {P.rel(OFF)} (清单: {P.rel(MANIFEST)})')
  109. print(f'还原: python scripts/products_state.py --on')
  110. return 0
  111. def do_on() -> int:
  112. if not MANIFEST.exists():
  113. print('没有清单 (没挪过, 或清单已删)')
  114. return 1
  115. man = json.loads(MANIFEST.read_text(encoding='utf-8'))
  116. n = 0
  117. for it in man['items']:
  118. src, dst = P.resolve(it['to']), P.resolve(it['rel'])
  119. if not src.exists():
  120. print(f' [!] 备份里没有 {it["to"]}, 跳过')
  121. continue
  122. dst.parent.mkdir(parents=True, exist_ok=True)
  123. if dst.exists():
  124. shutil.rmtree(dst) if dst.is_dir() else dst.unlink()
  125. shutil.move(str(src), str(dst))
  126. n += 1
  127. print(f' 还原 {it["rel"]:34s} {it["files"]:5d} 件')
  128. if man.get('baseline_kept') or (OFF / '_baseline_kept' / KEEP[0]).exists():
  129. b = OFF / '_baseline_kept' / KEEP[0] # 不看清单标记, 直接看备份在不在 (更稳)
  130. if b.exists():
  131. target = P.store() / KEEP[0]
  132. target.parent.mkdir(parents=True, exist_ok=True)
  133. if target.exists():
  134. shutil.rmtree(target)
  135. shutil.move(str(b), str(target))
  136. print(f' 还原 {P.rel(target):34s} (随包基线)')
  137. if OFF.exists() and not any(OFF.rglob('*')):
  138. shutil.rmtree(OFF, ignore_errors=True)
  139. print(f'\n共还原 {n} 项')
  140. return 0
  141. def do_status() -> int:
  142. items = plan()
  143. if not items:
  144. print('当前: 产物**已全部挪走** (页面应呈空状态)')
  145. if MANIFEST.exists():
  146. man = json.loads(MANIFEST.read_text(encoding='utf-8'))
  147. print(f' 挪走时间 {man["at"]}, 共 {len(man["items"])} 项')
  148. for it in man['items']:
  149. print(f' {it["rel"]:34s} {it["files"]:5d} 件 {it["mb"]:8.1f} MB')
  150. return 0
  151. print(f'当前: 产物**在位** ({len(items)} 项)')
  152. for src, _ in items:
  153. n, mb = count(src)
  154. print(f' {P.rel(src):34s} {n:5d} 件 {mb:8.1f} MB')
  155. return 0
  156. if __name__ == '__main__':
  157. ap = argparse.ArgumentParser()
  158. g = ap.add_mutually_exclusive_group(required=True)
  159. g.add_argument('--off', action='store_true', help='挪走全部产物')
  160. g.add_argument('--on', action='store_true', help='全部挪回')
  161. g.add_argument('--status', action='store_true', help='看当前状态')
  162. ap.add_argument('--archive-old', action='store_true',
  163. help='--off 前把上一代暂存区存档到 _products_off_prev_<时间戳>/ (暂存区已有同名产物时必须加)')
  164. a = ap.parse_args()
  165. sys.exit(do_off(archive=a.archive_old) if a.off else do_on() if a.on else do_status())