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/CLAUDE.md b/CLAUDE.md index b5a800d..ef6d6ac 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -131,12 +131,15 @@ 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 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 2675674..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,9 +151,9 @@ py::array_t invert_displacement_map(py::array_tGetOutput(); - if (PyErr_CheckSignals() != 0) throw py::error_already_set(); + WARPKIT_CHECK_SIGNALS(); inv_map->Update(); - if (PyErr_CheckSignals() != 0) throw py::error_already_set(); + WARPKIT_CHECK_SIGNALS(); itk::ImageRegionConstIteratorWithIndex inv_map_it(inv_map, inv_map->GetLargestPossibleRegion()); py::array_t inverted_displacement_map(displacement_map); @@ -170,7 +182,7 @@ 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(); + WARPKIT_CHECK_SIGNALS(); // Get the displacement field shape const ssize_t* shape = displacement_field.shape(); @@ -227,9 +239,9 @@ py::array_t invert_displacement_field(py::array_tGetOutput(); - if (PyErr_CheckSignals() != 0) throw py::error_already_set(); + WARPKIT_CHECK_SIGNALS(); inv_field->Update(); - if (PyErr_CheckSignals() != 0) throw py::error_already_set(); + WARPKIT_CHECK_SIGNALS(); itk::ImageRegionConstIteratorWithIndex inv_field_it(inv_field, inv_field->GetLargestPossibleRegion()); py::array_t inverted_displacement_field(displacement_field); @@ -261,7 +273,7 @@ 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(); + WARPKIT_CHECK_SIGNALS(); // Get the displacement field shape const ssize_t* shape = displacement_field.shape(); @@ -315,9 +327,9 @@ py::array_t compute_jacobian_determinant(py::array_tGetOutput(); - if (PyErr_CheckSignals() != 0) throw py::error_already_set(); + WARPKIT_CHECK_SIGNALS(); jacobian_determinant->Update(); - if (PyErr_CheckSignals() != 0) throw py::error_already_set(); + WARPKIT_CHECK_SIGNALS(); // Convert to numpy array py::array_t jacobian_determinant_array({shape[0], shape[1], shape[2]}); @@ -461,9 +473,9 @@ py::array_t resample( // Get the output typename OutputImageType::Pointer output_image = warp_filter->GetOutput(); - if (PyErr_CheckSignals() != 0) throw py::error_already_set(); + WARPKIT_CHECK_SIGNALS(); output_image->Update(); - if (PyErr_CheckSignals() != 0) throw py::error_already_set(); + 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)}); @@ -481,7 +493,7 @@ 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(); + WARPKIT_CHECK_SIGNALS(); // Setup types using ImageType = typename itk::Image; @@ -545,7 +557,7 @@ 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(); + WARPKIT_CHECK_SIGNALS(); // Return the hausdorff distance return hausdorff_distance; diff --git a/pyproject.toml b/pyproject.toml index b538789..88bc4d1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -122,9 +122,10 @@ 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). 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 diff --git a/src/warpkit.cpp b/src/warpkit.cpp index fc74d85..c8c78db 100644 --- a/src/warpkit.cpp +++ b/src/warpkit.cpp @@ -5,7 +5,7 @@ namespace py = pybind11; -PYBIND11_MODULE(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"),