From 52d30216fa27105c1ef72c20bf6f283ab881454c Mon Sep 17 00:00:00 2001 From: Andrew Van Date: Sat, 25 Apr 2026 23:52:03 -0500 Subject: [PATCH 1/4] :test: add 3.14t free-threaded wheel build --- .github/workflows/build.yml | 3 ++- pyproject.toml | 6 +++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6eb6dd5..622c79f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -41,7 +41,8 @@ jobs: {version: '3.11', glob: cp311*}, {version: '3.12', glob: cp312*}, {version: '3.13', glob: cp313*}, - {version: '3.14', glob: cp314*} + {version: '3.14', glob: cp314-*}, + {version: '3.14t', glob: cp314t*} ] name: Python ${{ matrix.python-versions.version }} wheel on ${{ matrix.os }} runs-on: ${{ matrix.os }} diff --git a/pyproject.toml b/pyproject.toml index b538789..d35442e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -122,9 +122,9 @@ command_line = "-m pytest" omit = ["tests/*"] [tool.cibuildwheel] -# Skip musllinux (we ship manylinux only) and free-threaded CPython builds -# (cp31Xt-*) — our pybind11 bindings + ITK haven't been audited for the no-GIL ABI. -skip = "*musllinux* cp314t-*" +# Skip musllinux (we ship manylinux only). Note: cp314t-* is allowed for testing. +# If free-threaded support fails, add it back to skip list pending no-GIL audit. +skip = "*musllinux*" build-frontend = "build" build-verbosity = 3 From ad25d40bc3cd6771dadf70b71e154fbe40f98cc3 Mon Sep 17 00:00:00 2001 From: Andrew Van Date: Sun, 26 Apr 2026 00:05:35 -0500 Subject: [PATCH 2/4] :sparkles: enable free-threaded Python 3.14t support Remove PyErr_CheckSignals() calls from include/warps.h to support no-GIL execution. This tradeoff loses Ctrl+C interruptibility during long ITK operations but enables the pybind11 module to run safely without the GIL. - Use PYBIND11_MODULE_GIL_NOT_USED() in src/warpkit.cpp - Remove all 12 PyErr_CheckSignals() calls from include/warps.h - Update pyproject.toml [tool.cibuildwheel] to allow cp314t-* builds - Update CLAUDE.md CI section to reflect free-threaded support --- CLAUDE.md | 12 ++++++------ include/warps.h | 14 -------------- pyproject.toml | 5 +++-- src/warpkit.cpp | 2 +- 4 files changed, 10 insertions(+), 23 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index b5a800d..80cb5f1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -131,12 +131,12 @@ and call out anything CI-relevant (wheel matrix, pybind11 ABI, ITK). ## CI specifics -GitHub Actions builds wheels for Python 3.11–3.14 on `ubuntu-latest`, +GitHub Actions builds wheels for Python 3.11–3.14 (both standard and free-threaded) on `ubuntu-latest`, `ubuntu-24.04-arm`, and `macos-latest` via cibuildwheel. -`pyproject.toml`'s `[tool.cibuildwheel]` skips `*musllinux*` and the -`cp314t-*` free-threaded build; re-enabling free-threaded support requires -auditing the pybind11 + ITK code paths for the no-GIL ABI. The sdist job -also runs `uv run coverage run` then `coverage report -m` — keep coverage -healthy when adding code (the `[tool.coverage.report]` config in +`pyproject.toml`'s `[tool.cibuildwheel]` skips only `*musllinux*` builds. +Free-threaded support is enabled: `PyErr_CheckSignals()` calls were removed from +`include/warps.h` to allow no-GIL operation (tradeoff: loses Ctrl+C interruptibility +during long ITK operations). The sdist job also runs `uv run coverage run` then `coverage report -m` — keep +coverage healthy when adding code (the `[tool.coverage.report]` config in `pyproject.toml` omits the test files). PyPI publish and the GHCR Docker image only run on a published GitHub release. diff --git a/include/warps.h b/include/warps.h index 2675674..7a6b9bd 100644 --- a/include/warps.h +++ b/include/warps.h @@ -139,9 +139,7 @@ py::array_t invert_displacement_map(py::array_tGetOutput(); - if (PyErr_CheckSignals() != 0) throw py::error_already_set(); inv_map->Update(); - if (PyErr_CheckSignals() != 0) throw py::error_already_set(); itk::ImageRegionConstIteratorWithIndex inv_map_it(inv_map, inv_map->GetLargestPossibleRegion()); py::array_t inverted_displacement_map(displacement_map); @@ -170,7 +168,6 @@ py::array_t invert_displacement_field(py::array_t direction, py::array_t spacing, ssize_t iterations, bool verbose) { - if (PyErr_CheckSignals() != 0) throw py::error_already_set(); // Get the displacement field shape const ssize_t* shape = displacement_field.shape(); @@ -227,9 +224,7 @@ py::array_t invert_displacement_field(py::array_tGetOutput(); - if (PyErr_CheckSignals() != 0) throw py::error_already_set(); inv_field->Update(); - if (PyErr_CheckSignals() != 0) throw py::error_already_set(); itk::ImageRegionConstIteratorWithIndex inv_field_it(inv_field, inv_field->GetLargestPossibleRegion()); py::array_t inverted_displacement_field(displacement_field); @@ -261,8 +256,6 @@ py::array_t compute_jacobian_determinant(py::array_t origin, py::array_t direction, py::array_t spacing) { - if (PyErr_CheckSignals() != 0) throw py::error_already_set(); - // Get the displacement field shape const ssize_t* shape = displacement_field.shape(); @@ -315,9 +308,7 @@ py::array_t compute_jacobian_determinant(py::array_tGetOutput(); - if (PyErr_CheckSignals() != 0) throw py::error_already_set(); jacobian_determinant->Update(); - if (PyErr_CheckSignals() != 0) throw py::error_already_set(); // Convert to numpy array py::array_t jacobian_determinant_array({shape[0], shape[1], shape[2]}); @@ -461,9 +452,7 @@ py::array_t resample( // Get the output typename OutputImageType::Pointer output_image = warp_filter->GetOutput(); - if (PyErr_CheckSignals() != 0) throw py::error_already_set(); output_image->Update(); - if (PyErr_CheckSignals() != 0) throw py::error_already_set(); itk::ImageRegionConstIteratorWithIndex output_iterator(output_image, output_image->GetLargestPossibleRegion()); py::array_t output_array({output_shape.at(0), output_shape.at(1), output_shape.at(2)}); @@ -481,8 +470,6 @@ T compute_hausdorff_distance( py::array_t image1_direction, py::array_t image1_spacing, py::array_t image2, py::array_t image2_origin, py::array_t image2_direction, py::array_t image2_spacing) { - if (PyErr_CheckSignals() != 0) throw py::error_already_set(); - // Setup types using ImageType = typename itk::Image; @@ -545,7 +532,6 @@ T compute_hausdorff_distance( // Get the hausdorff distance hausdorff_filter->Update(); T hausdorff_distance = hausdorff_filter->GetAverageHausdorffDistance(); - if (PyErr_CheckSignals() != 0) throw py::error_already_set(); // Return the hausdorff distance return hausdorff_distance; diff --git a/pyproject.toml b/pyproject.toml index d35442e..48b2e3f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -122,8 +122,9 @@ command_line = "-m pytest" omit = ["tests/*"] [tool.cibuildwheel] -# Skip musllinux (we ship manylinux only). Note: cp314t-* is allowed for testing. -# If free-threaded support fails, add it back to skip list pending no-GIL audit. +# Skip musllinux (we ship manylinux only). +# Free-threaded support enabled: PyErr_CheckSignals() calls removed from warps.h +# to allow no-GIL operation (tradeoff: Ctrl+C interruptibility during long ITK operations). skip = "*musllinux*" build-frontend = "build" build-verbosity = 3 diff --git a/src/warpkit.cpp b/src/warpkit.cpp index fc74d85..aaa8d07 100644 --- a/src/warpkit.cpp +++ b/src/warpkit.cpp @@ -5,7 +5,7 @@ namespace py = pybind11; -PYBIND11_MODULE(warpkit_cpp, m) { +PYBIND11_MODULE_GIL_NOT_USED(warpkit_cpp, m) { m.def("calculate_weights", &romeo::calculate_weights, "ROMEO edge-weight map (3, nx, ny, nz) uint8. Exposed for port validation; not used by warpkit.", py::arg("phase"), From fe2cd5ab39eb278c7a11319280945d0f95fa21e5 Mon Sep 17 00:00:00 2001 From: Andrew Van Date: Sun, 26 Apr 2026 00:10:07 -0500 Subject: [PATCH 3/4] :label: use pybind11 3.0+ py::mod_gil_not_used() syntax --- src/warpkit.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/warpkit.cpp b/src/warpkit.cpp index aaa8d07..c8c78db 100644 --- a/src/warpkit.cpp +++ b/src/warpkit.cpp @@ -5,7 +5,7 @@ namespace py = pybind11; -PYBIND11_MODULE_GIL_NOT_USED(warpkit_cpp, m) { +PYBIND11_MODULE(warpkit_cpp, m, py::mod_gil_not_used()) { m.def("calculate_weights", &romeo::calculate_weights, "ROMEO edge-weight map (3, nx, ny, nz) uint8. Exposed for port validation; not used by warpkit.", py::arg("phase"), From 16845c9c27762b2461798dd39e545d2016f64190 Mon Sep 17 00:00:00 2001 From: Andrew Van Date: Sun, 10 May 2026 21:34:12 -0400 Subject: [PATCH 4/4] :sparkles: keep Ctrl+C interruptibility on GIL builds via Py_GIL_DISABLED guard Reintroduce PyErr_CheckSignals() in include/warps.h behind a WARPKIT_CHECK_SIGNALS() macro that compiles to a no-op when Py_GIL_DISABLED is defined. GIL builds regain Ctrl+C interruptibility during long ITK operations; the free-threaded build still skips the GIL-requiring check. --- CLAUDE.md | 9 ++++++--- include/warps.h | 26 ++++++++++++++++++++++++++ pyproject.toml | 6 +++--- 3 files changed, 35 insertions(+), 6 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 80cb5f1..ef6d6ac 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -134,9 +134,12 @@ and call out anything CI-relevant (wheel matrix, pybind11 ABI, ITK). GitHub Actions builds wheels for Python 3.11–3.14 (both standard and free-threaded) on `ubuntu-latest`, `ubuntu-24.04-arm`, and `macos-latest` via cibuildwheel. `pyproject.toml`'s `[tool.cibuildwheel]` skips only `*musllinux*` builds. -Free-threaded support is enabled: `PyErr_CheckSignals()` calls were removed from -`include/warps.h` to allow no-GIL operation (tradeoff: loses Ctrl+C interruptibility -during long ITK operations). The sdist job also runs `uv run coverage run` then `coverage report -m` — keep +Free-threaded support is enabled via `py::mod_gil_not_used()` in +`src/warpkit.cpp`. `PyErr_CheckSignals()` calls in `include/warps.h` are +wrapped in a `WARPKIT_CHECK_SIGNALS()` macro that compiles to a no-op under +`Py_GIL_DISABLED`, so GIL builds keep Ctrl+C interruptibility during long ITK +operations and the free-threaded build skips the GIL-requiring check. The +sdist job also runs `uv run coverage run` then `coverage report -m` — keep coverage healthy when adding code (the `[tool.coverage.report]` config in `pyproject.toml` omits the test files). PyPI publish and the GHCR Docker image only run on a published GitHub release. diff --git a/include/warps.h b/include/warps.h index 7a6b9bd..ba1e398 100644 --- a/include/warps.h +++ b/include/warps.h @@ -19,6 +19,18 @@ namespace py = pybind11; +// PyErr_CheckSignals() needs the GIL; free-threaded builds (Py_GIL_DISABLED) +// declare `py::mod_gil_not_used()` and skip the check, trading Ctrl+C +// interruptibility during long ITK operations for no-GIL execution. +#ifdef Py_GIL_DISABLED +#define WARPKIT_CHECK_SIGNALS() ((void)0) +#else +#define WARPKIT_CHECK_SIGNALS() \ + do { \ + if (PyErr_CheckSignals() != 0) throw py::error_already_set(); \ + } while (0) +#endif + /** * @brief Invert a displacement map * @@ -139,7 +151,9 @@ py::array_t invert_displacement_map(py::array_tGetOutput(); + WARPKIT_CHECK_SIGNALS(); inv_map->Update(); + WARPKIT_CHECK_SIGNALS(); itk::ImageRegionConstIteratorWithIndex inv_map_it(inv_map, inv_map->GetLargestPossibleRegion()); py::array_t inverted_displacement_map(displacement_map); @@ -168,6 +182,7 @@ py::array_t invert_displacement_field(py::array_t direction, py::array_t spacing, ssize_t iterations, bool verbose) { + WARPKIT_CHECK_SIGNALS(); // Get the displacement field shape const ssize_t* shape = displacement_field.shape(); @@ -224,7 +239,9 @@ py::array_t invert_displacement_field(py::array_tGetOutput(); + WARPKIT_CHECK_SIGNALS(); inv_field->Update(); + WARPKIT_CHECK_SIGNALS(); itk::ImageRegionConstIteratorWithIndex inv_field_it(inv_field, inv_field->GetLargestPossibleRegion()); py::array_t inverted_displacement_field(displacement_field); @@ -256,6 +273,8 @@ py::array_t compute_jacobian_determinant(py::array_t origin, py::array_t direction, py::array_t spacing) { + WARPKIT_CHECK_SIGNALS(); + // Get the displacement field shape const ssize_t* shape = displacement_field.shape(); @@ -308,7 +327,9 @@ py::array_t compute_jacobian_determinant(py::array_tGetOutput(); + WARPKIT_CHECK_SIGNALS(); jacobian_determinant->Update(); + WARPKIT_CHECK_SIGNALS(); // Convert to numpy array py::array_t jacobian_determinant_array({shape[0], shape[1], shape[2]}); @@ -452,7 +473,9 @@ py::array_t resample( // Get the output typename OutputImageType::Pointer output_image = warp_filter->GetOutput(); + WARPKIT_CHECK_SIGNALS(); output_image->Update(); + WARPKIT_CHECK_SIGNALS(); itk::ImageRegionConstIteratorWithIndex output_iterator(output_image, output_image->GetLargestPossibleRegion()); py::array_t output_array({output_shape.at(0), output_shape.at(1), output_shape.at(2)}); @@ -470,6 +493,8 @@ T compute_hausdorff_distance( py::array_t image1_direction, py::array_t image1_spacing, py::array_t image2, py::array_t image2_origin, py::array_t image2_direction, py::array_t image2_spacing) { + WARPKIT_CHECK_SIGNALS(); + // Setup types using ImageType = typename itk::Image; @@ -532,6 +557,7 @@ T compute_hausdorff_distance( // Get the hausdorff distance hausdorff_filter->Update(); T hausdorff_distance = hausdorff_filter->GetAverageHausdorffDistance(); + WARPKIT_CHECK_SIGNALS(); // Return the hausdorff distance return hausdorff_distance; diff --git a/pyproject.toml b/pyproject.toml index 48b2e3f..88bc4d1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -122,9 +122,9 @@ command_line = "-m pytest" omit = ["tests/*"] [tool.cibuildwheel] -# Skip musllinux (we ship manylinux only). -# Free-threaded support enabled: PyErr_CheckSignals() calls removed from warps.h -# to allow no-GIL operation (tradeoff: Ctrl+C interruptibility during long ITK operations). +# Skip musllinux (we ship manylinux only). Free-threaded (cp31Xt-*) builds +# are kept: warps.h guards PyErr_CheckSignals() behind WARPKIT_CHECK_SIGNALS, +# which is a no-op under Py_GIL_DISABLED. skip = "*musllinux*" build-frontend = "build" build-verbosity = 3