build: add single-command release script + portable zip artifacts
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>
This commit is contained in:
71
.github/workflows/build.yml
vendored
71
.github/workflows/build.yml
vendored
@@ -1,8 +1,18 @@
|
|||||||
name: Build installers
|
name: Build installers
|
||||||
|
|
||||||
# Triggers:
|
# Triggers:
|
||||||
# * Tag push (v*) → produces installers, attaches to a GitHub Release.
|
# * Tag push (v*) → produces installers + portable zips, attaches them
|
||||||
# * Manual dispatch → produces installers as workflow artifacts only.
|
# 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):
|
# What this workflow doesn't do (yet):
|
||||||
# * Code signing (Mac Developer ID, Windows code-signing cert).
|
# * Code signing (Mac Developer ID, Windows code-signing cert).
|
||||||
@@ -29,14 +39,17 @@ jobs:
|
|||||||
matrix:
|
matrix:
|
||||||
include:
|
include:
|
||||||
- os: macos-latest
|
- os: macos-latest
|
||||||
artifact_name: DataTools-mac.dmg
|
platform: mac
|
||||||
artifact_path: dist/DataTools-*-mac.dmg
|
installer_glob: dist/DataTools-*-mac.dmg
|
||||||
|
portable_glob: dist/DataTools-*-mac-portable.zip
|
||||||
- os: windows-latest
|
- os: windows-latest
|
||||||
artifact_name: DataTools-win.exe
|
platform: win
|
||||||
artifact_path: dist/DataTools-*-win-setup.exe
|
installer_glob: dist/DataTools-*-win-setup.exe
|
||||||
|
portable_glob: dist/DataTools-*-win-portable.zip
|
||||||
- os: ubuntu-latest
|
- os: ubuntu-latest
|
||||||
artifact_name: DataTools-linux.AppImage
|
platform: linux
|
||||||
artifact_path: dist/DataTools-*-linux-x86_64.AppImage
|
installer_glob: dist/DataTools-*-linux-x86_64.AppImage
|
||||||
|
portable_glob: '' # AppImage is already a portable single file
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
@@ -50,7 +63,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
pip install --upgrade pip
|
pip install --upgrade pip
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
pip install pyinstaller
|
pip install pyinstaller pillow
|
||||||
|
|
||||||
- name: Read version
|
- name: Read version
|
||||||
id: 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))")
|
VER=$(python -c "import re; print(re.search(r'__version__\s*=\s*\"([^\"]+)\"', open('src/__init__.py').read()).group(1))")
|
||||||
echo "version=$VER" >> "$GITHUB_OUTPUT"
|
echo "version=$VER" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Generate platform icons
|
||||||
|
run: python build/generate_icons.py
|
||||||
|
|
||||||
- name: Build PyInstaller bundle
|
- name: Build PyInstaller bundle
|
||||||
run: pyinstaller build/datatools.spec --clean --noconfirm
|
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'
|
if: matrix.os == 'macos-latest'
|
||||||
run: bash build/macos/build_dmg.sh "${{ steps.version.outputs.version }}"
|
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)
|
- name: Install Inno Setup (Windows)
|
||||||
if: matrix.os == 'windows-latest'
|
if: matrix.os == 'windows-latest'
|
||||||
run: choco install innosetup --no-progress -y
|
run: choco install innosetup --no-progress -y
|
||||||
@@ -78,6 +98,10 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
iscc /DAppVersion=${{ steps.version.outputs.version }} build\installer.iss
|
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)
|
- name: Install AppImage tooling (Linux)
|
||||||
if: matrix.os == 'ubuntu-latest'
|
if: matrix.os == 'ubuntu-latest'
|
||||||
run: |
|
run: |
|
||||||
@@ -92,17 +116,32 @@ jobs:
|
|||||||
|
|
||||||
# ---- Upload + release ----------------------------------------
|
# ---- Upload + release ----------------------------------------
|
||||||
|
|
||||||
- name: Upload artifact
|
- name: Upload installer artifact
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: ${{ matrix.artifact_name }}
|
name: DataTools-${{ matrix.platform }}-installer
|
||||||
path: ${{ matrix.artifact_path }}
|
path: ${{ matrix.installer_glob }}
|
||||||
if-no-files-found: error
|
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')
|
if: startsWith(github.ref, 'refs/tags/v')
|
||||||
uses: softprops/action-gh-release@v2
|
uses: softprops/action-gh-release@v2
|
||||||
with:
|
with:
|
||||||
files: ${{ matrix.artifact_path }}
|
files: ${{ matrix.installer_glob }}
|
||||||
fail_on_unmatched_files: true
|
fail_on_unmatched_files: true
|
||||||
generate_release_notes: 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
|
||||||
|
|||||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -11,6 +11,11 @@ dist/
|
|||||||
build/build/
|
build/build/
|
||||||
build/__pycache__/
|
build/__pycache__/
|
||||||
build/dist/
|
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/
|
.pytest_cache/
|
||||||
|
|
||||||
# Claude Code agent worktrees + local settings
|
# Claude Code agent worktrees + local settings
|
||||||
|
|||||||
@@ -19,23 +19,53 @@ build/
|
|||||||
│ Mac .app bundle config. Reads the version
|
│ Mac .app bundle config. Reads the version
|
||||||
│ from src/__init__.py.
|
│ from src/__init__.py.
|
||||||
├── installer.iss Inno Setup script — Windows .exe installer.
|
├── 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/
|
├── macos/
|
||||||
│ └── build_dmg.sh Wraps dist/DataTools.app into a .dmg with a
|
│ ├── build_dmg.sh Wraps dist/DataTools.app into a .dmg with a
|
||||||
│ drag-to-/Applications layout.
|
│ │ drag-to-/Applications layout (installer).
|
||||||
|
│ └── build_zip.sh Wraps dist/DataTools.app into a portable
|
||||||
|
│ .zip via ditto (preserves bundle metadata).
|
||||||
├── appimage/
|
├── appimage/
|
||||||
│ ├── AppRun Entry point invoked when the AppImage runs.
|
│ ├── AppRun Entry point invoked when the AppImage runs.
|
||||||
│ ├── datatools.desktop Linux desktop-entry metadata.
|
│ ├── datatools.desktop Linux desktop-entry metadata.
|
||||||
│ └── build.sh Wraps dist/DataTools/ into an .AppImage.
|
│ └── build.sh Wraps dist/DataTools/ into an .AppImage.
|
||||||
├── hooks/ PyInstaller hooks for libs the static analyser
|
├── hooks/ PyInstaller hooks for libs the static analyser
|
||||||
│ └── hook-streamlit.py misses (Streamlit's dynamic imports).
|
│ └── hook-streamlit.py misses (Streamlit's dynamic imports).
|
||||||
├── icon.icns macOS app icon (TODO: produce from a 1024×1024
|
├── icon.{ico,icns,png} Generated by generate_icons.py — gitignored.
|
||||||
│ 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).
|
|
||||||
└── README.md this file
|
└── 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-<ver>-mac.dmg` | `DataTools-<ver>-mac-portable.zip` (ditto .app)|
|
||||||
|
| Windows | `DataTools-<ver>-win-setup.exe` | `DataTools-<ver>-win-portable.zip` |
|
||||||
|
| Linux | `DataTools-<ver>-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
|
CI: `.github/workflows/build.yml` runs the full pipeline on tag push
|
||||||
(matrix: macos-latest, windows-latest, ubuntu-latest) and attaches
|
(matrix: macos-latest, windows-latest, ubuntu-latest) and attaches
|
||||||
the resulting installers to a GitHub Release. Manual
|
the resulting installers to a GitHub Release. Manual
|
||||||
@@ -43,12 +73,46 @@ the resulting installers to a GitHub Release. Manual
|
|||||||
|
|
||||||
## Releasing
|
## 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-<ver>-win-setup.exe` + `DataTools-<ver>-win-portable.zip`
|
||||||
|
- macOS host → `DataTools-<ver>-mac.dmg` + `DataTools-<ver>-mac-portable.zip`
|
||||||
|
- Linux host → `DataTools-<ver>-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`.
|
1. Bump `__version__` in `src/__init__.py`.
|
||||||
2. `git commit -am "release: vX.Y.Z" && git tag vX.Y.Z`.
|
2. `git commit -am "release: vX.Y.Z" && git tag vX.Y.Z`.
|
||||||
3. `git push && git push --tags`.
|
3. `git push && git push --tags`.
|
||||||
4. CI builds all three platforms and creates a GitHub Release with
|
4. CI builds all three platforms and creates a Release with the
|
||||||
the installers attached.
|
installers + portable zips attached.
|
||||||
5. Mirror the GitHub Release assets to Gumroad (manual until v2).
|
5. Mirror the Release assets to Gumroad (manual until v2).
|
||||||
|
|
||||||
## Signing (Phase 2 — needs accounts/credentials)
|
## Signing (Phase 2 — needs accounts/credentials)
|
||||||
|
|
||||||
|
|||||||
69
build/build_portable_zip.py
Normal file
69
build/build_portable_zip.py
Normal file
@@ -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 <platform> <version>
|
||||||
|
|
||||||
|
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-<version>-<platform>-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 <platform> <version>\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
|
||||||
|
# ``<base>.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())
|
||||||
78
build/generate_icons.py
Normal file
78
build/generate_icons.py
Normal file
@@ -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 <REPO>/build/. The source PNG is at
|
||||||
|
# <REPO>/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())
|
||||||
@@ -1,11 +1,26 @@
|
|||||||
; Inno Setup script for DataTools — Windows installer.
|
; Inno Setup script for DataTools — Windows installer.
|
||||||
;
|
;
|
||||||
; Compile from the repo root:
|
; 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
|
; CI passes the version via /DAppVersion to keep src/__init__.py the
|
||||||
; single source of truth. Local manual builds: pass /DAppVersion or
|
; single source of truth. Local manual builds: pass /DAppVersion or
|
||||||
; let the default kick in.
|
; 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
|
#ifndef AppVersion
|
||||||
#define AppVersion "0.0.0-dev"
|
#define AppVersion "0.0.0-dev"
|
||||||
@@ -18,11 +33,15 @@ AppVersion={#AppVersion}
|
|||||||
AppVerName=DataTools {#AppVersion}
|
AppVerName=DataTools {#AppVersion}
|
||||||
AppPublisher=DataTools
|
AppPublisher=DataTools
|
||||||
AppPublisherURL=https://datatools.app
|
AppPublisherURL=https://datatools.app
|
||||||
|
AppSupportURL=https://datatools.app/support
|
||||||
|
AppUpdatesURL=https://datatools.app/releases
|
||||||
DefaultDirName={autopf}\DataTools
|
DefaultDirName={autopf}\DataTools
|
||||||
DefaultGroupName=DataTools
|
DefaultGroupName=DataTools
|
||||||
DisableProgramGroupPage=yes
|
DisableProgramGroupPage=yes
|
||||||
OutputDir=..\dist
|
OutputDir=..\dist
|
||||||
OutputBaseFilename=DataTools-{#AppVersion}-win-setup
|
OutputBaseFilename=DataTools-{#AppVersion}-win-setup
|
||||||
|
SetupIconFile=icon.ico
|
||||||
|
UninstallDisplayIcon={app}\DataTools.exe
|
||||||
Compression=lzma2/max
|
Compression=lzma2/max
|
||||||
SolidCompression=yes
|
SolidCompression=yes
|
||||||
WizardStyle=modern
|
WizardStyle=modern
|
||||||
@@ -30,20 +49,37 @@ ArchitecturesInstallIn64BitMode=x64
|
|||||||
PrivilegesRequired=lowest
|
PrivilegesRequired=lowest
|
||||||
PrivilegesRequiredOverridesAllowed=dialog
|
PrivilegesRequiredOverridesAllowed=dialog
|
||||||
; Allow per-user install (no UAC prompt) when admin isn't available.
|
; 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]
|
[Languages]
|
||||||
Name: "english"; MessagesFile: "compiler:Default.isl"
|
Name: "english"; MessagesFile: "compiler:Default.isl"
|
||||||
|
|
||||||
[Tasks]
|
[Tasks]
|
||||||
Name: "desktopicon"; Description: "Create a &desktop shortcut"; GroupDescription: "Additional shortcuts:"
|
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]
|
[Files]
|
||||||
Source: "..\dist\DataTools\*"; DestDir: "{app}"; Flags: recursesubdirs ignoreversion
|
Source: "..\dist\DataTools\*"; DestDir: "{app}"; Flags: recursesubdirs ignoreversion
|
||||||
|
|
||||||
[Icons]
|
[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: "{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]
|
[Run]
|
||||||
Filename: "{app}\DataTools.exe"; Description: "Launch DataTools"; Flags: nowait postinstall skipifsilent
|
Filename: "{app}\DataTools.exe"; Description: "Launch DataTools"; Flags: nowait postinstall skipifsilent
|
||||||
|
|||||||
38
build/macos/build_zip.sh
Executable file
38
build/macos/build_zip.sh
Executable file
@@ -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 <version>
|
||||||
|
#
|
||||||
|
# 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-<version>-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))"
|
||||||
348
build/make_release.py
Normal file
348
build/make_release.py
Normal file
@@ -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-<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())
|
||||||
Reference in New Issue
Block a user