Porting a setup.py GDAL build to scikit-build-core
This page answers one question: your setup.py probes gdal-config, builds one Extension and branches on sys.platform, and you need the same wheel out of a CMake-driven backend — so what does each piece become, and how do you prove the artifact did not change? It sits inside the Migrating Legacy setup.py Spatial Builds section of the Modern Python Build Tooling & Wheel Configuration reference, and gives you the line-by-line translation and the comparison that ends the migration.
Context & Root Cause
A legacy spatial setup.py is an imperative program that computes the build. It shells out to gdal-config for flags, hard-codes paths where that binary does not exist, adds compiler options per platform, and hands the result to a single Extension. It works, and it has three structural problems: it runs on the build machine so its platform branches describe the wrong machine under cross-compilation, it duplicates discovery logic that GDAL’s own CMake package already provides, and increasingly it depends on modules that newer Pythons have removed.
Porting it is mostly translation. The risk is not the mechanics but the tacit knowledge — a conditional added years ago for a reason nobody remembers. That is why the procedure starts by building a reference wheel and ends by comparing against it, exactly as migrating legacy setup.py spatial builds describes for the migration as a whole.
Solution / Fix
This targets scikit-build-core 0.9+, CMake 3.28+, Cython 3.0+, GDAL 3.8.x and PROJ 9.3.x.
1. Capture the reference
python -m build --wheel -o reference/
unzip -l reference/*.whl > reference/contents.txt
unzip -o reference/*.whl -d /tmp/ref >/dev/null
readelf -d /tmp/ref/**/_ext*.so | grep NEEDED | sort > reference/needed.txt
2. Translate discovery
# before — probing on the build machine
gdal_cflags = subprocess.check_output(["gdal-config", "--cflags"]).decode().split()
gdal_libs = subprocess.check_output(["gdal-config", "--libs"]).decode().split()
# after — the package describes itself, including transitive requirements
find_package(GDAL 3.8 CONFIG REQUIRED)
find_package(PROJ 9.3 CONFIG REQUIRED)
3. Translate the extension
# before
Extension("geo_core._ext",
sources=["src/geo_core/_ext.pyx"],
include_dirs=[numpy.get_include()],
extra_compile_args=gdal_cflags + ["-fvisibility=hidden"],
extra_link_args=gdal_libs,
py_limited_api=True)
# after
find_package(Python REQUIRED COMPONENTS Interpreter Development.Module NumPy)
python_add_library(_ext MODULE src/geo_core/_ext.c WITH_SOABI)
target_link_libraries(_ext PRIVATE GDAL::GDAL PROJ::proj Python::NumPy)
target_compile_definitions(_ext PRIVATE Py_LIMITED_API=0x03090000)
set_target_properties(_ext PROPERTIES C_VISIBILITY_PRESET hidden)
install(TARGETS _ext DESTINATION geo_core)
4. Translate the platform branches
# before — describes the build host
if sys.platform == "darwin":
extra_compile_args += ["-stdlib=libc++"]
# after — describes the target
if(APPLE)
target_compile_options(_ext PRIVATE -stdlib=libc++)
endif()
5. Switch the backend
[build-system]
requires = ["scikit-build-core>=0.9", "Cython>=3.0", "numpy>=1.23"]
build-backend = "scikit_build_core.build"
[tool.scikit-build]
wheel.py-api = "cp39"
Verification
# 1. Same files
python -m build --wheel -o dist/
diff <(unzip -l reference/*.whl | awk '{print $4}' | sort) \
<(unzip -l dist/*.whl | awk '{print $4}' | sort)
# expected: differences only in dist-info metadata
# 2. Same linkage
unzip -o dist/*.whl -d /tmp/new >/dev/null
diff reference/needed.txt <(readelf -d /tmp/new/**/_ext*.so | grep NEEDED | sort)
# expected: identical
# 3. Same behaviour in a clean container
for w in reference/*.whl dist/*.whl; do
docker run --rm -v "$PWD:/s" python:3.12-slim bash -c \
"pip install -q /s/$w && python -c 'import geo_core; print(geo_core.selftest())'"
done
# expected: identical output
Check two is the informative one. A NEEDED list that gained an entry usually means the CMake config package brought a transitive requirement the old link line omitted — often an improvement, and always worth understanding before shipping.
Translating Cython
Most legacy spatial builds compile Cython, and the mechanics differ enough between the two worlds to be worth stating.
find_program(CYTHON_EXECUTABLE cython REQUIRED)
add_custom_command(
OUTPUT ${CMAKE_CURRENT_BINARY_DIR}/_ext.c
COMMAND ${CYTHON_EXECUTABLE} -3 --directive binding=True
${CMAKE_CURRENT_SOURCE_DIR}/src/geo_core/_ext.pyx
-o ${CMAKE_CURRENT_BINARY_DIR}/_ext.c
DEPENDS src/geo_core/_ext.pyx
VERBATIM)
python_add_library(_ext MODULE ${CMAKE_CURRENT_BINARY_DIR}/_ext.c WITH_SOABI)
The directive note in the diagram is the part that changes behaviour. Cython 3 defaults differ from 0.29 — the language level and binding in particular — so a port that leaves them implicit can produce different generated C from the same .pyx, and the resulting difference shows up as a behavioural change rather than a build failure.
What to Do With the Old Conditionals
The platform branches are where the tacit knowledge lives, and each one needs a decision rather than a mechanical translation.
The second row is the one that converts a translation into a fix. A build that branched on sys.platform produced correct results only because it always ran on the machine it was building for; the moment cross-compilation enters — as it does for aarch64 wheels — the condition tests the wrong thing. Moving to the build system’s own target predicates removes that class of bug entirely, which is one of the better arguments for doing the port at all.
Pitfalls & Alternatives
Porting and upgrading in one change. Changing the backend and bumping GDAL together makes any difference in the artifact unattributable. Port first, prove equivalence, then upgrade.
Deleting setup.py before the port is proven. Keep it working until the comparison passes; a two-line shim afterwards is fine if a tool in your workflow still wants one.
Forgetting the sdist contents. The new build needs CMakeLists.txt and any CMake modules; an sdist without them cannot be built from source at all, which only affects users on platforms you do not publish for — that is, the users least able to diagnose it.
Assuming the wheel tag carries over. The abi3 declaration moves from py_limited_api=True to the backend’s own setting. Dropping it silently publishes version-specific wheels, which the filename check catches immediately.
Frequently Asked Questions
How long should this take?
For a package with one extension and a handful of conditionals, a day of work and a week of release-candidate soak. The authoring is quick; recovering the intent behind the old conditionals is what takes the time, which is why writing them down before starting is the highest-leverage step.
Do I need CMake experience?
Less than you would expect for this shape of project. The file above is close to complete for a single-extension package, and the parts that grow — vendoring, cross-compilation — are covered in the surrounding chapters when you need them.
What if find_package(GDAL CONFIG) fails where gdal-config worked?
The config package is installed by GDAL’s own CMake build; a distribution package that installs only the gdal-config script may not provide it. Falling back to pkg_check_modules is legitimate, and stating the requirement clearly in the error message matters more than which mechanism finds it.
Should the generated C go into the source distribution?
Only if you want a source build to work without Cython installed. Including it makes the sdist self-contained and means the C can go stale relative to the .pyx; excluding it adds Cython to the build requirements. Either is defensible; be explicit about which you chose.
Does the port change the wheel a user receives?
It should not, and the three comparisons are how you know. A difference in the NEEDED list is the most likely and usually reflects the config package supplying a transitive requirement that the old link line left implicit.
What about the tests and CI?
They should not need to change at all, which is a useful signal: if a test breaks during the port, it was testing the build rather than the package. The CI matrix keeps working because it calls the same build frontend.
What if the old build produced several extensions?
Each becomes its own target with its own install rule, and the pattern above repeats unchanged. Shared compile options move into an interface target or a variable rather than being repeated, which is one of the places the CMake version ends up shorter than the original script.
How do I keep the reference wheel meaningful?
Build it from the same commit you are porting, on the same machine, with the same native libraries available. A reference built weeks earlier against a different GDAL compares two variables at once and tells you nothing definite about the port.
Is there anything the old script could do that CMake cannot?
Nothing that matters for a build, and one thing worth naming: arbitrary Python at configure time. Where the old script computed something dynamically, the port usually replaces it with a declared option — which is a better outcome, because a computed value that nobody can see is exactly the kind of hidden input a reproducible build has to remove.
Does the port change how contributors build locally?
It usually simplifies it: one command through the build frontend, with CMake discovering the dependencies rather than a script probing for them. Document the local command in the same change, because a contributor whose muscle memory is the old invocation will otherwise hit a confusing failure.
Related
- Migrating legacy setup.py spatial builds — the staged migration this page is the last step of.
- Integrating CMake with scikit-build-core — the layers between pip and the compiler, and where each setting belongs.
- Declaring native build dependencies in pyproject.toml — making the ported build fail legibly when GDAL is absent.