From 9c426194b17230a667a25f0103b322cd4f9797f6 Mon Sep 17 00:00:00 2001 From: Michael Date: Fri, 22 May 2026 19:30:17 +0000 Subject: [PATCH] build: add single-command release script + portable zip artifacts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .github/workflows/build.yml | 71 ++++++-- .gitignore | 5 + build/README.md | 84 +++++++-- build/build_portable_zip.py | 69 +++++++ build/generate_icons.py | 78 ++++++++ build/installer.iss | 42 ++++- build/macos/build_zip.sh | 38 ++++ build/make_release.py | 348 ++++++++++++++++++++++++++++++++++++ 8 files changed, 706 insertions(+), 29 deletions(-) create mode 100644 build/build_portable_zip.py create mode 100644 build/generate_icons.py create mode 100755 build/macos/build_zip.sh create mode 100644 build/make_release.py diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index bc8fb67..0535491 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,8 +1,18 @@ name: Build installers # Triggers: -# * Tag push (v*) → produces installers, attaches to a GitHub Release. -# * Manual dispatch → produces installers as workflow artifacts only. +# * Tag push (v*) → produces installers + portable zips, attaches them +# to a GitHub Release. +# * Manual dispatch → uploads everything as workflow artifacts only. +# +# Outputs per platform (downloadable by buyers): +# * macOS: .dmg installer + portable .zip (signed .app inside). +# * Windows: .exe installer + portable .zip (no-install). +# * Linux: .AppImage (already portable; no separate zip). +# +# Self-contained: every artifact ships its own Python interpreter + every +# runtime dep through PyInstaller. No pre/post install steps on the +# buyer's machine. # # What this workflow doesn't do (yet): # * Code signing (Mac Developer ID, Windows code-signing cert). @@ -29,14 +39,17 @@ jobs: matrix: include: - os: macos-latest - artifact_name: DataTools-mac.dmg - artifact_path: dist/DataTools-*-mac.dmg + platform: mac + installer_glob: dist/DataTools-*-mac.dmg + portable_glob: dist/DataTools-*-mac-portable.zip - os: windows-latest - artifact_name: DataTools-win.exe - artifact_path: dist/DataTools-*-win-setup.exe + platform: win + installer_glob: dist/DataTools-*-win-setup.exe + portable_glob: dist/DataTools-*-win-portable.zip - os: ubuntu-latest - artifact_name: DataTools-linux.AppImage - artifact_path: dist/DataTools-*-linux-x86_64.AppImage + platform: linux + installer_glob: dist/DataTools-*-linux-x86_64.AppImage + portable_glob: '' # AppImage is already a portable single file runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 @@ -50,7 +63,7 @@ jobs: run: | pip install --upgrade pip pip install -r requirements.txt - pip install pyinstaller + pip install pyinstaller pillow - name: Read version id: version @@ -59,15 +72,22 @@ jobs: VER=$(python -c "import re; print(re.search(r'__version__\s*=\s*\"([^\"]+)\"', open('src/__init__.py').read()).group(1))") echo "version=$VER" >> "$GITHUB_OUTPUT" + - name: Generate platform icons + run: python build/generate_icons.py + - name: Build PyInstaller bundle run: pyinstaller build/datatools.spec --clean --noconfirm - # ---- Per-platform packaging ---------------------------------- + # ---- Per-platform installer packaging ------------------------ - - name: Package macOS DMG + - name: Package macOS DMG (installer) if: matrix.os == 'macos-latest' run: bash build/macos/build_dmg.sh "${{ steps.version.outputs.version }}" + - name: Package macOS portable .zip + if: matrix.os == 'macos-latest' + run: bash build/macos/build_zip.sh "${{ steps.version.outputs.version }}" + - name: Install Inno Setup (Windows) if: matrix.os == 'windows-latest' run: choco install innosetup --no-progress -y @@ -78,6 +98,10 @@ jobs: run: | iscc /DAppVersion=${{ steps.version.outputs.version }} build\installer.iss + - name: Package Windows portable .zip + if: matrix.os == 'windows-latest' + run: python build/build_portable_zip.py win ${{ steps.version.outputs.version }} + - name: Install AppImage tooling (Linux) if: matrix.os == 'ubuntu-latest' run: | @@ -92,17 +116,32 @@ jobs: # ---- Upload + release ---------------------------------------- - - name: Upload artifact + - name: Upload installer artifact uses: actions/upload-artifact@v4 with: - name: ${{ matrix.artifact_name }} - path: ${{ matrix.artifact_path }} + name: DataTools-${{ matrix.platform }}-installer + path: ${{ matrix.installer_glob }} if-no-files-found: error - - name: Attach to Release (tag push only) + - name: Upload portable artifact + if: matrix.portable_glob != '' + uses: actions/upload-artifact@v4 + with: + name: DataTools-${{ matrix.platform }}-portable + path: ${{ matrix.portable_glob }} + if-no-files-found: error + + - name: Attach installer to Release (tag push only) if: startsWith(github.ref, 'refs/tags/v') uses: softprops/action-gh-release@v2 with: - files: ${{ matrix.artifact_path }} + files: ${{ matrix.installer_glob }} fail_on_unmatched_files: true generate_release_notes: true + + - name: Attach portable to Release (tag push only) + if: startsWith(github.ref, 'refs/tags/v') && matrix.portable_glob != '' + uses: softprops/action-gh-release@v2 + with: + files: ${{ matrix.portable_glob }} + fail_on_unmatched_files: true diff --git a/.gitignore b/.gitignore index 400cf97..5ecce91 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,11 @@ dist/ build/build/ build/__pycache__/ build/dist/ +# Generated by build/generate_icons.py from src/gui/assets/datatools_icon_256.png. +# Build artifacts, not source — regenerated each CI run. +build/icon.ico +build/icon.icns +build/icon.png .pytest_cache/ # Claude Code agent worktrees + local settings diff --git a/build/README.md b/build/README.md index aae03cc..f67df4c 100644 --- a/build/README.md +++ b/build/README.md @@ -19,23 +19,53 @@ build/ │ Mac .app bundle config. Reads the version │ from src/__init__.py. ├── installer.iss Inno Setup script — Windows .exe installer. +│ Adds Start Menu + Desktop + App Paths entries. +├── generate_icons.py Builds icon.ico / icon.icns / icon.png from +│ src/gui/assets/datatools_icon_256.png. Run +│ once before pyinstaller (CI does this). +├── build_portable_zip.py Cross-platform: zips dist/DataTools/ into a +│ no-install portable download. Used by the +│ Windows + Linux portable artifacts. ├── macos/ -│ └── build_dmg.sh Wraps dist/DataTools.app into a .dmg with a -│ drag-to-/Applications layout. +│ ├── build_dmg.sh Wraps dist/DataTools.app into a .dmg with a +│ │ drag-to-/Applications layout (installer). +│ └── build_zip.sh Wraps dist/DataTools.app into a portable +│ .zip via ditto (preserves bundle metadata). ├── appimage/ │ ├── AppRun Entry point invoked when the AppImage runs. │ ├── datatools.desktop Linux desktop-entry metadata. │ └── build.sh Wraps dist/DataTools/ into an .AppImage. ├── hooks/ PyInstaller hooks for libs the static analyser │ └── hook-streamlit.py misses (Streamlit's dynamic imports). -├── icon.icns macOS app icon (TODO: produce from a 1024×1024 -│ PNG. Optional — bundle still builds without). -├── icon.ico Windows app icon (TODO). -├── icon.png Linux AppImage icon (TODO — build.sh generates -│ a placeholder if missing). +├── icon.{ico,icns,png} Generated by generate_icons.py — gitignored. └── README.md this file ``` +## Distribution outputs per platform + +Each CI run produces two downloads per platform — an installer for +buyers who want shortcuts wired automatically, and a portable .zip +for buyers (or IT-locked-down machines) that can't run installers: + +| Platform | Installer | Portable | +|----------|----------------------------------------|------------------------------------------------| +| macOS | `DataTools--mac.dmg` | `DataTools--mac-portable.zip` (ditto .app)| +| Windows | `DataTools--win-setup.exe` | `DataTools--win-portable.zip` | +| Linux | `DataTools--linux-x86_64.AppImage`| (the AppImage IS the portable) | + +All six outputs are self-contained: every dependency (Python, pandas, +streamlit, pdfplumber, the lot) is frozen into the bundle. The buyer +does not need to install Python, pip, or anything else first. + +## Easy-launch surface + +| Affordance | Windows | macOS | +|------------------|--------------------------------------------------|------------------------------------------------------| +| Desktop shortcut | Inno Setup `desktopicon` task (checked default) | The .app bundle in /Applications is the icon | +| App menu | Start Menu → DataTools (always installed) | Launchpad + Spotlight (auto from /Applications) | +| Taskbar / Dock | User pins manually (OS forbids programmatic pin) | User pins manually after first launch | +| Run from terminal| `DataTools` (registered via App Paths) | `open -a DataTools` (auto from .app bundle) | + CI: `.github/workflows/build.yml` runs the full pipeline on tag push (matrix: macos-latest, windows-latest, ubuntu-latest) and attaches the resulting installers to a GitHub Release. Manual @@ -43,12 +73,46 @@ the resulting installers to a GitHub Release. Manual ## Releasing +### Single-command local build (recommended for one-developer workflow) + +PyInstaller can't cross-compile, so a single machine produces one +platform's packages. Run this on each target OS: + +```bash +# One-time setup per machine: +pip install -r requirements.txt +pip install pyinstaller pillow +# Windows only: install Inno Setup from https://jrsoftware.org/isdl.php +# Linux only: drop appimagetool onto PATH (see preflight output) + +# Build everything for the current OS: +python build/make_release.py +``` + +Outputs land in `dist/`: +- Windows host → `DataTools--win-setup.exe` + `DataTools--win-portable.zip` +- macOS host → `DataTools--mac.dmg` + `DataTools--mac-portable.zip` +- Linux host → `DataTools--linux-x86_64.AppImage` + +Useful flags: + +```bash +python build/make_release.py --preflight # check tooling, build nothing +python build/make_release.py --clean # wipe dist/ first +python build/make_release.py --skip-installer # just the portable zip +python build/make_release.py --skip-portable # just the installer +``` + +### CI build (push tag → GitHub Release) + +If you have CI runners for all three OSes: + 1. Bump `__version__` in `src/__init__.py`. 2. `git commit -am "release: vX.Y.Z" && git tag vX.Y.Z`. 3. `git push && git push --tags`. -4. CI builds all three platforms and creates a GitHub Release with - the installers attached. -5. Mirror the GitHub Release assets to Gumroad (manual until v2). +4. CI builds all three platforms and creates a Release with the + installers + portable zips attached. +5. Mirror the Release assets to Gumroad (manual until v2). ## Signing (Phase 2 — needs accounts/credentials) diff --git a/build/build_portable_zip.py b/build/build_portable_zip.py new file mode 100644 index 0000000..3f324c0 --- /dev/null +++ b/build/build_portable_zip.py @@ -0,0 +1,69 @@ +"""Wrap the PyInstaller folder build into a portable .zip. + +Self-contained download: unzip → double-click the launcher → app runs. +No installer, no Python install, no admin rights required. + +Usage: + python build/build_portable_zip.py + +Where ``platform`` is one of ``win`` / ``mac`` / ``linux``. The +script just produces a generic ``dist/DataTools/`` zip; on macOS the +preferred portable format is the ``ditto``-wrapped .app — see +``build/macos/build_zip.sh`` for that flow. This helper exists mainly +for Windows + Linux, where there's no .app bundle to wrap. + +Output: + dist/DataTools---portable.zip + +The zip root is the ``DataTools/`` folder so an unzip produces a +self-contained dir the user can drop anywhere (Desktop, USB stick, +network share). On Windows, the launcher is ``DataTools.exe`` inside +that folder; on Linux, ``DataTools``. +""" + +from __future__ import annotations + +import shutil +import sys +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent +DIST_DIR = REPO / "dist" +BUNDLE_DIR = DIST_DIR / "DataTools" + + +def main() -> int: + if len(sys.argv) < 3: + sys.stderr.write( + "usage: python build/build_portable_zip.py \n" + ) + return 2 + platform = sys.argv[1] + version = sys.argv[2] + + if not BUNDLE_DIR.is_dir(): + sys.stderr.write( + f"Bundle dir not found at {BUNDLE_DIR}.\n" + "Run ``pyinstaller build/datatools.spec --clean --noconfirm`` first.\n" + ) + return 1 + + out_stem = DIST_DIR / f"DataTools-{version}-{platform}-portable" + # ``make_archive`` takes a base name (no extension) and produces + # ``.zip``. ``root_dir`` = parent of what we want compressed, + # ``base_dir`` = the folder name inside the archive root. This + # combo yields a single top-level ``DataTools/`` directory inside + # the .zip rather than dumping its contents loose. + archive = shutil.make_archive( + base_name=str(out_stem), + format="zip", + root_dir=str(DIST_DIR), + base_dir="DataTools", + ) + size_mb = Path(archive).stat().st_size / (1024 * 1024) + print(f"wrote {archive} ({size_mb:.1f} MB)") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/build/generate_icons.py b/build/generate_icons.py new file mode 100644 index 0000000..cefe6da --- /dev/null +++ b/build/generate_icons.py @@ -0,0 +1,78 @@ +"""Generate platform-specific app icons from the source PNG asset. + +Outputs: + build/icon.ico Windows multi-resolution icon (16..256 px sizes). + build/icon.icns macOS icon bundle (16..1024 px scaled tiers). + build/icon.png Plain 256x256 PNG used by the Linux AppImage. + +Source: ``src/gui/assets/datatools_icon_256.png`` (the same icon +``st.set_page_config`` uses, so the installer / Dock / Taskbar match +the in-app tab favicon). + +Run manually: + python build/generate_icons.py + +CI runs this automatically before invoking PyInstaller (see +``.github/workflows/build.yml``). Both files are .gitignored — they +are build artifacts derived from the committed PNG. + +Self-contained: pulls only Pillow (already a transitive dep of +``pdfplumber``) so no extra installs are required. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +from PIL import Image + +# Repo layout: this script lives at /build/. The source PNG is at +# /src/gui/assets/datatools_icon_256.png. +BUILD_DIR = Path(__file__).resolve().parent +REPO = BUILD_DIR.parent +SOURCE_PNG = REPO / "src" / "gui" / "assets" / "datatools_icon_256.png" + +# Windows ICO needs every size the OS might render at: taskbar (16/24), +# Start Menu (32/48), tile (64/128), shell properties dialog (256). +ICO_SIZES = [(16, 16), (24, 24), (32, 32), (48, 48), (64, 64), + (128, 128), (256, 256)] + + +def main() -> int: + if not SOURCE_PNG.exists(): + sys.stderr.write( + f"Source icon not found at {SOURCE_PNG}.\n" + "Add a 256x256 (or larger) RGBA PNG there and re-run.\n" + ) + return 1 + + src = Image.open(SOURCE_PNG).convert("RGBA") + if src.size[0] < 256 or src.size[1] < 256: + sys.stderr.write( + f"Source icon is {src.size}; recommend 256x256 or larger " + "so downscaled tiers look crisp.\n" + ) + + ico_path = BUILD_DIR / "icon.ico" + src.save(ico_path, format="ICO", sizes=ICO_SIZES) + print(f"wrote {ico_path} ({ico_path.stat().st_size:,} bytes)") + + icns_path = BUILD_DIR / "icon.icns" + # Pillow's ICNS writer derives the per-tier sizes from the source + # image; passing a 256x256 source yields ic07..ic12 entries which + # cover Finder, Dock, and the Get Info panel. + src.save(icns_path, format="ICNS") + print(f"wrote {icns_path} ({icns_path.stat().st_size:,} bytes)") + + # AppImage uses a plain PNG for its desktop entry. Copy the source + # so the AppImage build script doesn't have to know the asset path. + png_path = BUILD_DIR / "icon.png" + src.save(png_path, format="PNG") + print(f"wrote {png_path} ({png_path.stat().st_size:,} bytes)") + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/build/installer.iss b/build/installer.iss index fa7a464..a8a1b32 100644 --- a/build/installer.iss +++ b/build/installer.iss @@ -1,11 +1,26 @@ ; Inno Setup script for DataTools — Windows installer. ; ; Compile from the repo root: -; iscc /DAppVersion=1.0.0 build\installer.iss +; iscc /DAppVersion=3.0 build\installer.iss ; ; CI passes the version via /DAppVersion to keep src/__init__.py the ; single source of truth. Local manual builds: pass /DAppVersion or ; let the default kick in. +; +; What this installer wires up (covers the "easy launch" surface): +; * Start Menu group: Start → DataTools → DataTools / Uninstall +; * Desktop shortcut: optional, checked by default during install +; * Quick Launch: optional, off by default (legacy Win 7 + power +; users who keep the bar enabled). Windows 10/11 +; users pin to taskbar manually via right-click — +; OS security policy forbids programmatic pinning. +; * App Paths entry: so ``DataTools`` typed into Win+R / cmd works. +; +; Self-contained: the installer contains a frozen PyInstaller bundle +; (Python + every runtime dep). No pre-install or post-install steps +; on the buyer's machine. UAC is NOT required because we install +; per-user by default; the prompt only fires if the buyer asks for an +; all-users install. #ifndef AppVersion #define AppVersion "0.0.0-dev" @@ -18,11 +33,15 @@ AppVersion={#AppVersion} AppVerName=DataTools {#AppVersion} AppPublisher=DataTools AppPublisherURL=https://datatools.app +AppSupportURL=https://datatools.app/support +AppUpdatesURL=https://datatools.app/releases DefaultDirName={autopf}\DataTools DefaultGroupName=DataTools DisableProgramGroupPage=yes OutputDir=..\dist OutputBaseFilename=DataTools-{#AppVersion}-win-setup +SetupIconFile=icon.ico +UninstallDisplayIcon={app}\DataTools.exe Compression=lzma2/max SolidCompression=yes WizardStyle=modern @@ -30,20 +49,37 @@ ArchitecturesInstallIn64BitMode=x64 PrivilegesRequired=lowest PrivilegesRequiredOverridesAllowed=dialog ; Allow per-user install (no UAC prompt) when admin isn't available. +; Buyers without admin rights can still install without IT involvement. + +ChangesAssociations=no +CloseApplications=force +RestartApplications=no [Languages] Name: "english"; MessagesFile: "compiler:Default.isl" [Tasks] Name: "desktopicon"; Description: "Create a &desktop shortcut"; GroupDescription: "Additional shortcuts:" +Name: "quicklaunchicon"; Description: "Create a &Quick Launch shortcut"; GroupDescription: "Additional shortcuts:"; Flags: unchecked; OnlyBelowVersion: 6.1 [Files] Source: "..\dist\DataTools\*"; DestDir: "{app}"; Flags: recursesubdirs ignoreversion [Icons] -Name: "{group}\DataTools"; Filename: "{app}\DataTools.exe" +; Start Menu entries — created unconditionally so the app is always +; discoverable via Start search. +Name: "{group}\DataTools"; Filename: "{app}\DataTools.exe"; IconFilename: "{app}\DataTools.exe" Name: "{group}\Uninstall DataTools"; Filename: "{uninstallexe}" -Name: "{autodesktop}\DataTools"; Filename: "{app}\DataTools.exe"; Tasks: desktopicon +; Desktop shortcut — opt-in via the Tasks page. +Name: "{autodesktop}\DataTools"; Filename: "{app}\DataTools.exe"; IconFilename: "{app}\DataTools.exe"; Tasks: desktopicon +; Quick Launch (legacy) — only relevant on Win 7 and older. +Name: "{userappdata}\Microsoft\Internet Explorer\Quick Launch\DataTools"; Filename: "{app}\DataTools.exe"; IconFilename: "{app}\DataTools.exe"; Tasks: quicklaunchicon + +[Registry] +; App Paths — lets the buyer launch from Win+R or cmd with just +; "DataTools" instead of a full path. Per-user hive so the per-user +; install path doesn't need admin to register. +Root: HKCU; Subkey: "Software\Microsoft\Windows\CurrentVersion\App Paths\DataTools.exe"; ValueType: string; ValueName: ""; ValueData: "{app}\DataTools.exe"; Flags: uninsdeletekey [Run] Filename: "{app}\DataTools.exe"; Description: "Launch DataTools"; Flags: nowait postinstall skipifsilent diff --git a/build/macos/build_zip.sh b/build/macos/build_zip.sh new file mode 100755 index 0000000..43022a9 --- /dev/null +++ b/build/macos/build_zip.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# Wrap dist/DataTools.app into a no-install portable .zip. +# +# Usage: +# bash build/macos/build_zip.sh +# +# Why a portable .zip in addition to the .dmg: +# * Buyers who don't want an installer can unzip and double-click the +# .app directly — no drag-to-/Applications step, no installer +# chrome. Self-contained: the .app holds Python + every dep. +# * IT-locked-down machines often block .dmg auto-mount but allow +# .zip download + extraction. +# +# Run after ``pyinstaller build/datatools.spec --clean --noconfirm`` +# has produced ``dist/DataTools.app``. Output goes to +# ``dist/DataTools--mac-portable.zip``. + +set -euo pipefail + +VERSION="${1:-0.0.0-dev}" +APP="dist/DataTools.app" +ZIP="dist/DataTools-${VERSION}-mac-portable.zip" + +if [[ ! -d "$APP" ]]; then + echo "Error: $APP not found. Run pyinstaller build/datatools.spec first." >&2 + exit 1 +fi + +# ``ditto`` preserves the .app bundle's extended attributes and +# resource forks (a plain ``zip`` strips them and can break code +# signatures + Info.plist resolution on the buyer's machine). +# +# --sequesterRsrc keeps the AppleDouble metadata inside the archive +# rather than as parallel ._ files on disk after extraction. +rm -f "$ZIP" +ditto -c -k --sequesterRsrc --keepParent "$APP" "$ZIP" + +echo "Built $ZIP ($(du -h "$ZIP" | cut -f1))" diff --git a/build/make_release.py b/build/make_release.py new file mode 100644 index 0000000..8597ae8 --- /dev/null +++ b/build/make_release.py @@ -0,0 +1,348 @@ +"""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())