Smoke-testing GDAL wheels in a clean container
This page answers one question: how do you prove a GDAL/PROJ wheel actually works before publishing it — by installing it in a container that has none of your build dependencies and running a functional transform, so you catch missing bundled libraries and missing datum data that the build box always hides? It sits inside the Testing and Validating Spatial Wheels section of the Modern Python Build Tooling & Wheel Configuration reference, and gives you the container commands, a functional test that exercises GDAL and PROJ, and the musllinux variant.
Context & Root Cause
The build machine is the worst place to test a wheel, because it is contaminated: it has GDAL, PROJ, GEOS, and their data directories installed to make the build, so a wheel that forgot to bundle a library or a data file imports anyway by silently borrowing the host’s copy. The user’s machine has none of that, so the defect surfaces there first — the exact outcome testing is meant to prevent. A clean container reproduces the user’s environment: a minimal python:3.12-slim image with no geospatial anything, into which the wheel is installed and then made to do real work.
“Real work” matters as much as the clean environment. A bare import proves the libraries load, but not that PROJ can find its datum database or that GDAL can open a dataset — both need the data files that repair tools never bundle. A functional smoke test runs an actual coordinate transform and opens a raster, exercising the code paths that DataDirError and driver-registration bugs hide behind. This is the load-and-function half of the validation ladder in Testing and Validating Spatial Wheels.
Solution / Fix
This targets Docker, python:3.12-slim (glibc) or python:3.12-alpine (musl), and a repaired wheel in dist/.
1. Install and import in a pristine glibc container
docker run --rm -v "$PWD/dist:/d" python:3.12-slim bash -c '
pip install --no-cache-dir /d/*manylinux*.whl &&
python -c "from osgeo import gdal, ogr, osr; print(\"import ok\", gdal.__version__)"'
# expected: import ok 3.8.x
--no-cache-dir and the bare image guarantee nothing but the wheel provides GDAL.
2. Run a functional transform and raster open
docker run --rm -v "$PWD/dist:/d" python:3.12-slim bash -c '
pip install --no-cache-dir /d/*manylinux*.whl &&
python - <<PY
import pyproj
from osgeo import gdal
# PROJ: needs proj.db — fails with DataDirError if data unbundled
t = pyproj.Transformer.from_crs(4326, 3857, always_xy=True)
print("transform:", t.transform(5.0, 52.0))
# GDAL: driver registration + in-memory dataset
gdal.UseExceptions()
ds = gdal.GetDriverByName("MEM").Create("", 4, 4, 1)
print("gdal mem dataset:", ds.RasterXSize, ds.RasterYSize)
PY'
# expected: a coordinate pair and "gdal mem dataset: 4 4"
3. Make it a reusable script
# smoke.sh — parametrized on image and wheel glob
IMG="${1:-python:3.12-slim}"; GLOB="${2:-*manylinux*}"
docker run --rm -v "$PWD/dist:/d" "$IMG" bash -c \
"pip install -q /d/$GLOB.whl && python /d/../ci/smoke_test.py"
Verification
# 1. glibc smoke test passes
bash ci/smoke.sh python:3.12-slim '*manylinux*' # expected: exit 0, prints transform + dataset
# 2. musllinux wheel must be tested on Alpine, not slim
bash ci/smoke.sh python:3.12-alpine '*musllinux*' # expected: exit 0
# 3. Oldest supported interpreter (abi3 floor) also imports
docker run --rm -v "$PWD/dist:/d" python:3.9-slim \
bash -c "pip install -q /d/*manylinux*.whl && python -c 'from osgeo import gdal'"
# expected: no error — proves the Stable ABI floor holds
A clean exit on glibc, musl, and the 3.9 floor confirms the wheel is genuinely self-contained across the platforms it claims.
What “Clean” Has to Mean
The word does a lot of work in this page, and a container that is only mostly clean will pass a wheel that is broken. Four properties define it, and each corresponds to a way that build-machine state has been observed to leak into a passing test.
The fourth property is the quietest of the four. Running python -c "import mypkg" from a directory that contains a mypkg/ folder imports the source tree, not the wheel — so a test that appears to exercise the artifact is exercising the repository. Changing directory before the import, or invoking with python -I, removes the ambiguity entirely.
From Smoke Test to Meaningful Test
A smoke test that only imports proves the loader found everything. It says nothing about whether the library can do its job, and the gap between those two statements is where the most annoying user reports live.
Adding the version assertion is worth a sentence of its own: comparing the reported GDAL and PROJ versions against the ones you intended to vendor catches the case where the wheel loaded a library successfully — the host’s — and everything downstream looked fine.
Frequently Asked Questions
Which base image should the test use?
Match your users, not your build. python:3.12-slim is the common default because it is close to what a Dockerfile in the wild will use; python:3.12-alpine is required for musllinux wheels; and testing the oldest interpreter your abi3 floor promises is worth a cell of its own. What matters is that none of them has geospatial libraries installed.
Should the smoke test run the full test suite?
No — that is a different job with a different purpose. The suite tests your code’s behaviour and needs fixtures, data and time; the smoke test asks whether the artifact is loadable and functional, and its value comes from being fast enough to run on every wheel in the matrix. Keep them separate so that neither one’s failures obscure the other’s.
How do I smoke-test a wheel for an architecture the runner cannot execute?
Under emulation with docker run --platform, accepting the slowness for a test that runs once per wheel rather than throughout a build, or on a native runner in a follow-up job. The static checks — tag, external references, architecture of each object — need no execution at all and should run on every wheel regardless.
Can the smoke test replace auditwheel show?
No, and the two catch different things. A wheel can import perfectly in a slim container while still declaring a platform tag that promises more compatibility than it delivers, because the container happened to have a new enough glibc. Run both: the static check on the tag, the dynamic check on behaviour.
How long should the smoke test take?
Seconds. Its value comes from running on every wheel in the matrix without anyone weighing whether it is worth it, so the moment it needs fixtures, downloads or a minute of setup it has become something else. If a check needs more than that, move it to the test suite and keep the gate fast.
Should the container be built specially for the test?
No — use a stock interpreter image. A purpose-built test image accumulates convenience packages over time, and each one is a chance for the wheel to borrow something instead of carrying it. The stock image’s uselessness is the feature.
What about testing the package’s actual behaviour?
That belongs in the test suite, run in an environment where you do install the dependencies it needs. Conflating the two produces a gate that is slow and a test suite that is weak. The smoke test asks whether the artifact works at all; the suite asks whether the code is right.
What should the test do about network access?
Disable it, or at least disable PROJ’s use of it. With PROJ_NETWORK unset to on, a missing datum grid is fetched from a CDN and the transform succeeds, so the test passes while the wheel ships nothing. Setting PROJ_NETWORK=OFF in the container makes the test measure what the artifact actually contains, which is the only property a packaging gate can meaningfully assert.
Pitfalls & Alternatives
Testing in the build container. Reusing the build image (which has GDAL) defeats the entire purpose — the wheel passes by borrowing host libraries. Always use a minimal image with no geospatial packages.
Import-only smoke tests. A bare import misses DataDirError and driver-registration failures because those trigger only on use. Run a transform and a dataset operation, not just an import.
Testing musllinux on glibc. A musllinux wheel installed in python:3.12-slim may pip-resolve to a different manylinux wheel or fail to load. Match the image libc to the wheel tag — the distinction is the subject of manylinux2014 vs musllinux for spatial libs. The same reasoning applies to any other environment variable the geospatial stack reads: unset it in the container so the test measures the wheel rather than the machine it happens to be running on.
Related
- Testing and Validating Spatial Wheels — the parent guide placing this in the four-gate validation ladder.
- Verifying wheel tags with auditwheel show — the static gate that runs before this runtime one.
- Bundling proj.db and datum grids in a wheel — the fix when the functional transform raises
DataDirError.