Mastering pyproject.toml for Spatial Wheels
The pyproject.toml manifest is the build contract for every geospatial Python package, and getting it wrong is why so many GDAL, PROJ, and Shapely wheels compile cleanly in CI yet fail to import on a user’s machine. This guide sits inside Modern Python Build Tooling & Wheel Configuration, the parent reference for compiling and distributing spatial Python packages, and focuses narrowly on the declarative manifest that drives backend selection, ABI-aware dependency bounds, package discovery, and C-extension routing. It targets build 1.2.x, setuptools 69+, Cython 3.0.x, NumPy 2.x with a NumPy 1.x ABI floor, scikit-build-core 0.9.x, and meson-python 0.16.x, and shows how to author a manifest that produces relocatable wheels for shapely, pyproj, rasterio, and fiona rather than a metadata file that merely satisfies pip.
Prerequisites & Environment
The manifest is parsed before any compiler runs, so the toolchain that reads it must be pinned just as tightly as the libraries it links against. A pyproject.toml that builds against setuptools 80 and NumPy 2.1 on a developer laptop but setuptools 69 and NumPy 1.26 on a runner produces two binaries with different ABIs from one source tree. Pin every participant before authoring a single table, and source the geospatial system libraries from the locked toolchain described in environment isolation with Pixi and Conda so the headers the build sees are the headers the wheel claims.
| Component | Pinned version | Why it matters for the manifest |
|---|---|---|
build |
1.2.2 |
PEP 517 front end; creates the isolated env from [build-system].requires |
pip |
24.2 |
Resolver that enforces requires-python and dependencies at install time |
setuptools |
69.5.1 |
Default backend; reads [tool.setuptools] package and data maps |
wheel |
0.43.0 |
Produces and tags the .whl; consulted for py_limited_api emission |
Cython |
3.0.10 |
Build-time only; must be in requires, never in dependencies |
| NumPy (build) | >=2.0 headers, 1.x ABI |
Compile against the oldest ABI you support to stay forward-compatible |
scikit-build-core |
0.9.8 |
CMake-backed backend for C++17 spatial extensions |
meson-python |
0.16.0 |
Alternative backend with pure-TOML extension declaration |
| Build image | quay.io/pypa/manylinux_2_28_x86_64 |
glibc floor baked into the platform tag the wheel claims |
Set the reproducibility floor in the build environment so the manifest produces a bit-stable wheel on every rerun. These belong in the CI job environment, not in pyproject.toml:
export SOURCE_DATE_EPOCH=$(git log -1 --pretty=%ct)
export PYTHONHASHSEED=0
export PIP_NO_BUILD_ISOLATION=0 # keep isolation ON; only disable deliberately
export PIP_CONSTRAINT=constraints/build.txt
For stacks that pin system GDAL/PROJ through Conda, lock the build environment first — the manifest’s [build-system].requires covers only Python build dependencies, so libgdal.so and proj.db must already exist in the prefix before build is invoked. The lock-file workflow that guarantees this is covered in configuring Pixi environments for wheel building.
Core Configuration
A spatial manifest has three load-bearing tables: [build-system] decides who compiles, [project] declares what the wheel needs at runtime, and the backend-specific [tool.*] table maps where the compiled outputs land. Treat each as a separate contract.
The [build-system] table dictates how pip and build invoke the compiler toolchain in an isolated virtual environment. The requires array must be exhaustive — omitting Cython or NumPy here triggers silent fallbacks, missing-header errors, or an ABI mismatch when compiling against spatial C-APIs:
[build-system]
requires = [
"setuptools>=69.0",
"wheel>=0.43",
"Cython>=3.0,<3.1",
"numpy>=2.0.0", # build against 2.x headers; the ABI floor is set in code
"packaging>=23.1",
]
build-backend = "setuptools.build_meta"
The [project] table declares runtime dependencies with strict lower bounds. Upper bounds belong only where a documented ABI break exists — the NumPy 2.0 transition or a GEOS 3.12 C-API change — never as defensive guesswork that strands the wheel against future releases:
[project]
name = "geo-core"
version = "2.4.1"
requires-python = ">=3.9"
dependencies = [
"shapely>=2.0.0",
"pyproj>=3.4.0",
"rasterio>=1.3.0",
"numpy>=1.24", # runtime floor; matches the compiled ABI, not the build headers
]
[project.optional-dependencies]
test = ["pytest>=8.0", "hypothesis>=6.100"]
When a wheel is installed, pip validates requires-python and resolves dependencies against the target environment. For spatial stacks this narrow window is critical: extensions link against system-level libgdal.so or libproj.so, and a mismatched runtime version causes segmentation faults or — more insidiously — silent coordinate-transformation errors. The reason the runtime NumPy floor (>=1.24) can be lower than the build header (>=2.0) is the forward-compatibility guarantee of the NumPy ABI, which is part of the broader C-API versus CPython ABI compatibility contract.
The third table maps packages and compiled outputs. setuptools cannot declare extension objects in pyproject.toml, so its TOML config covers discovery and data only:
[tool.setuptools.packages.find]
where = ["src"]
exclude = ["tests*", "benchmarks*"]
[tool.setuptools.package-data]
"geo_core" = ["py.typed"]
"geo_core.data" = ["*.json", "proj.db"] # data files survive the build but not ABI repair
That last line is the most common silent failure: proj.db and GDAL’s gdal_data/ are not shared objects, so the repair tools ignore them entirely. If they are not declared as package data, the wheel imports cleanly and then raises at the first reprojection. The downstream staging that depends on this layout is detailed in build artifact structuring and packaging.
Step-by-Step Implementation
Author the manifest in the order the build consumes it: backend, metadata, package map, extension, then build and repair. Each step is a concrete edit or a runnable command.
-
Pin the build backend in an isolated environment. Write the
[build-system]table above, then confirm the front end can construct the isolated env without falling back to a legacysetup.py egg_info:python -m build --sdist --no-isolation=false 2>&1 | grep -i "Installing build dependencies" -
Declare ABI-aware metadata. Fill in
[project]with the lower-boundeddependenciesand an explicitrequires-python. Validate the table against the PEP 621 schema before building:pipx run validate-pyproject pyproject.toml # Validating pyproject.toml ... valid -
Map packages and exclude test fixtures. Use
[tool.setuptools.packages.find]with asrc/layout so benchmark data and tests never leak into the wheel, and declare every non-.sodata file explicitly. -
Declare the C extension in a
setup.pyshim.setuptoolshas no TOML surface forExtensionobjects, so a minimalsetup.pysits beside the manifest and carries only the compile and link directives:# setup.py — extension metadata only; everything else lives in pyproject.toml from setuptools import setup, Extension ext = Extension( "geo_core._gdal", sources=["src/geo_core/gdal_wrap.c"], define_macros=[ ("NPY_NO_DEPRECATED_API", "NPY_1_7_API_VERSION"), # pin the NumPy ABI floor ("Py_LIMITED_API", "0x03090000"), # opt into the stable ABI ], extra_compile_args=["-O2", "-fPIC", "-fvisibility=hidden"], extra_link_args=["-Wl,-rpath,$ORIGIN/lib"], py_limited_api=True, ) setup(ext_modules=[ext]) -
Or switch to a pure-TOML backend for C++ extensions. Projects that want extension configuration without a
setup.pyshould adopt the scikit-build-core backend that translates the manifest into CMake invocations, ormeson-python. The[build-system]table changes accordingly:[build-system] requires = ["scikit-build-core>=0.9", "numpy>=2.0"] build-backend = "scikit_build_core.build" [tool.scikit-build] cmake.version = ">=3.26" wheel.py-api = "cp39" # emit an abi3 / Py_LIMITED_API wheel -
Build the raw wheel into a quarantine directory. Keep the unrepaired wheel away from the publishable output so a half-finished artifact can never be uploaded:
python -m build --wheel --outdir dist/raw/ -
Repair to embed the platform tag. The
auditwheel repairstep bundles the linked GDAL/PROJ shared objects and rewritesRPATHto$ORIGIN/.libs, promoting the barelinux_x86_64tag to themanylinux_2_28tag the manifest’s image floor promises:auditwheel repair dist/raw/*.whl \ --plat manylinux_2_28_x86_64 \ --wheel-dir dist/
Verification
Confirm the manifest produced what it claimed before trusting a green check. Each command targets a different contract: schema validity, ABI floor, bundled libraries, and a clean import.
Validate the manifest itself and inspect the metadata build generated from it:
python -m build --wheel --outdir dist/raw/
unzip -p dist/raw/geo_core-2.4.1-*.whl '*/METADATA' | grep -E '^(Requires-Python|Requires-Dist)'
# Requires-Python: >=3.9
# Requires-Dist: shapely>=2.0.0
# Requires-Dist: pyproj>=3.4.0
Confirm the stable-ABI opt-in actually produced an abi3 tag rather than a version-locked one — a cp312 tag here means py_limited_api did not take:
ls dist/*.whl
# geo_core-2.4.1-cp39-abi3-manylinux_2_28_x86_64.whl
auditwheel show dist/*.whl
# ... is consistent with the following platform tag: "manylinux_2_28_x86_64".
# The following external shared libraries are required: libgdal.so.34, libproj.so.25, ...
Prove the wheel installs and imports in an isolated environment, resolving its shared libraries against the bundled copies rather than the host — the RPATH mechanics behind this are covered in shared library path resolution:
python -m venv /tmp/verify && /tmp/verify/bin/pip install --no-deps dist/*.whl
/tmp/verify/bin/python -c "import geo_core; print(geo_core.__version__)"
# 2.4.1
Optimization & Edge Cases
Once the manifest builds clean, the remaining decisions are about backend choice, ABI floors, and what the resolver is allowed to do.
- Compile against the oldest NumPy ABI you support. With NumPy 2.x you build against the 2.x headers but keep
NPY_NO_DEPRECATED_APIpinned toNPY_1_7_API_VERSION, which lets a single binary import under both NumPy 1.x and 2.x runtimes. Drop thedefine_macrosguard and the wheel hard-pins to whatever NumPy was present at build time. abi3is rare but worth it where it fits. TargetingPy_LIMITED_API=0x03090000collapses a per-interpreter matrix into one wheel that imports on 3.9+, but only if no symbol outside the limited set is touched. Heavy NumPy C-API use often disqualifies a spatial extension; weigh it against the version-pinning matrix in C-API versus CPython ABI compatibility.- Choose the backend by the extension’s language surface. Pure-C extensions with a thin wrapper stay on
setuptoolsplus asetup.pyshim; C++17 spatial codebases that already ship aCMakeLists.txtbelong onscikit-build-core, tuned as in optimizing scikit-build-core for GDAL.meson-pythonis the third option when you want pure-TOML extension declaration without CMake. - Editable installs need PEP 660 support.
pip install -e .against a C-extension only works if the backend implementsbuild_editable;setuptools64+ andscikit-build-coreboth do, but the rebuild-on-import behaviour differs and can mask a stale.so. - musl and glibc need separate builds, not separate tags. The same manifest produces a
musllinux_1_2wheel only when built inside a musl image with musl-linked GDAL; you cannot re-tag a glibc binary. The trade-off is laid out in manylinux2014 vs musllinux for spatial libs. - Cache the resolver, not the build tree. Key
~/.cache/pipand a CMake build directory per platform tag; the full invalidation strategy lives in async build execution and cache strategies.
Troubleshooting
ModuleNotFoundError: No module named 'numpy' (raised during build, not at runtime)
The build backend tried to import numpy to locate headers, but NumPy was missing from [build-system].requires. Build isolation creates a clean environment that contains only what requires lists — add numpy>=2.0 there. The same applies to Cython; a missing entry surfaces as a .pyx-cannot-be-compiled error instead.
ImportError: numpy.core.multiarray failed to import
The extension was compiled against a newer NumPy ABI than the runtime provides. This is the classic effect of dropping the NPY_NO_DEPRECATED_API floor or letting build isolation pull a different NumPy than the runtime resolver. Rebuild against the oldest supported NumPy headers and confirm the runtime dependencies floor matches.
error: Multiple top-level packages discovered in a flat-layout: ['geo_core', 'tests', 'benchmarks']
setuptools auto-discovery found more than one importable directory and refuses to guess. Either move the package under src/ and set [tool.setuptools.packages.find] where = ["src"], or list packages explicitly — never ship tests inside the wheel.
BackendUnavailable: Cannot import 'setuptools.build_meta'
The build-backend string is wrong or setuptools is absent from requires. Confirm build-backend = "setuptools.build_meta" exactly and that setuptools>=69 appears in [build-system].requires; a typo here makes pip fall back to a legacy path that ignores the manifest entirely.
RuntimeError: Unsupported compiler -- at least C++17 support is needed (from scikit-build-core)
The CMake toolchain selected a compiler older than the spatial extension requires. Set the floor in CMake and rebuild inside the manylinux_2_28 Docker base images whose GCC satisfies C++17; a related find_package(PROJ) failure is resolved in fixing CMake find_package for PROJ.
Related
- Modern Python Build Tooling & Wheel Configuration — the parent reference tying backends, base images, and registry publishing together.
- Integrating CMake with scikit-build-core — the CMake-backed backend to adopt when a
setup.pyshim is no longer enough for C++ spatial extensions. - Build artifact structuring and packaging — what happens to the wheel this manifest produces, from staging to
twine check. - Environment isolation with Pixi and Conda — locking the system GDAL/PROJ that
[build-system].requiresdeliberately does not cover. - C-API vs CPython ABI compatibility — the ABI rules behind
Py_LIMITED_API, the NumPy floor, and the dependency bounds declared here.
Further reading: PEP 517 and PEP 621 for the authoritative build-backend and project-metadata specifications.