#!/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())