Files
WebChecker/build_app.py
T
2026-04-24 17:33:20 -04:00

320 lines
11 KiB
Python

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