Skip to content

Commit addc106

Browse files
CavRileyhjmjohnson
andcommitted
ENH: Add platform-specific Python build scripts for Linux, macOS, and Windows
Implements platform build scripts using the new Python infrastructure. Linux/manylinux builds use dockcross with auditwheel for manylinux compliance. macOS builds integrate delocate and respect MACOSX_DEPLOYMENT_TARGET. Windows builds use delvewheel for wheel repair. Removes legacy shell scripts superseded by the new Python implementations. Co-authored-by: Hans J. Johnson <hans-johnson@uiowa.edu>
1 parent d0a0f29 commit addc106

31 files changed

+1758
-2883
lines changed

cmake/ITKPythonPackage_SuperBuild.cmake

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,7 @@ endif()
243243
message(STATUS "SuperBuild - Python3_INCLUDE_DIR: ${Python3_INCLUDE_DIR}")
244244
message(STATUS "SuperBuild - Python3_INCLUDE_DIRS: ${Python3_INCLUDE_DIRS}")
245245
message(STATUS "SuperBuild - Python3_LIBRARY: ${Python3_LIBRARY}")
246+
message(STATUS "SuperBuild - Python3_SABI_LIBRARY: ${Python3_SABI_LIBRARY}")
246247
message(STATUS "SuperBuild - Python3_EXECUTABLE: ${Python3_EXECUTABLE}")
247248
message(STATUS "SuperBuild - Searching for python[OK]")
248249
message(STATUS "SuperBuild - DOXYGEN_EXECUTABLE: ${DOXYGEN_EXECUTABLE}")
@@ -306,6 +307,7 @@ if(NOT ITKPythonPackage_ITK_BINARY_REUSE)
306307
-DDOXYGEN_EXECUTABLE:FILEPATH=${DOXYGEN_EXECUTABLE}
307308
-DPython3_INCLUDE_DIR:PATH=${Python3_INCLUDE_DIR}
308309
-DPython3_LIBRARY:FILEPATH=${Python3_LIBRARY}
310+
-DPython3_SABI_LIBRARY:FILEPATH=${Python3_SABI_LIBRARY}
309311
-DPython3_EXECUTABLE:FILEPATH=${Python3_EXECUTABLE}
310312
${ep_common_cmake_cache_args} ${tbb_args} ${ep_itk_cmake_cache_args}
311313
${ep_download_extract_timestamp_arg}

scripts/build_python_instance_base.py

Lines changed: 222 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
1+
import copy
12
import os
23
import shutil
34
import subprocess
45
import sys
56
from abc import ABC, abstractmethod
67
from collections import OrderedDict
78
from collections.abc import Callable
9+
from os import environ
810
from pathlib import Path
911

1012
from BuildManager import BuildManager
@@ -19,12 +21,41 @@
1921

2022

2123
class BuildPythonInstanceBase(ABC):
22-
"""
23-
Abstract base class to build wheels for a single Python environment.
24-
25-
Concrete subclasses implement platform-specific details by delegating to
26-
injected helper functions. This avoids circular imports with the script
27-
that defines those helpers.
24+
"""Abstract base class to build wheels for a single Python environment.
25+
26+
Concrete subclasses implement platform-specific details (environment
27+
setup, wheel fixup, tarball creation) while this class provides the
28+
shared build orchestration, CMake configuration, and wheel-building
29+
logic.
30+
31+
Parameters
32+
----------
33+
platform_env : str
34+
Platform/environment identifier (e.g. ``'manylinux228-py311'``).
35+
build_dir_root : Path
36+
Root directory for all build artifacts.
37+
package_env_config : dict
38+
Mutable configuration dictionary populated throughout the build.
39+
cleanup : bool
40+
Whether to remove intermediate build artifacts.
41+
build_itk_tarball_cache : bool
42+
Whether to create a reusable tarball of the ITK build tree.
43+
cmake_options : list[str]
44+
Extra ``-D`` options forwarded to CMake.
45+
windows_extra_lib_paths : list[str]
46+
Additional library paths for Windows wheel fixup (delvewheel).
47+
dist_dir : Path
48+
Output directory for built wheel files.
49+
module_source_dir : Path, optional
50+
Path to an external ITK remote module to build.
51+
module_dependencies_root_dir : Path, optional
52+
Directory where remote module dependencies are cloned.
53+
itk_module_deps : str, optional
54+
Colon-delimited dependency specifications for remote modules.
55+
skip_itk_build : bool, optional
56+
Skip the ITK C++ build step.
57+
skip_itk_wheel_build : bool, optional
58+
Skip the ITK wheel build step.
2859
"""
2960

3061
def __init__(
@@ -166,6 +197,7 @@ def __init__(
166197
)
167198

168199
def update_venv_itk_build_configurations(self) -> None:
200+
"""Set ``Python3_ROOT_DIR`` in ITK build configurations from venv info."""
169201
# Python3_EXECUTABLE, Python3_INCLUDE_DIR, and Python3_LIBRARY are validated
170202
# and resolved by find_package(Python3) in cmake/ITKPythonPackage_SuperBuild.cmake
171203
# when not already defined. Python3_ROOT_DIR is set here to guide that search.
@@ -248,6 +280,7 @@ def run(self) -> None:
248280
print("=" * 80)
249281

250282
def build_superbuild_support_components(self):
283+
"""Configure and build the superbuild support components (ITK source, TBB)."""
251284
# -----------------------------------------------------------------------
252285
# Build required components (optional local ITK source, TBB builds) used to populate the archive cache
253286

@@ -297,13 +330,22 @@ def build_superbuild_support_components(self):
297330
)
298331

299332
def fixup_wheels(self, lib_paths: str = ""):
333+
"""Apply platform-specific fixups to ``itk_core`` wheels for TBB linkage."""
300334
# TBB library fix-up (applies to itk_core wheel)
301335
tbb_wheel = "itk_core"
302336
for wheel in (self.build_dir_root / "dist").glob(f"{tbb_wheel}*.whl"):
303337
self.fixup_wheel(str(wheel), lib_paths)
304338

305339
def final_wheel_import_test(self, installed_dist_dir: Path):
306-
self.echo_check_call(
340+
"""Install and smoke-test all ITK wheels from *installed_dist_dir*.
341+
342+
Parameters
343+
----------
344+
installed_dist_dir : Path
345+
Directory containing the built ``.whl`` files to install and
346+
verify.
347+
"""
348+
exit_status = self.echo_check_call(
307349
[
308350
self.package_env_config["PYTHON_EXECUTABLE"],
309351
"-m",
@@ -316,7 +358,10 @@ def final_wheel_import_test(self, installed_dist_dir: Path):
316358
str(installed_dist_dir),
317359
]
318360
)
319-
print("Wheel successfully installed.")
361+
if exit_status == 0:
362+
print("Wheels successfully installed.")
363+
else:
364+
print(f"Failed to install wheels: {exit_status}")
320365
# Basic imports
321366
self.echo_check_call(
322367
[self.package_env_config["PYTHON_EXECUTABLE"], "-c", "import itk;"]
@@ -392,6 +437,24 @@ def find_unix_exectable_paths(
392437
self,
393438
venv_dir: Path,
394439
) -> tuple[str, str, str, str, str]:
440+
"""Resolve Python interpreter and virtualenv paths on Unix.
441+
442+
Parameters
443+
----------
444+
venv_dir : Path
445+
Root of the Python virtual environment.
446+
447+
Returns
448+
-------
449+
tuple[str, str, str, str, str]
450+
``(python_executable, python_include_dir, python_library,
451+
venv_bin_path, venv_base_dir)``.
452+
453+
Raises
454+
------
455+
FileNotFoundError
456+
If the Python executable does not exist under *venv_dir*.
457+
"""
395458
python_executable = venv_dir / "bin" / "python"
396459
if not python_executable.exists():
397460
raise FileNotFoundError(f"Python executable not found: {python_executable}")
@@ -426,48 +489,170 @@ def find_unix_exectable_paths(
426489
str(venv_dir),
427490
)
428491

429-
@abstractmethod
430492
def clone(self):
431-
# each subclass must implement this method that is used to clone itself
432-
pass
493+
"""Return a deep copy of this instance for building a dependency.
433494
434-
@abstractmethod
435-
def venv_paths(self):
436-
pass
495+
Uses ``self.__class__`` so the returned object is the same concrete
496+
subclass as the original (e.g. ``LinuxBuildPythonInstance``).
497+
"""
498+
cls = self.__class__
499+
new = cls.__new__(cls)
500+
new.__dict__ = copy.deepcopy(self.__dict__)
501+
return new
502+
503+
def venv_paths(self) -> None:
504+
"""Populate ``venv_info_dict`` from the pixi-managed Python interpreter.
505+
506+
Default Unix implementation shared by Linux and macOS. Windows
507+
overrides this method with its own path conventions.
508+
"""
509+
primary_python_base_dir = Path(
510+
self.package_env_config["PYTHON_EXECUTABLE"]
511+
).parent.parent
512+
(
513+
python_executable,
514+
python_include_dir,
515+
python_library,
516+
venv_bin_path,
517+
venv_base_dir,
518+
) = self.find_unix_exectable_paths(primary_python_base_dir)
519+
self.venv_info_dict = {
520+
"python_executable": python_executable,
521+
"python_include_dir": python_include_dir,
522+
"python_library": python_library,
523+
"venv_bin_path": venv_bin_path,
524+
"venv_base_dir": venv_base_dir,
525+
"python_root_dir": primary_python_base_dir,
526+
}
437527

438528
@abstractmethod
439529
def fixup_wheel(
440530
self, filepath, lib_paths: str = "", remote_module_wheel: bool = False
441531
): # pragma: no cover - abstract
532+
"""Apply platform-specific wheel repairs (auditwheel, delocate, delvewheel).
533+
534+
Parameters
535+
----------
536+
filepath : str
537+
Path to the ``.whl`` file to fix.
538+
lib_paths : str, optional
539+
Additional library search paths (semicolon-delimited on Windows).
540+
remote_module_wheel : bool, optional
541+
True when fixing a wheel for an external remote module.
542+
"""
442543
pass
443544

444545
@abstractmethod
445546
def build_tarball(self):
547+
"""Create a compressed archive of the ITK build tree for caching."""
446548
pass
447549

448-
@abstractmethod
449-
def post_build_cleanup(self):
450-
pass
550+
def post_build_cleanup(self) -> None:
551+
"""Remove intermediate build artifacts, leaving ``dist/`` intact.
552+
553+
Actions:
554+
- remove oneTBB-prefix (symlink or dir)
555+
- remove ITKPythonPackage/, tools/, _skbuild/, build/
556+
- remove top-level *.egg-info
557+
- remove ITK-* build tree and tarballs
558+
- if ITK_MODULE_PREQ is set, remove cloned module dirs
559+
"""
560+
base = Path(self.package_env_config["IPP_SOURCE_DIR"])
561+
562+
def rm(tree_path: Path):
563+
try:
564+
_remove_tree(tree_path)
565+
except Exception:
566+
pass
567+
568+
# 1) unlink oneTBB-prefix if it's a symlink or file
569+
tbb_prefix_dir = base / "oneTBB-prefix"
570+
try:
571+
if tbb_prefix_dir.is_symlink() or tbb_prefix_dir.is_file():
572+
tbb_prefix_dir.unlink(missing_ok=True) # type: ignore[arg-type]
573+
elif tbb_prefix_dir.exists():
574+
rm(tbb_prefix_dir)
575+
except Exception:
576+
pass
577+
578+
# 2) standard build directories
579+
for rel in ("ITKPythonPackage", "tools", "_skbuild", "build"):
580+
rm(base / rel)
581+
582+
# 3) egg-info folders at top-level
583+
for p in base.glob("*.egg-info"):
584+
rm(p)
585+
586+
# 4) ITK build tree and tarballs
587+
target_arch = self.package_env_config["ARCH"]
588+
for p in base.glob(f"ITK-*-{self.package_env_config}_{target_arch}"):
589+
rm(p)
590+
591+
# Tarballs
592+
for p in base.glob(f"ITKPythonBuilds-{self.package_env_config}*.tar.zst"):
593+
rm(p)
594+
595+
# 5) Optional module prerequisites cleanup (ITK_MODULE_PREQ)
596+
# Format: "InsightSoftwareConsortium/ITKModuleA@v1.0:Kitware/ITKModuleB@sha"
597+
itk_preq = self.package_env_config.get("ITK_MODULE_PREQ") or environ.get(
598+
"ITK_MODULE_PREQ", ""
599+
)
600+
if itk_preq:
601+
for entry in itk_preq.split(":"):
602+
entry = entry.strip()
603+
if not entry:
604+
continue
605+
try:
606+
module_name = entry.split("@", 1)[0].split("/", 1)[1]
607+
except Exception:
608+
continue
609+
rm(base / module_name)
451610

452611
@abstractmethod
453612
def prepare_build_env(self) -> None: # pragma: no cover - abstract
613+
"""Set up platform-specific build environment and CMake configurations.
614+
615+
Must populate ``self.venv_info_dict``, configure TBB settings,
616+
and set the ITK binary build directory in
617+
``self.cmake_itk_source_build_configurations``.
618+
"""
454619
pass
455620

456621
@abstractmethod
457622
def post_build_fixup(self) -> None: # pragma: no cover - abstract
458-
pass
623+
"""Run platform-specific post-build wheel fixups.
459624
460-
@abstractmethod
461-
def final_import_test(self) -> None: # pragma: no cover - abstract
625+
Called after all wheels are built but before the final import
626+
test. Typically invokes ``fixup_wheel`` or ``fixup_wheels``.
627+
"""
462628
pass
463629

630+
def final_import_test(self) -> None: # pragma: no cover
631+
"""Install and smoke-test the built wheels."""
632+
self.final_wheel_import_test(installed_dist_dir=self.dist_dir)
633+
464634
@abstractmethod
465635
def discover_python_venvs(
466636
self, platform_os_name: str, platform_architechure: str
467637
) -> list[str]:
638+
"""Return available Python environment names for the given platform.
639+
640+
Parameters
641+
----------
642+
platform_os_name : str
643+
Operating system identifier.
644+
platform_architechure : str
645+
CPU architecture identifier.
646+
647+
Returns
648+
-------
649+
list[str]
650+
Sorted list of discovered environment names.
651+
"""
468652
pass
469653

470654
def build_external_module_python_wheel(self):
655+
"""Build a wheel for an external ITK remote module via scikit-build-core."""
471656
self.module_source_dir = Path(self.module_source_dir)
472657
out_dir = self.module_source_dir / "dist"
473658
out_dir.mkdir(parents=True, exist_ok=True)
@@ -546,6 +731,14 @@ def build_external_module_python_wheel(self):
546731
if py_include:
547732
defs.set("Python3_INCLUDE_DIR:PATH", py_include)
548733

734+
# Pass Python library paths when explicitly known (Windows)
735+
py_library = self.venv_info_dict.get("python_library", "")
736+
if py_library:
737+
defs.set("Python3_LIBRARY:FILEPATH", str(py_library))
738+
py_sabi_library = self.venv_info_dict.get("python_sabi_library", "")
739+
if py_sabi_library:
740+
defs.set("Python3_SABI_LIBRARY:FILEPATH", str(py_sabi_library))
741+
549742
# Allow command-line cmake -D overrides to win last
550743
if self.cmake_cmdline_definitions:
551744
defs.update(self.cmake_cmdline_definitions.items())
@@ -567,6 +760,7 @@ def build_external_module_python_wheel(self):
567760
self.fixup_wheel(str(wheel), remote_module_wheel=True)
568761

569762
def build_itk_python_wheels(self):
763+
"""Build all ITK Python wheels listed in ``WHEEL_NAMES.txt``."""
570764
# Build wheels
571765
for wheel_name in self.wheel_names:
572766
print("#")
@@ -640,6 +834,7 @@ def build_itk_python_wheels(self):
640834
_remove_tree(bp / "Wrapping" / "Generators" / "CastXML")
641835

642836
def build_wrapped_itk_cplusplus(self):
837+
"""Configure and build the ITK C++ libraries with Python wrapping."""
643838
# Clean up previous invocations
644839
if (
645840
self.cleanup
@@ -940,9 +1135,14 @@ def create_posix_tarball(self):
9401135
if issues:
9411136
print("Compatibility warnings above - review before using in CI/CD")
9421137

943-
@abstractmethod
9441138
def get_pixi_environment_name(self):
945-
pass
1139+
"""Return the pixi environment name for this build instance.
1140+
1141+
The pixi environment name is the same as the platform_env and
1142+
is related to the environment setups defined in pixi.toml
1143+
in the root of this git directory that contains these scripts.
1144+
"""
1145+
return self.platform_env
9461146

9471147
def echo_check_call(
9481148
self,

0 commit comments

Comments
 (0)