04/22 Add build_app script
This commit is contained in:
+317
@@ -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()
|
||||
Reference in New Issue
Block a user