Declaring native build dependencies in pyproject.toml
This page answers one question: build-system.requires can only name Python packages, yet your build needs GDAL headers, PROJ and a C++ compiler — so where does each of those get declared, and how do you make a source build fail with a useful message instead of a compiler error? It sits inside the Mastering pyproject.toml for Spatial Wheels section of the Modern Python Build Tooling & Wheel Configuration reference, and gives you the split, the early check and the documentation that goes with it.
Context & Root Cause
build-system.requires describes an isolated Python environment the backend runs in. It is resolved by an installer that only knows about Python distributions, so there is no syntax for “GDAL 3.8 development headers” — and no mechanism by which one could be installed, since a wheel-based installer has no way to put a C library on the system.
That leaves a gap for a source build. A user running pip install geo-core on an unsupported platform gets the sdist, the backend runs, and the compile fails somewhere inside a header include with a message about gdal.h. Everything worked as designed, and the message is unhelpful. The remedy is not to make the manifest express something it cannot; it is to fail earlier, in the build file, with a message that names the requirement and points at the documentation.
Solution / Fix
This targets scikit-build-core 0.9+, CMake 3.28+, GDAL 3.8.x and PROJ 9.3.x.
1. Put the Python build tools where they belong
[build-system]
requires = [
"scikit-build-core>=0.9",
"Cython>=3.0",
"numpy>=1.23", # for headers; the runtime dependency is separate
]
build-backend = "scikit_build_core.build"
2. State the native requirement in the build file, with a version
find_package(GDAL 3.8 CONFIG QUIET)
if(NOT GDAL_FOUND)
message(FATAL_ERROR
"GDAL 3.8 or newer with development headers is required to build geo-core "
"from source.\n"
" Debian/Ubuntu: apt install libgdal-dev\n"
" conda-forge: conda install -c conda-forge gdal\n"
" Homebrew: brew install gdal\n"
"Prebuilt wheels are available for Linux, macOS and Windows — this error "
"means pip fell back to a source build.")
endif()
3. Say the same thing in the documentation and the metadata
[project]
description = "Geospatial bindings (wheels bundle GDAL; source builds require GDAL >= 3.8)"
[project.urls]
"Build requirements" = "https://example.org/geo-core/building-from-source"
4. Make the source distribution actually buildable
[tool.scikit-build]
sdist.include = ["CMakeLists.txt", "cmake/*.cmake", "src/**", "ci/native-versions.lock"]
An sdist missing the build files cannot be compiled by anyone, which turns a source fallback from inconvenient into impossible.
Verification
# 1. The source build fails early and legibly without GDAL
docker run --rm -v "$PWD:/s" python:3.12-slim bash -c \
"pip install /s 2>&1 | tail -12"
# expected: the FATAL_ERROR message naming GDAL, not a compiler error about gdal.h
# 2. The sdist contains everything a build needs
python -m build --sdist
tar tzf dist/*.tar.gz | grep -E 'CMakeLists.txt|src/.*\.c$' | head
# expected: the build files are present
# 3. It builds where the dependency is present
docker run --rm -v "$PWD:/s" condaforge/miniforge3 bash -c \
"conda install -y -q -c conda-forge gdal proj compilers cmake ninja >/dev/null &&
pip install /s && python -c 'import geo_core; print(\"built from source\")'"
The first check is the one users experience. Running it in CI on every change keeps the message accurate — a requirement that moves from 3.6 to 3.8 without the message following it is worse than no message at all.
Why the Error Message Is the Deliverable
For a package that publishes wheels for the common platforms, a source build only happens when something has already gone slightly wrong: an unsupported platform, an old interpreter, a policy that forbids binary wheels. The person hitting it is therefore already off the happy path, and the quality of the message determines whether they can help themselves.
The last line of the message — that wheels exist and this fallback means one was not matched — is the part most often omitted and the part that resolves the most cases. A user on Python 3.13 the week it is released, or on a platform you do not publish for, is usually better served by learning that than by installing a C toolchain.
Keeping the message honest is a small maintenance obligation. When the minimum GDAL version moves, the message has to move with it, and the CI check that exercises the failure path is what enforces that.
Optional Native Dependencies
Some native dependencies are genuinely optional — a format driver, a compression codec, a networking backend — and expressing that cleanly keeps a source build possible for users who do not need them.
The third row exists because silent omission is the failure mode of optional dependencies. A distribution packager building your sdist without WebP produces a package whose users cannot open WebP-compressed rasters, with nothing anywhere saying so. A build option they can switch on converts that into a check, and exposing the resulting capability set at run time — the same provenance() pattern used for versions — lets a user confirm what they have.
Pitfalls & Alternatives
Putting a native library in build-system.requires. There is sometimes a similarly-named Python package on the index, and requiring it installs something that is not what the build needs. The native requirement belongs in the build file.
Relying on pyproject.toml to document the requirement. Metadata is read by tools, not by people hitting a build error. The message printed at the moment of failure is what gets read.
Using REQUIRED without a message. CMake’s default failure names the package and stops, which is better than a compiler error and much worse than a message naming the install command. The three extra lines are the whole value.
Shipping an sdist that cannot be built. Omitting CMakeLists.txt or a vendored source directory makes the fallback path fail for a reason unrelated to dependencies. Verify by building from the sdist in CI, not from the working tree.
Frequently Asked Questions
Could a future standard express native dependencies?
Proposals exist for describing external dependencies in metadata, and even a complete standard would describe rather than install them — no Python installer can put a C library on a system. The practical value would be tooling that reads the description and tells a user what to install, which is what the error message does today.
Should the minimum native version match the one I vendor?
Not necessarily. The vendored version is what wheels contain; the minimum in the build file is what a source build requires, and it can be lower if your code only uses older APIs. Keeping them close is simpler, and where they differ, say so in the documentation.
What about build dependencies that are Python packages wrapping native code?
They belong in build-system.requires like any Python package, and it is worth checking that a wheel exists for every platform you support — a build requirement that itself needs compiling makes a source build recursive.
How do I handle a dependency needed only on one platform?
Conditionally in the build file, using the build system’s platform predicates rather than sys.platform, so the condition describes the target rather than the machine running the build. That distinction matters as soon as cross-compilation enters the picture.
Is --no-build-isolation a reasonable workaround for users?
For a user who has already installed the build requirements it works, and it is worth documenting for packagers. It is not a substitute for correct declarations, because it just moves responsibility for the Python build environment onto the person running the command.
Should the error message link to documentation?
Yes, and to a page that stays valid. A URL in a build error is one of the few places a user will reliably follow, which makes it worth pointing at a stable page describing the source-build requirements rather than at a section anchor that may move.
Should the check run for wheel builds as well as source builds?
It runs in both, because it is part of the configure step — and in a wheel build it is harmless, since the environment always has GDAL. Its value is entirely in the source-build path, and it costs a millisecond in the path where it never fires.
What if a user has GDAL but not its headers?
That is the common case on distributions that split development files into a separate package, and the message should name it: libgdal-dev, not gdal. A message that names the runtime package sends the user to install something they already have.
Related
- Mastering pyproject.toml for spatial wheels — which table owns which decision.
- Integrating CMake with scikit-build-core — where the native requirement is enforced.
- Migrating legacy setup.py spatial builds — moving these declarations out of an imperative script.