Platform-Specific ABI Quirks for Geospatial Wheels
A geospatial wheel that imports flawlessly on Linux can still fail on macOS with a code-signing error or on Windows with DLL load failed, because each platform enforces its own binary-interface rules on top of the CPython ABI: macOS has fat universal2 binaries, @rpath install names, and notarization; Windows has no RPATH at all and a search order that ignores the directory next to your .pyd. This guide sits under the Geospatial C-Extension Fundamentals & ABI Architecture reference and maps the per-platform divergences that the Linux-centric shared library path resolution rules do not cover. It targets GDAL 3.8+, PROJ 9.3+, delocate 0.11+ and delvewheel 1.x, cibuildwheel 3.0+, and macOS 12+/Windows Server 2022 runners.
Prerequisites & Environment
- macOS: Xcode command-line tools (
otool,install_name_tool,codesign),delocate0.11+, and an Apple Silicon or Intel runner. Foruniversal2, botharm64andx86_64slices of every native library. - Windows: Visual Studio 2022 build tools (
dumpbin),delvewheel1.x, and a GDAL/PROJ build compiled with the same MSVC toolset as the extension. cibuildwheel3.0+ to orchestrate all three from one matrix, as detailed in cibuildwheel vs manual Docker matrix for GDAL wheels.
# macOS: inspect an extension's install names
otool -L build/_geospatial_ext.cpython-312-darwin.so
# Windows (Dev Prompt): inspect a .pyd's DLL imports
dumpbin /dependents build\_geospatial_ext.cp312-win_amd64.pyd
Core Configuration
The mechanisms differ, but each platform has one “repair” tool that makes the wheel relocatable:
| Concern | Linux | macOS | Windows |
|---|---|---|---|
| Relocation token | $ORIGIN (RPATH) |
@loader_path (install name) |
none — copy DLLs adjacent |
| Repair tool | auditwheel |
delocate |
delvewheel |
| Extra gate | platform tag policy | code signature / notarization | DLL name mangling |
| Diagnostic | readelf -d |
otool -l |
dumpbin /dependents |
macOS install names are absolute by default (/opt/gdal/lib/libgdal.dylib); delocate rewrites them to @loader_path-relative and copies the .dylibs into the wheel, the direct analogue of auditwheel’s $ORIGIN rewrite. Windows has no equivalent of RPATH, so delvewheel instead copies the DLLs into the package and mangles their names to avoid collisions, then the package must call os.add_dll_directory at import.
Step-by-Step Implementation
-
macOS — build the slices you promise in the tag. A wheel tagged
universal2must containarm64andx86_64code in every binary; the full build is in building universal2 GDAL wheels for Apple Silicon. -
macOS — delocate then verify signatures:
delocate-wheel -w repaired/ -v dist/*.whl codesign --verify --deep repaired/*.whl 2>&1 || echo "sign before notarizing" -
Windows — delvewheel repair:
delvewheel repair -w repaired dist\*.whl -
Windows — ensure the import shim is present. The package
__init__.pymust register the bundled DLL directory, the fix detailed in fixing “DLL load failed” for GDAL on Windows.
Verification
# macOS: every install name must be @loader_path or a system framework
otool -L repaired/_geospatial_ext*.so | grep -v '@loader_path' | grep -vE '/usr/lib|/System'
# expected: empty (no absolute non-system paths)
:: Windows: the pyd's GDAL import must resolve to a bundled, mangled DLL
dumpbin /dependents repaired\_geospatial_ext*.pyd | findstr /i gdal
:: expected: gdal-<hash>.dll (a mangled name, present in the package)
# Both: clean-machine import is the acceptance test
python -c "from osgeo import gdal; print(gdal.__version__)"
Optimization & Edge Cases
universal2doubles build time and size. Two architecture slices means two native builds; if your users are entirely on Apple Silicon, ship a singlearm64wheel and skip the fat binary.- Windows DLL mangling defeats manual
LoadLibrary. If code callsLoadLibrary("gdal.dll")directly,delvewheel’s renamedgdal-<hash>.dllwill not be found — go through the package, never a hard-coded name. - macOS notarization rejects unsigned nested dylibs.
delocatecopies unsigned.dylibs; sign them before submitting for notarization or the whole wheel is refused.
Troubleshooting
ImportError: dlopen(...): code signature invalid on Apple Silicon. Rewriting an install name with install_name_tool invalidates the signature; re-sign after every modification, and let delocate handle the ordering.
ImportError: DLL load failed while importing _gdal: The specified module could not be found. The DLL search order never looked next to the .pyd. Register the directory with os.add_dll_directory, covered fully in the Windows deep-dive.
incompatible architecture (have 'arm64', need 'x86_64'). A universal2 wheel is missing a slice in one bundled library. Rebuild that dependency with -arch arm64 -arch x86_64 or lipo-merge the two.
Related
- Building universal2 GDAL wheels for Apple Silicon — the fat-binary build and
delocatefusing for macOS. - Fixing “DLL load failed” for GDAL on Windows — the DLL search order and
add_dll_directoryshim. - Shared library path resolution — the Linux
$ORIGIN/RPATH baseline these platforms diverge from.
Further Reading
delvewheelanddelocateproject READMEs for the per-platform repair internals.