bootstrap.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324
  1. # -*- coding: utf-8 -*-
  2. r"""观澜 · **模块包**独立安装/自检/运行/卸载的引导器(P9-B 起,配合"分别打包")。
  3. 背景(用户令 2026-09-28):前端、后端、算法、数据接入及管理要能**分别打包**。每个模块包 = 公共层 +
  4. 该模块 + 它依赖的模块(按 `configs/modules.yaml` 的 `allow` 递归)。单包装进总包时,模块目录里的
  5. `install/run/uninstall` 直接调用安装根统一入口;**单独解压一个模块包时**(包根没有安装根那套 `install.ps1`
  6. 或本模块不是集成层),走这里:自建 `.venv`、离线装依赖、自检、按模块起服务或跑链、卸载。
  7. 用法(在**模块包根目录**下执行;也可用模块目录里的 install/run/uninstall 脚本转发过来):
  8. python app_common/app_common_guanlan/bootstrap.py install --module app_frontEnd
  9. python app_common/app_common_guanlan/bootstrap.py check --module app_frontEnd
  10. python app_common/app_common_guanlan/bootstrap.py run --module app_frontEnd [--port 28100]
  11. python app_common/app_common_guanlan/bootstrap.py run --module app_ETL --cmd rebuild_all -- --dry-run
  12. python app_common/app_common_guanlan/bootstrap.py list --module app_algorithmModel
  13. python app_common/app_common_guanlan/bootstrap.py uninstall --module app_frontEnd [--purge]
  14. 退出码:0 成功 · 2 用法/环境不满足 · 3 自检不过 · 4 运行失败 · 5 安装失败。
  15. """
  16. from __future__ import annotations
  17. import argparse
  18. import json
  19. import os
  20. import pathlib
  21. import subprocess
  22. import sys
  23. import time
  24. WIN = os.name == 'nt'
  25. # 每个模块的"可运行入口"(模块包里 run 的候选;`--cmd` 取这些名字)
  26. RUNNABLES = {
  27. 'app_backEnd': {'serve': '启动全部服务(网关 + 工作台 + CMS + 仿真×2 + 三维)',
  28. 'check': '系统自检',
  29. 'status': '各服务端口/进程状态',
  30. 'stop': '停止本启动器起的服务'},
  31. 'app_frontEnd': {'portal': '起一个静态服务把门户与工作台页面放出来(默认端口 28100)'},
  32. 'app_ETL': {'raw_scan': '输入数据扫描(发现新增/变化 → 该重跑哪几步)',
  33. 'raw_data_check': '输入数据放置体检',
  34. 'rebuild_all': '整链重算(默认加 --dry-run,真要跑请显式给 -- --apply)',
  35. 'scada_slim_build': '10 分钟标准仓重建'},
  36. 'app_algorithmModel': {'service': '算法服务(FastAPI,P10):起 HTTP 接口 + /docs(默认端口 18050)',
  37. 'rudong_model_run': '判级与曲线主链(L0/谱闸/定级)',
  38. 'rudong_fusion_run': '融合面与四轴',
  39. 'rudong_line_energy_share': '发电量线与能量占比',
  40. 'tcm_mask_thresholds_build': 'TCM 掩码阈值反解'},
  41. 'app_ontology': {'ontology_release_build': '本体发布层 r1/r2 生成',
  42. 'sop_findings_from_ledger': '由重算台账生成 claim'},
  43. 'app_common': {},
  44. 'app_qualityGate': {'module_boundary_audit': '模块边界审计',
  45. 'config_audit': '配置统一审计',
  46. 'pages_audit': '页面归口审计',
  47. 'chain_gap_check': '重算链缺口检查',
  48. 'delivery_docs_build': '三件交付文档构建与自检'},
  49. }
  50. MODULES = tuple(RUNNABLES)
  51. # 支持"分别打包"的四个模块(用户令点名的)+ 其余三个(同一机制,顺带可用)
  52. FOUR = ('app_frontEnd', 'app_backEnd', 'app_algorithmModel', 'app_ETL')
  53. def root_of(f: str | pathlib.Path) -> pathlib.Path:
  54. """包根 = 同时含 `VERSION`(或 `configs/`)与 `app_common/` 的最近上层目录。"""
  55. p = pathlib.Path(f).resolve()
  56. for d in [p.parent] + list(p.parents):
  57. if (d / 'app_common').is_dir() and ((d / 'VERSION').is_file() or (d / 'configs').is_dir()):
  58. return d
  59. return p.parents[1]
  60. ROOT = root_of(__file__)
  61. INFO = ROOT / 'install-info.json'
  62. def py_in_venv() -> str:
  63. return str(ROOT / '.venv' / ('Scripts/python.exe' if WIN else 'bin/python'))
  64. def say(msg: str = '') -> None:
  65. print(msg, flush=True)
  66. def load_modules() -> dict:
  67. """读安装根单件配置 `modules.yaml`(走配置取用口;包结构异常时才兜底手拼)。"""
  68. try:
  69. from app_common.app_common_guanlan.api import paths as _P
  70. p = _P.config('modules.yaml')
  71. except Exception: # noqa: BLE001
  72. p = ROOT / 'configs' / 'modules.yaml' # config-path-allow: 取用口不可用时的兜底
  73. if not p.is_file():
  74. return {}
  75. try:
  76. import yaml
  77. reg = yaml.safe_load(p.read_text(encoding='utf-8')) or {}
  78. return {m['name']: m for m in (reg.get('modules') or [])}
  79. except Exception: # noqa: BLE001 (无 yaml 时退回最小解析:只认 name/allow 行)
  80. out, cur = {}, None
  81. for ln in p.read_text(encoding='utf-8').splitlines():
  82. s = ln.strip()
  83. if s.startswith('- name:'):
  84. cur = s.split(':', 1)[1].strip()
  85. out[cur] = {'allow': []}
  86. elif cur and s.startswith('allow:'):
  87. body = s.split('[', 1)[-1].rstrip(']')
  88. out[cur]['allow'] = [x.strip() for x in body.split(',') if x.strip()]
  89. return out
  90. def closure(name: str, reg: dict | None = None) -> list[str]:
  91. """依赖闭包(依赖在前,自身在后);app_common 永远在最前。"""
  92. reg = reg if reg is not None else load_modules()
  93. seen: list[str] = []
  94. def walk(n: str) -> None:
  95. if n in seen:
  96. return
  97. for d in (reg.get(n, {}).get('allow') or []):
  98. walk(d)
  99. seen.append(n)
  100. walk(name)
  101. if 'app_common' in reg and 'app_common' not in seen:
  102. seen.insert(0, 'app_common')
  103. return seen
  104. def module_api_files(m: str) -> list[pathlib.Path]:
  105. d = ROOT / m / f'{m}_guanlan'
  106. return sorted(d.glob('*.py')) + sorted(d.rglob('*/api.py'))
  107. def cmd_check(a) -> int:
  108. say(f'== 自检(模块包 · {a.module})==')
  109. say(f' 包根: {ROOT}')
  110. bad: list[str] = []
  111. reg = load_modules()
  112. if not reg:
  113. bad.append('读不到 configs/modules.yaml(模块包必须带安装根单件配置)')
  114. cl = closure(a.module, reg)
  115. say(f' 依赖闭包: {" → ".join(cl)}')
  116. # ① 目录与入口
  117. for m in cl:
  118. d = ROOT / m
  119. if not (d / f'{m}_guanlan' / 'api.py').is_file():
  120. bad.append(f'{m}/{m}_guanlan/api.py 不在位')
  121. if not ((d / 'README.MD').is_file() or (d / 'README.md').is_file()):
  122. bad.append(f'{m}/README.MD 不在位')
  123. for s in ('install.bat', 'install.sh', 'uninstall.bat', 'uninstall.sh', 'run.bat', 'run.sh'):
  124. if not (d / s).is_file():
  125. bad.append(f'{m}/{s} 不在位')
  126. # ② 版本一致(真源是 app_common 的 version.py;根 VERSION 是给它做镜像的标记文件)
  127. import re as _re
  128. vp = ROOT / 'app_common' / 'app_common_guanlan' / 'version.py'
  129. v2 = ''
  130. if vp.is_file():
  131. m = _re.search(r"^VERSION\s*=\s*'([^']+)'", vp.read_text(encoding='utf-8'), _re.M)
  132. v2 = m.group(1) if m else ''
  133. vf = ROOT / 'VERSION'
  134. v1 = ''
  135. if vf.is_file():
  136. m1 = _re.search(r"([0-9]+\.[0-9]+\.[0-9]+)", vf.read_text(encoding='utf-8'))
  137. v1 = m1.group(1) if m1 else vf.read_text(encoding='utf-8').strip()
  138. if v1 and v2 and v1 != v2:
  139. bad.append(f'VERSION({v1}) 与版本真源 app_common/.../version.py({v2}) 不一致')
  140. say(f' 版本: VERSION={v1 or "—"} · 版本真源={v2 or "—"}')
  141. # ③ 配置单源(安装根单件 vs 模块 configs/)
  142. try:
  143. from app_common.app_common_guanlan.api import paths as _P2
  144. root_cfg_dir = _P2.CONFIGS
  145. except Exception: # noqa: BLE001
  146. root_cfg_dir = ROOT / 'configs' # config-path-allow: 取用口不可用时的兜底
  147. root_cfg = {p.relative_to(root_cfg_dir).as_posix() for p in root_cfg_dir.rglob('*') if p.is_file()}
  148. for m in cl:
  149. mc = ROOT / m / 'configs'
  150. if not mc.is_dir():
  151. continue
  152. for p in mc.rglob('*'):
  153. if p.is_file() and p.relative_to(mc).as_posix() in root_cfg:
  154. bad.append(f'配置双份: configs/{p.relative_to(mc).as_posix()} 与 {m}/configs/ 同名(必须单源)')
  155. # ④ 导入冒烟(每个模块的公开面)
  156. sys.path.insert(0, str(ROOT))
  157. for m in cl:
  158. mod = f'{m}.{m}_guanlan.api'
  159. try:
  160. __import__(mod)
  161. except Exception as e: # noqa: BLE001
  162. bad.append(f'导入 {mod} 失败: {type(e).__name__}: {str(e)[:90]}')
  163. if bad:
  164. for b in bad:
  165. say(f' [X] {b}')
  166. say(f'结论: 自检不过({len(bad)} 项)')
  167. return 3
  168. say(' [OK] 目录与入口齐全 · 版本一致 · 配置单源 · 公开面可导入')
  169. say('结论: 自检通过')
  170. return 0
  171. def cmd_install(a) -> int:
  172. say(f'== 安装(模块包 · {a.module})==')
  173. vpy = py_in_venv()
  174. if not pathlib.Path(vpy).is_file():
  175. say(f' 建 .venv: {ROOT / ".venv"}')
  176. r = subprocess.run([sys.executable, '-m', 'venv', str(ROOT / '.venv')])
  177. if r.returncode:
  178. say(' [X] 建虚拟环境失败')
  179. return 5
  180. req = ROOT / 'requirements.txt'
  181. if req.is_file():
  182. wheels = ROOT / 'wheels'
  183. args = [vpy, '-m', 'pip', 'install', '-q', '-r', str(req)]
  184. offline = wheels.is_dir() and any(wheels.rglob('*.whl'))
  185. if offline:
  186. args += ['--no-index', '--find-links', str(wheels)]
  187. say(f' 装依赖({"离线 wheels" if offline else "在线 pip"})…')
  188. r = subprocess.run(args)
  189. if r.returncode:
  190. say(' [X] 依赖安装失败')
  191. return 5
  192. info = {}
  193. if INFO.is_file():
  194. try:
  195. info = json.loads(INFO.read_text(encoding='utf-8'))
  196. except Exception: # noqa: BLE001
  197. info = {}
  198. info.update(dict(name='观澜·模块包', version=(ROOT / 'VERSION').read_text(encoding='utf-8').strip()
  199. if (ROOT / 'VERSION').is_file() else '',
  200. module=a.module, mode='module-package', python=vpy,
  201. installed_at=time.strftime('%Y-%m-%d %H:%M:%S')))
  202. INFO.write_text(json.dumps(info, ensure_ascii=False, indent=1), encoding='utf-8')
  203. say(f' 已写安装记录 {INFO.name}')
  204. return cmd_check(a)
  205. def _run_script(script: str, extra: list[str]) -> int:
  206. p = ROOT / 'scripts' / f'{script}.py'
  207. if not p.is_file():
  208. say(f' [X] 找不到 scripts/{script}.py')
  209. return 4
  210. cmd = [py_in_venv() if pathlib.Path(py_in_venv()).is_file() else sys.executable, '-X', 'utf8', str(p)] + extra
  211. say(' $ ' + ' '.join(cmd))
  212. return subprocess.run(cmd, cwd=str(ROOT)).returncode
  213. def cmd_list(a) -> int:
  214. say(f'== {a.module} 可运行入口 ==')
  215. for k, v in RUNNABLES.get(a.module, {}).items():
  216. say(f' {k:26s} {v}')
  217. say('\n用法: run --module %s --cmd <名> [-- 透传给该脚本的参数]' % a.module)
  218. return 0
  219. def cmd_run(a) -> int:
  220. m = a.module
  221. say(f'== 运行(模块包 · {m})==')
  222. if m == 'app_backEnd':
  223. if a.cmd in (None, 'serve') and (ROOT / 'guanlan.py').is_file():
  224. return subprocess.run([py_in_venv() if pathlib.Path(py_in_venv()).is_file() else sys.executable,
  225. '-X', 'utf8', str(ROOT / 'guanlan.py'), 'serve'], cwd=str(ROOT)).returncode
  226. if a.cmd in ('check', 'status', 'stop'):
  227. return subprocess.run([py_in_venv() if pathlib.Path(py_in_venv()).is_file() else sys.executable,
  228. '-X', 'utf8', str(ROOT / 'guanlan.py'), a.cmd], cwd=str(ROOT)).returncode
  229. if m == 'app_frontEnd' and a.cmd in (None, 'portal'):
  230. port = str(a.port or 28100)
  231. return _run_script('static_server', ['--port', port, '--host', '127.0.0.1', '--dir', str(ROOT / 'release')])
  232. if m == 'app_algorithmModel' and a.cmd == 'service':
  233. return _run_script('algorithm_service', ['--port', str(a.port or 18050)])
  234. cmd = a.cmd or (next(iter(RUNNABLES.get(m, {})), None))
  235. if not cmd:
  236. say(' 本模块没有可直接运行的入口;用 list 看可选名')
  237. return 2
  238. extra = list(a.rest or [])
  239. if m == 'app_ETL' and cmd == 'rebuild_all' and not extra:
  240. extra = ['--dry-run'] # 默认只看计划,真跑要显式给参数
  241. return _run_script(cmd, extra)
  242. def cmd_uninstall(a) -> int:
  243. say(f'== 卸载(模块包 · {a.module})==')
  244. import shutil
  245. keep_root = ROOT
  246. # 有质量门(含卸载器)时走统一卸载器,行为与总包一致
  247. un = ROOT / 'scripts' / 'guanlan_uninstall.py'
  248. if un.is_file():
  249. say(' 走统一卸载器 scripts/guanlan_uninstall.py(与总包同一套行为)')
  250. cmd = [py_in_venv() if pathlib.Path(py_in_venv()).is_file() else sys.executable, '-X', 'utf8', str(un)]
  251. if a.purge:
  252. cmd.append('--purge')
  253. return subprocess.run(cmd, cwd=str(keep_root)).returncode
  254. for rel in ('.venv', 'run'):
  255. p = keep_root / rel
  256. if p.exists():
  257. shutil.rmtree(p, ignore_errors=True)
  258. say(f' 已删 {rel}/')
  259. if INFO.is_file():
  260. INFO.unlink()
  261. say(' 已删安装记录 install-info.json')
  262. if a.purge:
  263. for rel in ('data', 'outputs', 'logs'):
  264. p = keep_root / rel
  265. if p.exists():
  266. shutil.rmtree(p, ignore_errors=True)
  267. say(f' 已删 {rel}/(--purge)')
  268. say('结论: 卸载完成(保留程序目录;要连目录一起删就手工删包根)')
  269. return 0
  270. def main() -> int:
  271. ap = argparse.ArgumentParser(description='观澜 · 模块包引导器(安装/自检/运行/卸载)')
  272. ap.add_argument('action', choices=('install', 'check', 'run', 'list', 'uninstall'))
  273. ap.add_argument('--module', required=True, choices=MODULES)
  274. ap.add_argument('--cmd', default=None)
  275. ap.add_argument('--port', type=int, default=None)
  276. ap.add_argument('--purge', action='store_true')
  277. ap.add_argument('rest', nargs='*', help='透传给被调脚本的参数(放在 -- 之后)')
  278. a = ap.parse_args()
  279. if (ROOT / 'app_common' / 'app_common_guanlan' / 'api.py').is_file() is False:
  280. say('[X] 包根看起来不是模块包(缺 app_common/app_common_guanlan/api.py)')
  281. return 2
  282. return {'install': cmd_install, 'check': cmd_check, 'run': cmd_run, 'list': cmd_list,
  283. 'uninstall': cmd_uninstall}[a.action](a)
  284. if __name__ == '__main__':
  285. sys.exit(main())