diff --git a/pyproject.toml b/pyproject.toml index 4c1d517..380ab78 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,6 +23,9 @@ dev = [ "pytest-cov>=5.0", "ruff>=0.6", ] +build = [ + "pyinstaller>=6.15", +] [tool.hatch.build.targets.wheel] packages = ["src/git_auto_tracker"] diff --git a/scripts/build_exe.py b/scripts/build_exe.py new file mode 100644 index 0000000..e5d0a10 --- /dev/null +++ b/scripts/build_exe.py @@ -0,0 +1,145 @@ +"""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() \ No newline at end of file