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()
|
||||||
@@ -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", "")
|
||||||
Reference in New Issue
Block a user