"""Build a self-contained git-auto-tracker executable (local dev option). Creates a dedicated build environment (.venv-build), installs the package non-editable together with PyInstaller, builds the executable and smoke-tests it against a temporary fixture repository. Git is NOT bundled: it remains an external prerequisite on the target machine (functional specification, Section 28). Usage (from the project root, any Python >= 3.12): python scripts/build_exe.py build + smoke test (onedir) python scripts/build_exe.py --onefile single-file executable python scripts/build_exe.py --skip-smoke build without smoke test """ from __future__ import annotations import argparse import shutil import subprocess import sys import tempfile import venv from pathlib import Path ROOT = Path(__file__).resolve().parents[1] BUILD_VENV = ROOT / ".venv-build" ENTRY = ROOT / "src" / "git_auto_tracker" / "__main__.py" DIST = ROOT / "dist" NAME = "git-auto-tracker" def _log(message: str) -> None: print(f"[build] {message}") def _run(cmd: list[str], cwd: Path | None = None) -> subprocess.CompletedProcess: _log(f"$ {' '.join(cmd)}") return subprocess.run(cmd, cwd=str(cwd) if cwd else None, check=True) def create_build_env() -> Path: if BUILD_VENV.exists(): _log(f"Removing stale build environment {BUILD_VENV}") shutil.rmtree(BUILD_VENV) _log(f"Creating clean build environment {BUILD_VENV}") venv.EnvBuilder(with_pip=True).create(BUILD_VENV) python = BUILD_VENV / ( "Scripts/python.exe" if sys.platform == "win32" else "bin/python" ) # Non-editable install: reliable input for PyInstaller module analysis. # Argument list, no shell: the '>=6.15' requirement string is safe here. _run([str(python), "-m", "pip", "install", "--quiet", ".", "pyinstaller>=6.15"]) return python def build(python: Path, onefile: bool) -> Path: mode = "--onefile" if onefile else "--onedir" _run( [ str(python), "-m", "PyInstaller", "--name", NAME, mode, "--console", "--noconfirm", "--clean", "--distpath", str(DIST), "--workpath", str(ROOT / "build" / "pyinstaller"), "--specpath", str(ROOT / "build"), str(ENTRY), ] ) suffix = ".exe" if sys.platform == "win32" else "" exe = DIST / (NAME + suffix) if onefile else DIST / NAME / (NAME + suffix) if not exe.is_file(): raise SystemExit(f"expected executable not found: {exe}") return exe def smoke(exe: Path) -> None: """--version, then a real dry-run against a fixture repository.""" _log("Smoke testing the executable") proc = subprocess.run([str(exe), "--version"], capture_output=True, text=True) if proc.returncode != 0 or "git-auto-tracker" not in proc.stdout: raise SystemExit(f"smoke test failed: --version returned {proc.returncode}") with tempfile.TemporaryDirectory(prefix="gat-smoke-") as tmp: repo = Path(tmp) / "project" repo.mkdir() _run(["git", "init", "-b", "main"], cwd=repo) _run(["git", "config", "user.name", "Smoke Test"], cwd=repo) _run(["git", "config", "user.email", "smoke@example.com"], cwd=repo) (repo / "README.md").write_text("v1\n", encoding="utf-8") _run(["git", "add", "."], cwd=repo) _run(["git", "commit", "-m", "initial"], cwd=repo) (repo / "README.md").write_text("v2\n", encoding="utf-8") cfg = Path(tmp) / "cfg.yaml" cfg.write_text( "repository:\n" f' path: "{repo.as_posix()}"\n' " branch: main\n" "logging:\n" " level: INFO\n" " console: true\n" " file: null\n", encoding="utf-8", ) proc = subprocess.run( [str(exe), "--config", str(cfg), "run", "--dry-run"], capture_output=True, text=True, encoding="utf-8", ) if proc.returncode != 0 or "SCADA automatic change capture" not in proc.stdout: print(proc.stdout, file=sys.stderr) print(proc.stderr, file=sys.stderr) raise SystemExit(f"smoke test failed: dry-run exited with {proc.returncode}") _log("Smoke test passed") def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument( "--onefile", action="store_true", help="single-file executable instead of onedir", ) parser.add_argument( "--skip-smoke", action="store_true", help="skip the smoke test" ) args = parser.parse_args() python = create_build_env() exe = build(python, args.onefile) _log(f"Executable: {exe}") if not args.skip_smoke: smoke(exe) _log("Done.") if __name__ == "__main__": main()