Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 112 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ on:
release:
types:
- published
workflow_dispatch:

jobs:
lint:
Expand Down Expand Up @@ -66,6 +67,117 @@ jobs:
with:
name: wheel-${{ matrix.os }}-cp${{ matrix.python-versions.version }}
path: ./wheelhouse/*.whl
binaries-build-linux:
name: PyInstaller bundle (${{ matrix.target }})
strategy:
fail-fast: false
matrix:
include:
- target: linux-x86_64
runner: ubuntu-latest
container: quay.io/pypa/manylinux_2_28_x86_64
- target: linux-aarch64
runner: ubuntu-24.04-arm
container: quay.io/pypa/manylinux_2_28_aarch64
runs-on: ${{ matrix.runner }}
container: ${{ matrix.container }}
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Trust workspace dir for git (container UID mismatch)
run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
- name: Install uv
uses: astral-sh/setup-uv@v8.1.0
- name: Set up Python
run: uv python install 3.11
- name: Build extension + install dev deps
run: uv sync --group dev --config-setting editable_mode=strict -v
- name: Install PyInstaller
run: uv pip install --python .venv/bin/python pyinstaller
- name: Build bundle
run: .venv/bin/python packaging/pyinstaller/build_bundle.py --target ${{ matrix.target }}
- name: Smoke test (--version per binary)
run: |
BUNDLE=$(ls -d packaging/pyinstaller/dist/warpkit-*/ | head -1)
for bin in "$BUNDLE"wk-*; do
"$bin" --version
done
- name: Smoke test (wk-unwrap-phase end-to-end)
run: |
BUNDLE=$(ls -d packaging/pyinstaller/dist/warpkit-*/ | head -1)
mkdir -p smoke-out
"$BUNDLE/wk-unwrap-phase" \
--magnitude tests/data/test_data/*part-mag_bold.nii.gz \
--phase tests/data/test_data/*part-phase_bold.nii.gz \
--metadata tests/data/test_data/*part-mag_bold.json \
--out-prefix smoke-out/unwrap
ls smoke-out/
- uses: actions/upload-artifact@v7
with:
name: binaries-${{ matrix.target }}
path: packaging/pyinstaller/dist/warpkit-*-${{ matrix.target }}.zip
if-no-files-found: error
binaries-build-macos:
name: PyInstaller bundle (macos-arm64)
runs-on: macos-latest
env:
MACOSX_DEPLOYMENT_TARGET: "11.0"
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Install uv
uses: astral-sh/setup-uv@v8.1.0
- name: Set up Python
run: uv python install 3.11
- name: Build extension + install dev deps
run: uv sync --group dev --config-setting editable_mode=strict -v
- name: Install PyInstaller
run: uv pip install --python .venv/bin/python pyinstaller
- name: Build bundle
run: .venv/bin/python packaging/pyinstaller/build_bundle.py --target macos-arm64
- name: Smoke test (--version per binary)
run: |
BUNDLE=$(ls -d packaging/pyinstaller/dist/warpkit-*/ | head -1)
for bin in "$BUNDLE"wk-*; do
"$bin" --version
done
- name: Smoke test (wk-unwrap-phase end-to-end)
run: |
BUNDLE=$(ls -d packaging/pyinstaller/dist/warpkit-*/ | head -1)
mkdir -p smoke-out
"$BUNDLE/wk-unwrap-phase" \
--magnitude tests/data/test_data/*part-mag_bold.nii.gz \
--phase tests/data/test_data/*part-phase_bold.nii.gz \
--metadata tests/data/test_data/*part-mag_bold.json \
--out-prefix smoke-out/unwrap
ls smoke-out/
- uses: actions/upload-artifact@v7
with:
name: binaries-macos-arm64
path: packaging/pyinstaller/dist/warpkit-*-macos-arm64.zip
if-no-files-found: error
binaries-publish:
name: Attach binaries to release
if: github.event_name == 'release' && github.event.action == 'published'
needs: [binaries-build-linux, binaries-build-macos]
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/download-artifact@v8
with:
path: bins
pattern: binaries-*
merge-multiple: true
- name: Upload zips to release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh release upload "${{ github.event.release.tag_name }}" bins/*.zip --clobber --repo "${{ github.repository }}"
sdist-build:
name: Sdist and coverage
runs-on: ubuntu-latest
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ MANIFEST
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# but our committed bundle spec is checked in
!packaging/pyinstaller/warpkit.spec

# Installer logs
pip-log.txt
Expand Down
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,11 @@ The phase-unwrapping core is a self-contained C++17 port of [ROMEO](https://gith
pip install warpkit
```

Pre-built wheels are published for Linux (x86_64) and macOS (universal2). If `pip` falls back to a source build and fails, please open an issue with the output of `pip install warpkit -v`.
Pre-built wheels are published for Linux (x86_64 + aarch64) and macOS (universal2). If `pip` falls back to a source build and fails, please open an issue with the output of `pip install warpkit -v`.

### Standalone binaries (no Python required)

Each [GitHub release](https://github.com/vanandrew/warpkit/releases) attaches a zip per arch (`linux-x86_64`, `linux-aarch64`, `macos-arm64`) containing all seven `wk-*` CLIs as standalone binaries — no Python install or system ITK needed. Extract, add to `PATH`, and run. See the bundled `README.md` inside the zip for install/PATH instructions and the macOS Gatekeeper note.

### Docker

Expand Down
155 changes: 155 additions & 0 deletions packaging/pyinstaller/build_bundle.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
"""Drive PyInstaller to produce a versioned --onedir bundle + zip for one target.

Usage (from repo root, inside an env with warpkit + pyinstaller installed):

python packaging/pyinstaller/build_bundle.py --target linux-x86_64

Produces:
packaging/pyinstaller/dist/warpkit-${VERSION}/ (the bundle)
packaging/pyinstaller/dist/warpkit-${VERSION}-${TARGET}.zip
"""

from __future__ import annotations

import argparse
import platform
import shutil
import subprocess
import sys
from pathlib import Path

SCRIPTS = [
"wk-medic",
"wk-unwrap-phase",
"wk-compute-fieldmap",
"wk-apply-warp",
"wk-convert-warp",
"wk-convert-fieldmap",
"wk-compute-jacobian",
]


def detect_target() -> str:
system = platform.system().lower()
machine = platform.machine().lower()
if system == "linux":
if machine in ("x86_64", "amd64"):
return "linux-x86_64"
if machine in ("aarch64", "arm64"):
return "linux-aarch64"
if system == "darwin":
if machine in ("arm64", "aarch64"):
return "macos-arm64"
if machine in ("x86_64", "amd64"):
return "macos-x86_64"
raise RuntimeError(f"unsupported target: {system}/{machine}")


def get_version() -> str:
from warpkit import __version__

return __version__


def run_pyinstaller(spec: Path, dist: Path, work: Path) -> None:
cmd = [
sys.executable,
"-m",
"PyInstaller",
"--noconfirm",
"--clean",
"--distpath",
str(dist),
"--workpath",
str(work),
str(spec),
]
subprocess.run(cmd, check=True)


def adhoc_sign_macos(bundle: Path) -> None:
if platform.system().lower() != "darwin":
return
# Ad-hoc sign every Mach-O in the bundle. `codesign -s -` is sufficient to
# let users override Gatekeeper after recursively clearing the
# com.apple.quarantine xattr (see bundle_README.md).
targets = [bundle / name for name in SCRIPTS]
targets.extend(p for p in bundle.rglob("*.dylib") if p.is_file())
targets.extend(p for p in (bundle / "_internal").rglob("*.so") if p.is_file())
failures: list[str] = []
for t in targets:
result = subprocess.run(
["codesign", "--force", "--sign", "-", "--timestamp=none", str(t)],
capture_output=True,
text=True,
)
if result.returncode != 0:
failures.append(f"{t} (exit {result.returncode}): {result.stderr.rstrip()}")
if failures:
raise RuntimeError(
"ad-hoc codesign failed for one or more targets:\n "
+ "\n ".join(failures)
)
Comment thread
vanandrew marked this conversation as resolved.


def write_readme(bundle: Path, version: str, target: str) -> None:
template = Path(__file__).parent / "bundle_README.md"
body = (
template.read_text(encoding="utf-8")
.replace("@VERSION@", version)
.replace("@TARGET@", target)
)
(bundle / "README.md").write_text(body, encoding="utf-8")


def make_zip(bundle: Path, out_zip: Path) -> None:
# shutil.make_archive's base_dir keeps a tidy top-level folder inside the zip.
base_name = str(out_zip.with_suffix(""))
shutil.make_archive(
base_name=base_name,
format="zip",
root_dir=str(bundle.parent),
base_dir=bundle.name,
)


def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--target",
default=None,
help="target triple (e.g. linux-x86_64); auto-detected by default",
)
args = parser.parse_args()

here = Path(__file__).parent
spec = here / "warpkit.spec"
dist = here / "dist"
work = here / "build"
target = args.target or detect_target()
version = get_version()

if dist.exists():
shutil.rmtree(dist)
if work.exists():
shutil.rmtree(work)

run_pyinstaller(spec, dist, work)

raw_bundle = dist / "warpkit"
if not raw_bundle.is_dir():
raise RuntimeError(f"PyInstaller did not produce {raw_bundle}")
versioned = dist / f"warpkit-{version}"
raw_bundle.rename(versioned)

adhoc_sign_macos(versioned)
write_readme(versioned, version, target)

out_zip = dist / f"warpkit-{version}-{target}.zip"
make_zip(versioned, out_zip)
print(f"wrote {out_zip}")
return 0


if __name__ == "__main__":
raise SystemExit(main())
49 changes: 49 additions & 0 deletions packaging/pyinstaller/bundle_README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# warpkit @VERSION@ — standalone binaries (@TARGET@)

This bundle contains the seven `wk-*` CLIs as standalone binaries. No Python
install or system ITK required — everything is in `_internal/`.

## Install

Extract anywhere and put the bundle directory on your `PATH`:

```sh
# example: install to /opt
sudo mv warpkit-@VERSION@ /opt/
echo 'export PATH=/opt/warpkit-@VERSION@:$PATH' >> ~/.bashrc
```

Or symlink each `wk-*` onto an existing `PATH` entry — the bootloader resolves
`_internal/` relative to the real binary, so symlinks work fine:

```sh
for bin in /opt/warpkit-@VERSION@/wk-*; do
sudo ln -s "$bin" /usr/local/bin/$(basename "$bin")
done
```

**Do not separate the binaries from `_internal/`** — they all share the
embedded interpreter and dependency tree.

## macOS Gatekeeper

Binaries are ad-hoc signed, not Apple-notarized, so on first launch macOS will
quarantine them — and not just the top-level `wk-*` binaries: every `.dylib`
and `.so` inside `_internal/` is also flagged. Strip the quarantine attribute
recursively from the whole bundle:

```sh
xattr -r -d com.apple.quarantine /opt/warpkit-@VERSION@
```

## Available CLIs

- `wk-medic` — full MEDIC distortion correction pipeline
- `wk-unwrap-phase` — ROMEO multi-echo phase unwrapping
- `wk-compute-fieldmap` — compute B0 field map from unwrapped phase
- `wk-apply-warp` — apply a displacement field to an image
- `wk-convert-warp` — convert between warp field conventions
- `wk-convert-fieldmap` — convert between field-map representations
- `wk-compute-jacobian` — compute the Jacobian determinant of a warp

Run any of them with `--help` for usage.
15 changes: 15 additions & 0 deletions packaging/pyinstaller/hooks/hook-warpkit.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import importlib.util

# PyInstaller's static analysis usually picks up `warpkit_cpp.cpython-*.so` via
# the import graph (`from .warpkit_cpp import *` in warpkit/__init__.py), but
# only when the resolved warpkit package directory has the .so right next to
# __init__.py — which is the case for an editable install (cmake-build-extension
# drops the .so into the source tree) and for a wheel install (the .so lives in
# site-packages/warpkit/). Resolve the .so explicitly via importlib.util so the
# hook works regardless of install layout.
binaries = []
spec = importlib.util.find_spec("warpkit.warpkit_cpp")
if spec is not None and spec.origin:
binaries.append((spec.origin, "warpkit"))

hiddenimports = ["warpkit.warpkit_cpp"]
7 changes: 7 additions & 0 deletions packaging/pyinstaller/launchers/wk-apply-warp.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import multiprocessing

multiprocessing.freeze_support()

from warpkit.scripts.apply_warp import main # noqa: E402

main()
7 changes: 7 additions & 0 deletions packaging/pyinstaller/launchers/wk-compute-fieldmap.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import multiprocessing

multiprocessing.freeze_support()

from warpkit.scripts.compute_fieldmap import main # noqa: E402

main()
7 changes: 7 additions & 0 deletions packaging/pyinstaller/launchers/wk-compute-jacobian.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import multiprocessing

multiprocessing.freeze_support()

from warpkit.scripts.compute_jacobian import main # noqa: E402

main()
Loading