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.

How the three load-bearing pyproject.toml tables drive a relocatable spatial wheel build The pyproject.toml manifest holds three tables. The build-system table decides who compiles, the project table declares what the wheel needs at runtime with ABI-bounded dependencies, and the tool table maps where compiled outputs land. All three feed python -m build, which constructs an isolated environment and runs the backend. The backend compiles the C extension into a .so against the NumPy 1.7 ABI floor, build emits a bare linux_x86_64 wheel into a quarantine directory, auditwheel repair bundles libgdal and libproj and rewrites the run path, and the result is a tagged manylinux relocatable wheel. pyproject.toml WHO COMPILES [build-system] selects backend in an isolated env WHAT IT NEEDS [project] resolves ABI- bounded deps WHERE IT LANDS [tool.*] maps packages + extensions python -m build constructs isolated env from .requires backend compiles C extension emits _gdal.so · NumPy 1.7 ABI floor raw wheel → dist/raw/ bare linux_x86_64 tag · quarantined auditwheel repair bundles libgdal/libproj · rewrites RPATH geo_core-2.4.1-cp39-abi3-manylinux_2_28 tagged · relocatable wheel

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.

  1. 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 legacy setup.py egg_info:

    python -m build --sdist --no-isolation=false 2>&1 | grep -i "Installing build dependencies"
    
  2. Declare ABI-aware metadata. Fill in [project] with the lower-bounded dependencies and an explicit requires-python. Validate the table against the PEP 621 schema before building:

    pipx run validate-pyproject pyproject.toml
    # Validating pyproject.toml ... valid
    
  3. Map packages and exclude test fixtures. Use [tool.setuptools.packages.find] with a src/ layout so benchmark data and tests never leak into the wheel, and declare every non-.so data file explicitly.

  4. Declare the C extension in a setup.py shim. setuptools has no TOML surface for Extension objects, so a minimal setup.py sits 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])
    
  5. Or switch to a pure-TOML backend for C++ extensions. Projects that want extension configuration without a setup.py should adopt the scikit-build-core backend that translates the manifest into CMake invocations, or meson-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
    
  6. 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/
    
  7. Repair to embed the platform tag. The auditwheel repair step bundles the linked GDAL/PROJ shared objects and rewrites RPATH to $ORIGIN/.libs, promoting the bare linux_x86_64 tag to the manylinux_2_28 tag 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_API pinned to NPY_1_7_API_VERSION, which lets a single binary import under both NumPy 1.x and 2.x runtimes. Drop the define_macros guard and the wheel hard-pins to whatever NumPy was present at build time.
  • abi3 is rare but worth it where it fits. Targeting Py_LIMITED_API=0x03090000 collapses 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 setuptools plus a setup.py shim; C++17 spatial codebases that already ship a CMakeLists.txt belong on scikit-build-core, tuned as in optimizing scikit-build-core for GDAL. meson-python is 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 implements build_editable; setuptools 64+ and scikit-build-core both 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_2 wheel 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/pip and 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.


Further reading: PEP 517 and PEP 621 for the authoritative build-backend and project-metadata specifications.