145 lines
4.8 KiB
Python
145 lines
4.8 KiB
Python
"""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() |