From bdb7e01c2fed47dc67c46f6dde0d174f374784b8 Mon Sep 17 00:00:00 2001 From: "Hans J. Johnson" Date: Sun, 26 Jul 2026 16:22:55 -0500 Subject: [PATCH 1/9] COMP: Make ITKVtkGlue wrapping abi3-compatible Exchange pointers with VTK's Python layer through the `__this__` and `Addr=0x...` encodings using only Limited API calls, instead of through vtkPythonUtil, whose header chain accesses PyTypeObject members that Py_LIMITED_API hides. Dropping VTK::WrappingPythonCore from the wrapping link interface is required for correctness, not tidiness: that library is built against one libpython, so an extension linking it cannot be version-agnostic. Neither encoding is documented VTK API, so PythonVtkGlueABI3EncodingTest asserts both still hold and PythonVtkGlueRoundTripTest exercises the typemaps end to end. Closes: #6711 --- Modules/Bridge/VtkGlue/CMakeLists.txt | 1 - Modules/Bridge/VtkGlue/itk-module-init.cmake | 1 - .../Bridge/VtkGlue/wrapping/CMakeLists.txt | 16 +- Modules/Bridge/VtkGlue/wrapping/VtkGlue.i | 137 +++++++++++++++--- .../VtkGlue/wrapping/test/CMakeLists.txt | 32 ++++ .../wrapping/test/VtkGlueABI3EncodingTest.py | 100 +++++++++++++ .../wrapping/test/VtkGlueRoundTripTest.py | 111 ++++++++++++++ 7 files changed, 362 insertions(+), 36 deletions(-) create mode 100644 Modules/Bridge/VtkGlue/wrapping/test/CMakeLists.txt create mode 100644 Modules/Bridge/VtkGlue/wrapping/test/VtkGlueABI3EncodingTest.py create mode 100644 Modules/Bridge/VtkGlue/wrapping/test/VtkGlueRoundTripTest.py diff --git a/Modules/Bridge/VtkGlue/CMakeLists.txt b/Modules/Bridge/VtkGlue/CMakeLists.txt index e4f395a2de0..74231aaff7e 100644 --- a/Modules/Bridge/VtkGlue/CMakeLists.txt +++ b/Modules/Bridge/VtkGlue/CMakeLists.txt @@ -99,7 +99,6 @@ set(_required_vtk_libraries ) if(ITK_WRAP_PYTHON) list(APPEND _required_vtk_libraries - VTK::WrappingPythonCore VTK::CommonCore VTK::CommonDataModel VTK::CommonExecutionModel) diff --git a/Modules/Bridge/VtkGlue/itk-module-init.cmake b/Modules/Bridge/VtkGlue/itk-module-init.cmake index fa7ad74c8c2..550f1dce272 100644 --- a/Modules/Bridge/VtkGlue/itk-module-init.cmake +++ b/Modules/Bridge/VtkGlue/itk-module-init.cmake @@ -37,7 +37,6 @@ if(ITK_WRAP_PYTHON) list( APPEND _required_vtk_libraries - VTK::WrappingPythonCore VTK::CommonCore VTK::CommonDataModel VTK::CommonExecutionModel diff --git a/Modules/Bridge/VtkGlue/wrapping/CMakeLists.txt b/Modules/Bridge/VtkGlue/wrapping/CMakeLists.txt index a0a587c0b76..5222e6ce330 100644 --- a/Modules/Bridge/VtkGlue/wrapping/CMakeLists.txt +++ b/Modules/Bridge/VtkGlue/wrapping/CMakeLists.txt @@ -1,15 +1,3 @@ itk_wrap_module(ITKVtkGlue) -if(ITK_USE_PYTHON_LIMITED_API) - message( - FATAL_ERROR - "The ITKVtkGlue module can only built without Python limited API due to VTK limitations." - "Please set `ITK_USE_PYTHON_LIMITED_API` to `FALSE`." - ) -else() - list( - APPEND - WRAPPER_SWIG_LIBRARY_FILES - "${CMAKE_CURRENT_SOURCE_DIR}/VtkGlue.i" - ) - itk_auto_load_and_end_wrap_submodules() -endif() +list(APPEND WRAPPER_SWIG_LIBRARY_FILES "${CMAKE_CURRENT_SOURCE_DIR}/VtkGlue.i") +itk_auto_load_and_end_wrap_submodules() diff --git a/Modules/Bridge/VtkGlue/wrapping/VtkGlue.i b/Modules/Bridge/VtkGlue/wrapping/VtkGlue.i index 54d41993bd9..4cd3a5cd324 100644 --- a/Modules/Bridge/VtkGlue/wrapping/VtkGlue.i +++ b/Modules/Bridge/VtkGlue/wrapping/VtkGlue.i @@ -51,44 +51,141 @@ %module(package="itk",threads="1") VtkGluePython %{ -#include "vtkPythonUtil.h" -#include "vtkVersion.h" -#if (VTK_MAJOR_VERSION > 5 ||((VTK_MAJOR_VERSION == 5)&&(VTK_MINOR_VERSION > 6))) -#define vtkPythonGetObjectFromPointer vtkPythonUtil::GetObjectFromPointer -#define vtkPythonGetPointerFromObject vtkPythonUtil::GetPointerFromObject -#endif +#include +#include +#include + +// Pointer exchange with VTK's Python layer using only the Limited API, so this +// module stays abi3 and needs no link against VTK::WrappingPythonCore. +namespace itkVtkGlueABI3 +{ + +inline PyObject * +ImportClass(const char * moduleName, const char * className) +{ + PyObject * mod = PyImport_ImportModule(moduleName); + if (!mod) + { + return nullptr; + } + PyObject * cls = PyObject_GetAttrString(mod, className); + Py_DECREF(mod); + return cls; +} + +// Parses the `__p_` encoding VTK publishes as `__this__`. The +// isinstance() gate is what makes trusting that string safe: without it any +// object exposing a forged `__this__` would be cast to a native pointer. +inline void * +GetPointerFromObject(PyObject * obj, const char * moduleName, const char * className) +{ + PyObject * cls = ImportClass(moduleName, className); + if (!cls) + { + return nullptr; + } + const int isInstance = PyObject_IsInstance(obj, cls); + Py_DECREF(cls); + if (isInstance < 0) + { + return nullptr; + } + if (isInstance == 0) + { + PyErr_Format(PyExc_TypeError, "expected a VTK %s instance", className); + return nullptr; + } + + PyObject * thisStr = PyObject_GetAttrString(obj, "__this__"); + if (!thisStr) + { + PyErr_Clear(); + PyErr_Format(PyExc_TypeError, "expected a VTK %s instance", className); + return nullptr; + } + + void * ptr = nullptr; + Py_ssize_t len = 0; + const char * s = PyUnicode_AsUTF8AndSize(thisStr, &len); + if (s && len > 4 && s[0] == '_' && std::strlen(s) == static_cast(len)) + { + const char * sep = std::strstr(s + 1, "_p_"); + if (sep && std::strcmp(sep + 3, className) == 0) + { + std::uintptr_t addr = 0; + // '_' is not a hex digit, so the conversion stops at the separator. + if (std::sscanf(s + 1, "%" SCNxPTR, &addr) == 1 && addr != 0) + { + ptr = reinterpret_cast(addr); + } + } + } + Py_DECREF(thisStr); + + if (!ptr) + { + PyErr_Format(PyExc_TypeError, "expected a VTK %s instance", className); + } + return ptr; +} + +// Reconstructs through VTK's own `Addr=0x...` path so the IsA() check, the +// object map, and reference counting all stay VTK's responsibility. +// `__new__` is called explicitly rather than `cls(addr)`: vtkmodules.util.data_model +// registers keyword-only `override` subclasses for the data-model classes, whose +// __init__ would reject the positional address string. +inline PyObject * +GetObjectFromPointer(void * ptr, const char * moduleName, const char * className) +{ + if (!ptr) + { + Py_RETURN_NONE; + } + + PyObject * cls = ImportClass(moduleName, className); + if (!cls) + { + return nullptr; + } + + char addr[64]; + std::snprintf(addr, sizeof(addr), "Addr=0x%" PRIxPTR, reinterpret_cast(ptr)); + PyObject * obj = PyObject_CallMethod(cls, "__new__", "Os", cls, addr); + Py_DECREF(cls); + return obj; +} + +} // namespace itkVtkGlueABI3 %} %typemap(out) vtkImageExport* { - PyImport_ImportModule("vtk"); - $result = vtkPythonGetObjectFromPointer ( (vtkImageExport*)$1 ); + $result = itkVtkGlueABI3::GetObjectFromPointer($1, "vtkmodules.vtkIOImage", "vtkImageExport"); + if (!$result) { SWIG_fail; } } %typemap(out) vtkImageImport* { - PyImport_ImportModule("vtk"); - $result = vtkPythonGetObjectFromPointer ( (vtkImageImport*)$1 ); + $result = itkVtkGlueABI3::GetObjectFromPointer($1, "vtkmodules.vtkIOImage", "vtkImageImport"); + if (!$result) { SWIG_fail; } } %typemap(out) vtkImageData* { - PyImport_ImportModule("vtk"); - $result = vtkPythonGetObjectFromPointer ( (vtkImageData*)$1 ); + $result = itkVtkGlueABI3::GetObjectFromPointer($1, "vtkmodules.vtkCommonDataModel", "vtkImageData"); + if (!$result) { SWIG_fail; } } %typemap(in) vtkImageData* { - $1 = NULL; - $1 = (vtkImageData*) vtkPythonGetPointerFromObject ( $input, "vtkImageData" ); - if ( $1 == NULL ) { SWIG_fail; } + $1 = static_cast(itkVtkGlueABI3::GetPointerFromObject($input, "vtkmodules.vtkCommonDataModel", "vtkImageData")); + if (!$1) { SWIG_fail; } } %typemap(out) vtkPolyData* { - PyImport_ImportModule("vtk"); - $result = vtkPythonGetObjectFromPointer ( (vtkPolyData*)$1 ); + $result = itkVtkGlueABI3::GetObjectFromPointer($1, "vtkmodules.vtkCommonDataModel", "vtkPolyData"); + if (!$result) { SWIG_fail; } } %typemap(in) vtkPolyData* { - $1 = NULL; - $1 = (vtkPolyData*) vtkPythonGetPointerFromObject ( $input, "vtkPolyData" ); - if ( $1 == NULL ) { SWIG_fail; } + $1 = static_cast(itkVtkGlueABI3::GetPointerFromObject($input, "vtkmodules.vtkCommonDataModel", "vtkPolyData")); + if (!$1) { SWIG_fail; } } #endif diff --git a/Modules/Bridge/VtkGlue/wrapping/test/CMakeLists.txt b/Modules/Bridge/VtkGlue/wrapping/test/CMakeLists.txt new file mode 100644 index 00000000000..5038faae83f --- /dev/null +++ b/Modules/Bridge/VtkGlue/wrapping/test/CMakeLists.txt @@ -0,0 +1,32 @@ +list(FIND ITK_WRAP_IMAGE_DIMS 2 wrap_2_index) +if( + ITK_WRAP_PYTHON + AND + VTK_WRAP_PYTHON + AND + ITK_WRAP_float + AND + wrap_2_index + GREATER + -1 +) + itk_python_add_test( + NAME PythonVtkGlueABI3EncodingTest + COMMAND + ${CMAKE_CURRENT_SOURCE_DIR}/VtkGlueABI3EncodingTest.py + ) + itk_python_add_test( + NAME PythonVtkGlueRoundTripTest + COMMAND + ${CMAKE_CURRENT_SOURCE_DIR}/VtkGlueRoundTripTest.py + ) + # itkTestDriver prepends ITK's own entries to whatever PYTHONPATH it inherits. + set_property( + TEST + PythonVtkGlueABI3EncodingTest + PythonVtkGlueRoundTripTest + PROPERTY + ENVIRONMENT + "PYTHONPATH=${VTK_PREFIX_PATH}/${VTK_PYTHONPATH}" + ) +endif() diff --git a/Modules/Bridge/VtkGlue/wrapping/test/VtkGlueABI3EncodingTest.py b/Modules/Bridge/VtkGlue/wrapping/test/VtkGlueABI3EncodingTest.py new file mode 100644 index 00000000000..1264b3434d1 --- /dev/null +++ b/Modules/Bridge/VtkGlue/wrapping/test/VtkGlueABI3EncodingTest.py @@ -0,0 +1,100 @@ +# ========================================================================== +# +# Copyright NumFOCUS +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0.txt +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# ========================================================================== + +"""Canary for the two VTK wrapper encodings the ITKVtkGlue abi3 typemaps rely on. + +VtkGlue.i exchanges pointers with VTK through `__this__` and the `Addr=0x...` +argument to `__new__` rather than through vtkPythonUtil, because vtkPythonUtil's +header chain is not usable under Py_LIMITED_API. Neither encoding is documented +VTK API, so this test fails loudly and specifically if VTK changes either one. + +`__new__` is used rather than plain construction because vtkmodules.util.data_model +registers keyword-only `override` subclasses for vtkImageData and vtkPolyData; +`cls(addr)` reaches those and raises TypeError. + +Both encodings and the `__new__` string branch are present in every VTK 9.x from +9.0.0 onward, so the module's VTK 9.1 floor is unchanged. The branch is gated on +the type not being a heap type, which is the thing to re-check if VTK ever makes +its wrapped types heap types. +""" + +import re +import sys + +from vtkmodules.vtkCommonDataModel import vtkImageData, vtkPolyData +from vtkmodules.vtkIOImage import vtkImageExport, vtkImageImport + +# `_<2*sizeof(void*) hex digits>_p_`, per vtkPythonUtil::ManglePointer. +THIS_RE = re.compile(r"^_([0-9a-fA-F]+)_p_(\w+)$") + +failures = [] + + +def check(condition, message): + if not condition: + failures.append(message) + + +for cls in (vtkImageData, vtkPolyData, vtkImageExport, vtkImageImport): + name = cls.__name__ + obj = cls() + + this = getattr(obj, "__this__", None) + check(this is not None, f"{name}: instance has no __this__ attribute") + if this is None: + continue + + match = THIS_RE.match(this) + check( + match is not None, + f"{name}: __this__ {this!r} does not match __p_", + ) + if match is None: + continue + + address = int(match.group(1), 16) + check(address != 0, f"{name}: __this__ encodes a null address") + check( + match.group(2) == name, + f"{name}: __this__ encodes class {match.group(2)!r}, expected {name!r}", + ) + + # The reconstruction path VtkGlue.i's `out` typemaps drive. + try: + rebuilt = cls.__new__(cls, f"Addr=0x{address:x}") + except Exception as exception: # noqa: BLE001 - report any refusal verbatim + failures.append(f"{name}: Addr=0x... reconstruction raised {exception!r}") + continue + + rebuilt_this = getattr(rebuilt, "__this__", None) + check( + rebuilt_this == this, + f"{name}: reconstruction yielded __this__ {rebuilt_this!r}, expected {this!r}", + ) + check( + isinstance(rebuilt, cls), + f"{name}: reconstruction yielded {type(rebuilt)!r}, not a {name} instance", + ) + +if failures: + print("VTK wrapper encodings assumed by VtkGlue.i have changed:", file=sys.stderr) + for failure in failures: + print(f" - {failure}", file=sys.stderr) + sys.exit(1) + +print("VTK __this__ and Addr=0x... encodings are intact.") diff --git a/Modules/Bridge/VtkGlue/wrapping/test/VtkGlueRoundTripTest.py b/Modules/Bridge/VtkGlue/wrapping/test/VtkGlueRoundTripTest.py new file mode 100644 index 00000000000..6c6eb808955 --- /dev/null +++ b/Modules/Bridge/VtkGlue/wrapping/test/VtkGlueRoundTripTest.py @@ -0,0 +1,111 @@ +# ========================================================================== +# +# Copyright NumFOCUS +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0.txt +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# ========================================================================== + +"""Round-trip itk.Image -> vtkImageData -> itk.Image through the VtkGlue filters. + +This exercises the SWIG typemaps in VtkGlue.i directly: ImageToVTKImageFilter +returns `vtkImageData *` (the `out` typemap) and VTKImageToImageFilter accepts +`vtkImageData *` (the `in` typemap). The pure-numpy helpers in itk.support.extras +bypass both, so they are deliberately not used here. +""" + +import sys + +import numpy as np +from vtkmodules.vtkCommonDataModel import vtkImageData + +import itk + +Dimension = 2 +PixelType = itk.F +ImageType = itk.Image[PixelType, Dimension] + +reference_array = np.arange(6 * 4, dtype=np.float32).reshape((4, 6)) +image = itk.image_from_array(reference_array) +image.SetSpacing([0.5, 2.0]) +image.SetOrigin([-3.0, 7.0]) + +to_vtk = itk.ImageToVTKImageFilter[ImageType].New() +to_vtk.SetInput(image) +to_vtk.Update() +vtk_image = to_vtk.GetOutput() + +if not isinstance(vtk_image, vtkImageData): + print( + f"out typemap returned {type(vtk_image)!r}, expected vtkImageData", + file=sys.stderr, + ) + sys.exit(1) + +if vtk_image.GetDimensions()[:2] != (6, 4): + print(f"unexpected VTK dimensions {vtk_image.GetDimensions()}", file=sys.stderr) + sys.exit(1) + +from_vtk = itk.VTKImageToImageFilter[ImageType].New() +from_vtk.SetInput(vtk_image) +from_vtk.Update() +result = from_vtk.GetOutput() + +failures = [] + +result_array = itk.array_from_image(result) +if not np.array_equal(result_array, reference_array): + failures.append( + f"pixel buffer differs:\n{result_array}\nexpected:\n{reference_array}" + ) + +if not np.allclose(list(result.GetSpacing()), [0.5, 2.0]): + failures.append(f"spacing {list(result.GetSpacing())}, expected [0.5, 2.0]") + +if not np.allclose(list(result.GetOrigin()), [-3.0, 7.0]): + failures.append(f"origin {list(result.GetOrigin())}, expected [-3.0, 7.0]") + +direction = itk.array_from_matrix(result.GetDirection()) +if not np.allclose(direction, np.identity(Dimension)): + failures.append(f"direction {direction}, expected identity") + + +# The `in` typemap must reject bad input rather than dereference it. The forged +# case matters most: without an isinstance() gate a crafted `__this__` would be +# cast to a native pointer and segfault instead of raising. +class ForgedVtkImageData: + __this__ = "_00000000deadbeef_p_vtkImageData" + + +for bad, description in ( + ("not a vtkImageData", "a str"), + (ForgedVtkImageData(), "an object with a forged __this__"), + (vtkImageData, "the class rather than an instance"), +): + try: + itk.VTKImageToImageFilter[ImageType].New().SetInput(bad) + except TypeError: + pass + except Exception as exception: # noqa: BLE001 - anything but TypeError is a defect + failures.append( + f"in typemap raised {exception!r} for {description}, expected TypeError" + ) + else: + failures.append(f"in typemap accepted {description}; expected TypeError") + +if failures: + for failure in failures: + print(f" - {failure}", file=sys.stderr) + sys.exit(1) + +print("itk.Image <-> vtkImageData round trip preserved geometry and pixels.") From c2cde99781194eabb8629e1a974798622cdcd6f6 Mon Sep 17 00:00:00 2001 From: "Hans J. Johnson" Date: Sun, 26 Jul 2026 17:39:40 -0500 Subject: [PATCH 2/9] COMP: Always pass USE_SABI to python3_add_library The abi3 floor and the Python wrapping floor are both 3.11, so the non-SABI branch was unreachable for every supported interpreter. --- Wrapping/macro_files/itk_end_wrap_module.cmake | 18 ++---------------- 1 file changed, 2 insertions(+), 16 deletions(-) diff --git a/Wrapping/macro_files/itk_end_wrap_module.cmake b/Wrapping/macro_files/itk_end_wrap_module.cmake index 04bde56dc4b..5bef72f2fe6 100644 --- a/Wrapping/macro_files/itk_end_wrap_module.cmake +++ b/Wrapping/macro_files/itk_end_wrap_module.cmake @@ -483,31 +483,17 @@ ${DO_NOT_WAIT_FOR_THREADS_CALLS} # build all the c++ files from this module in a common lib if(NOT TARGET ${lib}) # -- START PYTHON MODULE CREATION - if(ITK_USE_PYTHON_LIMITED_API) - set( - _Python3_ABI_SETTINGS - USE_SABI - ${_ITK_MINIMUM_SUPPORTED_LIMITED_API_VERSION} - WITH_SOABI - ) - else() - unset(_Python3_ABI_SETTINGS) - endif() - #Python3_add_library sets PREFIX "" and the correct extension suffix automatically. # No manual SUFFIX editing is needed. - #WITH_SOABI can be added to Python3_add_library(...) if you want the SOABI tag - #in the filename for non-SABI builds. - #Use Development.Module for normal CPython extensions. Use Development.SABIModule plus Py_LIMITED_API for abi3-compatible builds. python3_add_library( ${lib} MODULE - ${_Python3_ABI_SETTINGS} + USE_SABI ${_ITK_MINIMUM_SUPPORTED_LIMITED_API_VERSION} + WITH_SOABI ${cpp_file} ${ITK_WRAP_PYTHON_CXX_FILES} ${WRAPPER_LIBRARY_CXX_SOURCES} ) - unset(_Python3_ABI_SETTINGS) # Override the default naming convensions for libraries # and add an ITK python prefix of "_" for the generated names set_target_properties( From af67a31b4849dd6edcb7aa6d9cf8a4836f985e2e Mon Sep 17 00:00:00 2001 From: "Hans J. Johnson" Date: Sun, 26 Jul 2026 17:40:10 -0500 Subject: [PATCH 3/9] COMP: Build itkPyCommand.cxx against Python3::SABIModule The Python3::Module fallback was reachable only without the limited API. A missing SABIModule target is now an explicit configure error rather than a silent downgrade. --- Modules/Core/Common/src/CMakeLists.txt | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/Modules/Core/Common/src/CMakeLists.txt b/Modules/Core/Common/src/CMakeLists.txt index d7da6aa50ba..6fbebe497b7 100644 --- a/Modules/Core/Common/src/CMakeLists.txt +++ b/Modules/Core/Common/src/CMakeLists.txt @@ -162,17 +162,19 @@ set( ) if(ITK_WRAP_PYTHON) - if(ITK_USE_PYTHON_LIMITED_API AND TARGET Python3::SABIModule) - set(_itk_python_target Python3::SABIModule) - else() - set(_itk_python_target Python3::Module) + if(NOT TARGET Python3::SABIModule) + message( + FATAL_ERROR + "ITK_WRAP_PYTHON=ON requires the Python3 Development.SABIModule component, " + "but the Python3::SABIModule target was not defined." + ) endif() list(APPEND ITKCommon_SRCS itkPyCommand.cxx) set_source_files_properties( itkPyCommand.cxx PROPERTIES INCLUDE_DIRECTORIES - "$" + "$" ) endif() @@ -263,7 +265,7 @@ if(UNIX) endif() endif() if(ITK_WRAP_PYTHON) - target_link_libraries(ITKCommon PRIVATE ${_itk_python_target}) + target_link_libraries(ITKCommon PRIVATE Python3::SABIModule) if(MSVC) target_link_directories(ITKCommon PRIVATE ${Python3_LIBRARY_DIRS}) endif() From 34c5ecce1d5922daa716918c885b695053a3f669 Mon Sep 17 00:00:00 2001 From: "Hans J. Johnson" Date: Sun, 26 Jul 2026 17:41:20 -0500 Subject: [PATCH 4/9] COMP: Remove the ITK_USE_PYTHON_LIMITED_API option Python wrapping requires 3.11, which is also the abi3 floor, so the option could only ever be on. Every wrapped module is now built as an abi3 extension and the CMake >= 3.26 requirement applies whenever ITK_WRAP_PYTHON is enabled. --- CMake/ITKSetPython3Vars.cmake | 70 ++++++----------------------------- 1 file changed, 12 insertions(+), 58 deletions(-) diff --git a/CMake/ITKSetPython3Vars.cmake b/CMake/ITKSetPython3Vars.cmake index 520764c2b3a..4bc863ce58b 100644 --- a/CMake/ITKSetPython3Vars.cmake +++ b/CMake/ITKSetPython3Vars.cmake @@ -94,7 +94,6 @@ else() ) endif() - # start section to define package components based on LIMITED_API support and choices # Cached so the abi3 floor is a single source of truth shared with the module # wrapping macros (itk_end_wrap_module.cmake USE_SABI). set( @@ -104,65 +103,21 @@ else() "Minimum Python minor version for the abi3 / Limited API floor" ) - # Force ITK_WRAP_PYTHON_VERSION if SKBUILD_SABI_COMPONENT requests it - string( - FIND - "${SKBUILD_SABI_COMPONENT}" - "SABIModule" - _SKBUILD_SABI_COMPONENT_REQUIRED - ) - if(NOT DEFINED ITK_USE_PYTHON_LIMITED_API) - if( - ( - _SKBUILD_SABI_COMPONENT_REQUIRED - GREATER - -1 - ) - OR - ITK_WRAP_PYTHON_VERSION - VERSION_GREATER_EQUAL - ${_ITK_MINIMUM_SUPPORTED_LIMITED_API_VERSION} + if(CMAKE_VERSION VERSION_LESS "3.26") + message( + FATAL_ERROR + "CMake version ${CMAKE_VERSION} is too old for Python wrapping: " + "the FindPython Development.SABIModule component requires CMake >= 3.26. " + "Either upgrade CMake or set ITK_WRAP_PYTHON=OFF." ) - set( - ITK_USE_PYTHON_LIMITED_API - 1 - CACHE BOOL - "Configure Python's limited API for Python minor version compatibility." - ) - else() - set( - ITK_USE_PYTHON_LIMITED_API - 0 - CACHE BOOL - "Configure Python's limited API for Python minor version compatibility." - ) - endif() - mark_as_advanced(ITK_USE_PYTHON_LIMITED_API) endif() - unset(_SKBUILD_SABI_COMPONENT_REQUIRED) - if(ITK_USE_PYTHON_LIMITED_API) - if(CMAKE_VERSION VERSION_LESS "3.26") - message( - FATAL_ERROR - "CMake version ${CMAKE_VERSION} is too old for Python limited API wrapping: " - "the FindPython Development.SABIModule component requires CMake >= 3.26. " - "Either upgrade CMake or disable ITK_USE_PYTHON_LIMITED_API." - ) - endif() - set( - _python_find_components - Interpreter - Development.Module - Development.SABIModule - ) - else() - set( - _python_find_components - Interpreter - Development.Module - ) - endif() + set( + _python_find_components + Interpreter + Development.Module + Development.SABIModule + ) if(BUILD_TESTING) # NumPy Required for testing ITK PythonTests, prefer to fail early if not installed list(APPEND _python_find_components NumPy) @@ -253,7 +208,6 @@ else() unset(_python_find_components) # _ITK_MINIMUM_SUPPORTED_LIMITED_API_VERSION is cached (INTERNAL) and intentionally # left set so itk_end_wrap_module.cmake can pin USE_SABI to the same floor. - # end section to define package components based on LIMITED_API support and choices if(DEFINED _specified_Python3_EXECUTABLE) set( Python3_EXECUTABLE From 19bc90790a30236b75a49cf096614b1311b5cf43 Mon Sep 17 00:00:00 2001 From: "Hans J. Johnson" Date: Sun, 26 Jul 2026 17:41:55 -0500 Subject: [PATCH 5/9] DOC: State the CMake 3.26 floor for Python wrapping Development.SABIModule requires CMake >= 3.26. Builds without ITK_WRAP_PYTHON keep the older floor. --- Documentation/docs/contributing/python_packaging.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Documentation/docs/contributing/python_packaging.md b/Documentation/docs/contributing/python_packaging.md index ed74df0ae0f..880e5b1c3a6 100644 --- a/Documentation/docs/contributing/python_packaging.md +++ b/Documentation/docs/contributing/python_packaging.md @@ -17,11 +17,15 @@ This section describes how to build Python packages for the main ITK libraries u ## Prerequisites Building ITK Python wheels requires the following: -- CMake >= 3.16 +- CMake >= 3.26, for the `FindPython` `Development.SABIModule` component - Git - C++ Compiler (see [scikit-build platform specific requirements](https://scikit-build.readthedocs.io/en/latest/generators.html)) - Python >= 3.11 +Wrapped modules are always built as stable-ABI (`abi3`) extensions, so a single +set of wheels serves every supported Python version. Builds that do not set +`ITK_WRAP_PYTHON=ON` are unaffected and still accept older CMake. + ## Automated Platform Scripts The following sections outline how to use the ITKPythonPackage project to build wheels on Linux, macOS, and Windows. Each script will fetch tagged ITK sources, build ITK with Python wrappings, and package binaries into the Python wheel archive format for distribution. From 92c84942128da11131093f06250f20a2eb1f499f Mon Sep 17 00:00:00 2001 From: "Hans J. Johnson" Date: Sun, 26 Jul 2026 18:49:37 -0500 Subject: [PATCH 6/9] COMP: Stop requesting the Python3 Development.Module component Every wrapped module is an abi3 extension built against Python3::SABIModule; nothing refers to Python3::Module. --- CMake/ITKSetPython3Vars.cmake | 2 -- 1 file changed, 2 deletions(-) diff --git a/CMake/ITKSetPython3Vars.cmake b/CMake/ITKSetPython3Vars.cmake index 4bc863ce58b..0534edcca3c 100644 --- a/CMake/ITKSetPython3Vars.cmake +++ b/CMake/ITKSetPython3Vars.cmake @@ -69,7 +69,6 @@ else() Interpreter # NOTE: dockcross build environments do not supply # `Development` python-dev resources - Development.Module Development.SABIModule NumPy ) @@ -115,7 +114,6 @@ else() set( _python_find_components Interpreter - Development.Module Development.SABIModule ) if(BUILD_TESTING) From e8d7aebce190d41d0380bdf992deaecf7355aff8 Mon Sep 17 00:00:00 2001 From: "Hans J. Johnson" Date: Sun, 26 Jul 2026 18:49:53 -0500 Subject: [PATCH 7/9] COMP: Drop Development.Module from the Python component diagnostics The component is no longer requested, so it can neither be missing nor inform the remedy text. --- CMake/ITKSetPython3Vars.cmake | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/CMake/ITKSetPython3Vars.cmake b/CMake/ITKSetPython3Vars.cmake index 0534edcca3c..8179cc06033 100644 --- a/CMake/ITKSetPython3Vars.cmake +++ b/CMake/ITKSetPython3Vars.cmake @@ -156,15 +156,7 @@ else() NumPy: ${Python3_EXECUTABLE} -m pip install numpy" ) endif() - if( - Development.Module - IN_LIST - _missing_python_components - OR - Development.SABIModule - IN_LIST - _missing_python_components - ) + if(Development.SABIModule IN_LIST _missing_python_components) string( APPEND _missing_component_remedy @@ -189,7 +181,6 @@ else() Python3_Interpreter_FOUND=${Python3_Interpreter_FOUND} Python3_Compiler_FOUND=${Python3_Compiler_FOUND} Python3_Development_FOUND=${Python3_Development_FOUND} - Python3_Development.Module_FOUND=${Python3_Development.Module_FOUND} Python3_Development.SABIModule_FOUND=${Python3_Development.SABIModule_FOUND} Python3_Development.Embed_FOUND=${Python3_Development.Embed_FOUND} Python3_NumPy_FOUND=${Python3_NumPy_FOUND} From e2ade13f2c3091f45fc17f62c0a1708395f59e58 Mon Sep 17 00:00:00 2001 From: "Hans J. Johnson" Date: Sun, 26 Jul 2026 18:50:10 -0500 Subject: [PATCH 8/9] COMP: Derive the abi3 floor from the Python wrapping floor Two independent 3.11 literals could drift apart even though abi3-only wrapping makes them necessarily equal. --- CMake/ITKSetPython3Vars.cmake | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/CMake/ITKSetPython3Vars.cmake b/CMake/ITKSetPython3Vars.cmake index 8179cc06033..33adf102df6 100644 --- a/CMake/ITKSetPython3Vars.cmake +++ b/CMake/ITKSetPython3Vars.cmake @@ -93,11 +93,12 @@ else() ) endif() - # Cached so the abi3 floor is a single source of truth shared with the module - # wrapping macros (itk_end_wrap_module.cmake USE_SABI). + # Wrapping requires this version and every module is abi3, so the wrapping + # floor and the Limited API floor are the same number. Cached so + # itk_end_wrap_module.cmake can pin USE_SABI to it. set( _ITK_MINIMUM_SUPPORTED_LIMITED_API_VERSION - 3.11 + ${ITK_WRAP_PYTHON_MINIMUM_VERSION} CACHE INTERNAL "Minimum Python minor version for the abi3 / Limited API floor" ) From d542b02554f0b49df7582da3266f5fad19e73e32 Mon Sep 17 00:00:00 2001 From: "Hans J. Johnson" Date: Sun, 26 Jul 2026 18:50:58 -0500 Subject: [PATCH 9/9] COMP: Find Python3 once for wrapping builds The component list no longer depends on a discovered version, so the range search and the exact-version re-search collapse into one call. NumPy now follows BUILD_TESTING consistently: the first search had always demanded it, while only the second was checked. --- CMake/ITKSetPython3Vars.cmake | 55 ++++++++++++++--------------------- 1 file changed, 22 insertions(+), 33 deletions(-) diff --git a/CMake/ITKSetPython3Vars.cmake b/CMake/ITKSetPython3Vars.cmake index 33adf102df6..a3f07778615 100644 --- a/CMake/ITKSetPython3Vars.cmake +++ b/CMake/ITKSetPython3Vars.cmake @@ -61,38 +61,6 @@ if(NOT PYTHON_DEVELOPMENT_REQUIRED) ) set(ITK_WRAP_PYTHON_VERSION "ITK_WRAP_PYTHON=OFF") else() - # set(Python3_FIND_REGISTRY LAST) # default is FIRST. Do we need/want this? - find_package( - Python3 - ${PYTHON_VERSION_MIN}...${PYTHON_VERSION_MAX} - COMPONENTS - Interpreter - # NOTE: dockcross build environments do not supply - # `Development` python-dev resources - Development.SABIModule - NumPy - ) - set(ITK_WRAP_PYTHON_VERSION "${Python3_VERSION}") - - # An empty Python3_VERSION means the range found nothing; otherwise the second - # find_package below runs unversioned and silently accepts a below-floor Python. - if( - NOT - Python3_VERSION - OR - Python3_VERSION - VERSION_LESS - ${ITK_WRAP_PYTHON_MINIMUM_VERSION} - ) - message( - FATAL_ERROR - "ITK Python wrapping (ITK_WRAP_PYTHON=ON) requires Python >= ${ITK_WRAP_PYTHON_MINIMUM_VERSION}, " - "but no such interpreter was found in range ${PYTHON_VERSION_MIN}...${PYTHON_VERSION_MAX} " - "(resolved version '${Python3_VERSION}', Python3_EXECUTABLE='${Python3_EXECUTABLE}'). " - "Provide a Python >= ${ITK_WRAP_PYTHON_MINIMUM_VERSION} interpreter, or set ITK_WRAP_PYTHON=OFF." - ) - endif() - # Wrapping requires this version and every module is abi3, so the wrapping # floor and the Limited API floor are the same number. Cached so # itk_end_wrap_module.cmake can pin USE_SABI to it. @@ -123,13 +91,34 @@ else() endif() set(_missing_required_component FALSE) set(_missing_python_components "") + # set(Python3_FIND_REGISTRY LAST) # default is FIRST. Do we need/want this? find_package( Python3 - ${ITK_WRAP_PYTHON_VERSION} + ${PYTHON_VERSION_MIN}...${PYTHON_VERSION_MAX} COMPONENTS + # NOTE: dockcross build environments do not supply + # `Development` python-dev resources ${_python_find_components} ) set(ITK_WRAP_PYTHON_VERSION "${Python3_VERSION}") + + # An empty Python3_VERSION means the range found nothing. + if( + NOT + Python3_VERSION + OR + Python3_VERSION + VERSION_LESS + ${ITK_WRAP_PYTHON_MINIMUM_VERSION} + ) + message( + FATAL_ERROR + "ITK Python wrapping (ITK_WRAP_PYTHON=ON) requires Python >= ${ITK_WRAP_PYTHON_MINIMUM_VERSION}, " + "but no such interpreter was found in range ${PYTHON_VERSION_MIN}...${PYTHON_VERSION_MAX} " + "(resolved version '${Python3_VERSION}', Python3_EXECUTABLE='${Python3_EXECUTABLE}'). " + "Provide a Python >= ${ITK_WRAP_PYTHON_MINIMUM_VERSION} interpreter, or set ITK_WRAP_PYTHON=OFF." + ) + endif() message(STATUS "Python3_FOUND=${Python3_FOUND}") foreach(_required_component ${_python_find_components}) if(NOT Python3_${_required_component}_FOUND)