Testing and Validating Spatial Wheels

A geospatial wheel that compiles and repairs cleanly can still be broken in ways only a runtime check reveals: a missing datum database, a platform tag PyPI will reject, or a bundled library that resolves to the host on the build box and to nothing on a user’s machine. This guide sits under the Modern Python Build Tooling & Wheel Configuration reference and defines the validation gate every spatial wheel should pass before publication — static tag inspection, clean-container smoke tests, and functional transforms that exercise GDAL and PROJ end to end. It targets auditwheel 6.x, delocate 0.11+, twine 5.x, and wheels built to the abi3 contract from C-API vs CPython ABI compatibility.

The four-gate validation ladder from static inspection to functional test A built wheel passes through four sequential gates: twine check validates metadata, auditwheel show validates the platform tag and external references, a clean-container import proves the bundled libraries load, and a functional transform proves GDAL and PROJ actually work. Passing all four qualifies the wheel for publishing. twine check metadata auditwheel show platform tag clean import bundled libs load functional transform works each gate is necessary; none alone is sufficient

Prerequisites & Environment

  • The repaired wheels under test (from wheelhouse/ or dist/), not the source tree.
  • auditwheel 6.x (Linux) / delocate 0.11+ (macOS) for static inspection.
  • Docker with a clean runtime image (python:3.12-slim) that has no geospatial libraries — the only honest test environment.
  • twine 5.x for metadata validation.
# The wheels to validate — list them first
ls dist/*.whl

Core Configuration

The four gates are ordered from cheapest to most thorough, and each catches a class the previous cannot:

Gate Command Catches
Metadata twine check dist/* Broken long-description, missing fields PyPI rejects
Platform tag auditwheel show wheel.whl Unrepaired linux_x86_64 tag, host-leaking references
Load clean-container import Missing/mislinked bundled libgdal/libproj
Function transform in clean container Missing proj.db, wrong datum data, silent API breaks

Running only the first two is the common mistake: a wheel can have a perfect manylinux_2_28 tag and still raise DataDirError at the first transform because the data files were never packaged. The load and function gates are covered in depth by smoke-testing GDAL wheels in a clean container, and the tag gate by verifying wheel tags with auditwheel show.

Step-by-Step Implementation

  1. Metadata gate:

    python -m twine check dist/*.whl    # PASSED for each
    
  2. Tag gate:

    auditwheel show dist/*manylinux*.whl | grep -iE 'platform|following'
    # platform tag must be manylinux_2_28_x86_64; external refs must be only base libc
    
  3. Load gate in a clean container:

    docker run --rm -v "$PWD/dist:/d" python:3.12-slim \
      bash -c "pip install /d/*manylinux*.whl && python -c 'from osgeo import gdal'"
    
  4. Function gate — a transform that needs the datum database:

    docker run --rm -v "$PWD/dist:/d" python:3.12-slim bash -c "
      pip install /d/*manylinux*.whl &&
      python -c \"import pyproj; print(pyproj.Transformer.from_crs(4326,3857).transform(52,5))\""
    

Verification

The gate passes only when all four succeed on the same wheel:

# One-shot gate: any failure exits non-zero
set -e
python -m twine check dist/*manylinux*.whl
auditwheel show dist/*manylinux*.whl | grep -q manylinux_2_28
docker run --rm -v "$PWD/dist:/d" python:3.12-slim bash -c \
  "pip install -q /d/*manylinux*.whl && python -c 'import pyproj; pyproj.Transformer.from_crs(4326,3857).transform(52,5)'"
echo "ALL GATES PASSED"

ALL GATES PASSED means the wheel is publishable. A non-zero exit before it names the failing gate, which maps directly to a fix in the child pages.

Optimization & Edge Cases

  • Test the oldest supported interpreter. An abi3 wheel floored at 3.9 should be smoke-tested on 3.9 and the newest 3.x, since the Stable ABI is the contract being validated.
  • musllinux needs an Alpine image. A musllinux_1_2 wheel cannot be validated in a glibc slim image; use python:3.12-alpine.
  • Wire the gate into CI as a job. Running these only locally means they run rarely; add them as a test stage after the build matrix.

Troubleshooting

twine check warns about the description. A malformed README long_description blocks upload. Fix the metadata in pyproject.toml and rebuild.

Tag says linux_x86_64. The wheel was never repaired. Run auditwheel repair before testing — the tag gate exists precisely to catch this.

Import passes but transform raises DataDirError. The library loaded but the data did not ship. Bundle it per bundling proj.db and datum grids in a wheel.

Further Reading

  • auditwheel and twine documentation for the authoritative check semantics.