build.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345
  1. """Build script for the Bambuddy Windows installer.
  2. Stages all artifacts under ``installers/windows/build/staging/`` for the
  3. Inno Setup compiler to package. Run this on Windows (or in a Windows CI
  4. runner) — it pip-installs Bambuddy's deps against the embedded Python it
  5. downloads, which requires the matching platform.
  6. Steps:
  7. 1. Download python.org embeddable distribution for Windows x64
  8. 2. Configure embedded Python (allow site-packages)
  9. 3. Bootstrap pip into the embedded distribution
  10. 4. Install ``requirements.txt`` into the embedded Python
  11. 5. Build the React frontend (``frontend/npm run build``)
  12. 6. Stage backend source + frontend bundle
  13. 7. Download NSSM
  14. 8. Download ffmpeg static build for Windows
  15. 9. Print "ready for ISCC" message
  16. After this script succeeds, run::
  17. "C:\\Program Files (x86)\\Inno Setup 6\\ISCC.exe" bambuddy.iss
  18. to produce the final installer .exe under ``build/output/``.
  19. """
  20. from __future__ import annotations
  21. import argparse
  22. import shutil
  23. import subprocess
  24. import sys
  25. import urllib.request
  26. import zipfile
  27. from pathlib import Path
  28. # Repo root: installers/windows/build.py -> ../../
  29. REPO_ROOT = Path(__file__).resolve().parents[2]
  30. INSTALLER_DIR = Path(__file__).resolve().parent
  31. BUILD_DIR = INSTALLER_DIR / "build"
  32. STAGING = BUILD_DIR / "staging"
  33. DOWNLOADS = BUILD_DIR / "downloads"
  34. # Python 3.13 — matches Dockerfile (python:3.13-slim-trixie). Bump when
  35. # the Dockerfile bumps; the Windows installer should track production.
  36. PYTHON_VERSION = "3.13.1"
  37. PYTHON_EMBED_URL = f"https://www.python.org/ftp/python/{PYTHON_VERSION}/python-{PYTHON_VERSION}-embed-amd64.zip"
  38. # NSSM 2.24 is the long-time stable build. The official site has been
  39. # unreliable; use the GitHub mirror that nssm.cc itself links to.
  40. NSSM_VERSION = "2.24"
  41. NSSM_URL = f"https://nssm.cc/release/nssm-{NSSM_VERSION}.zip"
  42. # ffmpeg static build. BtbN's gyan-equivalent build is the most reliable
  43. # automated source. Pin to a release tag so builds are reproducible.
  44. FFMPEG_URL = "https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-win64-gpl.zip"
  45. # get-pip.py for bootstrapping pip into the embedded distribution
  46. GET_PIP_URL = "https://bootstrap.pypa.io/get-pip.py"
  47. def log(msg: str) -> None:
  48. print(f"[build] {msg}", flush=True)
  49. def download(url: str, dest: Path) -> Path:
  50. """Download ``url`` to ``dest`` if not already present."""
  51. if dest.exists():
  52. log(f"already downloaded: {dest.name}")
  53. return dest
  54. dest.parent.mkdir(parents=True, exist_ok=True)
  55. log(f"downloading {url}")
  56. with urllib.request.urlopen(url) as resp, open(dest, "wb") as f: # noqa: S310 — pinned URLs
  57. shutil.copyfileobj(resp, f)
  58. return dest
  59. def unzip(zip_path: Path, dest: Path) -> None:
  60. log(f"unzipping {zip_path.name} -> {dest}")
  61. dest.mkdir(parents=True, exist_ok=True)
  62. with zipfile.ZipFile(zip_path) as zf:
  63. zf.extractall(dest)
  64. def stage_embedded_python() -> Path:
  65. """Download and configure the embedded Python distribution."""
  66. target = STAGING / "python"
  67. if target.exists():
  68. shutil.rmtree(target)
  69. zip_path = download(
  70. PYTHON_EMBED_URL,
  71. DOWNLOADS / f"python-{PYTHON_VERSION}-embed-amd64.zip",
  72. )
  73. unzip(zip_path, target)
  74. # Edit pythonXY._pth to allow site-packages. The embedded distribution
  75. # ships with `import site` commented out — uncomment it so pip-installed
  76. # packages in Lib\site-packages are importable.
  77. pth_files = list(target.glob("python3*._pth"))
  78. if not pth_files:
  79. raise RuntimeError(f"no python3*._pth file found in {target}")
  80. pth = pth_files[0]
  81. content = pth.read_text()
  82. content = content.replace("#import site", "import site")
  83. # Also add Lib\site-packages explicitly. The embedded distribution
  84. # doesn't include this path by default even with `import site` enabled.
  85. if "Lib\\site-packages" not in content and "Lib/site-packages" not in content:
  86. content = content.rstrip() + "\nLib\\site-packages\n"
  87. pth.write_text(content)
  88. # Bootstrap pip
  89. get_pip = download(GET_PIP_URL, DOWNLOADS / "get-pip.py")
  90. log("bootstrapping pip into embedded Python")
  91. subprocess.run(
  92. [str(target / "python.exe"), str(get_pip), "--no-warn-script-location"],
  93. check=True,
  94. )
  95. # Install setuptools + wheel. The embedded distribution ships without
  96. # them, and get-pip.py installs only pip — but pip needs
  97. # ``setuptools.build_meta`` (PEP 517 backend) to build any source-only
  98. # package. Bambuddy's requirements.txt hits this with pyftpdlib 2.2.0
  99. # which is sdist-only on PyPI; other source-only packages would fail
  100. # the same way without this step.
  101. log("installing setuptools + wheel for PEP 517 builds")
  102. subprocess.run(
  103. [
  104. str(target / "python.exe"),
  105. "-m",
  106. "pip",
  107. "install",
  108. "--no-warn-script-location",
  109. "setuptools",
  110. "wheel",
  111. ],
  112. check=True,
  113. )
  114. return target
  115. def install_requirements(python_dir: Path) -> None:
  116. """Install Bambuddy's requirements.txt into the embedded Python."""
  117. py = python_dir / "python.exe"
  118. requirements = REPO_ROOT / "requirements.txt"
  119. log(f"installing requirements.txt into {python_dir}")
  120. subprocess.run(
  121. [
  122. str(py),
  123. "-m",
  124. "pip",
  125. "install",
  126. "--no-warn-script-location",
  127. "-r",
  128. str(requirements),
  129. ],
  130. check=True,
  131. )
  132. def build_frontend() -> Path:
  133. """Run ``npm ci && npm run build`` and return the dist path."""
  134. frontend = REPO_ROOT / "frontend"
  135. dist = frontend / "dist"
  136. log("running npm ci in frontend/")
  137. npm = shutil.which("npm")
  138. if not npm:
  139. raise RuntimeError("npm not found on PATH — install Node.js 22 LTS")
  140. subprocess.run([npm, "ci"], cwd=frontend, check=True, shell=False)
  141. log("running npm run build in frontend/")
  142. subprocess.run([npm, "run", "build"], cwd=frontend, check=True, shell=False)
  143. if not dist.exists():
  144. raise RuntimeError(f"expected frontend build output at {dist}")
  145. return dist
  146. def stage_backend(frontend_dist: Path) -> None:
  147. """Copy backend source + frontend bundle into the staging tree.
  148. The runtime layout under STAGING/app/ mirrors a Bambuddy checkout:
  149. ``backend/`` (source), ``static/`` (frontend bundle served by FastAPI).
  150. """
  151. app = STAGING / "app"
  152. if app.exists():
  153. shutil.rmtree(app)
  154. app.mkdir(parents=True)
  155. # Backend source — copy the package tree, skip caches/tests/migrations
  156. log("staging backend source")
  157. shutil.copytree(
  158. REPO_ROOT / "backend",
  159. app / "backend",
  160. ignore=shutil.ignore_patterns(
  161. "__pycache__",
  162. "*.pyc",
  163. "tests",
  164. ".pytest_cache",
  165. ),
  166. )
  167. # Frontend bundle — FastAPI's StaticFiles mounts from app/static
  168. log("staging frontend bundle")
  169. shutil.copytree(frontend_dist, app / "static")
  170. def stage_nssm() -> None:
  171. target = STAGING / "bin"
  172. target.mkdir(parents=True, exist_ok=True)
  173. zip_path = download(NSSM_URL, DOWNLOADS / f"nssm-{NSSM_VERSION}.zip")
  174. extract = DOWNLOADS / f"nssm-{NSSM_VERSION}-extracted"
  175. if not extract.exists():
  176. unzip(zip_path, extract)
  177. # The zip nests as nssm-2.24/win64/nssm.exe
  178. src = next(extract.rglob("win64/nssm.exe"))
  179. log(f"staging nssm.exe from {src}")
  180. shutil.copy(src, target / "nssm.exe")
  181. def stage_ffmpeg() -> None:
  182. target = STAGING / "bin"
  183. target.mkdir(parents=True, exist_ok=True)
  184. zip_path = download(FFMPEG_URL, DOWNLOADS / "ffmpeg-win64-gpl.zip")
  185. extract = DOWNLOADS / "ffmpeg-extracted"
  186. if not extract.exists():
  187. unzip(zip_path, extract)
  188. src = next(extract.rglob("bin/ffmpeg.exe"))
  189. log(f"staging ffmpeg.exe from {src}")
  190. shutil.copy(src, target / "ffmpeg.exe")
  191. # ffprobe is used by some camera/timelapse paths
  192. ffprobe = next(extract.rglob("bin/ffprobe.exe"), None)
  193. if ffprobe is not None:
  194. shutil.copy(ffprobe, target / "ffprobe.exe")
  195. def stage_service_scripts() -> None:
  196. """Copy the service install/uninstall .bat files into staging."""
  197. service_src = INSTALLER_DIR / "service"
  198. service_dst = STAGING / "service"
  199. if service_dst.exists():
  200. shutil.rmtree(service_dst)
  201. shutil.copytree(service_src, service_dst)
  202. def write_version_file() -> None:
  203. """Write the installer version as both a plain VERSION file and an
  204. Inno Setup include file so the .iss script can pick it up at compile
  205. time without a fragile file-read hack.
  206. Reads from pyproject.toml's [project] version line for the source of
  207. truth. Falls back to ``0.0.0+dev`` if not parseable.
  208. """
  209. version = "0.0.0+dev"
  210. pyproject = REPO_ROOT / "pyproject.toml"
  211. if pyproject.exists():
  212. for line in pyproject.read_text().splitlines():
  213. line = line.strip()
  214. if line.startswith("version =") or line.startswith('version="'):
  215. # version = "0.1.5" -> 0.1.5
  216. version = line.split("=", 1)[1].strip().strip('"').strip("'")
  217. break
  218. (STAGING / "VERSION").write_text(version)
  219. # Inno Setup include — bambuddy.iss does `#include "build\staging\version.iss"`
  220. iss_version = STAGING / "version.iss"
  221. iss_version.write_text(f'#define MyAppVersion "{version}"\n')
  222. log(f"staged VERSION = {version}")
  223. def main() -> int:
  224. parser = argparse.ArgumentParser(description=__doc__)
  225. parser.add_argument(
  226. "--skip-frontend",
  227. action="store_true",
  228. help="Skip frontend build (use existing frontend/dist/)",
  229. )
  230. parser.add_argument(
  231. "--skip-pip",
  232. action="store_true",
  233. help="Skip pip install (use existing staged Python)",
  234. )
  235. parser.add_argument(
  236. "--allow-non-windows",
  237. action="store_true",
  238. help=(
  239. "Override the Windows-only guard. Only useful if you have a "
  240. "working wine + windows-python toolchain. Not exercised by CI."
  241. ),
  242. )
  243. args = parser.parse_args()
  244. if sys.platform != "win32" and not args.allow_non_windows:
  245. log("ERROR: this build script must run on Windows.")
  246. log("")
  247. log("It downloads a Windows embeddable Python distribution and")
  248. log("pip-installs Bambuddy's requirements.txt against it — both")
  249. log("require executing python.exe, which only runs on Windows.")
  250. log("")
  251. log("Supported build paths:")
  252. log(" 1. GitHub Actions: trigger '.github/workflows/windows-")
  253. log(" installer.yml' (Actions tab -> Windows Installer ->")
  254. log(" Run workflow). Downloads the .exe as a workflow artifact.")
  255. log(" 2. Windows VM / box: clone, install Python 3.13 + Node 22 +")
  256. log(" Inno Setup 6, run this script.")
  257. log("")
  258. log("Unsupported escape hatch (cross-build under Wine): rerun with")
  259. log("--allow-non-windows. Requires wine + a Windows Python in $PATH")
  260. log("via wine python.exe — fragile and not exercised by CI.")
  261. return 1
  262. BUILD_DIR.mkdir(parents=True, exist_ok=True)
  263. DOWNLOADS.mkdir(parents=True, exist_ok=True)
  264. STAGING.mkdir(parents=True, exist_ok=True)
  265. python_dir = stage_embedded_python()
  266. if not args.skip_pip:
  267. install_requirements(python_dir)
  268. if args.skip_frontend:
  269. frontend_dist = REPO_ROOT / "frontend" / "dist"
  270. if not frontend_dist.exists():
  271. raise RuntimeError("--skip-frontend given but frontend/dist/ doesn't exist")
  272. else:
  273. frontend_dist = build_frontend()
  274. stage_backend(frontend_dist)
  275. stage_nssm()
  276. stage_ffmpeg()
  277. stage_service_scripts()
  278. write_version_file()
  279. log("")
  280. log("=" * 60)
  281. log("Staging complete.")
  282. log(f"Staged tree: {STAGING}")
  283. log("")
  284. log("Next: compile the Inno Setup script:")
  285. log(' "C:\\Program Files (x86)\\Inno Setup 6\\ISCC.exe" bambuddy.iss')
  286. log("")
  287. log(f"Installer will be written to: {BUILD_DIR / 'output'}")
  288. log("=" * 60)
  289. return 0
  290. if __name__ == "__main__":
  291. sys.exit(main())