Files
Nighthawk42andClaude Opus 4.8 c0a5dc49bf Add PyInstaller/flet-pack build tooling for standalone exes (v2.0.0-alpha)
Add packaging/ with entry scripts and build_exes.py: the CLI is frozen with
PyInstaller (one-file console) and the GUI with `flet pack` (PyInstaller plus
the bundled Flet desktop runtime). Both bundle settings.yaml as data so the
frozen binaries load their defaults via importlib.resources, and the script
zips the results into release/ for upload.

Bump version to 2.0.0a0, gitignore build artifacts (*.exe, *.zip, *.spec,
dist/build/release), add a `build` extra, and document pre-built binaries and
the build process in the README.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 21:31:53 -04:00

173 lines
5.4 KiB
Python

"""Build standalone Windows executables for the CLI and GUI.
* CLI -> PyInstaller (one-file, console).
* GUI -> ``flet pack`` (PyInstaller under the hood, but with the Flet desktop
runtime bundled — plain PyInstaller cannot ship a working Flet app).
Both bundle the package's ``settings.yaml`` as data so the frozen binaries can
read their default configuration via ``importlib.resources``.
Usage (from the repo root, in an env with the ``build`` extra installed)::
uv pip install -e ".[build]"
python packaging/build_exes.py # build both + zip
python packaging/build_exes.py --skip-gui # CLI only
python packaging/build_exes.py --no-zip # don't produce the release zip
Artifacts land in ``dist/`` and the zip in ``release/``. All of that is
gitignored.
"""
from __future__ import annotations
import argparse
import os
import shutil
import subprocess
import sys
import zipfile
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
PKG_DIR = REPO_ROOT / "src" / "bnet_auth_tool"
SETTINGS = PKG_DIR / "settings.yaml"
DIST = REPO_ROOT / "dist"
# `flet pack` wipes its --distpath, so give the GUI its own and copy the result
# into dist/ afterwards. That way CLI and GUI builds never clobber each other.
GUI_DIST = REPO_ROOT / "build" / "gui-dist"
RELEASE = REPO_ROOT / "release"
ENTRY_CLI = REPO_ROOT / "packaging" / "pyi_entry_cli.py"
ENTRY_GUI = REPO_ROOT / "packaging" / "pyi_entry_gui.py"
CLI_NAME = "bnet-auth"
GUI_NAME = "bnet-auth-gui"
DATA_SEP = ";" if os.name == "nt" else ":"
EXE_SUFFIX = ".exe" if os.name == "nt" else ""
def _version() -> str:
sys.path.insert(0, str(REPO_ROOT / "src"))
from bnet_auth_tool import __version__
return __version__
def _release_label(version: str) -> str:
# PEP 440 "2.0.0a0" -> human "2.0.0-alpha" for tags/zip names.
return version.replace("a0", "-alpha").replace("b0", "-beta").replace("rc0", "-rc")
def _run(cmd: list[str]) -> None:
print(f"\n$ {' '.join(cmd)}\n", flush=True)
subprocess.run(cmd, check=True, cwd=REPO_ROOT)
def build_cli() -> Path:
_run(
[
sys.executable,
"-m",
"PyInstaller",
"--onefile",
"--console",
"--clean",
"--noconfirm",
"--name",
CLI_NAME,
"--add-data",
f"{SETTINGS}{DATA_SEP}bnet_auth_tool",
str(ENTRY_CLI),
]
)
return DIST / f"{CLI_NAME}{EXE_SUFFIX}"
def build_gui() -> Path:
# `flet pack` wraps PyInstaller and bundles the Flet desktop runtime.
# Its --add-data uses "source:destination"; pass a *relative* source so the
# drive-letter colon in an absolute Windows path doesn't confuse the parser.
settings_rel = SETTINGS.relative_to(REPO_ROOT).as_posix()
_run(
[
sys.executable,
"-m",
"flet.cli",
"pack",
"-y", # don't prompt to delete the build dir
str(ENTRY_GUI),
"--name",
GUI_NAME,
"--distpath",
str(GUI_DIST),
"--add-data",
f"{settings_rel}:bnet_auth_tool",
]
)
# Copy the GUI exe into the shared dist/ so it sits next to the CLI exe.
built = GUI_DIST / f"{GUI_NAME}{EXE_SUFFIX}"
DIST.mkdir(exist_ok=True)
dest = DIST / f"{GUI_NAME}{EXE_SUFFIX}"
if built.exists():
shutil.copy2(built, dest)
return dest
def make_zip(exes: list[Path], version: str) -> Path:
RELEASE.mkdir(exist_ok=True)
label = _release_label(version)
plat = "windows-x64" if os.name == "nt" else os.name
zip_path = RELEASE / f"bnet-auth-tool-{label}-{plat}.zip"
readme = (
f"Battle.net Authenticator Tool {label}\n"
f"{'=' * 40}\n\n"
"bnet-auth.exe - command-line interface (run in a terminal)\n"
"bnet-auth-gui.exe - desktop GUI (double-click)\n\n"
"Both share one encrypted vault in your user data dir. ALPHA build:\n"
"online attach/retrieve is unverified against Blizzard's API; the\n"
"offline vault/TOTP/migration features are the supported core.\n\n"
"Source & docs: https://github.com/Nighthawk42/bnet_auth_tool\n"
)
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
for exe in exes:
zf.write(exe, exe.name)
zf.writestr("README.txt", readme)
return zip_path
def main() -> int:
parser = argparse.ArgumentParser(description="Build CLI/GUI executables.")
parser.add_argument("--skip-gui", action="store_true", help="build the CLI only")
parser.add_argument("--skip-cli", action="store_true", help="build the GUI only")
parser.add_argument("--no-zip", action="store_true", help="skip the release zip")
args = parser.parse_args()
version = _version()
print(f"Building bnet-auth-tool {version} ({_release_label(version)})")
exes: list[Path] = []
if not args.skip_cli:
exes.append(build_cli())
if not args.skip_gui:
exes.append(build_gui())
missing = [str(p) for p in exes if not p.exists()]
if missing:
print(f"\nERROR: expected artifacts not found: {missing}", file=sys.stderr)
return 1
print("\nBuilt:")
for exe in exes:
print(f" {exe} ({exe.stat().st_size / 1_000_000:.1f} MB)")
if not args.no_zip and exes:
zip_path = make_zip(exes, version)
print(f"\nPackaged: {zip_path}")
return 0
if __name__ == "__main__":
raise SystemExit(main())