Integrating CMake with scikit-build-core
Bridging a mature C/C++ spatial stack — GDAL, PROJ, GEOS — to a modern Python wheel is a compilation problem that the Modern Python Build Tooling & Wheel Configuration reference treats as a build-backend contract. This chapter narrows that contract to one pipeline: using scikit-build-core (>=0.9.0) as the PEP 517 backend that drives CMake (>=3.26) and Ninja (>=1.11) to produce ABI-stable extension wheels. It covers the pyproject.toml declarations that translate Python metadata into CMake cache variables, a production CMakeLists.txt that links native spatial libraries, the step-by-step build invocation, and the verification commands that prove the result is correct. The complementary manifest details live in mastering pyproject.toml for spatial wheels; the binary-interface rules that the compiled .so must honour are governed by Geospatial C-Extension Fundamentals & ABI Architecture.
The backend mediates between the Python build frontend and the native toolchain:
Prerequisites & Environment
scikit-build-core only orchestrates the build; the surrounding toolchain must be present and version-pinned before the first build_wheel call. Pin these tools explicitly so a clean CI runner produces the same artifact as a maintainer laptop:
| Component | Minimum version | Why it matters |
|---|---|---|
scikit-build-core |
0.9.0 | Stable [tool.scikit-build] schema and editable-install support |
| CMake | 3.26 | FindPython Development.Module component and WITH_SOABI |
| Ninja | 1.11 | Deterministic parallel builds; the only generator the backend assumes |
| GDAL | 3.4+ | Ships GDALConfig.cmake for find_package config mode |
| PROJ | 9.0+ | Ships proj-config.cmake; required transitively by GDAL |
| Python | 3.9+ | Matches the requires-python floor used below |
The native libraries must expose their CMake config packages on CMAKE_PREFIX_PATH, not just their headers. The cleanest way to guarantee that GDAL, PROJ, and a matching compiler are all discoverable — and identical across macOS ARM64, Linux x86_64, and Windows MSVC runners — is a locked environment provisioned through environment isolation with pixi and conda. For redistributable Linux wheels, build inside the manylinux_2_28 Docker base images that anchor glibc compliance, so the toolchain and sysroot match the wheel tag you intend to publish.
Set the discovery hints before invoking the build:
# Point CMake at the config packages the backend will consume
export CMAKE_PREFIX_PATH="$CONDA_PREFIX:${CMAKE_PREFIX_PATH}"
export PKG_CONFIG_PATH="$CONDA_PREFIX/lib/pkgconfig:${PKG_CONFIG_PATH}"
cmake --version # expect >= 3.26
ninja --version # expect >= 1.11
Core Configuration
Two files define the entire pipeline: pyproject.toml selects and constrains the backend, and CMakeLists.txt describes the native target. Everything else is derived.
pyproject.toml
The manifest declares the backend, enforces minimum tool versions, and sets wheel-specific constraints to prevent silent fallbacks or ABI mismatches:
[build-system]
requires = ["scikit-build-core>=0.9.0", "cmake>=3.26", "ninja>=1.11"]
build-backend = "scikit_build_core.build"
[project]
name = "geospatial-ext"
version = "1.2.0"
requires-python = ">=3.9"
dependencies = ["numpy>=1.24"]
[tool.scikit-build]
cmake.version = ">=3.26"
ninja.make-fallback = false
wheel.expand-macos-universal-tags = true
sdist.exclude = [".github", "tests", "docs"]
# Forward cache variables straight into the CMake configure step
[tool.scikit-build.cmake.define]
GEOSPATIAL_USE_SYSTEM_PROJ = "ON"
CMAKE_BUILD_TYPE = "Release"
The [tool.scikit-build] table maps Python metadata onto CMake cache variables. Setting ninja.make-fallback = false is the single most important line for reproducibility: it forces a hard failure when Ninja is missing instead of silently degrading to GNU Make, which breaks parallel-build guarantees and invalidates cache consistency. The cmake.define table is the supported way to pass values such as GEOSPATIAL_USE_SYSTEM_PROJ into configure without a wrapper script. Granular control over wheel tags, build-tag injection, and source-distribution pruning is documented in mastering pyproject.toml for spatial wheels.
CMakeLists.txt
Geospatial extensions demand strict ABI alignment, explicit symbol visibility, and deterministic library linkage. A production CMakeLists.txt uses CMake’s native FindPython rather than legacy FindPythonLibs or distutils shims:
cmake_minimum_required(VERSION 3.26)
project(geospatial_ext LANGUAGES CXX)
# Modern CMake Python discovery (scikit-build-core supplies the hints)
find_package(Python 3.9 COMPONENTS Interpreter Development.Module REQUIRED)
# Geospatial dependency resolution (config mode via CMAKE_PREFIX_PATH)
find_package(GDAL 3.4 REQUIRED)
find_package(PROJ 9.0 REQUIRED)
# Extension target. python_add_library(... WITH_SOABI) comes from CMake's
# FindPython: it applies the correct .cpython-*.so suffix and links
# Python::Module automatically.
python_add_library(geospatial_ext MODULE WITH_SOABI src/bindings.cpp)
target_link_libraries(geospatial_ext PRIVATE GDAL::GDAL PROJ::proj)
target_compile_features(geospatial_ext PRIVATE cxx_std_17)
# ABI & visibility controls
set_target_properties(geospatial_ext PROPERTIES
CXX_VISIBILITY_PRESET hidden
VISIBILITY_INLINES_HIDDEN ON
)
# Install into the wheel root (repair tooling fixes RPATH downstream)
install(TARGETS geospatial_ext LIBRARY DESTINATION .)
Three decisions carry the most weight:
Python::Modulelinks against the Python C-API without dragging inlibpython, avoiding duplicate-symbol errors when the extension is imported into a host interpreter.CXX_VISIBILITY_PRESET hiddenkeeps internal symbols local, which prevents collisions when multiple C++ runtimes or conflicting library versions coexist downstream — a recurring hazard in deep spatial stacks. The rules behind that hazard are set out in C-API vs CPython ABI compatibility.python_add_library(... MODULE WITH_SOABI)applies the platform-specific suffix and linksPython::Module. The classicscikit-buildpython_extension_module()macro is not shipped withscikit-build-core, so it must not be used here.
Step-by-Step Implementation
Each step is a runnable command or a concrete config block; none are prose-only.
-
Lay out the project so the backend can find sources and config.
geospatial-ext/ ├── pyproject.toml ├── CMakeLists.txt └── src/ └── bindings.cpp -
Provision the locked toolchain (see Prerequisites) and export the discovery paths so config mode resolves GDAL and PROJ:
pixi install # or: conda env create -f environment.yml export CMAKE_PREFIX_PATH="$CONDA_PREFIX:${CMAKE_PREFIX_PATH}" -
Build the wheel through the PEP 517 frontend. Never call CMake directly — let the backend stage the build tree:
python -m build --wheel # -> dist/geospatial_ext-1.2.0-cp311-cp311-linux_x86_64.whl -
Repair the Linux wheel so bundled
.sofiles are vendored and the tag is upgraded from rawlinux_x86_64to a portable target.auditwheel repairrewrites RPATHs and copies external libraries into the wheel’s.libs/directory:auditwheel repair dist/*.whl \ --plat manylinux_2_28_x86_64 \ -w dist/repaired/ -
Repair the macOS wheel with
delocate, which copies linked dynamic libraries into.dylibs/and rewrites their install names:delocate-wheel -w dist/repaired/ -v dist/*.whl -
Develop iteratively with an editable install when changing C++ sources;
scikit-build-corerebuilds the extension on import:pip install --no-build-isolation -Ceditable.rebuild=true -e .
The choice of which native libraries to vendor versus link against the host — and how that decision changes the repair step — is covered in vendoring PROJ and GDAL vs system libraries.
Verification
Confirm each layer independently: the wheel tag, the bundled libraries, and a real import.
# 1. Inspect the repaired wheel's platform tag and bundled libs
auditwheel show dist/repaired/geospatial_ext-1.2.0-cp311-cp311-manylinux_2_28_x86_64.whl
Expected output names the platform and the libraries pulled in:
geospatial_ext-...manylinux_2_28_x86_64.whl is consistent with
the following platform tag: "manylinux_2_28_x86_64".
The wheel references external versioned symbols in these
system-provided shared libraries: libc.so.6, libstdc++.so.6.
# 2. Confirm the extension resolves all shared libraries at runtime
pip install dist/repaired/*.whl
python -c "import geospatial_ext; print(geospatial_ext.__file__)"
ldd $(python -c "import geospatial_ext, os; print(geospatial_ext.__file__)")
A correctly repaired extension shows GDAL and PROJ resolving into the wheel’s .libs/ directory rather than a host path:
libgdal.so.34 => /.../geospatial_ext.libs/libgdal-<hash>.so.34
libproj.so.25 => /.../geospatial_ext.libs/libproj-<hash>.so.25
If ldd reports not found for any spatial library, the RPATH was not rewritten — return to the repair step. The mechanics of how the loader searches for these objects are detailed in shared library path resolution.
Optimization & Edge Cases
Build caching. CMake reconfiguration dominates incremental build time. Cache the CMake build directory and the compiler cache keyed on the toolchain and CMakeLists.txt hash; the broader caching model — including how scikit-build-core interacts with ccache and parallel matrix runs — is laid out in async build execution and cache strategies. Enable ccache through the backend:
[tool.scikit-build.cmake.define]
CMAKE_CXX_COMPILER_LAUNCHER = "ccache"
Stable ABI is usually a trap here. Python’s abi3 (Py_LIMITED_API) lets one wheel serve many interpreter versions, but geospatial extensions lean on the NumPy C-API and pybind11, which are not part of the limited API. Targeting abi3 will compile and then fail at runtime on a different Python minor. Prefer explicit cpXY tags unless every dependency is limited-API-clean; the trade-off is analysed in C-API vs CPython ABI compatibility.
musl vs glibc. A manylinux wheel will not load on Alpine. Build a separate musllinux_1_2 wheel in a musl image; the two cannot share a tag and must be produced in their own matrix legs.
Cross-compilation for macOS universal2. wheel.expand-macos-universal-tags = true only tags the wheel — the compiler must actually emit both slices. Set CMAKE_OSX_ARCHITECTURES="arm64;x86_64" and ensure GDAL/PROJ are themselves universal, or the link step fails on the missing slice. Building the underlying toolchain for a foreign architecture is covered in cross-compiler toolchain setup.
Matrix pruning. Because requires-python = ">=3.9" and the extension is not abi3, each Python minor needs its own wheel. Prune aggressively — drop end-of-life interpreters and exotic architectures unless telemetry justifies them — to keep the CI matrix tractable.
Troubleshooting
CMake Error at CMakeLists.txt: Could NOT find Python (missing: Python_INCLUDE_DIRS Development.Module)
The Development.Module component is unavailable because only a Python runtime (not its headers) is installed, or CMake is older than 3.18. Install the development headers (python3-dev / the conda python package includes them) and confirm cmake --version reports >=3.26. Do not fall back to FindPythonLibs.
ninja: error: loading 'build.ninja': No such file or directory — or, with the recommended config, an immediate hard stop at configure time. This appears when Ninja is missing and ninja.make-fallback = false correctly refuses to degrade to Make. Install Ninja (pip install ninja or the system package) so the declared generator is present.
CMake Error: Could NOT find PROJ (missing: PROJ_DIR) (or the equivalent for GDAL). find_package is running in config mode but proj-config.cmake is not on CMAKE_PREFIX_PATH. Export the prefix that contains lib/cmake/proj/ before building. The full resolution decision tree — config mode vs pkg-config fallback, and PROJ’s transitive role under GDAL — is in fixing CMake find_package for PROJ.
ImportError: .../geospatial_ext.cpython-311-x86_64-linux-gnu.so: undefined symbol: _ZN4GDAL...
The extension was linked against one GDAL ABI but loaded against another, or symbol visibility leaked. Verify the build-time and runtime GDAL match, keep CXX_VISIBILITY_PRESET hidden, and rebuild in a clean isolated environment. Trimming GDAL to only the components you link — which shrinks the surface for exactly this failure — is covered in optimizing scikit-build-core for GDAL.
auditwheel: error: cannot repair "...whl" to "manylinux_2_28_x86_64" ABI because of the presence of too-recent versioned symbols
The build linked against a newer glibc than the target tag allows. Build inside the matching manylinux image rather than on the host, then repeat the repair.
Related
- Mastering pyproject.toml for spatial wheels — the full manifest schema, dependency bounds, and wheel-tag overrides that complement the backend config here.
- Fixing CMake find_package for PROJ — diagnosing and repairing config-mode discovery failures for PROJ and its dependents.
- Optimizing scikit-build-core for GDAL — component selection and link pruning to cut wheel bloat and avoid transitive ABI traps.
- Environment isolation with pixi and conda — reproducible, locked toolchains that feed
scikit-build-coreidentical inputs across runners. - manylinux and manyarm Docker base images — the glibc-compliant build images that make the repair step’s platform tag legitimate.
Up one level: Modern Python Build Tooling & Wheel Configuration.
Further reading: the CMake FindPython module reference and PEP 517 define the discovery semantics and build-isolation guarantees this backend relies on.