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.

What Each Gate Actually Proves

The reason four gates exist rather than one is that a geospatial wheel has four independent ways of being wrong, and each failure mode is invisible to the checks that surround it. Metadata correctness is a property of the .dist-info directory. Tag correctness is a property of the wheel filename and the WHEEL file. Loadability is a property of the dynamic linker on the target machine. Functional correctness is a property of the data files and the version of PROJ that ended up inside. Nothing about a valid RECORD file tells you whether libproj.so.25 will resolve, and nothing about a successful import pyproj tells you whether proj.db shipped.

The diagram below maps each gate to the layer of the artifact it inspects, which is the quickest way to reason about where a given failure belongs:

Which layer of a spatial wheel each validation gate inspects A wheel is drawn as four stacked layers. The outermost layer is packaging metadata, inspected by twine check. Below it is the platform tag and external-reference table, inspected by auditwheel show. Below that are the bundled shared objects, exercised by a clean-container import. The innermost layer is the bundled data — proj.db and datum grids — which only a functional coordinate transform reaches. packaging metadata METADATA · RECORD · WHEEL platform tag + refs manylinux_2_28_x86_64 bundled shared objects libgdal · libproj · libgeos proj.db + datum grids twine check auditwheel show clean-container import functional transform each gate reaches exactly one layer deeper than the one above it

Read that layering as a debugging aid. When a user reports ImportError on install, the failing layer is the shared objects, so the tag gate and the load gate are the ones that should have caught it and the fix belongs in the repair step. When a user reports a CRSError or a wrong coordinate rather than a crash, the binary loaded correctly and the failing layer is the data — a different fix entirely, usually a missing PROJ_DATA directory or a proj.db from a different PROJ minor version than the linked library expects.

Two consequences follow that are easy to miss. First, the gates are not interchangeable with the package’s own test suite: unit tests run against the source tree in a developer environment that already has GDAL installed, so they exercise your Python logic against the host’s libraries and prove nothing about the wheel. The gates deliberately run against the built artifact in an environment with no geospatial libraries at all. Second, the gates must run on the same wheel file that will be uploaded — not a rebuild. A rebuild picks up a fresh manylinux image, possibly a newer patch of GDAL, and re-opens every question the gates just closed. Validate the artifact, publish the artifact.

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.
  • Disable network transforms while testing. Set PROJ_NETWORK=OFF so the functional gate can only use grids the wheel actually shipped. With networking on, PROJ silently downloads a missing grid from the CDN, the test passes, and every offline user gets a different answer.
  • Assert the data directory, not just the import. pyproj.datadir.get_data_dir() must resolve to a path inside the installed package. If it points at /usr/share/proj, the wheel is borrowing the host’s database and the gate has proved nothing about what you shipped.
  • Pin one coordinate as a regression fixture. A single known transform — a projected easting and northing to six decimal places — turns “the transform ran” into “the transform produced the same answer as last release”. PROJ minor bumps do change results for some datum pipelines, and you want that visible in a diff rather than in a user’s report.
  • Keep the gate’s images in the cache. Pulling three runtime images per run is usually the slowest part of an otherwise fast job; pre-pulling them in a warm-up step, or using a registry mirror, keeps the whole matrix inside the two-minute budget that stops anyone from disabling it.

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.

Wiring the Gate into CI

Validation that only runs when someone remembers to run it is not a gate. The durable shape is a dedicated test-wheels job that depends on the whole build matrix, downloads the collected artifacts, and runs the four checks against a matrix of runtime images rather than a single one. The runtime matrix matters more for spatial packages than for most: a wheel that imports cleanly on python:3.12-slim can still fail on python:3.9-slim because the abi3 floor was declared wrong, and a musllinux wheel can only be exercised on Alpine.

test-wheels:
  needs: [build]
  runs-on: ubuntu-latest
  strategy:
    fail-fast: false
    matrix:
      image:
        - python:3.9-slim      # the abi3 floor
        - python:3.12-slim     # the newest supported interpreter
        - python:3.12-alpine   # musllinux path
  steps:
    - uses: actions/download-artifact@v4
      with: { pattern: wheels-*, path: dist, merge-multiple: true }
    - name: Validate
      run: |
        docker run --rm -v "$PWD/dist:/d" ${{ matrix.image }} sh -c '
          pip install --no-index --find-links /d mypkg &&
          python -c "from osgeo import gdal; print(gdal.__version__)" &&
          python -c "import pyproj; print(pyproj.Transformer.from_crs(4326,3857).transform(52,5))"'

The --no-index --find-links /d pair is the load-bearing detail. Without it, pip may quietly reach PyPI and install the previous release instead of the artifact you just built, and the job goes green while testing nothing. The same reasoning applies to the --only-binary=:all: flag when you install by name: it prevents pip from falling back to an sdist and compiling GDAL from source inside the test container, which would take twenty minutes and validate the wrong thing.

The sequence below shows how a single failing gate should short-circuit the release, and which artifact each stage hands to the next:

The validation job as a short-circuiting stage between build and release The build matrix produces a collected wheelhouse. A validation job runs three runtime images in parallel — Python 3.9 slim, Python 3.12 slim and Python 3.12 Alpine. Every image must pass for the release job to run; any single failure stops the pipeline and the wheelhouse is discarded rather than published. build matrix wheelhouse/ python:3.9-slim abi3 floor holds python:3.12-slim glibc path python:3.12-alpine musl path all green? AND, not OR release upload discard no upload

Keep the job cheap enough that nobody is tempted to skip it. Installing from a local --find-links directory avoids network round-trips, the images are small, and the whole matrix normally finishes inside two minutes — an order of magnitude less than the build it protects. If it grows slower than that, the usual cause is a functional test that downloads datum grids at runtime; pin PROJ_NETWORK=OFF in the test environment so the transform uses only what the wheel shipped, which is what you actually want to prove.

Reading the Reports Like a Maintainer

The output of auditwheel show is the densest signal in the whole pipeline, and most maintainers skim it. It has three parts worth reading carefully. The first is the platform tag the tool believes the wheel qualifies for, which is a computed value derived from the highest glibc symbol version referenced by any bundled object — not a value you assert. If you built inside a manylinux_2_28 image but the report says manylinux_2_34, something in the build linked against a newer glibc than the image provides, usually a library installed from the distribution’s own repositories rather than compiled in place.

The second part is the list of external shared libraries the wheel still references. For a correctly repaired geospatial wheel that list should contain only the base platform set — libc, libm, libpthread, libdl, libstdc++, and on some builds libgcc_s. Anything else is a library the wheel expects the user’s machine to provide. A stray libcurl.so.4 means PROJ’s network-transform support was linked against the build image’s curl and will fail on any host without a matching soname; a stray libsqlite3.so.0 means proj.db access depends on the host’s SQLite, which is the sort of thing that works on every developer laptop and breaks in a minimal container.

The third part is the per-library symbol-version table that explains why the tag came out as it did. When a wheel’s tag is one release newer than expected, this table names the single symbol responsible — frequently a GLIBC_2.34 reference to pthread_* functions that moved into libc proper. Knowing that turns a vague “my tag is wrong” into a concrete decision: either accept the newer tag and drop support for older distributions, or rebuild against the older image and keep it.

delocate-listdeps plays the same role on macOS with two differences that catch people out. It reports install names rather than sonames, so the correct post-repair state is a set of @loader_path-relative paths rather than absolute /usr/local/lib ones; and it says nothing about the deployment target, which is tracked separately by MACOSX_DEPLOYMENT_TARGET and is the macOS analogue of the glibc floor. A macOS wheel whose install names are all relocated but whose deployment target is 14.0 will still refuse to install on a reader’s macOS 12 machine, with a message about an unsupported platform tag that mentions nothing about the linker.

On Windows there is no auditwheel-equivalent at all, which is why the load gate carries proportionally more weight there. The failure mode is a missing DLL rather than a missing soname, the error text is DLL load failed while importing _gdal: The specified module could not be found, and the only reliable check is importing the wheel in a clean Windows container or a fresh virtual environment on a machine with no OSGeo4W installation. Teams that ship Windows wheels without that check tend to discover the gap through user reports, because every developer machine in a GIS team already has the DLLs on PATH.

Finally, treat the reports as artifacts rather than console output. Writing auditwheel show output for every wheel into the job’s artifact bundle costs nothing and gives you a diffable record: when a release regresses, comparing this release’s report to the last one usually identifies the change in a single line, which is far faster than bisecting the build. The same applies to recording the resolved versions — gdal-config --version, proj --version, geos-config --version — alongside the wheels, so a coordinate that changed by half a metre between releases can be traced to a PROJ minor bump instead of being argued about.

Frequently Asked Questions

Do these gates replace the package’s own test suite?

No — they sit after it and answer a different question. The test suite proves your Python logic is correct against some GDAL. The gates prove the wheel you are about to upload contains a working GDAL and can be imported by a machine that has never had one installed. A package with a green test suite and no gates is exactly the package that ships a wheel with a linux_x86_64 tag.

Can I validate on the build runner instead of in a container?

Only for the metadata and tag gates. The build runner has the development headers, the system libproj, and a populated PROJ_DATA — so an import there can succeed against host libraries even when nothing was bundled. That is the single most common way a broken wheel reaches users: it was only ever imported on the machine that built it.

Which PROJ version should the functional test assert?

Assert the version you vendored, explicitly: pyproj.proj_version_str should match the PROJ you built against, and pyproj.datadir.get_data_dir() should point inside the installed package rather than into /usr/share/proj. A transform that quietly uses the host’s PROJ produces correct numbers on the build box and wrong ones on a user’s machine.

Where should the gate live if the project publishes several packages?

Factor it into one reusable workflow that takes the wheel directory and the import name as inputs, and call it from every package’s release pipeline. Spatial projects almost never ship a single wheel — a core bindings package, a data package, and one or two format extensions is a common shape — and each one duplicating a slightly different validation script is how the weakest copy ends up guarding the most important artifact.

How do I test wheels for an architecture my CI cannot run?

Run the load and function gates under emulation with docker run --platform linux/arm64 and QEMU binfmt, accepting that it is slow, or defer them to a native ARM runner in a scheduled job. The metadata and tag gates are architecture-independent and should always run on every wheel, since they catch the majority of packaging mistakes without executing any foreign code.

Further Reading

  • auditwheel and twine documentation for the authoritative check semantics.