products_state.py 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154
  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 do_off() -> int:
  48. items = plan()
  49. if not items:
  50. print('产物已经全部挪走了 (用 --status 看清单)')
  51. return 0
  52. OFF.mkdir(parents=True, exist_ok=True)
  53. man = []
  54. # 随包基线备份藏在 windscada 里面: 先摘出来, 它不是产物 (rebuild_from_raw.py --verify 要用)
  55. baseline = P.store() / KEEP[0]
  56. baseline_kept = False
  57. kept_at = OFF / '_baseline_kept' / KEEP[0]
  58. if baseline.exists():
  59. if not kept_at.exists():
  60. kept_at.parent.mkdir(parents=True, exist_ok=True)
  61. n, mb = count(baseline)
  62. shutil.move(str(baseline), str(kept_at))
  63. print(f' 保留 {P.rel(baseline):34s} {n:5d} 件 {mb:8.1f} MB (随包基线, 不算产物)')
  64. kept_at = OFF / '_baseline_kept' / KEEP[0]
  65. baseline_kept = kept_at.exists() # ★必须在 move **之后**判 (2026-09-11 曾写成之前 → 恒 False, --on 就不还原基线)
  66. for src, dst in items:
  67. if not src.exists():
  68. continue
  69. n, mb = count(src)
  70. dst.parent.mkdir(parents=True, exist_ok=True)
  71. shutil.move(str(src), str(dst))
  72. man.append(dict(rel=P.rel(src), to=P.rel(dst), files=n, mb=round(mb, 1)))
  73. print(f' 挪走 {P.rel(src):34s} {n:5d} 件 {mb:8.1f} MB')
  74. MANIFEST.write_text(json.dumps(dict(at=time.strftime('%Y-%m-%d %H:%M:%S'),
  75. baseline_kept=baseline_kept, items=man),
  76. ensure_ascii=False, indent=1), encoding='utf-8')
  77. print(f'\n共 {len(man)} 项已挪到 {P.rel(OFF)} (清单: {P.rel(MANIFEST)})')
  78. print(f'还原: python scripts/products_state.py --on')
  79. return 0
  80. def do_on() -> int:
  81. if not MANIFEST.exists():
  82. print('没有清单 (没挪过, 或清单已删)')
  83. return 1
  84. man = json.loads(MANIFEST.read_text(encoding='utf-8'))
  85. n = 0
  86. for it in man['items']:
  87. src, dst = P.resolve(it['to']), P.resolve(it['rel'])
  88. if not src.exists():
  89. print(f' [!] 备份里没有 {it["to"]}, 跳过')
  90. continue
  91. dst.parent.mkdir(parents=True, exist_ok=True)
  92. if dst.exists():
  93. shutil.rmtree(dst) if dst.is_dir() else dst.unlink()
  94. shutil.move(str(src), str(dst))
  95. n += 1
  96. print(f' 还原 {it["rel"]:34s} {it["files"]:5d} 件')
  97. if man.get('baseline_kept') or (OFF / '_baseline_kept' / KEEP[0]).exists():
  98. b = OFF / '_baseline_kept' / KEEP[0] # 不看清单标记, 直接看备份在不在 (更稳)
  99. if b.exists():
  100. target = P.store() / KEEP[0]
  101. target.parent.mkdir(parents=True, exist_ok=True)
  102. if target.exists():
  103. shutil.rmtree(target)
  104. shutil.move(str(b), str(target))
  105. print(f' 还原 {P.rel(target):34s} (随包基线)')
  106. if OFF.exists() and not any(OFF.rglob('*')):
  107. shutil.rmtree(OFF, ignore_errors=True)
  108. print(f'\n共还原 {n} 项')
  109. return 0
  110. def do_status() -> int:
  111. items = plan()
  112. if not items:
  113. print('当前: 产物**已全部挪走** (页面应呈空状态)')
  114. if MANIFEST.exists():
  115. man = json.loads(MANIFEST.read_text(encoding='utf-8'))
  116. print(f' 挪走时间 {man["at"]}, 共 {len(man["items"])} 项')
  117. for it in man['items']:
  118. print(f' {it["rel"]:34s} {it["files"]:5d} 件 {it["mb"]:8.1f} MB')
  119. return 0
  120. print(f'当前: 产物**在位** ({len(items)} 项)')
  121. for src, _ in items:
  122. n, mb = count(src)
  123. print(f' {P.rel(src):34s} {n:5d} 件 {mb:8.1f} MB')
  124. return 0
  125. if __name__ == '__main__':
  126. ap = argparse.ArgumentParser()
  127. g = ap.add_mutually_exclusive_group(required=True)
  128. g.add_argument('--off', action='store_true', help='挪走全部产物')
  129. g.add_argument('--on', action='store_true', help='全部挪回')
  130. g.add_argument('--status', action='store_true', help='看当前状态')
  131. a = ap.parse_args()
  132. sys.exit(do_off() if a.off else do_on() if a.on else do_status())