#!/usr/bin/env python """ Build script: compile frontend, then package everything into a distributable folder via PyInstaller. Optionally build a Windows installer via Inno Setup. Usage: python build.py # full build (windowed desktop EXE) python build.py --clean # clean previous build artifacts first python build.py --installer # build EXE then compile installer """ import argparse import glob import os import shutil import subprocess import sys from PyInstaller.utils.hooks import collect_submodules ROOT = os.path.dirname(os.path.abspath(__file__)) BACKEND = os.path.join(ROOT, "backend") FRONTEND = os.path.join(ROOT, "frontend") DIST = os.path.join(ROOT, "dist") BUILD = os.path.join(ROOT, "build") INSTALLER_DIR = os.path.join(ROOT, "installer") ISS_SCRIPT = os.path.join(ROOT, "school-meal-setup.iss") FRONTEND_BUILD = os.path.join(FRONTEND, "dist") STATIC_FE = os.path.join(BACKEND, "static", "frontend") SPEC_FILE = os.path.join(ROOT, "school-meal.spec") def run(cmd, cwd=None, shell=False): print(f" > {cmd if isinstance(cmd, str) else ' '.join(cmd)}") result = subprocess.run(cmd, cwd=cwd, shell=shell) if result.returncode != 0: print(f"ERROR: command failed with exit code {result.returncode}") sys.exit(1) def clean(): print("[clean] Removing previous build artifacts...") for d in [DIST, BUILD, STATIC_FE]: if os.path.isdir(d): shutil.rmtree(d) for f in glob.glob(os.path.join(ROOT, "*.spec")): os.remove(f) def build_frontend(): print("\n[1/3] Building React frontend...") run("npm run build", cwd=FRONTEND, shell=True) if not os.path.isdir(FRONTEND_BUILD): print("ERROR: frontend/dist not found after build") sys.exit(1) print(" Copying to backend/static/frontend/") if os.path.isdir(STATIC_FE): shutil.rmtree(STATIC_FE) shutil.copytree(FRONTEND_BUILD, STATIC_FE) venv_site = ( os.path.join(ROOT, ".venv", "Lib", "site-packages") if os.path.isdir(os.path.join(ROOT, ".venv")) else "" ) def create_spec(): """Generate the PyInstaller .spec file dynamically.""" print("\n[2/3] Creating PyInstaller spec...") # Collect Jazzmin admin theme static files jazzmin_static = os.path.join(venv_site, "jazzmin", "static") datas_extra = [] if os.path.isdir(jazzmin_static): for root, dirs, files in os.walk(jazzmin_static): rel = os.path.relpath(root, jazzmin_static) for f in files: src = os.path.join(root, f) dst = os.path.join("static", rel) if rel != "." else "static" datas_extra.append((src, dst)) datas = [ (os.path.join(BACKEND, "static"), "backend/static"), (os.path.join(BACKEND, "templates"), "backend/templates"), (os.path.join(BACKEND, "meals", "migrations"), "backend/meals/migrations"), (os.path.join(BACKEND, "config"), "backend/config"), (os.path.join(BACKEND, "meals", "management"), "backend/meals/management"), ] + datas_extra # Hidden imports hidden = [] for pkg in ["rest_framework", "corsheaders", "jazzmin", "rich"]: try: hidden.extend(collect_submodules(pkg)) except Exception: pass hidden += [ "meals.views_dashboard", "meals", "meals.apps", "meals.models", "meals.admin", "meals.urls", "meals.views", "meals.views_spa", "meals.serializers", "meals.management", "meals.management.commands", "meals.management.commands.seed_dishes", "meals.management.commands.import_dishes", "config", "config.settings", "config.wsgi", "config.asgi", "config.urls", "django.contrib.admin", "django.contrib.auth", "django.contrib.contenttypes", "django.contrib.sessions", "django.contrib.messages", "django.contrib.staticfiles", "pypinyin", "openpyxl", "tkinter", "tkinter.messagebox", "tkinter.constants", "webview", "webview.window", "webview.platforms.edgechromium", "pythonnet", "clr_loader", "cffi", "bottle", ] seen = set() unique_hidden = [h for h in hidden if not (h in seen or seen.add(h))] # Build spec content using list of lines to avoid f-string quoting issues desktop_py = os.path.join(ROOT, "desktop.py").replace("\\", "/") root_path = ROOT.replace("\\", "/") backend_path = BACKEND.replace("\\", "/") icon_path = os.path.join(ROOT, "app.ico").replace("\\", "/") lines = [ "# -*- mode: python ; coding: utf-8 -*-", "# Auto-generated by build.py", "block_cipher = None", "", "a = Analysis(", f" [r'{desktop_py}'],", f" pathex=[r'{root_path}', r'{backend_path}'],", " binaries=[],", " datas=[", ] for src, dst in datas: lines.append(f" (r'{src}', r'{dst}'),") lines += [ " ],", " hiddenimports=[", ] for h in unique_hidden: lines.append(f" '{h}',") lines += [ " ],", " hookspath=[],", " hooksconfig={},", " runtime_hooks=[],", " excludes=[],", " win_no_prefer_redirects=False,", " win_private_assemblies=False,", " cipher=block_cipher,", " noarchive=False,", ")", "", "pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)", "", "exe = EXE(", " pyz,", " a.scripts,", " [],", " exclude_binaries=True,", " name='school-meal',", " debug=False,", " bootloader_ignore_signals=False,", " strip=False,", " upx=True,", " console=False,", " disable_windowed_traceback=False,", " target_arch=None,", " codesign_identity=None,", " entitlements_file=None,", f" icon=r'{icon_path}',", ")", "", "coll = COLLECT(", " exe,", " a.binaries,", " a.zipfiles,", " a.datas,", " strip=False,", " upx=True,", " upx_exclude=[],", " name='school-meal',", ")", "", ] with open(SPEC_FILE, "w", encoding="utf-8") as f: f.write("\n".join(lines)) print(f" Spec written to {SPEC_FILE}") def run_pyinstaller(): print("\n[3/3] Running PyInstaller...") run([sys.executable, "-m", "PyInstaller", "--clean", "--noconfirm", SPEC_FILE]) out_dir = os.path.join(DIST, "school-meal") bat_path = os.path.join(out_dir, "school-meal.bat") with open(bat_path, "w", encoding="utf-8") as f: f.write("@echo off\n") f.write("chcp 65001 >nul\n") f.write("cd /d %~dp0\n") f.write('echo Starting school meal planner...\n') f.write('start "" school-meal.exe\n') size_mb = sum( os.path.getsize(os.path.join(dp, fn)) for dp, _, fnames in os.walk(out_dir) for fn in fnames ) / (1024 * 1024) print(f"\nBuild complete! Output: {out_dir}") print(f"Total size: {size_mb:.1f} MB") def find_iscc(): """Locate the Inno Setup command-line compiler.""" candidates = [ os.path.join(os.environ.get("ProgramFiles(x86)", ""), "Inno Setup 6", "ISCC.exe"), os.path.join(os.environ.get("ProgramFiles", ""), "Inno Setup 6", "ISCC.exe"), os.path.join(os.environ.get("ProgramFiles(x86)", ""), "Inno Setup 5", "ISCC.exe"), os.path.join(os.environ.get("ProgramFiles", ""), "Inno Setup 5", "ISCC.exe"), ] for path in candidates: if os.path.isfile(path): return path from shutil import which path = which("iscc") if path: return path return None def build_installer(): """Compile the Inno Setup .iss script to produce an installer .exe.""" print("\n[installer] Building Windows installer...") if not os.path.isfile(ISS_SCRIPT): print(f"ERROR: {ISS_SCRIPT} not found. Cannot build installer.") sys.exit(1) iscc = find_iscc() if not iscc: print("ERROR: Inno Setup compiler (ISCC.exe) not found.") print("Please install Inno Setup 6 from https://jrsoftware.org/isdl.php") sys.exit(1) print(f" Using: {iscc}") run([iscc, ISS_SCRIPT]) if os.path.isdir(INSTALLER_DIR): size_mb = sum( os.path.getsize(os.path.join(dp, fn)) for dp, _, fnames in os.walk(INSTALLER_DIR) for fn in fnames ) / (1024 * 1024) print(f"\nInstaller built! Output: {INSTALLER_DIR}") print(f"Installer size: {size_mb:.1f} MB") else: print("ERROR: Installer output directory not created.") sys.exit(1) def main(): parser = argparse.ArgumentParser(description="Build school-meal package") parser.add_argument("--clean", action="store_true", help="Clean previous build artifacts") parser.add_argument("--installer", action="store_true", help="Also build Windows installer after EXE build") args = parser.parse_args() if args.clean: clean() print("Clean done. Exiting.\n") return os.chdir(ROOT) build_frontend() create_spec() run_pyinstaller() if args.installer: build_installer() print("\nDone! Installer at installer/学校订餐菜单生成器-安装包.exe") else: print("\nDone! Run dist/school-meal/school-meal.exe to launch the desktop app.") if __name__ == "__main__": main()