From bcfbf516dfa077fc291c7b9585c0bbb1ceb12072 Mon Sep 17 00:00:00 2001 From: NguyenND Date: Wed, 22 Apr 2026 13:16:40 -0400 Subject: [PATCH] 04/22 Add build_app script --- build_app.py | 317 +++++++++++++++++++++++++++++++++++++++++++++++++++ hook_path.py | 29 +++++ 2 files changed, 346 insertions(+) create mode 100644 build_app.py create mode 100644 hook_path.py diff --git a/build_app.py b/build_app.py new file mode 100644 index 0000000..aa28107 --- /dev/null +++ b/build_app.py @@ -0,0 +1,317 @@ +#!/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. Copy config.ini into the dist/WebsiteChecker/ folder before + distributing to users, OR let users complete the first-run + database setup dialog on first launch. + 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 + 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", + "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(" [ ] Copy config.ini into the output folder (or let users") + print(" complete the first-run setup dialog on launch)") + 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() \ No newline at end of file diff --git a/hook_path.py b/hook_path.py new file mode 100644 index 0000000..7f1b10f --- /dev/null +++ b/hook_path.py @@ -0,0 +1,29 @@ +""" +hook_path.py — PyInstaller runtime hook for Website Checker. + +Executed by the PyInstaller bootloader before any Python code runs. +Prepends the bundle's _internal/ directory (where python3xx.dll lives) +to the Windows PATH so that LoadLibrary() can find the VC++ runtime DLLs +(vcruntime140.dll, msvcp140.dll, etc.) that python3xx.dll depends on. + +This resolves the error: + "Failed to load Python DLL ... python3xx.dll. + LoadLibrary: The specified module could not be found." + +Without this hook the DLL is present in the bundle but Windows cannot +satisfy its own import chain because the VC++ DLLs are not on PATH. +""" +import os +import sys + +# sys._MEIPASS is set by PyInstaller to the bundle's extraction directory +# (_internal/ for one-folder builds, a temp dir for --onefile builds). +try: + _bundle_dir = sys._MEIPASS +except AttributeError: + # Fallback when running outside a PyInstaller bundle (e.g. during testing) + _bundle_dir = os.path.dirname(os.path.abspath(sys.executable)) + +# Prepend — not append — so the bundle's DLLs take precedence over any +# system-wide copies that may be an incompatible version. +os.environ["PATH"] = _bundle_dir + os.pathsep + os.environ.get("PATH", "") \ No newline at end of file