One-developer workflow: ``python build/make_release.py`` on each target OS produces both the installer and a portable .zip for that platform. Preflight checks PyInstaller / Pillow / iscc / hdiutil / ditto / appimagetool and bails with install hints if anything is missing — no half-built dist/. New scripts: - build/make_release.py — orchestrator, auto-detects host OS. - build/generate_icons.py — icon.ico / icon.icns / icon.png from src/gui/assets/datatools_icon_256.png (Pillow ships ICO + ICNS writers; no platform tooling needed). - build/build_portable_zip.py — Win/Linux portable zip via stdlib. - build/macos/build_zip.sh — Mac portable .app via ditto so bundle metadata survives. installer.iss now adds: Quick Launch task (opt-in, legacy Win 7), App Paths registry entry (Win+R "DataTools" works), SetupIconFile, UninstallDisplayIcon, AppSupportURL, AppUpdatesURL. CI workflow uploads installer + portable per platform and attaches both to GitHub Releases on tag push. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
349 lines
12 KiB
Python
349 lines
12 KiB
Python
"""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-<ver>-win-setup.exe (Inno Setup)
|
|
+ DataTools-<ver>-win-portable.zip
|
|
* macOS: DataTools-<ver>-mac.dmg
|
|
+ DataTools-<ver>-mac-portable.zip
|
|
* Linux: DataTools-<ver>-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())
|