Callbacks

Callbacks let wrapped Fortran call a Python function while a PRIK call is running. They are useful for objective functions, progress hooks, custom transforms, and small pieces of user-defined numerical logic.

Declare the callback shape once with @prototype, then use that prototype name as the type of the procedure argument that accepts the callback.


The Short Version

Native callback argument Prototype spelling Python callable receives
Primitive scalar value, intent(in) dummy value: In(Float64) Independent np.float64 scalar
Primitive scalar reference input value: In(Addr(Float64)) Independent np.float64 scalar
Array input/output reference values: InOut(Float64[n]) NumPy array view
Fixed-length string output reference label: Out(String[8]) Writable rank-zero bytes storage
Derived-type input reference point: In(point_t) Generated wrapper object

Rule of thumb

Primitive callback arguments inside In(...), Out(...), or InOut(...) are native values by default.

Use Addr(T) inside the direction wrapper when a primitive dummy is passed by reference.

Arrays, strings, and derived-type callback arguments already use native storage or wrapper objects, so they do not need Addr(...) for ordinary reference dummies. Use Value(point_t) only for a supported derived-type callback dummy declared with the Fortran value attribute.


What The Callable Sees

Two declarations can appear around callbacks, and they control different calls:

Declaration Controls
@prototype The exact interface through which Fortran calls the callback adapter.
@native_call(...) How Python arguments are passed into the outer wrapped function.

For example, the wrapped function may need @native_call([Addr(Arg(1))]) because its value argument is passed to Fortran by reference:

from prik.contracts import Addr, Arg, Float64, In, native_call, prototype

@prototype
def scalar_callback(value: In(Addr(Float64))) -> Float64: ...

@native_call([Arg(0), Addr(Arg(1))])
def apply(callback: scalar_callback, value: Float64) -> Float64: ...

The two Addr(...) markers belong to different boundaries. The one inside the prototype describes how Fortran calls the callback. The one inside @native_call(...) describes how Python calls the wrapped function.

At runtime, pass an ordinary Python callable:

import numpy as np

api.apply(lambda value: np.float64(3.0 * value), np.float64(2.5))

The lambda receives converted Python objects, not Addr(...) markers.

Optional procedure dummies use the same Python spelling as other optional arguments. Omit the callable or pass None to make Fortran observe present(callback) == .false.:

api.run(np.int32(4))
api.run(np.int32(4), None)
api.run(np.int32(4), report)

Optional dummies inside a callback prototype arrive at the Python callable as None when the native callback invocation omits them. The callable keeps the prototype's full positional argument list, so one callable such as report(value, status=None) handles both call report(value) and call report(value, status).


Small Example

The source, generated contract, and Python call describe the same immediate callback. The result stays visible below the three views.

Fortran source

Create callbacks.f90:

module callbacks_api
  implicit none

  abstract interface
    real(8) function scalar_callback(value) result(output)
      real(8), intent(in) :: value
    end function scalar_callback
  end interface

contains

  real(8) function apply(callback, value) result(output)
    procedure(scalar_callback) :: callback
    real(8), intent(in) :: value
    output = callback(value)
  end function apply

end module callbacks_api

Build it:

python3 -m prik callbacks.f90 --out-dir build/callbacks

Generated Contract

The generated callbacks_api.pyi is:

from prik.contracts import Addr, Arg, Float64, In, native_call, prototype

@prototype
def scalar_callback(
    value: In(Addr(Float64))
) -> Float64: ...

@native_call([Arg(0), Addr(Arg(1))])
def apply(
    callback: scalar_callback,
    value: Float64
) -> Float64: ...

Generate it:

python3 -m prik generate --pyi callbacks.f90

Python Usage

import sys

import numpy as np

sys.path.insert(0, "build/callbacks")
from callbacks.callbacks_api import apply

result = apply(
    lambda value: np.float64(3.0 * value),
    np.float64(2.5)
)
print(result)  # 7.5

Result:

7.5

Choosing The Prototype Spelling

Prototype declarations describe the exact native callback interface. They are not Python runtime functions and they are not exported from the generated module. PRIK lowers each signature to an abstract Fortran interface under a generated prik_ name, then declares the callback adapter with procedure(prik_...).

For ordinary scalar and array callback arguments, use the same contract spellings you use elsewhere:

from prik.contracts import Addr, Float64, In, InOut, Int32, prototype

@prototype
def update_values(
    count: In(Addr(Int32)),
    scale: In(Float64),
    values: InOut(Float64[count])
) -> None: ...

Here count is a primitive reference dummy, while scale is a primitive value dummy. Python receives both as NumPy scalar values.

Direction also controls the adapter copy. In(...) supplies a read-only array view or an immutable string value, Out(...) exposes writable storage without copying an undefined incoming value, and InOut(...) copies the incoming value and writes changes back after the callback. Omitting the wrapper preserves an omitted Fortran intent rather than inventing one.

An assumed-shape callback dummy is spelled Float64[::], and Python receives the extent the native caller passed:

Fortran callback dummy Matching prototype
real(8), intent(in) :: values(count) values: In(Float64[count])
real(8), intent(in) :: values(:) values: In(Float64[::])

For scalar arguments, choose the spelling from the Fortran callback dummy:

Fortran callback dummy Matching prototype
real(8), intent(in) :: value value: In(Addr(Float64))
real(8), value, intent(in) :: value value: In(Float64)

Both forms call Python with an independent np.float64 scalar. The difference is the native calling convention PRIK must match.

A dummy the callee may write is different: PRIK generates rank-zero storage for it, because Python has no writable scalar. A dummy with no declared intent counts here — Fortran lets the callee both read and modify it, so PRIK is conservative and the contract records the missing direction by carrying no wrapper:

Fortran callback dummy Generated prototype Callback may
real(8), intent(in) :: f f: In(Addr(Float64)) read
real(8), intent(out) :: f f: Out(Float64[()]) write
real(8), intent(inout) :: f f: InOut(Float64[()]) read and write
real(8) :: f f: Float64[()] read and write

Pass --assume-intent-in-scalars to treat an undeclared scalar as input-only instead; the dummy still records no direction, it simply stops being writable.

Python receives a rank-zero NumPy view of the native storage. Assign through it; rebinding the name changes nothing the native caller will read:

def objective(x, f):
    f[...] = float(np.sum(x * x))   # delivers the value
    f = float(np.sum(x * x))        # rebinds a local name; the caller sees nothing

To keep an ordinary Python function, write a small adapter and pass that:

def objective(x):
    return float(np.sum(x * x))

def objective_prik(x, f):
    f[...] = objective(x)

A prototype keeps the native callback's argument list, so the Python callable mirrors the Fortran interface. To call a return-style function instead, edit the prototype to project the output:

@prototype
@native_call([Arg(0), Return("f", 0)])
def OBJ(x: In(Float64[::])) -> Float64: ...

Value(T) is only for supported non-primitive scalar value dummies, such as a derived-type callback dummy declared with the Fortran value attribute.


Key Rules

  • The callback is only valid during the wrapped native call.
  • Native code must not store the callback for later use.
  • Return the exact NumPy scalar type when PRIK expects a scalar callback result.
  • Primitive scalar callback arguments projected as values arrive as independent NumPy scalar values, whether the native dummy is value or reference. Writable reference scalars — out, inout, or no declared intent — arrive as rank-zero storage you assign through.
  • Arrays and derived-type arguments can expose live native state; copy data you need after the wrapped call returns.

Important Limitations

Supported callbacks are immediate, same-thread adapters. The native routine may call the Python callable while the wrapped call is active, and PRIK tears down the callback context when that wrapped call returns.

The current callback contract does not support:

  • Stored callbacks, persistent callbacks, procedure pointers, or callbacks invoked after the wrapped call returns. Pass the callable into each wrapped call that needs it.
  • Pure callback prototypes. A Python callback adapter calls the Python runtime, so it cannot satisfy a pure Fortran procedure contract. In particular, one pure prototype cannot be used both as a callback annotation and as a called specification function in an array extent.
  • Allocatable, pointer, polymorphic, or assumed-type callback arguments and results. Use plain scalars, fixed-shape primitive arrays, fixed-length strings, or supported scalar derived types.
  • Arrays passed by Fortran value, arrays of derived values, and array callback results without a complete fixed shape. Pass arrays by reference and give array results an exact primitive shape; an array argument may be assumed-shape.
  • Optional callback dummies passed by value. Use a reference dummy so absence has a C-interoperable null-pointer representation.
  • Variable-length callback strings. Use a fixed positive String[n] length.
  • Callback execution on a different Python thread. The callback must run on the same thread that entered the wrapper.

Callback exceptions and invalid return conversions are fatal at the callback boundary: PRIK prints the Python traceback and aborts the host process.


Next