"""Single-command release builder for DataTools. PyInstaller can't cross-compile — to produce a Windows .exe you run this on Windows, for a Mac .dmg you run it on macOS, for a Linux AppImage you run it on Linux. One script, one OS at a time. What this script does (in order): 1. Preflight — checks PyInstaller, Pillow, and the platform's packager (Inno Setup on Win / hdiutil + ditto on Mac / appimagetool on Linux) are reachable. Bails with install instructions if anything is missing. 2. Generates icon.ico / icon.icns / icon.png from the PNG asset. 3. Runs PyInstaller against build/datatools.spec. 4. Wraps the PyInstaller output into: * Windows: DataTools--win-setup.exe (Inno Setup) + DataTools--win-portable.zip * macOS: DataTools--mac.dmg + DataTools--mac-portable.zip * Linux: DataTools--linux-x86_64.AppImage 5. Prints what landed in dist/ and the byte sizes. Usage: python build/make_release.py # build everything for this OS python build/make_release.py --preflight # check tooling, don't build python build/make_release.py --skip-installer # only the portable zip python build/make_release.py --skip-portable # only the installer python build/make_release.py --clean # wipe dist/ first Run from the repo root or from build/ — either works. """ from __future__ import annotations import argparse import platform import re import shutil import subprocess import sys from pathlib import Path REPO = Path(__file__).resolve().parent.parent BUILD = REPO / "build" DIST = REPO / "dist" # --------------------------------------------------------------------------- # Output helpers — colourless so logs stay readable in any terminal/CI tail. # --------------------------------------------------------------------------- def _step(msg: str) -> None: print(f"\n==> {msg}", flush=True) def _ok(msg: str) -> None: print(f" ok: {msg}", flush=True) def _warn(msg: str) -> None: print(f" warn: {msg}", flush=True) def _err(msg: str) -> None: print(f" ERROR: {msg}", file=sys.stderr, flush=True) def _run(cmd: list[str], cwd: Path | None = None, env: dict | None = None) -> None: """Run *cmd*, stream output, exit on failure with a useful banner.""" printable = " ".join(map(str, cmd)) print(f" $ {printable}", flush=True) try: subprocess.run(cmd, check=True, cwd=cwd or REPO, env=env) except subprocess.CalledProcessError as e: _err(f"command failed (exit {e.returncode}): {printable}") sys.exit(e.returncode) except FileNotFoundError: _err(f"command not found: {cmd[0]}") sys.exit(127) # --------------------------------------------------------------------------- # Platform detection # --------------------------------------------------------------------------- def _detect_platform() -> str: """Return ``win`` / ``mac`` / ``linux`` based on sys.platform.""" p = sys.platform if p.startswith("win"): return "win" if p == "darwin": return "mac" if p.startswith("linux"): return "linux" _err(f"unsupported platform {p!r}; this script handles win/mac/linux only.") sys.exit(2) # --------------------------------------------------------------------------- # Version — single source of truth in src/__init__.py # --------------------------------------------------------------------------- def _read_version() -> str: init_py = (REPO / "src" / "__init__.py").read_text(encoding="utf-8") m = re.search(r'__version__\s*=\s*["\']([^"\']+)["\']', init_py) if not m: _err("could not parse __version__ from src/__init__.py") sys.exit(1) return m.group(1) # --------------------------------------------------------------------------- # Preflight — check tooling before doing anything destructive # --------------------------------------------------------------------------- def _have_module(name: str) -> bool: try: __import__(name) return True except ImportError: return False def _have_command(name: str) -> bool: return shutil.which(name) is not None # Per-platform install hints. The error messages quote these so a buyer # building from source isn't left guessing what to install next. _INSTALL_HINTS = { "pyinstaller": "pip install pyinstaller", "pil": "pip install pillow", "iscc": "Inno Setup (Windows): https://jrsoftware.org/isdl.php — install, then re-open the shell so iscc lands on PATH.", "hdiutil": "ships with macOS — if it's missing your Mac install is broken.", "ditto": "ships with macOS — if it's missing your Mac install is broken.", "appimagetool": "Linux: download appimagetool-x86_64.AppImage from https://github.com/AppImage/AppImageKit/releases, chmod +x, drop on PATH.", } def preflight(target: str) -> None: """Verify every tool the target build needs is reachable; exit if not.""" _step(f"preflight ({target})") missing: list[tuple[str, str]] = [] # Python-side deps — same on every platform. The ``_INSTALL_HINTS`` # lookup uses lowercase keys so module name capitalization doesn't # need to match. for mod in ("PyInstaller", "PIL"): if not _have_module(mod): hint = _INSTALL_HINTS.get(mod.lower(), f"pip install {mod}") missing.append((mod.lower(), hint)) else: _ok(f"{mod} importable") # PyInstaller's CLI must also be reachable as a binary, not just as # an importable module — the spec is invoked via the ``pyinstaller`` # command. ``python -m PyInstaller`` is a fine fallback so don't # hard-fail if only the CLI binary is missing. if _have_command("pyinstaller"): _ok("pyinstaller on PATH") else: _warn("pyinstaller binary not on PATH — will fall back to `python -m PyInstaller`") # Platform-specific packagers. if target == "win": if _have_command("iscc"): _ok("Inno Setup (iscc) on PATH") else: missing.append(("iscc", _INSTALL_HINTS["iscc"])) elif target == "mac": for tool in ("hdiutil", "ditto"): if _have_command(tool): _ok(f"{tool} on PATH") else: missing.append((tool, _INSTALL_HINTS[tool])) elif target == "linux": if _have_command("appimagetool"): _ok("appimagetool on PATH") else: missing.append(("appimagetool", _INSTALL_HINTS["appimagetool"])) if missing: _err("missing prerequisites:") for name, hint in missing: print(f" - {name}: {hint}", file=sys.stderr) sys.exit(1) _ok("all prerequisites present") # --------------------------------------------------------------------------- # Build steps # --------------------------------------------------------------------------- def step_generate_icons() -> None: _step("generate icons") _run([sys.executable, str(BUILD / "generate_icons.py")]) def step_pyinstaller(clean: bool) -> None: _step("pyinstaller bundle") # Use ``python -m PyInstaller`` so we don't depend on the binary # being on PATH (Windows users frequently see this — pip's # Scripts/ dir isn't auto-added). cmd = [sys.executable, "-m", "PyInstaller", str(BUILD / "datatools.spec"), "--noconfirm"] if clean: cmd.append("--clean") _run(cmd) def step_package_win(version: str, do_installer: bool, do_portable: bool) -> list[Path]: out: list[Path] = [] if do_installer: _step("Windows installer (Inno Setup)") _run(["iscc", f"/DAppVersion={version}", str(BUILD / "installer.iss")]) out.append(DIST / f"DataTools-{version}-win-setup.exe") if do_portable: _step("Windows portable .zip") _run([sys.executable, str(BUILD / "build_portable_zip.py"), "win", version]) out.append(DIST / f"DataTools-{version}-win-portable.zip") return out def step_package_mac(version: str, do_installer: bool, do_portable: bool) -> list[Path]: out: list[Path] = [] if do_installer: _step("macOS DMG (installer)") _run(["bash", str(BUILD / "macos" / "build_dmg.sh"), version]) out.append(DIST / f"DataTools-{version}-mac.dmg") if do_portable: _step("macOS portable .zip") _run(["bash", str(BUILD / "macos" / "build_zip.sh"), version]) out.append(DIST / f"DataTools-{version}-mac-portable.zip") return out def step_package_linux(version: str, do_installer: bool, do_portable: bool) -> list[Path]: # On Linux the AppImage IS the portable. We ignore the two flags # and always produce the single file — splitting wouldn't add # value. if not (do_installer or do_portable): return [] _step("Linux AppImage") _run(["bash", str(BUILD / "appimage" / "build.sh"), version]) return [DIST / f"DataTools-{version}-linux-x86_64.AppImage"] # --------------------------------------------------------------------------- # Orchestration # --------------------------------------------------------------------------- def _summarise(outputs: list[Path]) -> None: _step("done — outputs") if not outputs: _warn("no files produced (everything skipped via flags)") return for p in outputs: if p.exists(): size_mb = p.stat().st_size / (1024 * 1024) print(f" {p.relative_to(REPO)} ({size_mb:.1f} MB)") else: _warn(f"expected output missing: {p.relative_to(REPO)}") def main() -> int: parser = argparse.ArgumentParser( prog="make_release.py", description=( "Build the installer + portable zip for the current OS. " "Cross-compilation isn't supported by PyInstaller — run " "this once per platform you want to target." ), formatter_class=argparse.RawDescriptionHelpFormatter, ) parser.add_argument( "--platform", choices=("auto", "win", "mac", "linux"), default="auto", help="Override OS detection (mostly for testing). Default: auto.", ) parser.add_argument( "--preflight", action="store_true", help="Check tooling and exit without building.", ) parser.add_argument( "--clean", action="store_true", help="Wipe dist/ before building.", ) parser.add_argument( "--skip-installer", action="store_true", help="Don't build the OS installer (.exe / .dmg).", ) parser.add_argument( "--skip-portable", action="store_true", help="Don't build the portable .zip.", ) args = parser.parse_args() target = _detect_platform() if args.platform == "auto" else args.platform version = _read_version() do_installer = not args.skip_installer do_portable = not args.skip_portable print(f"DataTools release builder") print(f" target: {target} (host: {platform.platform()})") print(f" version: {version}") print(f" installer: {'yes' if do_installer else 'no'}") print(f" portable: {'yes' if do_portable else 'no'}") print(f" dist dir: {DIST}") if target != _detect_platform(): _warn( f"--platform {target} but host is {_detect_platform()}. " "PyInstaller can't cross-compile — the bundle will be for " "the HOST, only the packaging step will follow your override. " "Useful only for testing the packager paths." ) preflight(target) if args.preflight: return 0 if args.clean and DIST.exists(): _step(f"cleaning {DIST}") shutil.rmtree(DIST) step_generate_icons() step_pyinstaller(clean=args.clean) if target == "win": outputs = step_package_win(version, do_installer, do_portable) elif target == "mac": outputs = step_package_mac(version, do_installer, do_portable) else: outputs = step_package_linux(version, do_installer, do_portable) _summarise(outputs) return 0 if __name__ == "__main__": sys.exit(main())