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.
Prerequisites & Environment
- The repaired wheels under test (from
wheelhouse/ordist/), not the source tree. auditwheel6.x (Linux) /delocate0.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. twine5.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:
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
-
Metadata gate:
python -m twine check dist/*.whl # PASSED for each -
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 -
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'" -
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_2wheel cannot be validated in a glibcslimimage; usepython:3.12-alpine. - Wire the gate into CI as a job. Running these only locally means they run rarely; add them as a
teststage after the build matrix. - Disable network transforms while testing. Set
PROJ_NETWORK=OFFso 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:
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.
Related
- Smoke-testing GDAL wheels in a clean container — the load and function gates in detail, with a reusable test image.
- Verifying wheel tags with auditwheel show — reading the platform tag and external-reference report.
- Bundling proj.db and datum grids in a wheel — the fix when the function gate fails on data.
Further Reading
auditwheelandtwinedocumentation for the authoritative check semantics.