#!/usr/bin/env python3 """ build_app.py — Website Checker packaging helper Run this script to build a distributable bundle of Website Checker using PyInstaller. Usage ----- python build_app.py [--onefile] [--clean] [--no-upx] [--debug] Options ------- --onefile Build a single .exe instead of a one-folder bundle. The one-folder bundle is recommended for this app because matplotlib, reportlab, and plyer each bundle several data files that make a single .exe significantly slower to start. --clean Delete build/ and dist/ before building. --no-upx Disable UPX compression (use if UPX is not installed or triggers false-positive antivirus warnings). --debug Pass --debug=all to PyInstaller for verbose bootloader output. Output ------ dist/WebsiteChecker/ <- one-folder bundle (default, recommended) dist/WebsiteChecker.exe <- single-file bundle (--onefile) After Building -------------- 1. No config.ini required. DB credentials are stored in the OS keychain (Windows Credential Manager on Windows, Keychain on macOS). On first launch the Database Setup dialog will appear automatically. 2. app.log will be written to the same folder as the .exe at runtime. 3. The bundled app requires network access to the MySQL server on the configured port (default 3306). Prerequisites ------------- pip install pyinstaller keyring pip install -r requirements.txt """ import argparse import os import platform import shutil import subprocess import sys # ── Constants ───────────────────────────────────────────────────────────────── APP_NAME = "WebsiteChecker" SPEC_FILE = "WebsiteChecker.spec" ENTRY = "app.py" # ── Argument parsing ────────────────────────────────────────────────────────── def parse_args(): p = argparse.ArgumentParser( description="Build Website Checker with PyInstaller", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=__doc__, ) p.add_argument("--onefile", action="store_true", help="Build a single .exe (instead of one-folder bundle)") p.add_argument("--clean", action="store_true", help="Remove build/ and dist/ directories before building") p.add_argument("--no-upx", action="store_true", help="Disable UPX compression") p.add_argument("--debug", action="store_true", help="Pass --debug=all to PyInstaller for verbose output") return p.parse_args() # ── Checks ──────────────────────────────────────────────────────────────────── def check_environment(): """Verify all prerequisites are in order before starting the build.""" ok = True # Python version if sys.version_info < (3, 9): print("ERROR Python 3.9 or newer is required.") ok = False else: print(f" OK Python {sys.version.split()[0]}") # PyInstaller try: import PyInstaller print(f" OK PyInstaller {PyInstaller.__version__}") except ImportError: print("ERROR PyInstaller is not installed.") print(" Run: pip install pyinstaller") ok = False # Core app dependencies deps = { "mysql.connector": "mysql-connector-python", "bcrypt": "bcrypt", "openpyxl": "openpyxl", "cryptography": "cryptography", "keyring": "keyring", "matplotlib": "matplotlib", "plyer": "plyer", "reportlab": "reportlab", } for module, pkg in deps.items(): try: __import__(module) print(f" OK {pkg}") except ImportError: print(f"ERROR Missing dependency: {pkg}") print(f" Run: pip install {pkg}") ok = False # spec file if not os.path.exists(SPEC_FILE): print(f"ERROR {SPEC_FILE} not found.") print(f" Run this script from the Website Checker root directory.") ok = False else: print(f" OK {SPEC_FILE} found") # runtime hook if not os.path.exists("hook_path.py"): print(f"ERROR hook_path.py not found.") print(f" This file must sit alongside {SPEC_FILE} in the project root.") ok = False else: print(f" OK hook_path.py found") # entry point if not os.path.exists(ENTRY): print(f"ERROR {ENTRY} not found.") print(f" Run this script from the Website Checker root directory.") ok = False else: print(f" OK {ENTRY} found") print() if not ok: print("Build prerequisites not met. Resolve the errors above and retry.") sys.exit(1) # ── Clean ───────────────────────────────────────────────────────────────────── def clean_dirs(): for d in ("build", "dist", "__pycache__"): if os.path.exists(d): print(f" RM {d}/") shutil.rmtree(d) # Remove compiled bytecode from all sub-packages for root, dirs, files in os.walk("."): dirs[:] = [d for d in dirs if d not in ("dist", "build", ".git")] for f in files: if f.endswith(".pyc"): os.remove(os.path.join(root, f)) print() # ── Build ───────────────────────────────────────────────────────────────────── def _make_onefile_spec(base_spec_path): """ PyInstaller does not accept --onefile together with a .spec file. Instead, we generate a temporary spec that replaces the COLLECT block with a standalone EXE that bundles everything itself. The runtime_hook reference to hook_path.py is preserved unchanged. Returns the path to the temporary spec file. """ src = open(base_spec_path).read() # Replace 'exclude_binaries=True' with False so the EXE embeds binaries patched = src.replace("exclude_binaries=True", "exclude_binaries=False") # Comment out the COLLECT block — not used in onefile mode lines = patched.splitlines() out_lines = [] inside_collect = False for line in lines: if line.strip().startswith("coll = COLLECT("): inside_collect = True if inside_collect: out_lines.append("# " + line) if line.strip() == ")": inside_collect = False else: out_lines.append(line) onefile_spec = "\n".join(out_lines) tmp_path = base_spec_path.replace(".spec", "_onefile.spec") with open(tmp_path, "w") as fh: fh.write(onefile_spec) return tmp_path def build(args): spec_to_use = SPEC_FILE tmp_spec = None if args.onefile: print(f" BUILD Single-file executable ({APP_NAME}.exe)") print(f" INFO Generating temporary onefile spec (PyInstaller does not") print(f" accept --onefile alongside a .spec file)") tmp_spec = _make_onefile_spec(SPEC_FILE) spec_to_use = tmp_spec else: print(f" BUILD One-folder bundle (dist/{APP_NAME}/)") cmd = [ sys.executable, "-m", "PyInstaller", spec_to_use, "--noconfirm", ] if args.no_upx: cmd.append("--noupx") # correct PyInstaller 6.x spelling if args.debug: cmd.append("--debug=all") print(f" CMD {' '.join(cmd)}") print() result = subprocess.run(cmd, check=False) # Clean up the temporary spec if tmp_spec and os.path.exists(tmp_spec): os.remove(tmp_spec) return result.returncode # ── Post-build report ───────────────────────────────────────────────────────── def report(args, returncode): print() if returncode == 0: if args.onefile: exe = os.path.join("dist", f"{APP_NAME}.exe") out = exe else: out = os.path.join("dist", APP_NAME) exe = os.path.join(out, f"{APP_NAME}.exe") abs_out = os.path.abspath(out) print(f" BUILD SUCCEEDED") print(f" Output : {abs_out}") if not args.onefile: print(f" Run : {os.path.abspath(exe)}") print() print(" Post-build checklist:") print(" [ ] NO config.ini required — DB credentials are stored in the") print(" OS keychain (Windows Credential Manager / macOS Keychain).") print(" On first launch the Database Setup dialog will appear.") print(" [ ] Ensure keyring is installed: pip install keyring") print(" [ ] Test the .exe on a clean machine without Python installed") print(" [ ] Verify the MySQL connection works from the target machine") print(" [ ] Confirm app.log is written next to the .exe at runtime") if not args.onefile: try: import shutil as _sh size_mb = sum( os.path.getsize(os.path.join(dp, f)) for dp, _, files in os.walk(out) for f in files ) / 1_048_576 print(f"\n Bundle size: {size_mb:.1f} MB") except Exception: pass else: print(f" BUILD FAILED (exit code {returncode})") print() print(" Common fixes:") print(" * mysql.connector errors -> pip install mysql-connector-python") print(" * cryptography errors -> pip install cryptography") print(" * tkinter not found -> reinstall Python with tcl/tk support") print(" * Missing hidden import -> add it to hiddenimports in WebsiteChecker.spec") print(" * UPX errors -> run with --no-upx") print(" * plyer notification err -> add 'plyer.platforms.win.notification'") print(" to hiddenimports in the spec file") # ── Entry point ─────────────────────────────────────────────────────────────── def main(): print(f"Website Checker — Build Tool") print(f"Platform : {platform.system()} {platform.release()}") print(f"Python : {sys.executable}") print() args = parse_args() # Must be run from the project root if not os.path.exists(ENTRY): print(f"ERROR {ENTRY} not found.") print(f" Run build_app.py from the Website Checker root directory.") sys.exit(1) print("Checking prerequisites...") check_environment() if args.clean: print("Cleaning previous build artifacts...") clean_dirs() returncode = build(args) report(args, returncode) sys.exit(returncode) if __name__ == "__main__": main()