Skip to content

Bug: --direct-c passes 0 instead of absent pointer for optional scalar arguments #369

@GeorgGrassler

Description

@GeorgGrassler

Summary

When using f90wrap --direct-c, calling a wrapped Fortran subroutine with None
for an OPTIONAL scalar argument causes present(arg) to return .true. with
value 0 inside Fortran, instead of .false.. This breaks any code that tests
present() to decide whether to use a default value.

The plain f90wrap + f2py-f90wrap path handles this correctly.


Minimal reproducible example

Four files are needed. The Makefile has two targets that demonstrate both paths:

  • make test-cmake — builds via f90wrap --direct-c + CMake; bug present
  • make test-f2py — builds via plain f90wrap + f2py-f90wrap; works correctly

mymod.f90

module mymod
    implicit none
contains
    subroutine init(x, n)
        real, intent(out) :: x
        integer, intent(in), optional :: n
        integer, parameter :: n_default = 42
        if (.not. present(n)) then
            x = real(n_default)
        elseif (n >= 1) then
            x = real(n)
        else
            print *, "Error: n must be >= 1"
            print *, "n = ", n
            error stop
        end if
    end subroutine
end module

test.py

import mymod

print("Calling explicit n:")
x2 = mymod.mymod.init(n=5)
print(f"Result with n=5: {x2}")  # expected: 5.0

# Should use default (n=42) — but crashes with "Error: n must be >= 1"
print("Calling without explicit n:")
x = mymod.mymod.init()
print(f"Result: {x}")  # expected: 42.0

CMakeLists.txt (used by make test-cmake to build the direct-C wrapper)

cmake_minimum_required(VERSION 3.18)
project(optional_bug_example Fortran C)

find_package(Python3 REQUIRED COMPONENTS Interpreter Development.Module NumPy)
find_program(F90WRAP_EXECUTABLE f90wrap REQUIRED)

set(SRC ${CMAKE_CURRENT_SOURCE_DIR}/mymod.f90)

add_custom_command(
    OUTPUT
        ${CMAKE_CURRENT_BINARY_DIR}/f90wrap_mymod.f90
        ${CMAKE_CURRENT_BINARY_DIR}/_mymod.c
    COMMAND ${F90WRAP_EXECUTABLE} --direct-c -m mymod ${SRC}
    DEPENDS ${SRC}
    WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}
    COMMENT "Generating f90wrap direct-C bindings"
    VERBATIM
)

Python3_add_library(_mymod MODULE WITH_SOABI
    ${SRC}
    ${CMAKE_CURRENT_BINARY_DIR}/f90wrap_mymod.f90
    ${CMAKE_CURRENT_BINARY_DIR}/_mymod.c
)

target_include_directories(_mymod PRIVATE ${Python3_NumPy_INCLUDE_DIRS})
set_target_properties(_mymod PROPERTIES INSTALL_RPATH_USE_LINK_PATH TRUE)

Makefile

BUILDDIR_CMAKE := build_cmake
BUILDDIR_F2PY  := build_f2py

.PHONY: all build-cmake build-f2py test-cmake test-f2py test clean

all: test-cmake

# direct-C build via CMake (bug IS present)
build-cmake:
	cmake -B $(BUILDDIR_CMAKE) -S . -DCMAKE_BUILD_TYPE=Debug
	cmake --build $(BUILDDIR_CMAKE)
	cp $(BUILDDIR_CMAKE)/_mymod.cpython-*.so .
	cp $(BUILDDIR_CMAKE)/mymod.py .

test-cmake: build-cmake
	@echo "=== direct-C path (bug expected) ==="
	python test.py; true

# f2py build (bug is NOT present)
build-f2py:
	mkdir -p $(BUILDDIR_F2PY)
	cd $(BUILDDIR_F2PY) && f90wrap -m mymod ../mymod.f90
	cd $(BUILDDIR_F2PY) && f2py-f90wrap -c -m _mymod f90wrap_mymod.f90 ../mymod.f90
	cp $(BUILDDIR_F2PY)/_mymod.cpython-*.so .
	cp $(BUILDDIR_F2PY)/mymod.py .

test-f2py: build-f2py
	@echo "=== f2py path (no bug expected) ==="
	python test.py

test: test-cmake test-f2py

clean:
	rm -f _mymod.cpython-*.so _mymod.so f90wrap_mymod.f90 _mymod.c *.o *.mod mymod.py
	rm -rf $(BUILDDIR_CMAKE) $(BUILDDIR_F2PY)

make test output:

=== direct-C path (bug expected) ===

Calling explicit n:
Result with n=5: 5.0
Calling without explicit n:
 Error: n must be >= 1
 n =            0
ERROR STOP

=== f2py path (no bug expected) ===

Calling explicit n:
Result with n=5: 5.0
Calling without explicit n:
Result: 42.0

The direct-C path receives n = 0 and present(n) returns .true., while the
f2py path correctly receives a NULL pointer so present(n) returns .false..


Likely cause

f90wrap --direct-c generates a C wrapper (_mymod.c) where the None case for
an optional argument sets the value to 0 but leaves the pointer non-NULL:

/* generated by f90wrap --direct-c (buggy) */
int* n = &n_val;
if (py_n == Py_None) {
    n_val = 0;       /* pointer stays &n_val — Fortran sees present(n) == .true. */
} else {
    ...
}
F90WRAP_F_SYMBOL(f90wrap_mymod__init)(&x_val, n);

The Fortran binding wrapper generated by f90wrapgen.py declares n as
optional, so passing a NULL pointer would make present(n) return .false.
but the C layer never passes NULL.

This is in f90wrap/directc_cgen/arguments_scalar.py,
prepare_scalar_argument. Compare with the f2py path in
f90wrap/scripts/f2py_f90wrap.py which correctly uses:

'#varname#_capi == Py_None ? NULL : &#varname#,'

Potential fix

In prepare_scalar_argument, set the pointer to NULL instead of the value to
0 when None is received:

# f90wrap/directc_cgen/arguments_scalar.py

    if optional:
        gen.write(f"if (py_{arg.name} == Py_None) {{")
        gen.indent()
-       gen.write(f"{arg.name}_val = 0;")
+       gen.write(f"{arg.name} = NULL;")   # NULL → present() returns .false.
        gen.dedent()
        gen.write("} else {")

Environment

  • f90wrap version: 0.3.0
  • Python version: 3.13.5
  • Compiler: GNU Fortran (Debian 14.2.0-19) 14.2.0 / GCC 14.2.0

Sidenote

I have attached a zip of the above example.

optional_bug_example.zip

As promised @krystophny, I would do my best in finding bugs for you.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions