Building & Releasing (PyInstaller + Inno Setup)
For Contributors covered getting a change merged. This
page covers the last step: turning the toolbox's commands — CLI and GUI —
into standalone .exe files other people can run without installing Python
at all, and bundling all of them into one installer.
Why PyInstaller?
Not everyone who needs to run a tool like vrlab_crane_process is a Python
developer with a .venv set up (see Development Setup).
PyInstaller bundles the Python interpreter,
every dependency, and the script itself into one executable file. The
result — vrlab_crane_process.exe — can be copied to any folder, including
one on the PATH, and just run. No Python install, no venv, no pip
install on the machine that runs it.
The current setup
Every buildable command is bundled by one single PyInstaller "spec file" —
specs/toolbox.spec — rather than one spec per tool. It loops over a plain
TOOLS list ((exe_name, script path relative to src/vrlab_toolbox, console
window?, extra datas) per tool, covering both CLI tools and the two
PySide6 crosscheck GUIs plus the launcher below) and runs one Analysis +
EXE per entry, but feeds every tool's outputs into a single shared
COLLECT at the end:
TOOLS = [
("vrlab_check_xdf", os.path.join("cli", "check_mobi_xdf.py"), True, []),
("vrlab_crane_bids_crosscheck", os.path.join("gui", "crane_bids_crosscheck_gui.py"), False, []),
(
"vrlab_crane_process",
os.path.join("cli", "vrlab_crane_process.py"),
True,
[(os.path.join(REPO_ROOT, "references", "matched_debug_df_testa.parquet"), "references")],
),
# ... one entry per console-script command in pyproject.toml's [project.scripts]
]
for name, script, console, extra_datas in TOOLS:
a = Analysis([os.path.join(SRC_ROOT, script)], datas=[*extra_datas, *copy_metadata("vrlab-toolbox")], ...)
pyz = PYZ(a.pure)
exe = EXE(pyz, a.scripts, [], exclude_binaries=True, name=name, console=console, ...)
collect_args.extend([exe, a.binaries, a.zipfiles, a.datas])
COLLECT(*collect_args, name="vrlab_toolbox")
A few things worth knowing:
- One
COLLECT, not one per tool. Every tool used to be its own--onedirbuild (or its own spec file, before 2026-08-28), each carrying a full copy of the shared scientific-Python stack (numpy, scipy, mne, PySide6, …). Feeding all eleven tools'EXE/binaries/datasinto oneCOLLECTinstead means identical dependency files are only written to disk once, under a singlebuild_output/dist/vrlab_toolbox/folder — see The installer below. REPO_ROOT = os.path.join(SPECPATH, "..")—SPECPATHis a variable PyInstaller injects automatically, set to the spec file's own directory. Since the spec lives inspecs/, one level below the workspace root, entry-point and data paths are built fromREPO_ROOTrather than hardcoded relative paths, regardless of what directorypyinstalleris actually invoked from.matched_debug_df_testa.parquet(vrlab_crane_process's entry only) — a reference data file some processing code reads at runtime; without listing it here explicitly, PyInstaller wouldn't know to bundle it (only actual Python imports are detected automatically).copy_metadata("vrlab-toolbox")— bundles this package's installed metadata (version, name, …) into every tool. This is specifically because@click.version_option(package_name="vrlab-toolbox")looks up the installed package's version viaimportlib.metadataat runtime — and a frozen exe doesn't have a normalsite-packages/layout unless you tell PyInstaller to keep this piece of it.- The two GUI entries (
vrlab_crane_bids_crosscheck,vrlab_foh_bids_crosscheck) and the launcher's entry (vrlab_toolbox_launcher, see below) passconsole=False— a normal windowed app, no console window popping up behind it.
The toolbox launcher
src/vrlab_toolbox/gui/toolbox_launcher.py (console-script
vrlab_toolbox_launcher) is a small PySide6 window with one button per GUI
tool — currently "FOH BIDS Crosscheck" and "Crane BIDS Crosscheck" — each of
which just subprocess.Popens that tool's exe from the launcher's own
folder. CLI tools aren't buttons; they're listed as plain text underneath,
since they're meant to be run from a terminal (which is why getting the
toolbox folder onto PATH matters — see the installer below). It's the exe
the installer puts a desktop shortcut to.
Building it yourself
python -m pip install pyinstaller # not in requirements-dev.txt — rarely needed
# Windows — build.ps1 -Full (equivalent to what it runs internally, from an activated .venv)
pip install -e . --no-deps
pyinstaller --workpath build_output/work --distpath build_output/dist specs/toolbox.spec
build.ps1 itself doesn't call bare pip/pyinstaller like the snippet above -- it resolves
.venv\Scripts\python.exe once up front and runs both steps through python -m pip .../
python -m PyInstaller .... On a machine with more than one Python on PATH (a global
install, a scoop/pyenv shim, ...), calling bare pip/pyinstaller risks pip install -e .
writing fresh setuptools_scm version metadata into a different site-packages than the one
pyinstaller reads it back from via copy_metadata -- silently baking a stale version into
the exe, with no error. Pinning both to the same interpreter closes that gap; this only
matters when running the raw commands above by hand outside of build.ps1.
build.ps1 itself takes a flag rather than always doing a full rebuild:
.\build.ps1(no flag) — prints usage and builds nothing..\build.ps1 -Full— the full pipeline above: rebuild every exe, then assemble and compile the installer (see below). Slow, since PyInstaller re-bundles every tool from scratch..\build.ps1 -Exe— only the PyInstaller step, intobuild_output/dist/. Skips assemblingbuild_output/toolbox/and compiling the installer, so it doesn't need Inno Setup installed at all — useful when you're iterating on a tool's code and just want to check the exe builds..\build.ps1 -Inno— skips the PyInstaller step entirely and reuses whatever's already inbuild_output/dist/; just reassemblesbuild_output/toolbox/and recompilestoolbox_installer.iss. Use this after a-Fullor-Exebuild already succeeded and you're only iterating on the Inno Setup script itself — recompiling just the installer takes seconds instead of minutes.
Every artifact from any of the above — PyInstaller's intermediate work
files, its exe output, the assembled installer payload, and the compiled
installer — lands under one gitignored build_output/ folder
(build_output/work/, build_output/dist/, build_output/toolbox/,
build_output/installer/ respectively) instead of four separate top-level
folders.
# macOS/Linux — build_mac.sh
pyinstaller --onefile src/vrlab_toolbox/cli/vrlab_crane_process.py
pyinstaller --onefile src/vrlab_toolbox/cli/mobi_FOH_assess_data.py
The output lands in build_output/dist/. If PyInstaller complains about an
obsolete pathlib backport package being installed, pip uninstall
pathlib — modern Python already includes pathlib in the standard
library.
Going further
build_mac.sh only builds two of the toolbox's tools, as plain
--onefile builds — so it doesn't bundle the reference .parquet file
or the version metadata the specs/ builds do, and there's no
macOS/Linux equivalent of the toolbox-wide build or the installer below
(Inno Setup, which does the installer packaging, is Windows-only). A
known gap, not an intentional platform difference.
The installer
build.ps1 doesn't stop at building exes — after specs/toolbox.spec
builds, it also:
- Resolves a version string by reading
vrlab-toolbox's installed package metadata back out of the venv (importlib.metadata.version(...)via the same interpreter used for the PyInstaller build) -- the same version PyInstaller'scopy_metadataalready baked into the exe, so the installer can't disagree with what's actually running inside it. - Copies
build_output/dist/vrlab_toolbox/*into a freshbuild_output/toolbox/folder. - Compiles
toolbox_installer.iss(an Inno Setup script, in the workspace root) withISCC.exe, producingbuild_output\installer\VRLabToolboxSetup-<version>.exe, e.g.VRLabToolboxSetup-v1.2.0.exe(OutputDir/OutputBaseFilenameintoolbox_installer.iss's[Setup]section —MyAppVersionis passed in via/DMyAppVersion=<version>, resolved in step 1 above).
toolbox_installer.iss is deliberately a current-user, no-admin install
(PrivilegesRequired=lowest) — appropriate for lab machines where installing
software as an administrator often isn't an option:
- Installs to
{localappdata}\Programs\VRLabToolbox, a location any user can write to. - Adds that folder to the current user's
PATH(HKEY_CURRENT_USER\Environment, not the system-wideHKLMone) via a small Pascal Script block, and removes it again on uninstall. It also broadcasts aWM_SETTINGCHANGEmessage so this doesn't strictly require a logoff — though a new terminal window is still needed to pick up the change, since already-open terminals cached their environment at launch. - Puts a desktop shortcut to
vrlab_toolbox_launcher.exe(the launcher above) on the current user's desktop. CloseApplications=yes(Inno 6's own default, stated explicitly so it can't be silently turned off later) means Setup checks every file it's about to write for a process holding it open and prompts to close it before touching anything -- so a toolbox window left running during an upgrade gets caught here, not partway through file copying.- On top of that,
[Code]'sIsUpgrade/UninstallOldVersiondetect a previous install of the same product (matched byAppId, via its ownHKCUuninstall registry entry) and run its uninstaller fully silently (/VERYSILENT /NORESTART /SUPPRESSMSGBOXES) before the new version's files are copied. Inno's default behavior on an upgrade is an in-place file overwrite, which never removes a file the old version shipped that the new version doesn't (e.g. a renamed or dropped tool's stale.exe) -- the explicit uninstall-first step avoids that buildup instead.
toolbox_installer.iss produces a single VRLabToolboxSetup-<version>.exe
— no disk spanning, so there's no separate .bin payload file to keep
track of or lose. If the combined toolbox ever grows past Inno's ~2 GB single-file
limit, DiskSpanning=yes (with DiskSliceSize=max) would need to come
back, splitting the installer into a small .exe stub plus one or more
.bin parts users would have to download alongside it.
If ISCC.exe isn't found at its default install path, build.ps1 falls
back to checking PATH, and errors out clearly if Inno Setup isn't
installed at all — the exe-building steps still succeed either way, you
just won't get a compiled installer.
Version tags
Code Organization covered
setuptools_scm: this package's version isn't written by hand anywhere —
it's derived from git tags automatically. To cut a new version:
git tag v1.2.0
git push origin v1.2.0
That single tag push does two things: setuptools_scm picks it up as the
installed package's version (which is what ends up embedded in each exe via
copy_metadata, above, and shown by e.g. vrlab_crane_process --version),
and it triggers an automatic build — next section.
Automatic builds on tag push
.github/workflows/release.yml is a GitHub Actions
workflow — config that tells GitHub's own servers to run steps
automatically in response to an event, rather than on your machine. This
one is the piece that actually reacts to the tag push above:
on:
push:
tags:
- "v*"
jobs:
build:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install -r requirements.txt
- run: pip install -e . --no-deps
- run: pyinstaller --workpath build_output/work --distpath build_output/dist specs/toolbox.spec
- run: |
New-Item -ItemType Directory -Path build_output/toolbox | Out-Null
Copy-Item build_output\dist\vrlab_toolbox\* build_output\toolbox -Recurse
- run: choco install innosetup -y
- run: |
$version = "${{ github.ref_name }}" -replace '^v',''
& "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" toolbox_installer.iss "/DMyAppVersion=$version"
- uses: softprops/action-gh-release@v2
with:
files: build_output/installer/*.exe
Pushing any tag matching v* (e.g. v1.2.0) makes GitHub check out the
repo on a fresh Windows machine, install everything, build every tool's exe
from the single specs/toolbox.spec, install Inno Setup via
Chocolatey (not present on the runner by
default), compile the installer — using the pushed tag itself as the
installer's version, rather than git describe — and attach the compiled
installer (build_output/installer/*.exe, resolved via OutputDir in
toolbox_installer.iss) to a GitHub Release. All of it automatic, with no
one needing to run the build script by hand.
pyproject.toml itself doesn't trigger the build — that's this workflow
file, reacting to the tag. pyproject.toml's part is narrower but load-bearing:
it's what turns that same tag into the correct version number, via
setuptools_scm.
Going further
This workflow only runs on windows-latest — there's no automated
macOS/Linux release build yet, matching the manual build gap noted
above.
Next: Project Notes (in the sidebar) — the project's own history and still-open work.