"""Shared PyInstaller driver for product GUI standalone folders.""" from __future__ import annotations import os import shutil import subprocess import sys from dataclasses import dataclass, field from pathlib import Path from typing import Callable _BUILD_ROOT = Path(__file__).resolve().parents[2] if str(_BUILD_ROOT) not in sys.path: sys.path.insert(0, str(_BUILD_ROOT)) from common.paths import PYINSTALLER_WORK_DIR, REPO_ROOT, gui_dist_dir @dataclass class GuiBuildConfig: product: str entry_script: Path exe_name: str dist_name: str hidden_imports: list[str] = field(default_factory=list) exclude_modules: list[str] = field(default_factory=list) data_dirs: list[tuple[str, str]] = field(default_factory=list) readme_lines: list[str] = field(default_factory=list) windowed: bool = True upx_dir: str | None = r"C:\tools\upx" logo_name: str = "logo.png" work_dir: Path = PYINSTALLER_WORK_DIR spec_dir: Path = REPO_ROOT / "build" / "pyinstaller_work" def clean_dir(path: Path) -> None: if path.exists(): shutil.rmtree(path) path.mkdir(parents=True) def build_with_pyinstaller( config: GuiBuildConfig, *, verify_fn: Callable[[Path], None] | None = None, ) -> Path: """Run PyInstaller for one GUI product and copy bundled data folders.""" print("Using Python:", sys.executable) print(f"Building GUI product: {config.product}") dist_dir = gui_dist_dir(config.dist_name) clean_dir(config.work_dir) clean_dir(dist_dir) config.spec_dir.mkdir(parents=True, exist_ok=True) entry = config.entry_script if not entry.is_file(): raise FileNotFoundError(f"GUI entry script not found: {entry}") cmd = [ sys.executable, "-m", "PyInstaller", "--onedir", "--name", config.dist_name, f"--distpath={dist_dir.parent}", f"--workpath={config.work_dir}", f"--specpath={config.spec_dir}", "--noconfirm", "--clean", "--windowed" if config.windowed else "--console", ] for src_rel, dest_rel in config.data_dirs: src = (REPO_ROOT / src_rel).resolve() if not src.is_dir(): raise FileNotFoundError(f"Data directory missing: {src}") cmd.append(f"--add-data={src};{dest_rel}") logo = REPO_ROOT / config.logo_name if logo.is_file(): cmd.append(f"--add-data={logo.resolve()};.") for mod in config.hidden_imports: cmd.append(f"--hidden-import={mod}") for mod in config.exclude_modules: cmd.append(f"--exclude-module={mod}") if config.upx_dir and Path(config.upx_dir).is_dir(): cmd.append(f"--upx-dir={config.upx_dir}") cmd.append(str(entry.resolve())) env = os.environ.copy() env["PYTHONPATH"] = ( str((REPO_ROOT / "src").resolve()) + os.pathsep + str((REPO_ROOT / "examples").resolve()) + os.pathsep + env.get("PYTHONPATH", "") ) print("\n=== Running PyInstaller ===") print("Command:", " ".join(cmd)) subprocess.check_call(cmd, env=env, cwd=REPO_ROOT) final_exe = dist_dir / config.exe_name if not final_exe.is_file(): raise RuntimeError(f"Executable not found at {final_exe}") print("\n=== Copying data directories ===") for src_rel, dest_rel in config.data_dirs: src = REPO_ROOT / src_rel dest = dist_dir / dest_rel if not src.is_dir(): raise FileNotFoundError(f"Data directory not found: {src}") if dest.exists(): shutil.rmtree(dest) shutil.copytree(src, dest, dirs_exist_ok=True) print(f" {src} -> {dest}") if logo.is_file(): shutil.copy2(logo, dist_dir / config.logo_name) print(" Logo copied") if config.readme_lines: (dist_dir / "README.txt").write_text("\n".join(config.readme_lines) + "\n", encoding="utf-8") print(f"\n[SUCCESS] Standalone GUI folder: {dist_dir}") print(f"Executable: {final_exe}") if verify_fn is not None: try: print("\n=== Smoke test ===") verify_fn(dist_dir) except Exception as exc: print(f"\n[WARNING] Smoke test failed: {exc}") return dist_dir