320 lines
11 KiB
Python
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() |