Interfacing Python code with low-level code#
Introduced in Pyccel v2.1.
Pyccel now supports describing libraries written in low-level languages, directly using small .pyi
interface files. These files act as a contract: they declare the available functions, methods, and types, along with precise low-level signatures.
This gives you two benefits at once:
The
pyccel-wrap
command allows you to call the compiled code directly from Python.Pyccel understands those calls, so when you translate Python to C/Fortran code using Pyccel, the calls are re-emitted as direct calls to the original routines.
In practice, the .pyi
file creates a two-way bridge between Python and low-level code: easy to call from Python, and still translatable by Pyccel.
Contents#
Typical workflow (at a glance)#
Write/compile your C/Fortran library.
Write a
.pyi
stub with precise types and minimal build metadata.Wrap the library with a Python interface using
pyccel-wrap
to make it callable from Python.Develop in Python using the interface to the low-level code.
Run Pyccel to translate the new Python code back to C/Fortran code.
This preserves a clean Python API while enabling Pyccel to treat your calls as first-class, translatable operations.
Writing the stub file#
The stub file (.pyi
) describes the low-level library in a way that is understandable in a Python environment. It follows the same conventions as standard Python stub files (see the Python documentation), with a few additional rules specific to Pyccel. These additional rules mean that Pyccel cannot create an interface for every C/Fortran function.
In addition to the general rules above there are more specific rules for Fortran and C which are detailed later. The following are the general language-agnostic rules:
At the top of the file, you must include some metadata about the compilation
You must ensure that all functions, methods, and classes have precise type annotations that match the low-level signatures.
Particular care should be taken with integers. On most platforms Python’s default integer precision is equivalent to numpy.int64
, while the default integer precision in low-level languages like C and Fortran is usually equivalent to numpy.int32
.
Function argument names are expected to match the name of the argument in the low-level language (for languages such as Fortran, where such information can be used). If the function argument names are unknown please use positional-only arguments:
def f(a: int, b: float, /): ...
Name mapping with @low_level
#
You can use the @low_level
decorator to explicitly map a Python name to its low-level implementation name. This is not strictly required, but it is recommended to avoid surprises as Pyccel can rename symbols internally (e.g. to avoid collisions). Such collisions are rare in stub files but using the decorator removes any possible ambiguity.
This decorator also allows you to rename functions and classes. For example functions can be mapped to names which are meaningful in Python such as magic method names (e.g. __add__
).
Another common use case is for functions that accept different types of arguments. In Python stub files such functions are annotated with the @overload
decorator. Such functions may map to different low-level functions.
For example:
from typing import overload
import numpy as np
from pyccel.decorators import low_level
@low_level('ffunc')
@overload
def func(a : np.float32): ...
@low_level('dfunc')
@overload
def func(a : np.float64): ...
@low_level('ifunc')
@overload
def func(a : np.int32): ...
Compilation metadata#
Pyccel requires some information about the underlying low-level code in order to be able to compile the generated wrapper. This information takes the form of metadata which is placed in comments, usually at the top of the file. The syntax for such metadata is:
#$ header metavar key=val
Possible keys are:
includes
: Describes the include directories that must be passed to the compiler with the-I
flag. This should be a string, folders are separated by commas.libdirs
: Describes the library directories that must be passed to the compiler with the-L
flag. This should be a string, folders are separated by commas.libraries
: Describes the libraries which should be passed to the compiler with the-l
flag. This should be a string, libraries are separated by commas.flags
: Describes any additional compiler flags. This should be a string, options commas.ignore_at_import
: Indicates that the library doesn’t need to be imported (e.g. via ause
statement in Fortran) in order to be used. This should be a boolean.
Fortran specific rules#
Functions#
Python functions with a single non-array result, match Fortran functions.
Python functions with no results or multiple results, match Fortran subroutines.
Argument names must match unless they are positional-only.
Unless positional-only arguments are used, the Fortran printer prints the name of the argument being called. As a result, it is important that the argument names in the stub file match the argument names in the original code.
Multiple returns are interpreted as multiple
intent(out)
arguments. These are always the first arguments of the Fortran function and are not called by name.Returning arrays is not currently recommended as the support is still quite restrictive.
Array returns are interpreted as an
intent(out)
argument. If code calling such a method is translated, it is assumed that the array will be an allocatable and will be allocated with base-0 indexing. This output will be the first argument of the Fortran function. It will not be called by name.Lists, sets, and dictionaries are mapped to gFTL objects in the Fortran code.
The name of the Python file must match the name of the module that should be imported to use this method.
If there is no module to import (e.g. because the code is older than Fortran 90), this must be indicated with the appropriate metavariable:
#$ header metavar ignore_at_import=True
Classes#
The name stated in the
@low_level
decorator is the name of the type-bound procedure.
C specific rules#
Multiple returns are interpreted as multiple
intent(out)
arguments. These are always the first arguments of the Fortran function and are not called by name.Arrays are mapped to instances of STC’s
cspan
class as described in the documentation.Lists, sets, and dictionaries are mapped to STC objects in the C code.
The name of the Python file must match the name of the header file that should be imported to use this method.
If there is no header to include, this must be indicated with the appropriate metavariable:
#$ header metavar ignore_at_import=True
Fortran Example#
Suppose we have the following Fortran code that we want to be able to call from Python:
module class_property
use, intrinsic :: iso_c_binding, only : i64 => C_INT64_T, f64 => C_DOUBLE
implicit none
type :: Counter
integer(i64) :: ncounters
integer(i64), allocatable :: private_counters
contains
procedure :: create => counter_create
procedure :: free => counter_free
procedure :: increment_n => counter_increment_n
procedure :: n_nonzero => counter_n_nonzero
generic, public :: display => counter_display_element, counter_display_scaled
procedure :: counter_display_element, counter_display_scaled
end type Counter
contains
subroutine counter_create(this, ncounters)
class(Counter), intent(inout) :: this
integer(i64), intent(in) :: ncounters
this%ncounters = ncounters
allocate(this%private_counters(ncounters))
this%private_counters(:) = 0
end subroutine counter_create
subroutine counter_free(this)
class(Counter), intent(inout) :: this
deallocate(this%private_counters)
end subroutine counter_free
subroutine counter_increment_n(this, n)
class(Counter), intent(inout) :: this
integer(i64) :: n
this%private_counters(n) = this%private_counters(n) + 1
end subroutine counter_increment_n
subroutine counter_display_element(this, n)
class(Counter), intent(in) :: this
integer(i64), value :: n
print *, "Counter value:", this%private_counters(n)
end subroutine counter_display_element
subroutine counter_display_scaled(this, scale)
class(Counter), intent(in) :: this
real(f64), value :: scale
integer :: i
do i = 1, n
print *, "Counter value (scaled):", real(this%private_counters(i), f64) * scale
end do
end subroutine counter_display_scaled
function counter_n_nonzero(this) result(v)
class(Counter), intent(in) :: this
integer(i64) :: v
integer :: i
v = 0
do i = 1, n
v = v + this%private_counters(i)
end do
end function counter_n_nonzero
end module class_property
supposing the file is compiled to a library libclass_property.so
, we can describe this code with the following stub file:
#$ header metavar libraries="class_property"
#$ header metavar libdirs="."
from typing import overload
from pyccel.decorators import low_level
class Counter:
ncounters : np.int64
@low_level('create')
def __init__(self, ncounters: int) -> None: ...
@low_level('free')
def __del__(self) -> None: ...
@low_level('increment_n')
def __iadd__(self, n : int) -> None: ...
@property
def n_nonzero(self) -> int: ...
@low_level("counter_display_element")
@overload
def display(self, n: int) -> None: ...
@low_level("counter_display_scaled")
@overload
def display(self, scale: float) -> None: ...
We then run pyccel-wrap
pyccel-wrap class_property.pyi
This generates a file class_property.cpython-313-x86_64-linux-gnu.so
which is directly usable from Python.
More examples can be found in the tests. The stub files in this folder assume that the .mod
files were saved into the sub-folder __pyccel__mod__
.
C Example#
Suppose we have the following C code that we want to be able to call from Python:
struct mod__Counter {
int64_t* private_counter_arr;
int64_t value;
};
void mod__Counter__create(struct mod__Counter*, int64_t);
void mod__Counter__free(struct mod__Counter*);
void mod__Counter__increment(struct mod__Counter*, int64_t);
int64_t mod__Counter__n_nonzero(struct mod__Counter*);
void mod__Counter__display_element(struct mod__Counter*, int64_t);
void mod__Counter__display_scaled(struct mod__Counter*, double);
supposing the file is compiled to a library libclass_property.so
, we can describe this code with the following stub file:
#$ header metavar libraries="class_property"
#$ header metavar libdirs="."
from typing import overload
from pyccel.decorators import low_level
@low_level('Counter')
class Counter:
@low_level('mod__Counter__create')
def __init__(self, start: int) -> None: ...
@low_level('mod__Counter__free')
def __del__(self) -> None: ...
@low_level('mod__Counter__increment')
def __iadd__(self, n : int) -> None: ...
@low_level('mod__Counter__n_nonzero')
@property
def n_nonzero(self) -> int: ...
@low_level("mod__Counter__display_element")
@overload
def display(self, n: int) -> None: ...
@low_level("mod__Counter__display_scaled")
@overload
def display(self, scale: float) -> None: ...
We then run pyccel-wrap
pyccel-wrap class_property.pyi
This generates a file class_property.cpython-313-x86_64-linux-gnu.so
which is directly usable from Python.
More examples can be found in the tests.