Investment Plans workspace
Open raw ↗
#!/usr/bin/env python3
"""Build automation script for HAD Digital MVP.

Packages the application into a zero-dependency standalone Windows executable (.exe)
using PyInstaller. Supports single-file (.exe) and directory bundle modes.

Usage:
    python build_exe.py [--mode {onefile,onedir}] [--clean] [--verify]
"""

import argparse
import os
import shutil
import subprocess
import sys
import time
from pathlib import Path

# Paths relative to project root
current_dir = Path(__file__).resolve().parent
if current_dir.name in ("04_Build", "05_Test"):
    PROJECT_ROOT = current_dir.parent
else:
    PROJECT_ROOT = current_dir

MVP_DIR = PROJECT_ROOT / "MVP"
BUILD_DIR = PROJECT_ROOT / "04_Build" / "build"
DIST_DIR = PROJECT_ROOT / "dist"
BUILD_DIST_DIR = PROJECT_ROOT / "04_Build" / "dist"
SPEC_FILE = PROJECT_ROOT / "04_Build" / "HAD Digital.spec"
VERSION_FILE = PROJECT_ROOT / "MVP" / "VERSION.txt"


def print_banner():
    print("""
====================================================================
           HAD Digital MVP -- Automated Build System
      Zero-Dependency Standalone Windows Executable (.exe)
====================================================================
""")



def check_prerequisites():
    """Ensure required tools and source files exist."""
    print("[1/5] Checking build prerequisites...")

    # Check Python version
    if sys.version_info < (3, 10):
        print(f"[-] ERROR: Python 3.10+ required. Current version: {sys.version}")
        sys.exit(1)
    print(f"  [+] Python version: {sys.version.split()[0]} (OK)")

    # Check PyInstaller
    try:
        res = subprocess.run(
            [sys.executable, "-m", "PyInstaller", "--version"],
            capture_output=True,
            text=True,
            check=True,
        )
        print(f"  [+] PyInstaller version: {res.stdout.strip()} (OK)")
    except (subprocess.SubprocessError, FileNotFoundError):
        print("[-] ERROR: PyInstaller is not installed. Run: pip install pyinstaller")
        sys.exit(1)

    # Check critical source files
    critical_files = [
        MVP_DIR / "app.py",
        MVP_DIR / "database.py",
        MVP_DIR / "config_manager.py",
        MVP_DIR / "user_store.py",
        MVP_DIR / "ctcae_engine.py",
        MVP_DIR / "alert_engine.py",
        MVP_DIR / "data" / "ctcae_rules.json",
        MVP_DIR / "guidance" / "guidance.json",
        MVP_DIR / "static" / "index.html",
        MVP_DIR / "static" / "app.css",
        MVP_DIR / "static" / "app.js",
    ]
    for p in critical_files:
        if not p.exists():
            print(f"[-] ERROR: Missing required source file: {p}")
            sys.exit(1)
    print("  [+] All required source files present (OK)")


def kill_existing_processes():
    """Terminate any lingering HAD Digital processes to release Windows file locks."""
    try:
        subprocess.run(
            ["taskkill", "/F", "/IM", "HAD Digital.exe"],
            capture_output=True,
            check=False,
        )
    except Exception:
        pass
    time.sleep(0.5)


def clean_artifacts():
    """Remove previous build outputs."""
    print("[2/5] Cleaning prior build artifacts...")
    kill_existing_processes()
    targets = [
        BUILD_DIR,
        BUILD_DIST_DIR,
        DIST_DIR / "HAD Digital.exe",
        DIST_DIR / "HAD Digital",
        PROJECT_ROOT / "build",
        PROJECT_ROOT / "HAD Digital.spec",
        MVP_DIR / "build",
    ]
    for target in targets:
        if target.exists():
            print(f"  [-] Removing {target}")
            if target.is_dir():
                shutil.rmtree(target, ignore_errors=True)
            else:
                for _ in range(5):
                    try:
                        target.unlink()
                        break
                    except OSError:
                        time.sleep(0.5)



def stamp_version():
    """Read or create VERSION.txt metadata."""
    print("[3/5] Stamping version metadata...")
    version = "1.0.0"
    if VERSION_FILE.exists():
        try:
            content = VERSION_FILE.read_text(encoding="utf-8-sig")
            for line in content.splitlines():
                line = line.strip()
                if line.lower().startswith("version:"):
                    version = line.split(":", 1)[1].strip()
                    break
        except OSError:
            pass
    timestamp = time.strftime("%Y-%m-%d %H:%M:%S UTC", time.gmtime())
    print(f"  [+] Version: {version} | Built: {timestamp}")
    return version



def run_pyinstaller(mode="onefile"):
    """Execute PyInstaller build."""
    print(f"[4/5] Running PyInstaller packaging in '{mode}' mode...")

    DIST_DIR.mkdir(parents=True, exist_ok=True)
    BUILD_DIST_DIR.mkdir(parents=True, exist_ok=True)
    BUILD_DIR.mkdir(parents=True, exist_ok=True)

    if mode == "onefile" and SPEC_FILE.exists():
        cmd = [
            sys.executable,
            "-m",
            "PyInstaller",
            "--noconfirm",
            f"--workpath={BUILD_DIR}",
            f"--distpath={DIST_DIR}",
            str(SPEC_FILE),
        ]
    else:
        # Construct CLI invocation
        static_data = f"{MVP_DIR / 'static'};static"
        guidance_data = f"{MVP_DIR / 'guidance'};guidance"
        rules_data = f"{MVP_DIR / 'data' / 'ctcae_rules.json'};data"

        cmd = [
            sys.executable,
            "-m",
            "PyInstaller",
            "--noconfirm",
            "--name", "HAD Digital",
            f"--workpath={BUILD_DIR}",
            f"--distpath={DIST_DIR}",
            f"--paths={MVP_DIR}",
            f"--add-data={static_data}",
            f"--add-data={guidance_data}",
            f"--add-data={rules_data}",
            "--hidden-import=adapters",
            "--hidden-import=adapters.base",
            "--hidden-import=adapters.stub",
            "--hidden-import=adapters.glm",
            "--hidden-import=http.server",
            "--hidden-import=sqlite3",
            "--hidden-import=hashlib",
            "--hidden-import=json",
            "--hidden-import=uuid",
            "--hidden-import=datetime",
            "--hidden-import=urllib.parse",
            "--hidden-import=urllib.request",
            "--hidden-import=urllib.error",
            "--hidden-import=threading",
            "--hidden-import=secrets",
            "--hidden-import=email",
            "--hidden-import=email.message",
            "--hidden-import=email.parser",
            "--hidden-import=email.utils",
            "--exclude-module=tkinter",
            "--exclude-module=matplotlib",
            "--exclude-module=scipy",
            "--exclude-module=numpy",
            "--exclude-module=pandas",
            "--exclude-module=openpyxl",
            "--exclude-module=PIL",
            "--exclude-module=pytest",
            "--exclude-module=playwright",
            "--exclude-module=boto3",
            "--exclude-module=botocore",
            "--exclude-module=torch",
            "--exclude-module=unittest",
            "--exclude-module=xmlrpc",
            "--exclude-module=pydantic",
            "--exclude-module=fastapi",
            "--exclude-module=starlette",
            "--exclude-module=uvicorn",
            "--exclude-module=sqlalchemy",
            "--console",
        ]

        if mode == "onefile":
            cmd.append("--onefile")
        else:
            cmd.append("--onedir")

        cmd.append(str(MVP_DIR / "app.py"))

    print(f"  [>] Command: {' '.join(cmd)}")
    start_time = time.time()
    result = subprocess.run(cmd)
    elapsed = time.time() - start_time

    if result.returncode != 0:
        print(f"[-] ERROR: PyInstaller failed with return code {result.returncode}")
        sys.exit(result.returncode)

    # Mirror to 04_Build/dist/ so both dist/ and 04_Build/dist/ contain the output
    if mode == "onefile":
        built_exe = DIST_DIR / "HAD Digital.exe"
        if built_exe.exists():
            target_copy = BUILD_DIST_DIR / "HAD Digital.exe"
            try:
                shutil.copy2(built_exe, target_copy)
            except OSError:
                pass
    else:
        built_dir = DIST_DIR / "HAD Digital"
        if built_dir.exists():
            target_dir = BUILD_DIST_DIR / "HAD Digital"
            try:
                if target_dir.exists():
                    shutil.rmtree(target_dir, ignore_errors=True)
                shutil.copytree(built_dir, target_dir)
            except OSError:
                pass

    print(f"  [+] Packaging finished in {elapsed:.1f}s")


def verify_output(mode="onefile"):
    """Verify generated artifacts."""
    print("[5/5] Verifying generated build output...")
    if mode == "onefile":
        exe_path = DIST_DIR / "HAD Digital.exe"
        if not exe_path.exists():
            print(f"[-] ERROR: Expected output executable not found: {exe_path}")
            sys.exit(1)
        size_bytes = exe_path.stat().st_size
        size_mb = size_bytes / (1024 * 1024)
        print(f"  [+] Standalone Executable: {exe_path}")
        print(f"  [+] Binary Size: {size_mb:.2f} MB ({size_bytes:,} bytes)")
        if size_mb < 20.0:
            print(f"  [+] Size constraint satisfied: {size_mb:.2f} MB < 20.0 MB threshold (PASS)")
        else:
            print(f"  [!] WARNING: Binary size exceeds 20 MB threshold: {size_mb:.2f} MB")
        print("  [+] Mode: Single standalone executable (.exe) with zero host dependencies")
    else:
        bundle_dir = DIST_DIR / "HAD Digital"
        exe_path = bundle_dir / "HAD Digital.exe"
        if not exe_path.exists():
            print(f"[-] ERROR: Expected bundle executable not found: {exe_path}")
            sys.exit(1)
        print(f"  [+] Directory Bundle: {bundle_dir}")
        print(f"  [+] Executable: {exe_path}")

    print("\n[SUCCESS] Build completed successfully!")


def run_verification_test(mode="onefile"):
    """Run programmatic verification test suite against the built binary."""
    print("\n[*] Running programmatic verification script (05_Test/verify_mvp.py)...")
    verify_script = PROJECT_ROOT / "05_Test" / "verify_mvp.py"
    if not verify_script.exists():
        print(f"[-] ERROR: Verification script not found at {verify_script}")
        sys.exit(1)

    if mode == "onefile":
        exe_target = DIST_DIR / "HAD Digital.exe"
        cmd = [sys.executable, str(verify_script), "--exe", "--exe-path", str(exe_target)]
    else:
        exe_target = DIST_DIR / "HAD Digital" / "HAD Digital.exe"
        cmd = [sys.executable, str(verify_script), "--exe", "--exe-path", str(exe_target)]

    print(f"  [>] Command: {' '.join(cmd)}")
    res = subprocess.run(cmd)
    if res.returncode != 0:
        print(f"[-] ERROR: Verification script failed with return code {res.returncode}")
        sys.exit(res.returncode)
    print("  [+] Programmatic verification PASSED cleanly!")


def main():
    parser = argparse.ArgumentParser(description="Build standalone Windows executable for HAD Digital MVP")
    parser.add_argument(
        "--mode",
        choices=["onefile", "onedir"],
        default="onefile",
        help="Packaging mode: 'onefile' (single .exe, default) or 'onedir' (folder bundle)",
    )
    parser.add_argument("--clean", action="store_true", help="Clean build directories before packaging")
    parser.add_argument("--verify", action="store_true", help="Run programmatic verification test after build")
    args = parser.parse_args()

    print_banner()
    check_prerequisites()
    if args.clean:
        clean_artifacts()
    stamp_version()
    run_pyinstaller(mode=args.mode)
    verify_output(mode=args.mode)

    if args.verify:
        run_verification_test(mode=args.mode)


if __name__ == "__main__":
    main()