bootstrap.py 14 KB

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