diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 26e5bdc..551d517 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -10,6 +10,7 @@ on: release: types: - published + workflow_dispatch: jobs: lint: @@ -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 diff --git a/.gitignore b/.gitignore index a1910b9..42ae0a7 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md index f5601d9..5a45ea4 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/packaging/pyinstaller/build_bundle.py b/packaging/pyinstaller/build_bundle.py new file mode 100644 index 0000000..0e877a0 --- /dev/null +++ b/packaging/pyinstaller/build_bundle.py @@ -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) + ) + + +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()) diff --git a/packaging/pyinstaller/bundle_README.md b/packaging/pyinstaller/bundle_README.md new file mode 100644 index 0000000..0be6e8d --- /dev/null +++ b/packaging/pyinstaller/bundle_README.md @@ -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. diff --git a/packaging/pyinstaller/hooks/hook-warpkit.py b/packaging/pyinstaller/hooks/hook-warpkit.py new file mode 100644 index 0000000..26bb8c1 --- /dev/null +++ b/packaging/pyinstaller/hooks/hook-warpkit.py @@ -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"] diff --git a/packaging/pyinstaller/launchers/wk-apply-warp.py b/packaging/pyinstaller/launchers/wk-apply-warp.py new file mode 100644 index 0000000..7d2dd34 --- /dev/null +++ b/packaging/pyinstaller/launchers/wk-apply-warp.py @@ -0,0 +1,7 @@ +import multiprocessing + +multiprocessing.freeze_support() + +from warpkit.scripts.apply_warp import main # noqa: E402 + +main() diff --git a/packaging/pyinstaller/launchers/wk-compute-fieldmap.py b/packaging/pyinstaller/launchers/wk-compute-fieldmap.py new file mode 100644 index 0000000..ca5baee --- /dev/null +++ b/packaging/pyinstaller/launchers/wk-compute-fieldmap.py @@ -0,0 +1,7 @@ +import multiprocessing + +multiprocessing.freeze_support() + +from warpkit.scripts.compute_fieldmap import main # noqa: E402 + +main() diff --git a/packaging/pyinstaller/launchers/wk-compute-jacobian.py b/packaging/pyinstaller/launchers/wk-compute-jacobian.py new file mode 100644 index 0000000..1951da2 --- /dev/null +++ b/packaging/pyinstaller/launchers/wk-compute-jacobian.py @@ -0,0 +1,7 @@ +import multiprocessing + +multiprocessing.freeze_support() + +from warpkit.scripts.compute_jacobian import main # noqa: E402 + +main() diff --git a/packaging/pyinstaller/launchers/wk-convert-fieldmap.py b/packaging/pyinstaller/launchers/wk-convert-fieldmap.py new file mode 100644 index 0000000..803567c --- /dev/null +++ b/packaging/pyinstaller/launchers/wk-convert-fieldmap.py @@ -0,0 +1,7 @@ +import multiprocessing + +multiprocessing.freeze_support() + +from warpkit.scripts.convert_fieldmap import main # noqa: E402 + +main() diff --git a/packaging/pyinstaller/launchers/wk-convert-warp.py b/packaging/pyinstaller/launchers/wk-convert-warp.py new file mode 100644 index 0000000..b29ab98 --- /dev/null +++ b/packaging/pyinstaller/launchers/wk-convert-warp.py @@ -0,0 +1,7 @@ +import multiprocessing + +multiprocessing.freeze_support() + +from warpkit.scripts.convert_warp import main # noqa: E402 + +main() diff --git a/packaging/pyinstaller/launchers/wk-medic.py b/packaging/pyinstaller/launchers/wk-medic.py new file mode 100644 index 0000000..4dc023d --- /dev/null +++ b/packaging/pyinstaller/launchers/wk-medic.py @@ -0,0 +1,7 @@ +import multiprocessing + +multiprocessing.freeze_support() + +from warpkit.scripts.medic import main # noqa: E402 + +main() diff --git a/packaging/pyinstaller/launchers/wk-unwrap-phase.py b/packaging/pyinstaller/launchers/wk-unwrap-phase.py new file mode 100644 index 0000000..ad598f1 --- /dev/null +++ b/packaging/pyinstaller/launchers/wk-unwrap-phase.py @@ -0,0 +1,7 @@ +import multiprocessing + +multiprocessing.freeze_support() + +from warpkit.scripts.unwrap_phase import main # noqa: E402 + +main() diff --git a/packaging/pyinstaller/warpkit.spec b/packaging/pyinstaller/warpkit.spec new file mode 100644 index 0000000..1f10b2c --- /dev/null +++ b/packaging/pyinstaller/warpkit.spec @@ -0,0 +1,80 @@ +# PyInstaller multi-binary spec: one --onedir bundle, all wk-* CLIs share _internal/. +# Driven by packaging/pyinstaller/build_bundle.py. + +from pathlib import Path + +LAUNCHERS_DIR = Path(SPECPATH) / "launchers" +HOOKS_DIR = Path(SPECPATH) / "hooks" + +SCRIPTS = [ + "wk-medic", + "wk-unwrap-phase", + "wk-compute-fieldmap", + "wk-apply-warp", + "wk-convert-warp", + "wk-convert-fieldmap", + "wk-compute-jacobian", +] + +# nibabel + indexed_gzip rely on string-based imports that PyInstaller's static +# analysis misses; everything else (numpy/scipy/skimage/transforms3d) has a hook +# shipped with PyInstaller. +HIDDEN_IMPORTS = [ + "indexed_gzip", + "nibabel.streamlines", + "nibabel.nifti1", + "nibabel.nifti2", +] + +analyses = [] +for name in SCRIPTS: + a = Analysis( + [str(LAUNCHERS_DIR / f"{name}.py")], + pathex=[], + binaries=[], + datas=[], + hiddenimports=HIDDEN_IMPORTS, + hookspath=[str(HOOKS_DIR)], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + noarchive=False, + ) + analyses.append(a) + +# Deduplicate shared libraries/data across all analyses so _internal/ has one copy. +MERGE(*[(a, name, name) for a, name in zip(analyses, SCRIPTS)]) + +exe_list = [] +for a, name in zip(analyses, SCRIPTS): + pyz = PYZ(a.pure, a.zipped_data) + exe = EXE( + pyz, + a.scripts, + [], + exclude_binaries=True, + name=name, + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=False, + console=True, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, + ) + exe_list.append(exe) + +collect_args = list(exe_list) +for a in analyses: + collect_args.extend([a.binaries, a.zipfiles, a.datas]) + +COLLECT( + *collect_args, + strip=False, + upx=False, + upx_exclude=[], + name="warpkit", +) diff --git a/pyproject.toml b/pyproject.toml index f677180..9c466b3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,7 +49,7 @@ build-backend = "setuptools.build_meta" zip-safe = true [tool.setuptools.packages.find] -exclude = ["tests"] +exclude = ["tests", "packaging*"] [tool.setuptools.package-data] warpkit = ["py.typed", "*.pyi"]