host.controller_web_editor.build
Auto-build helper for the SPA.
A fresh checkout already includes the compiled bundle under static/,
so the server runs without Node installed. But when a contributor edits
the Svelte sources, we'd like the next python -m host.controller_web_editor
invocation to pick up their changes without making them remember
npm run build.
This module compares web/src/ mtimes against static/ mtimes and
runs the build if the sources are newer. When Node isn't on PATH or the
CONTROLLER_WEB_EDITOR_SKIP_BUILD env var is set, the build is
silently skipped (the checked-in artifacts still work).
1"""Auto-build helper for the SPA. 2 3A fresh checkout already includes the compiled bundle under ``static/``, 4so the server runs without Node installed. But when a contributor edits 5the Svelte sources, we'd like the next ``python -m host.controller_web_editor`` 6invocation to pick up their changes without making them remember 7``npm run build``. 8 9This module compares ``web/src/`` mtimes against ``static/`` mtimes and 10runs the build if the sources are newer. When Node isn't on PATH or the 11``CONTROLLER_WEB_EDITOR_SKIP_BUILD`` env var is set, the build is 12silently skipped (the checked-in artifacts still work). 13""" 14 15from __future__ import annotations 16 17import logging 18import os 19import shutil 20import subprocess 21from pathlib import Path 22 23 24log = logging.getLogger(__name__) 25 26HERE = Path(__file__).resolve().parent 27WEB_DIR = HERE / "web" 28SRC_DIR = WEB_DIR / "src" 29STATIC_DIR = HERE / "static" 30NODE_MODULES = WEB_DIR / "node_modules" 31 32 33def _latest_mtime(root: Path) -> float: 34 """Highest mtime under ``root`` (recursive), or 0.0 if empty/missing.""" 35 if not root.exists(): 36 return 0.0 37 best = 0.0 38 for path in root.rglob("*"): 39 if path.is_file(): 40 try: 41 m = path.stat().st_mtime 42 except OSError: 43 continue 44 if m > best: 45 best = m 46 return best 47 48 49def _resolve_npm() -> str | None: 50 """Return the absolute path to ``npm`` or ``None`` if not on PATH. 51 52 Windows installs npm as ``npm.cmd``; ``shutil.which`` finds either 53 once PATHEXT is honored, which it is by default. 54 """ 55 return shutil.which("npm") 56 57 58def _run(cmd: list[str], cwd: Path) -> bool: 59 log.info("running %s in %s", " ".join(cmd), cwd) 60 try: 61 subprocess.run(cmd, cwd=cwd, check=True, shell=False) 62 return True 63 except (subprocess.CalledProcessError, FileNotFoundError) as exc: 64 log.warning("build step failed: %s", exc) 65 return False 66 67 68def maybe_build_spa() -> str: 69 """Build the SPA if sources are newer than the checked-in bundle. 70 71 Returns one of: ``"skipped"``, ``"already_fresh"``, ``"built"``, or 72 ``"failed"``. Never raises -- this is a best-effort convenience. 73 """ 74 if os.environ.get("CONTROLLER_WEB_EDITOR_SKIP_BUILD"): 75 log.debug("skipping SPA build (CONTROLLER_WEB_EDITOR_SKIP_BUILD set)") 76 return "skipped" 77 78 if not WEB_DIR.is_dir(): 79 log.debug("no web/ directory; nothing to build") 80 return "skipped" 81 82 npm = _resolve_npm() 83 if not npm: 84 if not (STATIC_DIR / "index.html").is_file(): 85 log.warning( 86 "npm not found and static/index.html missing -- the SPA " 87 "won't load. Easiest fix: re-run scripts/controller_editor/" 88 "launch.{ps1,sh}, which auto-installs Node. Or install " 89 "Node.js manually from https://nodejs.org and run " 90 "'npm install && npm run build' in %s", WEB_DIR) 91 else: 92 log.debug("npm not on PATH; using existing static/ bundle") 93 return "skipped" 94 95 src_mtime = max( 96 _latest_mtime(SRC_DIR), 97 (WEB_DIR / "index.html").stat().st_mtime if (WEB_DIR / "index.html").is_file() else 0.0, 98 (WEB_DIR / "package.json").stat().st_mtime if (WEB_DIR / "package.json").is_file() else 0.0, 99 (WEB_DIR / "vite.config.ts").stat().st_mtime if (WEB_DIR / "vite.config.ts").is_file() else 0.0, 100 ) 101 static_mtime = _latest_mtime(STATIC_DIR) 102 103 if static_mtime >= src_mtime and (STATIC_DIR / "index.html").is_file(): 104 log.debug("static/ is fresh (mtime %.0f >= %.0f)", 105 static_mtime, src_mtime) 106 return "already_fresh" 107 108 log.info("SPA sources newer than static/; rebuilding") 109 if not NODE_MODULES.is_dir(): 110 # First-run npm install can take minutes -- drop --silent so the 111 # user sees progress instead of staring at a frozen terminal. 112 log.info("installing npm dependencies (first run, this may take a minute)") 113 if not _run([npm, "install"], cwd=WEB_DIR): 114 return "failed" 115 116 if not _run([npm, "run", "build"], cwd=WEB_DIR): 117 return "failed" 118 return "built" 119 120 121__all__ = ["maybe_build_spa"]
69def maybe_build_spa() -> str: 70 """Build the SPA if sources are newer than the checked-in bundle. 71 72 Returns one of: ``"skipped"``, ``"already_fresh"``, ``"built"``, or 73 ``"failed"``. Never raises -- this is a best-effort convenience. 74 """ 75 if os.environ.get("CONTROLLER_WEB_EDITOR_SKIP_BUILD"): 76 log.debug("skipping SPA build (CONTROLLER_WEB_EDITOR_SKIP_BUILD set)") 77 return "skipped" 78 79 if not WEB_DIR.is_dir(): 80 log.debug("no web/ directory; nothing to build") 81 return "skipped" 82 83 npm = _resolve_npm() 84 if not npm: 85 if not (STATIC_DIR / "index.html").is_file(): 86 log.warning( 87 "npm not found and static/index.html missing -- the SPA " 88 "won't load. Easiest fix: re-run scripts/controller_editor/" 89 "launch.{ps1,sh}, which auto-installs Node. Or install " 90 "Node.js manually from https://nodejs.org and run " 91 "'npm install && npm run build' in %s", WEB_DIR) 92 else: 93 log.debug("npm not on PATH; using existing static/ bundle") 94 return "skipped" 95 96 src_mtime = max( 97 _latest_mtime(SRC_DIR), 98 (WEB_DIR / "index.html").stat().st_mtime if (WEB_DIR / "index.html").is_file() else 0.0, 99 (WEB_DIR / "package.json").stat().st_mtime if (WEB_DIR / "package.json").is_file() else 0.0, 100 (WEB_DIR / "vite.config.ts").stat().st_mtime if (WEB_DIR / "vite.config.ts").is_file() else 0.0, 101 ) 102 static_mtime = _latest_mtime(STATIC_DIR) 103 104 if static_mtime >= src_mtime and (STATIC_DIR / "index.html").is_file(): 105 log.debug("static/ is fresh (mtime %.0f >= %.0f)", 106 static_mtime, src_mtime) 107 return "already_fresh" 108 109 log.info("SPA sources newer than static/; rebuilding") 110 if not NODE_MODULES.is_dir(): 111 # First-run npm install can take minutes -- drop --silent so the 112 # user sees progress instead of staring at a frozen terminal. 113 log.info("installing npm dependencies (first run, this may take a minute)") 114 if not _run([npm, "install"], cwd=WEB_DIR): 115 return "failed" 116 117 if not _run([npm, "run", "build"], cwd=WEB_DIR): 118 return "failed" 119 return "built"
Build the SPA if sources are newer than the checked-in bundle.
Returns one of: "skipped", "already_fresh", "built", or
"failed". Never raises -- this is a best-effort convenience.