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.
Who Is Driving Whom
The relationship between the build frontend, the backend and CMake is the part most worth being precise about, because a configuration set in the wrong layer is silently ignored rather than rejected.
Build isolation deserves particular attention in a geospatial build, because it is where a carefully-constructed environment quietly disappears: the backend is installed into a fresh virtual environment, and anything you set up by activating a conda prefix is visible only if it survives as an exported environment variable.
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.
Frequently Asked Questions
Do I still need a setup.py alongside pyproject.toml?
No, and keeping one is actively confusing. A modern backend reads everything from pyproject.toml; a leftover setup.py invites contributors to add build logic in a file that the backend never executes, and occasionally causes tooling to take a legacy code path. Delete it once the migration is complete, and move anything it did into CMakeLists.txt or the backend’s configuration table.
How do I pass a CMake definition from CI without editing the manifest?
Through the backend’s configuration-settings channel or the environment variable it reads, both of which reach the configure step. Prefer the manifest for anything that is a property of the project — the toolchain file, the minimum library versions — and reserve the CI channel for things that genuinely vary per run, such as a cross-build sysroot path or a cache directory.
Why does my build ignore CMAKE_ARGS that work when I run CMake by hand?
Because build isolation starts the backend in a fresh environment. Variables exported in your interactive shell are not inherited unless the CI job exports them into the build step, and an activated conda prefix is not visible at all. The fix is to set them in the manifest, which travels with the project, rather than in a shell profile that only exists on one machine.
Should the CMake build compile GDAL, or only link it?
Link it. A CMakeLists.txt that also downloads and compiles GDAL turns every wheel build into a twelve-minute compile and makes the configure step depend on the network. Provide GDAL through the environment — a pinned conda prefix, a prebuilt image layer, a cached prefix — and let CMake do what it is good at, which is finding it and linking against it correctly.
How do I debug a configure that picks the wrong library?
Read CMakeCache.txt rather than the console output: it records what discovery actually decided, which is frequently different from what the configuration asked for. Then remember that the cache is written once — after changing anything that affects discovery, delete the build directory, or you will be reading a stale decision and concluding that your fix did nothing.
Does the backend handle the wheel tag, or does CMake?
The backend, from the interpreter and platform it is building for, with the final say going to the repair tool that recomputes the platform component. Nothing in CMakeLists.txt should try to influence the tag; if the resulting tag is wrong, the cause is in the compilation environment — usually a library linked from outside the base image — rather than in the CMake configuration.
What is the minimum a CMakeLists.txt for a spatial extension needs?
A minimum CMake version, the project declaration, a find of the Python development component the backend provides, finds for the geospatial libraries in config mode, a module target for the extension, and an install rule placing it inside the package directory. Anything beyond that — vendoring, downloads, tag manipulation — is worth questioning before it is added.
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.