2025, Nov 27 05:00

How to build f2py Fortran extensions with scikit-build-core so .pyf interfaces shape Python signatures

Fix f2py builds that ignore .pyf: use scikit-build-core to run numpy.f2py -c in CMake, add meson, and generate correct Python signatures for Fortran code.

Switching a f2py-based Fortran extension from legacy numpy.distutils to scikit-build-core can surface an odd failure mode: the module builds and imports, but the Python signatures ignore the .pyf interface file. That is exactly what happens when f2py is not invoked in a way that applies the .pyf directives. Below is a minimal, reproducible path from the broken behavior to a working scikit-build-core setup.

The symptom

The extension builds and drops geopack_tsyganenko.cpython-313-darwin.so into site-packages. Import works, but function signatures expose intent incorrectly. Instead of reporting output variables as return values, they show up as inputs, which implies the .pyf interface was not actually honored during the build.

The failing CMake shape

The following CMake layout runs numpy.f2py to generate sources, then compiles them with python_add_library. The resulting module imports, but the .pyf semantics are not reflected in the exported signatures:

cmake_minimum_required(VERSION 3.17.2...3.29)
project(${SKBUILD_PROJECT_NAME} LANGUAGES C Fortran)

find_package(
  Python
  COMPONENTS Interpreter Development.Module NumPy
  REQUIRED)

set(CMAKE_VERBOSE_MAKEFILE ON)
set(CMAKE_Fortran_FLAGS "-w")

execute_process(
  COMMAND "${PYTHON_EXECUTABLE}" -c
          "import numpy.f2py; print(numpy.f2py.get_include())"
  OUTPUT_VARIABLE F2PY_INC_DIR
  OUTPUT_STRIP_TRAILING_WHITESPACE)

add_library(f2py_obj OBJECT "${F2PY_INC_DIR}/fortranobject.c")
target_link_libraries(f2py_obj PUBLIC Python::NumPy)
target_include_directories(f2py_obj PUBLIC "${F2PY_INC_DIR}")
set_property(TARGET f2py_obj PROPERTY POSITION_INDEPENDENT_CODE ON)

set(PYF_PATH ${CMAKE_CURRENT_SOURCE_DIR}/src/tsyganenko/geopack.pyf)
set(SRC_GEOPACK ${CMAKE_CURRENT_SOURCE_DIR}/src/tsyganenko/geopack.f)
set(SRC_T96 ${CMAKE_CURRENT_SOURCE_DIR}/src/tsyganenko/T96.f)
set(SRC_T02 ${CMAKE_CURRENT_SOURCE_DIR}/src/tsyganenko/T02.f)
set(PY_MOD_NAME geopack_tsyganenko)
set(GEN_C ${CMAKE_CURRENT_BINARY_DIR}/${PY_MOD_NAME}module.c)
set(GEN_WRAPS ${CMAKE_CURRENT_BINARY_DIR}/${PY_MOD_NAME}-f2pywrappers.f)

add_custom_command(
  OUTPUT ${GEN_C} ${GEN_WRAPS}
  DEPENDS
    ${PYF_PATH}
    ${SRC_GEOPACK}
    ${SRC_T96}
    ${SRC_T02}
  VERBATIM
  COMMAND "${PYTHON_EXECUTABLE}" -m numpy.f2py
    "${PYF_PATH}"
    "${SRC_GEOPACK}"
    "${SRC_T96}"
    "${SRC_T02}"
    -m ${PY_MOD_NAME}
    --lower
)

python_add_library(
  geopack_tsyganenko MODULE "${GEN_C}"
  "${GEN_WRAPS}"
  "${PYF_PATH}"
  "${SRC_GEOPACK}"
  "${SRC_T96}"
  "${SRC_T02}"
  WITH_SOABI)

target_link_libraries(${PY_MOD_NAME} PRIVATE f2py_obj)

install(TARGETS ${PY_MOD_NAME} DESTINATION .)

Why this fails to reflect the .pyf

The behavior demonstrates that the build did not apply the .pyf interface during the compilation step that produced the extension module. Calling numpy.f2py to generate intermediate sources and then compiling them separately resulted in a module whose Python-level signatures did not match the .pyf intent. Running f2py directly with -c on the same inputs, on the other hand, produced the expected result, which confirms the difference comes from how the build invokes f2py.

The working approach

Invoking numpy.f2py with -c inside CMake aligns the build with the command-line invocation that yields correct signatures. The CMake target is expressed as a custom command that produces the final shared object, with a thin target to make it part of the default build and an install rule that ships the produced .so:

cmake_minimum_required(VERSION 3.17.2...3.29)
project(${SKBUILD_PROJECT_NAME} LANGUAGES C Fortran)

find_package(
  Python
  COMPONENTS Interpreter Development.Module NumPy
  REQUIRED)

execute_process(
  COMMAND "${PYTHON_EXECUTABLE}" -c
          "import numpy.f2py; print(numpy.f2py.get_include())"
  OUTPUT_VARIABLE F2PY_INC_DIR
  OUTPUT_STRIP_TRAILING_WHITESPACE)

add_library(f2py_obj OBJECT "${F2PY_INC_DIR}/fortranobject.c")
target_link_libraries(f2py_obj PUBLIC Python::NumPy)
target_include_directories(f2py_obj PUBLIC "${F2PY_INC_DIR}")
set_property(TARGET f2py_obj PROPERTY POSITION_INDEPENDENT_CODE ON)

set(FFLAGS "-w -O2 -fbacktrace -fno-automatic -fPIC")
set(PYF_FILEPATH ${CMAKE_CURRENT_SOURCE_DIR}/src/geopack_tsyganenko/Geopack.pyf)
set(SRC_GEOPACK ${CMAKE_CURRENT_SOURCE_DIR}/src/geopack_tsyganenko/Geopack.for)
set(SRC_T96_FOR ${CMAKE_CURRENT_SOURCE_DIR}/src/geopack_tsyganenko/T96.for)
set(SRC_T02_FOR ${CMAKE_CURRENT_SOURCE_DIR}/src/geopack_tsyganenko/T01_01c.for)
set(PY_MOD_NAME geopack_tsyganenko)
set(BUILT_SO ${CMAKE_CURRENT_BINARY_DIR}/${PY_MOD_NAME}${CMAKE_SHARED_MODULE_SUFFIX})

add_custom_command(
  OUTPUT ${BUILT_SO}
  DEPENDS
    ${PYF_FILEPATH}
    ${SRC_GEOPACK}
    ${SRC_T96_FOR}
    ${SRC_T02_FOR}
  VERBATIM
  COMMAND "${PYTHON_EXECUTABLE}" -m numpy.f2py -c --f77flags="${FFLAGS}"
    "${PYF_FILEPATH}"
    "${SRC_GEOPACK}"
    "${SRC_T96_FOR}"
    "${SRC_T02_FOR}"
    -m ${PY_MOD_NAME}
    --lower
)

add_custom_target(${PY_MOD_NAME}_build ALL
  DEPENDS ${BUILT_SO}
)

install(DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}/ DESTINATION .
        FILES_MATCHING PATTERN "${PY_MOD_NAME}*${CMAKE_SHARED_MODULE_SUFFIX}")

In addition to the CMake change, updating the build-system requirements made the build complete cleanly. Adding meson alongside scikit-build-core and numpy in pyproject.toml resolved the trouble:

[build-system]
requires = ["scikit-build-core", "meson", "numpy"]
build-backend = "scikit_build_core.build"

[project]
name = "tsyganenko"
version = "2020.2.0"
dependencies = [
    "matplotlib",
    "numpy",
    "pandas"
]
requires-python = ">=3.12"
authors = [
    {name = "John C Coxon", email = "work@johncoxon.co.uk"},
    {name = "Sebastien de Larquier"}
]
description = "A Python wrapper for N A Tsyganenko’s field-line tracing routines."
readme = "README.md"
license = "MIT"
license-files = ["LICENCE.txt"]
keywords = [
    "magnetic field",
    "magnetosphere"
]
classifiers = ["Development Status :: 4 - Beta",
               "Intended Audience :: Science/Research",
               "Natural Language :: English",
               "Programming Language :: Python :: 3",
               "Topic :: Scientific/Engineering :: Physics"
]

[project.optional-dependencies]
notebook = ["jupyter"]

[project.urls]
Geopack = "https://geo.phys.spbu.ru/~tsyganenko/empirical-models/"
doi = "https://doi.org/10.5281/zenodo.3937276"
repository = "https://github.com/johncoxon/tsyganenko"

[tool.scikit-build]
ninja.version = ">=1.10"
cmake.version = ">=3.17.2"

[tool.setuptools.packages.find]
where = ["src"]

Why it is worth knowing

For f2py-based extensions, the .pyf file defines the Python-facing contract. If the build does not apply that file at the step that actually creates the extension module, you can end up with a shared object that imports fine but exports the wrong interface. Explicitly driving numpy.f2py -c inside CMake reproduces the exact behavior of a known-good manual invocation and ensures the .pyf directives govern the outcome.

Conclusion

When migrating a Fortran extension to scikit-build-core, make sure the compilation path uses numpy.f2py -c with your .pyf and sources, and wire that into CMake as the producer of the final shared object. If the build stalls or fails to complete, adding meson to build-system requirements can unblock the process. With those pieces in place, the exported Python signatures match the .pyf specification, and the module behaves as intended.