Fixing CMake find_package for PROJ

When building spatial Python extensions, find_package(PROJ) resolution failures are among the most persistent CI/CD blockers. Modern wheel pipelines operating under Modern Python Build Tooling & Wheel Configuration demand deterministic dependency resolution, yet PROJ’s CMake config exports frequently fracture under environment isolation, cross-compilation sysroots, or strict Conda/Pixi activation. This guide provides exact error-to-fix mappings, hardened pyproject.toml configurations, and CI-ready validation steps to restore deterministic builds across Linux, macOS, and Windows runners.

Exact Error Signatures & Root Cause Mapping

CMake emits distinct failure signatures depending on how the build environment leaks or strips PROJ paths. Match the exact log output to the corresponding remediation before modifying build configuration.

Error Signature Root Cause Immediate Fix
CMake Error: Could not find a package configuration file provided by "PROJ" PROJ_DIR/CMAKE_PREFIX_PATH not propagated into the isolated CMake generator context Explicitly pass prefix paths via cmake.define or CMAKE_ARGS in pyproject.toml
Could NOT find PROJ (missing: PROJ_LIBRARY PROJ_INCLUDE_DIR) Fallback to legacy FindPROJ.cmake module mode; pkg-config unavailable or sysroot mismatched Force CONFIG mode resolution and verify PROJConfig.cmake exists in $PREFIX/lib/cmake/proj
Found PROJ 9.1.0 but required 9.2.0 Version constraint mismatch in find_package(PROJ X.Y REQUIRED) or stale cached CMakeCache.txt Update find_package version floor, clear build cache, and enforce PROJ_VERSION check
PROJ_DIR-NOTFOUND Environment variable unset, stripped by CI sandbox, or overwritten by scikit-build-core isolation Declare PROJ_DIR as a build-system dependency hint and map to cmake.define

The core failure vector is path leakage. scikit-build-core executes CMake in a hermetic build directory, stripping host environment variables unless explicitly declared. PROJ’s modern CMake exports require precise prefix resolution, which breaks under manylinux cross-compilation or strict Conda environment activation.

Environment Diagnostics & Prefix Resolution

Before modifying pyproject.toml, validate that the runner exposes PROJ correctly. Run these diagnostics directly in the CI step or local shell:

# 1. Verify PROJ binary and pkg-config metadata
pkg-config --modversion proj 2>/dev/null || echo "pkg-config missing or PROJ not registered"

# 2. Locate CMake config exports
find /opt /usr/local "$CONDA_PREFIX" "$PIXI_PROJECT_ENV" -name "PROJConfig.cmake" 2>/dev/null | head -n 3

# 3. Resolve deterministic prefix for CMake
export PROJ_DIR=$(pkg-config --variable=prefix proj 2>/dev/null || echo "/usr/local")
export CMAKE_PREFIX_PATH="${PROJ_DIR}:${CMAKE_PREFIX_PATH}"

On manylinux/manyarm Docker images, PROJ is typically staged under /usr/local or /opt/_internal/cpython-*/lib. When using Pixi or Conda, activate the environment explicitly before invoking the build step, or pass the resolved prefix directly to the generator:

# Conda/Pixi explicit prefix resolution
export PROJ_DIR="${CONDA_PREFIX:-${PIXI_PROJECT_ENV:-/usr/local}}"
# Verify CMake can see it
cmake -DPROJ_DIR="$PROJ_DIR" --trace-expand -P /dev/null 2>&1 | grep -i proj

Refer to the official PROJ CMake integration reference for version-specific export behavior.

Hardened pyproject.toml Configuration

Modern scikit-build-core requires explicit CMake cache variable mapping to survive isolated build environments. Configure [tool.scikit-build] to forward PROJ paths, enforce CONFIG mode, and enable pkg-config fallbacks. This aligns with the architecture detailed in Integrating CMake with scikit-build-core.

[build-system]
requires = ["scikit-build-core>=0.9.0", "pybind11>=2.10"]
build-backend = "scikit_build_core.build"

[project]
name = "spatial-extension"
version = "1.0.0"
requires-python = ">=3.9"

[tool.scikit-build]
cmake.version = ">=3.25"
cmake.args = ["-DCMAKE_FIND_PACKAGE_PREFER_CONFIG=ON"]
wheel.py-api = "cp39"
wheel.packages = ["src/spatial_extension"]

# CMake cache variables — define the table once (declaring cmake.define both as
# an inline table and as a [section] is invalid TOML). Override per-runner via:
#   pip wheel . --config-settings=cmake.define.PROJ_DIR=/opt/proj
[tool.scikit-build.cmake.define]
PROJ_DIR = "/usr/local"
PROJ_USE_STATIC_LIBS = "OFF"
CMAKE_FIND_DEBUG_MODE = "OFF"

Key configuration notes:

  • cmake.version = ">=3.25" ensures modern FindPackage behavior and CONFIG mode prioritization.
  • CMAKE_FIND_PACKAGE_PREFER_CONFIG=ON makes CMake try CONFIG mode (PROJConfig.cmake) before the legacy FindPROJ.cmake module; to require config mode outright, call find_package(PROJ CONFIG REQUIRED) in CMakeLists.txt.
  • PROJ_DIR defaults to /usr/local and can be overridden per-runner with --config-settings=cmake.define.PROJ_DIR=/path (scikit-build-core also forwards CMAKE_PREFIX_PATH from the environment), guaranteeing deterministic resolution when CI strips variables.
  • Avoid hardcoding absolute paths in CMakeLists.txt. Always consume ${PROJ_DIR} or ${CMAKE_PREFIX_PATH} injected by the build backend.

Cross-Platform CI/CD Validation

After applying the configuration patch, validate linkage and wheel compliance before merging.

Linux (manylinux/manyarm)

# Build wheel
pip wheel . --no-build-isolation -w dist/

# Verify dynamic linkage against PROJ
ldd dist/spatial_extension*.so | grep -i proj

# Enforce manylinux compliance
auditwheel repair dist/*.whl --plat manylinux_2_34_x86_64 -w repaired/

macOS

# Build wheel
pip wheel . --no-build-isolation -w dist/

# Verify dylib linkage and rpath resolution
otool -L dist/spatial_extension*.so | grep -i proj

# Delocate to bundle PROJ if required
delocate-wheel -w repaired/ dist/*.whl -v

Windows

# Build wheel
pip wheel . --no-build-isolation -w dist/

# Verify DLL resolution
dumpbin /DEPENDENTS dist\spatial_extension*.pyd | findstr /i proj

CI Pipeline Integration

Embed these checks in your GitHub Actions or GitLab CI matrix to catch regressions early:

- name: Validate PROJ linkage
  run: |
    pip wheel . --no-build-isolation -w dist/
    pip install --no-index --find-links dist/ spatial-extension
    python -c "import spatial_extension; print('Import successful')"
    python -m pip install auditwheel
    auditwheel show dist/*.whl

Compliance & Deterministic Build Standards

Spatial Python wheels must adhere to PyPA packaging standards and geospatial ecosystem conventions:

  1. PEP 517/518 Compliance: Declare scikit-build-core in [build-system].requires. Never rely on implicit host CMake installations.
  2. Deterministic Builds: Disable CMAKE_FIND_DEBUG_MODE in production. Use --config-settings=cmake.define.PROJ_DIR=/path to override environment variables reproducibly.
  3. Wheel Tagging: Align wheel.py-api with your supported Python minor versions. Use auditwheel/delocate to bundle PROJ only when targeting manylinux/macOS wheels that lack system PROJ.
  4. Version Pinning: Enforce find_package(PROJ 9.2 REQUIRED) in CMakeLists.txt to prevent ABI drift. PROJ’s C API is stable, but internal symbol visibility changes between major releases.
  5. Build Isolation: Respect --no-build-isolation only for local debugging. CI runners should use isolated environments to guarantee reproducible PROJConfig.cmake resolution.

For authoritative guidance on declaring build dependencies and backend isolation, consult the PyPA build backend specifications.

By enforcing explicit prefix mapping, disabling legacy module fallbacks, and validating linkage post-build, you eliminate find_package(PROJ) flakiness and align spatial extension pipelines with enterprise-grade reproducibility standards.