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.

How the three platforms locate a bundled native library at import Three columns compare runtime library resolution. Linux uses an RPATH of $ORIGIN and the repair tool auditwheel. macOS uses install names rewritten to @loader_path by delocate plus code signing. Windows has no RPATH, relies on the DLL search order, and uses delvewheel to mangle and copy DLLs next to the pyd. Linux RPATH mechanism $ORIGIN/../pkg.libs repair: auditwheel tag: manylinux_2_28 loader reads RUNPATH macOS install-name mechanism @loader_path/../libs repair: delocate universal2 + notarize dyld reads LC_RPATH Windows no RPATH at all DLL search order repair: delvewheel mangle + copy DLLs add_dll_directory

Prerequisites & Environment

  • macOS: Xcode command-line tools (otool, install_name_tool, codesign), delocate 0.11+, and an Apple Silicon or Intel runner. For universal2, both arm64 and x86_64 slices of every native library.
  • Windows: Visual Studio 2022 build tools (dumpbin), delvewheel 1.x, and a GDAL/PROJ build compiled with the same MSVC toolset as the extension.
  • cibuildwheel 3.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

  1. macOS — build the slices you promise in the tag. A wheel tagged universal2 must contain arm64 and x86_64 code in every binary; the full build is in building universal2 GDAL wheels for Apple Silicon.

  2. macOS — delocate then verify signatures:

    delocate-wheel -w repaired/ -v dist/*.whl
    codesign --verify --deep repaired/*.whl 2>&1 || echo "sign before notarizing"
    
  3. Windows — delvewheel repair:

    delvewheel repair -w repaired dist\*.whl
    
  4. Windows — ensure the import shim is present. The package __init__.py must 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

  • universal2 doubles build time and size. Two architecture slices means two native builds; if your users are entirely on Apple Silicon, ship a single arm64 wheel and skip the fat binary.
  • Windows DLL mangling defeats manual LoadLibrary. If code calls LoadLibrary("gdal.dll") directly, delvewheel’s renamed gdal-<hash>.dll will not be found — go through the package, never a hard-coded name.
  • macOS notarization rejects unsigned nested dylibs. delocate copies 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.

Further Reading

  • delvewheel and delocate project READMEs for the per-platform repair internals.