Add PyInstaller Windows release build system.
Frozen builds resolve paths correctly, stage portable onedir output, and zip releases for distribution.
This commit is contained in:
322
build/build_release.py
Normal file
322
build/build_release.py
Normal file
@@ -0,0 +1,322 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Production Windows release builder for GODCWAK (PyInstaller onedir).
|
||||
|
||||
Usage:
|
||||
python build/build_release.py
|
||||
python build/build_release.py --debug # console window for troubleshooting
|
||||
python build/build_release.py --gpu # bundle CuPy/Numba if installed
|
||||
python build/build_release.py --clean --no-zip
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import textwrap
|
||||
import zipfile
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
BUILD_DIR = ROOT / "build"
|
||||
DIST_DIR = ROOT / "dist"
|
||||
RELEASE_DIR = ROOT / "release"
|
||||
SPEC_PATH = ROOT / "GODCWAK.spec"
|
||||
APP_NAME = "GODCWAK"
|
||||
APP_VERSION = "1.0.0"
|
||||
|
||||
|
||||
def _read_app_version() -> str:
|
||||
config = ROOT / "config.py"
|
||||
if not config.is_file():
|
||||
return APP_VERSION
|
||||
for line in config.read_text(encoding="utf-8").splitlines():
|
||||
if line.strip().startswith("APP_VERSION"):
|
||||
return line.split("=", 1)[1].strip().strip('"').strip("'")
|
||||
return APP_VERSION
|
||||
|
||||
|
||||
def _python_exe() -> str:
|
||||
return sys.executable
|
||||
|
||||
|
||||
def _ensure_pyinstaller() -> None:
|
||||
try:
|
||||
import PyInstaller # noqa: F401
|
||||
except ImportError as exc:
|
||||
raise SystemExit(
|
||||
"PyInstaller is not installed. Run:\n"
|
||||
f" {_python_exe()} -m pip install -r requirements-build.txt"
|
||||
) from exc
|
||||
|
||||
|
||||
def _write_spec(*, console: bool, include_gpu: bool, icon: Path | None) -> None:
|
||||
version_file = BUILD_DIR / "version_info.txt"
|
||||
exe_kwargs = []
|
||||
if version_file.is_file():
|
||||
exe_kwargs.append("version=str(ROOT / 'build' / 'version_info.txt'),")
|
||||
if icon:
|
||||
exe_kwargs.append("icon=str(ROOT / 'assets' / 'icon.ico'),")
|
||||
exe_extra = "\n ".join(exe_kwargs)
|
||||
|
||||
gpu_block = ""
|
||||
if not include_gpu:
|
||||
gpu_block = """
|
||||
excludes += [
|
||||
"cupy",
|
||||
"cupy_backends",
|
||||
"numba",
|
||||
"llvmlite",
|
||||
]
|
||||
"""
|
||||
|
||||
lines = [
|
||||
"# -*- mode: python ; coding: utf-8 -*-",
|
||||
"# Generated by build/build_release.py — do not edit by hand.",
|
||||
"from pathlib import Path",
|
||||
"",
|
||||
"from PyInstaller.utils.hooks import collect_all, collect_submodules",
|
||||
"",
|
||||
"block_cipher = None",
|
||||
f'ROOT = Path(r"{ROOT.as_posix()}")',
|
||||
"",
|
||||
'ctk_datas, ctk_binaries, ctk_hidden = collect_all("customtkinter")',
|
||||
'dark_datas, dark_binaries, dark_hidden = collect_all("darkdetect")',
|
||||
"",
|
||||
"datas = [",
|
||||
' (str(ROOT / "config_ollama.json"), "."),',
|
||||
"]",
|
||||
"assets = ROOT / \"assets\"",
|
||||
"if assets.is_dir():",
|
||||
' datas.append((str(assets), "assets"))',
|
||||
"",
|
||||
"datas += ctk_datas + dark_datas",
|
||||
"binaries = ctk_binaries + dark_binaries",
|
||||
"",
|
||||
"hiddenimports = [",
|
||||
' "PIL._tkinter_finder",',
|
||||
' "PIL.Image",',
|
||||
' "PIL.ImageTk",',
|
||||
' "aiohttp",',
|
||||
' "aiohttp_socks",',
|
||||
' "multidict",',
|
||||
' "yarl",',
|
||||
' "frozenlist",',
|
||||
' "aiosignal",',
|
||||
' "async_timeout",',
|
||||
' "charset_normalizer",',
|
||||
' "certifi",',
|
||||
' "socks",',
|
||||
' "urllib3",',
|
||||
' "requests",',
|
||||
' "numpy",',
|
||||
' "darkdetect",',
|
||||
' "config",',
|
||||
' "main",',
|
||||
"]",
|
||||
"hiddenimports += ctk_hidden + dark_hidden",
|
||||
'hiddenimports += collect_submodules("src")',
|
||||
"",
|
||||
"excludes = [",
|
||||
' "playwright",',
|
||||
' "matplotlib",',
|
||||
' "scipy",',
|
||||
' "pandas",',
|
||||
' "pytest",',
|
||||
' "IPython",',
|
||||
' "jupyter",',
|
||||
' "tkinter.test",',
|
||||
"]",
|
||||
gpu_block.rstrip(),
|
||||
"",
|
||||
"a = Analysis(",
|
||||
' [str(ROOT / "main.py")],',
|
||||
" pathex=[str(ROOT)],",
|
||||
" binaries=binaries,",
|
||||
" datas=datas,",
|
||||
" hiddenimports=hiddenimports,",
|
||||
' hookspath=[str(ROOT / "build" / "hooks")],',
|
||||
" hooksconfig={},",
|
||||
' runtime_hooks=[str(ROOT / "build" / "pyi_rth_godcwak.py")],',
|
||||
" excludes=excludes,",
|
||||
" win_no_prefer_redirects=False,",
|
||||
" win_private_assemblies=False,",
|
||||
" cipher=block_cipher,",
|
||||
" noarchive=False,",
|
||||
")",
|
||||
"",
|
||||
"pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)",
|
||||
"",
|
||||
"exe = EXE(",
|
||||
" pyz,",
|
||||
" a.scripts,",
|
||||
" [],",
|
||||
" exclude_binaries=True,",
|
||||
f' name="{APP_NAME}",',
|
||||
" debug=False,",
|
||||
" bootloader_ignore_signals=False,",
|
||||
" strip=False,",
|
||||
" upx=True,",
|
||||
f" console={console},",
|
||||
" disable_windowed_traceback=False,",
|
||||
" argv_emulation=False,",
|
||||
" target_arch=None,",
|
||||
" codesign_identity=None,",
|
||||
" entitlements_file=None,",
|
||||
]
|
||||
if exe_extra:
|
||||
lines.append(f" {exe_extra}")
|
||||
lines.extend([
|
||||
")",
|
||||
"",
|
||||
"coll = COLLECT(",
|
||||
" exe,",
|
||||
" a.binaries,",
|
||||
" a.zipfiles,",
|
||||
" a.datas,",
|
||||
" strip=False,",
|
||||
" upx=True,",
|
||||
" upx_exclude=[],",
|
||||
f' name="{APP_NAME}",',
|
||||
")",
|
||||
"",
|
||||
])
|
||||
SPEC_PATH.write_text("\n".join(lines), encoding="utf-8")
|
||||
|
||||
|
||||
def _run_pyinstaller(*, clean: bool) -> None:
|
||||
cmd = [
|
||||
_python_exe(),
|
||||
"-m",
|
||||
"PyInstaller",
|
||||
"--noconfirm",
|
||||
str(SPEC_PATH),
|
||||
"--distpath",
|
||||
str(DIST_DIR),
|
||||
"--workpath",
|
||||
str(BUILD_DIR / "pyinstaller"),
|
||||
]
|
||||
if clean:
|
||||
cmd.insert(4, "--clean")
|
||||
print("Running:", " ".join(cmd))
|
||||
subprocess.run(cmd, cwd=str(ROOT), check=True)
|
||||
|
||||
|
||||
def _stage_release(app_dir: Path, version: str, *, console: bool) -> None:
|
||||
"""Copy user-facing files next to the exe (writable config/data live here)."""
|
||||
app_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
config_dst = app_dir / "config_ollama.json"
|
||||
if not config_dst.exists():
|
||||
shutil.copy2(ROOT / "config_ollama.json", config_dst)
|
||||
|
||||
data_dir = app_dir / "data"
|
||||
data_dir.mkdir(exist_ok=True)
|
||||
readme = data_dir / "README.txt"
|
||||
if not readme.exists():
|
||||
readme.write_text(
|
||||
"GODCWAK stores passwords, proxies, and attempt logs in this folder.\n"
|
||||
"Keep this directory backed up and out of version control.\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
launcher = app_dir / "Start GODCWAK.bat"
|
||||
launcher.write_text(
|
||||
textwrap.dedent(
|
||||
f"""\
|
||||
@echo off
|
||||
title {APP_NAME}
|
||||
cd /d "%~dp0"
|
||||
start "" "{APP_NAME}.exe"
|
||||
"""
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
manifest = {
|
||||
"app": APP_NAME,
|
||||
"version": version,
|
||||
"built_at": datetime.now(timezone.utc).isoformat(),
|
||||
"platform": "win64",
|
||||
"python": sys.version.split()[0],
|
||||
"console": console,
|
||||
}
|
||||
(app_dir / "build_manifest.json").write_text(
|
||||
json.dumps(manifest, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def _create_zip(app_dir: Path, version: str) -> Path:
|
||||
RELEASE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
stamp = datetime.now(timezone.utc).strftime("%Y%m%d")
|
||||
zip_path = RELEASE_DIR / f"{APP_NAME}-v{version}-win64-{stamp}.zip"
|
||||
if zip_path.exists():
|
||||
zip_path.unlink()
|
||||
|
||||
with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as zf:
|
||||
for path in sorted(app_dir.rglob("*")):
|
||||
if path.is_file():
|
||||
zf.write(path, path.relative_to(app_dir.parent).as_posix())
|
||||
return zip_path
|
||||
|
||||
|
||||
def _parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Build GODCWAK Windows release with PyInstaller")
|
||||
parser.add_argument(
|
||||
"--debug",
|
||||
action="store_true",
|
||||
help="Build with a console window (for troubleshooting)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--gpu",
|
||||
action="store_true",
|
||||
help="Include CuPy/Numba in the bundle if installed (much larger build)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--clean",
|
||||
action="store_true",
|
||||
help="Clean PyInstaller cache before building",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-zip",
|
||||
action="store_true",
|
||||
help="Skip creating release/*.zip archive",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = _parse_args()
|
||||
version = _read_app_version()
|
||||
icon = ROOT / "assets" / "icon.ico"
|
||||
|
||||
os.chdir(ROOT)
|
||||
_ensure_pyinstaller()
|
||||
|
||||
print(f"Building {APP_NAME} v{version} (console={'yes' if args.debug else 'no'}, gpu={'yes' if args.gpu else 'no'})")
|
||||
_write_spec(console=args.debug, include_gpu=args.gpu, icon=icon if icon.is_file() else None)
|
||||
_run_pyinstaller(clean=args.clean)
|
||||
|
||||
app_dir = DIST_DIR / APP_NAME
|
||||
if not (app_dir / f"{APP_NAME}.exe").is_file():
|
||||
raise SystemExit(f"Build failed: missing {app_dir / f'{APP_NAME}.exe'}")
|
||||
|
||||
_stage_release(app_dir, version, console=args.debug)
|
||||
|
||||
print(f"\nBuild complete: {app_dir}")
|
||||
print(f"Run: {app_dir / f'{APP_NAME}.exe'}")
|
||||
|
||||
if not args.no_zip:
|
||||
zip_path = _create_zip(app_dir, version)
|
||||
print(f"Release archive: {zip_path}")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user