🚧 Add build script

This commit is contained in:
Jonas Bengtsson
2026-09-12 23:32:42 +02:00
parent 2e24683347
commit 1d67330f27
2 changed files with 148 additions and 0 deletions
+3
View File
@@ -23,6 +23,9 @@ dev = [
"pytest-cov>=5.0", "pytest-cov>=5.0",
"ruff>=0.6", "ruff>=0.6",
] ]
build = [
"pyinstaller>=6.15",
]
[tool.hatch.build.targets.wheel] [tool.hatch.build.targets.wheel]
packages = ["src/git_auto_tracker"] packages = ["src/git_auto_tracker"]
+145
View File
@@ -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()