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:

PEP 517 build sequence: frontend, scikit-build-core, CMake, compiler The build frontend calls build_wheel under PEP 517. scikit-build-core configures CMake and maps pyproject.toml metadata onto CMake cache variables. CMake drives the compiler to produce object files and a shared object, then returns a staged build tree. scikit-build-core packs that tree into a correctly tagged wheel and returns it to the frontend. Build frontend scikit-build-core CMake Compiler build_wheel · PEP 517 configure: map pyproject → cache vars compile extension object files + .so staged build tree wheel with correct tags

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::Module links against the Python C-API without dragging in libpython, avoiding duplicate-symbol errors when the extension is imported into a host interpreter.
  • CXX_VISIBILITY_PRESET hidden keeps 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 links Python::Module. The classic scikit-build python_extension_module() macro is not shipped with scikit-build-core, so it must not be used here.
Modern Python::Module linking versus legacy FindPythonLibs linking The recommended path links the extension with Python::Module: no libpython is pulled in, symbol visibility is hidden, and the WITH_SOABI suffix is applied, yielding an ABI-stable importable module. The legacy distutils or FindPythonLibs path links libpython directly, leaves symbol visibility at the default, and emits a generic suffix, which risks duplicate-symbol crashes when the module is imported into a host interpreter. Python::Module python_add_library(... WITH_SOABI) No libpython linked Hidden symbol visibility Correct .cpython-*.so suffix ABI-stable import no host-symbol collision vs FindPythonLibs legacy distutils shim Links libpython directly Default symbol visibility Generic / wrong suffix Duplicate-symbol risk crashes on import into host

Step-by-Step Implementation

Each step is a runnable command or a concrete config block; none are prose-only.

  1. Lay out the project so the backend can find sources and config.

    geospatial-ext/
    ├── pyproject.toml
    ├── CMakeLists.txt
    └── src/
        └── bindings.cpp
    
  2. 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}"
    
  3. 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
    
  4. Repair the Linux wheel so bundled .so files are vendored and the tag is upgraded from raw linux_x86_64 to a portable target. auditwheel repair rewrites RPATHs and copies external libraries into the wheel’s .libs/ directory:

    auditwheel repair dist/*.whl \
      --plat manylinux_2_28_x86_64 \
      -w dist/repaired/
    
  5. 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
    
  6. Develop iteratively with an editable install when changing C++ sources; scikit-build-core rebuilds 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.

The layers between pip and the compiler in a scikit-build-core build Five layers in sequence. Pip or the build tool creates an isolated environment and calls the backend. Scikit-build-core reads the pyproject configuration, generates a CMake invocation and manages the temporary build directory. CMake configures, resolving find_package calls against the environment it was given. The generator, usually Ninja, runs the compiler and linker. The resulting artifacts are collected back into a wheel. Each layer is annotated with the configuration key that belongs to it. pip / build isolated env scikit-build-core reads pyproject.toml cmake configure find_package runs here ninja compiler + linker wheel collected --no-build-isolation build-system.requires [tool.scikit-build] cmake.args · wheel.packages CMAKE_TOOLCHAIN_FILE CMAKE_PREFIX_PATH CFLAGS · LDFLAGS target_link_options auditwheel delocate a setting placed one layer too high is not an error — it is simply never consulted the two most common misplacements: linker flags in CFLAGS, and a toolchain file passed after the first configure build isolation is the other frequent surprise: the backend runs in a fresh environment that does not inherit your shell's variables unless they are exported

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.

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.

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.