🚧 Add build script
This commit is contained in:
@@ -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()
|
||||
Reference in New Issue
Block a user