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.

The three files a ported build ends up with The manifest holds the metadata, the dependencies and the backend configuration. The CMake file holds the discovery of GDAL and PROJ, the extension target and the install rule. A small optional setup.py shim remains only if a tool requires it. Everything the old script did is accounted for in the first two, and the reference wheel built before the migration is what proves the result is equivalent. pyproject.toml metadata · dependencies backend configuration CMakeLists.txt find GDAL and PROJ the target and the install rule setup.py (optional) two lines, or absent only if a tool needs it the reference wheel built before anything changed the migration is finished when the new wheel matches it in files, linkage and tags

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.

How Cython sources move from setuptools to a CMake-driven build Under setuptools, cythonize converts pyx files to C during setup and the resulting sources are compiled by the same call. Under CMake, a custom command runs the Cython compiler to generate C into the build directory, and the generated C becomes the source of the extension target. The important consequences are that the generated C is no longer written into the source tree and that the Cython directives must be set explicitly rather than inherited from cythonize defaults. setuptools _ext.pyx in the source tree cythonize() writes _ext.c beside it CMake _ext.pyx unchanged custom command generates into build/ the generated C no longer lands in the source tree, which keeps a checkout clean and the directives are set explicitly rather than inherited from cythonize's defaults set language_level and binding deliberately — Cython 3 changed both and include the generated C in the sdist only if you want a Cython-free source build
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.

Four questions to ask of each legacy conditional Does it still apply, given that toolchain defaults have moved? Does it describe the target rather than the host, which matters under cross-compilation? Is it now expressed better by the config package, which may already carry the flag? And does anyone depend on it, particularly a downstream packager relying on an environment-variable toggle? Each answer either translates the conditional, deletes it, or turns it into a declared build option. does it still apply? toolchain defaults move — a flag added in 2016 may now be the default test without it before keeping it host or target? sys.platform describes the build machine; APPLE and WIN32 describe the target translating this fixes a latent cross-compilation bug already covered? the config package may carry the include path, the flag or the requirement check before restating it does anyone rely on it? an environment toggle usually traces to a packager — make it a declared option

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.