# -*- coding: utf-8 -*- r"""观澜 · **模块包**独立安装/自检/运行/卸载的引导器(P9-B 起,配合"分别打包")。 背景(用户令 2026-09-28):前端、后端、算法、数据接入及管理要能**分别打包**。每个模块包 = 公共层 + 该模块 + 它依赖的模块(按 `configs/modules.yaml` 的 `allow` 递归)。单包装进总包时,模块目录里的 `install/run/uninstall` 直接调用安装根统一入口;**单独解压一个模块包时**(包根没有安装根那套 `install.ps1` 或本模块不是集成层),走这里:自建 `.venv`、离线装依赖、自检、按模块起服务或跑链、卸载。 用法(在**模块包根目录**下执行;也可用模块目录里的 install/run/uninstall 脚本转发过来): python app_common/app_common_guanlan/bootstrap.py install --module app_frontEnd python app_common/app_common_guanlan/bootstrap.py check --module app_frontEnd python app_common/app_common_guanlan/bootstrap.py run --module app_frontEnd [--port 28100] python app_common/app_common_guanlan/bootstrap.py run --module app_ETL --cmd rebuild_all -- --dry-run python app_common/app_common_guanlan/bootstrap.py list --module app_algorithmModel python app_common/app_common_guanlan/bootstrap.py uninstall --module app_frontEnd [--purge] 退出码:0 成功 · 2 用法/环境不满足 · 3 自检不过 · 4 运行失败 · 5 安装失败。 """ from __future__ import annotations import argparse import json import os import pathlib import subprocess import sys import time WIN = os.name == 'nt' # 每个模块的"可运行入口"(模块包里 run 的候选;`--cmd` 取这些名字) RUNNABLES = { 'app_backEnd': {'serve': '启动全部服务(网关 + 工作台 + CMS + 仿真×2 + 三维)', 'check': '系统自检', 'status': '各服务端口/进程状态', 'stop': '停止本启动器起的服务'}, 'app_frontEnd': {'portal': '起一个静态服务把门户与工作台页面放出来(默认端口 28100)'}, 'app_ETL': {'raw_scan': '输入数据扫描(发现新增/变化 → 该重跑哪几步)', 'raw_data_check': '输入数据放置体检', 'rebuild_all': '整链重算(默认加 --dry-run,真要跑请显式给 -- --apply)', 'scada_slim_build': '10 分钟标准仓重建'}, 'app_algorithmModel': {'service': '算法服务(FastAPI,P10):起 HTTP 接口 + /docs(默认端口 18050)', 'rudong_model_run': '判级与曲线主链(L0/谱闸/定级)', 'rudong_fusion_run': '融合面与四轴', 'rudong_line_energy_share': '发电量线与能量占比', 'tcm_mask_thresholds_build': 'TCM 掩码阈值反解'}, 'app_ontology': {'ontology_release_build': '本体发布层 r1/r2 生成', 'sop_findings_from_ledger': '由重算台账生成 claim'}, 'app_common': {}, 'app_qualityGate': {'module_boundary_audit': '模块边界审计', 'config_audit': '配置统一审计', 'pages_audit': '页面归口审计', 'chain_gap_check': '重算链缺口检查', 'delivery_docs_build': '三件交付文档构建与自检'}, } MODULES = tuple(RUNNABLES) # 支持"分别打包"的四个模块(用户令点名的)+ 其余三个(同一机制,顺带可用) FOUR = ('app_frontEnd', 'app_backEnd', 'app_algorithmModel', 'app_ETL') def root_of(f: str | pathlib.Path) -> pathlib.Path: """包根 = 同时含 `VERSION`(或 `configs/`)与 `app_common/` 的最近上层目录。""" p = pathlib.Path(f).resolve() for d in [p.parent] + list(p.parents): if (d / 'app_common').is_dir() and ((d / 'VERSION').is_file() or (d / 'configs').is_dir()): return d return p.parents[1] ROOT = root_of(__file__) INFO = ROOT / 'install-info.json' def py_in_venv() -> str: return str(ROOT / '.venv' / ('Scripts/python.exe' if WIN else 'bin/python')) def say(msg: str = '') -> None: print(msg, flush=True) def load_modules() -> dict: """读安装根单件配置 `modules.yaml`(走配置取用口;包结构异常时才兜底手拼)。""" try: from app_common.app_common_guanlan.api import paths as _P p = _P.config('modules.yaml') except Exception: # noqa: BLE001 p = ROOT / 'configs' / 'modules.yaml' # config-path-allow: 取用口不可用时的兜底 if not p.is_file(): return {} try: import yaml reg = yaml.safe_load(p.read_text(encoding='utf-8')) or {} return {m['name']: m for m in (reg.get('modules') or [])} except Exception: # noqa: BLE001 (无 yaml 时退回最小解析:只认 name/allow 行) out, cur = {}, None for ln in p.read_text(encoding='utf-8').splitlines(): s = ln.strip() if s.startswith('- name:'): cur = s.split(':', 1)[1].strip() out[cur] = {'allow': []} elif cur and s.startswith('allow:'): body = s.split('[', 1)[-1].rstrip(']') out[cur]['allow'] = [x.strip() for x in body.split(',') if x.strip()] return out def closure(name: str, reg: dict | None = None) -> list[str]: """依赖闭包(依赖在前,自身在后);app_common 永远在最前。""" reg = reg if reg is not None else load_modules() seen: list[str] = [] def walk(n: str) -> None: if n in seen: return for d in (reg.get(n, {}).get('allow') or []): walk(d) seen.append(n) walk(name) if 'app_common' in reg and 'app_common' not in seen: seen.insert(0, 'app_common') return seen def module_api_files(m: str) -> list[pathlib.Path]: d = ROOT / m / f'{m}_guanlan' return sorted(d.glob('*.py')) + sorted(d.rglob('*/api.py')) def cmd_check(a) -> int: say(f'== 自检(模块包 · {a.module})==') say(f' 包根: {ROOT}') bad: list[str] = [] reg = load_modules() if not reg: bad.append('读不到 configs/modules.yaml(模块包必须带安装根单件配置)') cl = closure(a.module, reg) say(f' 依赖闭包: {" → ".join(cl)}') # ① 目录与入口 for m in cl: d = ROOT / m if not (d / f'{m}_guanlan' / 'api.py').is_file(): bad.append(f'{m}/{m}_guanlan/api.py 不在位') if not ((d / 'README.MD').is_file() or (d / 'README.md').is_file()): bad.append(f'{m}/README.MD 不在位') for s in ('install.bat', 'install.sh', 'uninstall.bat', 'uninstall.sh', 'run.bat', 'run.sh'): if not (d / s).is_file(): bad.append(f'{m}/{s} 不在位') # ② 版本一致(真源是 app_common 的 version.py;根 VERSION 是给它做镜像的标记文件) import re as _re vp = ROOT / 'app_common' / 'app_common_guanlan' / 'version.py' v2 = '' if vp.is_file(): m = _re.search(r"^VERSION\s*=\s*'([^']+)'", vp.read_text(encoding='utf-8'), _re.M) v2 = m.group(1) if m else '' vf = ROOT / 'VERSION' v1 = '' if vf.is_file(): m1 = _re.search(r"([0-9]+\.[0-9]+\.[0-9]+)", vf.read_text(encoding='utf-8')) v1 = m1.group(1) if m1 else vf.read_text(encoding='utf-8').strip() if v1 and v2 and v1 != v2: bad.append(f'VERSION({v1}) 与版本真源 app_common/.../version.py({v2}) 不一致') say(f' 版本: VERSION={v1 or "—"} · 版本真源={v2 or "—"}') # ③ 配置单源(安装根单件 vs 模块 configs/) try: from app_common.app_common_guanlan.api import paths as _P2 root_cfg_dir = _P2.CONFIGS except Exception: # noqa: BLE001 root_cfg_dir = ROOT / 'configs' # config-path-allow: 取用口不可用时的兜底 root_cfg = {p.relative_to(root_cfg_dir).as_posix() for p in root_cfg_dir.rglob('*') if p.is_file()} for m in cl: mc = ROOT / m / 'configs' if not mc.is_dir(): continue for p in mc.rglob('*'): if p.is_file() and p.relative_to(mc).as_posix() in root_cfg: bad.append(f'配置双份: configs/{p.relative_to(mc).as_posix()} 与 {m}/configs/ 同名(必须单源)') # ④ 导入冒烟(每个模块的公开面) sys.path.insert(0, str(ROOT)) for m in cl: mod = f'{m}.{m}_guanlan.api' try: __import__(mod) except Exception as e: # noqa: BLE001 bad.append(f'导入 {mod} 失败: {type(e).__name__}: {str(e)[:90]}') if bad: for b in bad: say(f' [X] {b}') say(f'结论: 自检不过({len(bad)} 项)') return 3 say(' [OK] 目录与入口齐全 · 版本一致 · 配置单源 · 公开面可导入') say('结论: 自检通过') return 0 def cmd_install(a) -> int: say(f'== 安装(模块包 · {a.module})==') vpy = py_in_venv() if not pathlib.Path(vpy).is_file(): say(f' 建 .venv: {ROOT / ".venv"}') r = subprocess.run([sys.executable, '-m', 'venv', str(ROOT / '.venv')]) if r.returncode: say(' [X] 建虚拟环境失败') return 5 req = ROOT / 'requirements.txt' if req.is_file(): wheels = ROOT / 'wheels' args = [vpy, '-m', 'pip', 'install', '-q', '-r', str(req)] offline = wheels.is_dir() and any(wheels.rglob('*.whl')) if offline: args += ['--no-index', '--find-links', str(wheels)] say(f' 装依赖({"离线 wheels" if offline else "在线 pip"})…') r = subprocess.run(args) if r.returncode: say(' [X] 依赖安装失败') return 5 info = {} if INFO.is_file(): try: info = json.loads(INFO.read_text(encoding='utf-8')) except Exception: # noqa: BLE001 info = {} info.update(dict(name='观澜·模块包', version=(ROOT / 'VERSION').read_text(encoding='utf-8').strip() if (ROOT / 'VERSION').is_file() else '', module=a.module, mode='module-package', python=vpy, installed_at=time.strftime('%Y-%m-%d %H:%M:%S'))) INFO.write_text(json.dumps(info, ensure_ascii=False, indent=1), encoding='utf-8') say(f' 已写安装记录 {INFO.name}') return cmd_check(a) def _run_script(script: str, extra: list[str]) -> int: p = ROOT / 'scripts' / f'{script}.py' if not p.is_file(): say(f' [X] 找不到 scripts/{script}.py') return 4 cmd = [py_in_venv() if pathlib.Path(py_in_venv()).is_file() else sys.executable, '-X', 'utf8', str(p)] + extra say(' $ ' + ' '.join(cmd)) return subprocess.run(cmd, cwd=str(ROOT)).returncode def cmd_list(a) -> int: say(f'== {a.module} 可运行入口 ==') for k, v in RUNNABLES.get(a.module, {}).items(): say(f' {k:26s} {v}') say('\n用法: run --module %s --cmd <名> [-- 透传给该脚本的参数]' % a.module) return 0 def cmd_run(a) -> int: m = a.module say(f'== 运行(模块包 · {m})==') if m == 'app_backEnd': if a.cmd in (None, 'serve') and (ROOT / 'guanlan.py').is_file(): return subprocess.run([py_in_venv() if pathlib.Path(py_in_venv()).is_file() else sys.executable, '-X', 'utf8', str(ROOT / 'guanlan.py'), 'serve'], cwd=str(ROOT)).returncode if a.cmd in ('check', 'status', 'stop'): return subprocess.run([py_in_venv() if pathlib.Path(py_in_venv()).is_file() else sys.executable, '-X', 'utf8', str(ROOT / 'guanlan.py'), a.cmd], cwd=str(ROOT)).returncode if m == 'app_frontEnd' and a.cmd in (None, 'portal'): port = str(a.port or 28100) return _run_script('static_server', ['--port', port, '--host', '127.0.0.1', '--dir', str(ROOT / 'release')]) if m == 'app_algorithmModel' and a.cmd == 'service': return _run_script('algorithm_service', ['--port', str(a.port or 18050)]) cmd = a.cmd or (next(iter(RUNNABLES.get(m, {})), None)) if not cmd: say(' 本模块没有可直接运行的入口;用 list 看可选名') return 2 extra = list(a.rest or []) if m == 'app_ETL' and cmd == 'rebuild_all' and not extra: extra = ['--dry-run'] # 默认只看计划,真跑要显式给参数 return _run_script(cmd, extra) def cmd_uninstall(a) -> int: say(f'== 卸载(模块包 · {a.module})==') import shutil keep_root = ROOT # 有质量门(含卸载器)时走统一卸载器,行为与总包一致 un = ROOT / 'scripts' / 'guanlan_uninstall.py' if un.is_file(): say(' 走统一卸载器 scripts/guanlan_uninstall.py(与总包同一套行为)') cmd = [py_in_venv() if pathlib.Path(py_in_venv()).is_file() else sys.executable, '-X', 'utf8', str(un)] if a.purge: cmd.append('--purge') return subprocess.run(cmd, cwd=str(keep_root)).returncode for rel in ('.venv', 'run'): p = keep_root / rel if p.exists(): shutil.rmtree(p, ignore_errors=True) say(f' 已删 {rel}/') if INFO.is_file(): INFO.unlink() say(' 已删安装记录 install-info.json') if a.purge: for rel in ('data', 'outputs', 'logs'): p = keep_root / rel if p.exists(): shutil.rmtree(p, ignore_errors=True) say(f' 已删 {rel}/(--purge)') say('结论: 卸载完成(保留程序目录;要连目录一起删就手工删包根)') return 0 def main() -> int: ap = argparse.ArgumentParser(description='观澜 · 模块包引导器(安装/自检/运行/卸载)') ap.add_argument('action', choices=('install', 'check', 'run', 'list', 'uninstall')) ap.add_argument('--module', required=True, choices=MODULES) ap.add_argument('--cmd', default=None) ap.add_argument('--port', type=int, default=None) ap.add_argument('--purge', action='store_true') ap.add_argument('rest', nargs='*', help='透传给被调脚本的参数(放在 -- 之后)') a = ap.parse_args() if (ROOT / 'app_common' / 'app_common_guanlan' / 'api.py').is_file() is False: say('[X] 包根看起来不是模块包(缺 app_common/app_common_guanlan/api.py)') return 2 return {'install': cmd_install, 'check': cmd_check, 'run': cmd_run, 'list': cmd_list, 'uninstall': cmd_uninstall}[a.action](a) if __name__ == '__main__': sys.exit(main())