A while ago, I set myself the challenge of creating Python bindings for a Fortran package. The main goal was to learn how to do it, but I ended up having to learn far more than I had initially planned.
Once that was done, I decided I should also learn how to make Julia bindings. Again, I was forced to dig deeper than expected — but in a good way. It made me revisit and improve the Python bindings.
Along the way, I realized there aren’t many projects out there that you can straightforwardly copy from. I hope these projects can be a small contribution toward filling that gap:
- Fortran source: odrpack
- Python bindings: odrpack-python
- Julia bindings: Odrpack.jl
The order of steps for creating bindings is slightly different between Python and Julia.
Python bindings
- Create a C API for the functions you want to wrap, along with the corresponding header files.
- Implement the bindings using a suitable framework (ctypes, cffi, pybind11, nanobind, etc.).
- Build wheels for all (or most) OS and architecture combinations.
- Publish to PyPI.
Julia bindings
- Create a C API for the functions you want to wrap; header files are optional.
- Create an
X_jllpackage (a Julia “binary wrapper”) for all (or most) OS and architecture combinations. - Write the bindings in pure Julia, invoking the
X_jllpackage. - Publish to the Julia General Registry.
The first step (creating the C API) is essentially the same for both Python and Julia. In absolute terms it’s not complicated, but there are many small details (skill issues included…) that can eat up a lot of time. More than once I found myself wishing that the Fortran Best Practices guide had a section on this topic; in my opinion, it’s something best learned by studying well-crafted examples.
When it comes to writing the bindings themselves, the experience in Python and Julia differs greatly. In Julia, there is essentially one standard way to do it: no ambiguity. In contrast, the multitude of Python binding frameworks is dizzying — and to make matters worse, writing the bindings and building the wheels must be considered together. It’s easy to end up with something that works perfectly on a given OS/architecture in your local development setup but refuses to run inside the (cloud) wheel builders. This is the main reason I initially chose pybind11 (and later switched to nanobind): I was confident they could be made to work in wheel builders.
Callbacks and Closures
What I struggled with most were callbacks and closures. Wrapping a function that takes another function as an argument requires special care:
-
The Fortran code cannot contain closures involving the callback.
With GCC, this results in a trampoline (executable stack), which causes all sorts of trouble. (No, special trampoline flags will not help.) Binaries (wheels orX_jll) cannot be generated for musllinux. Even worse, in Julia you get a stack overflow error when the code is executed from the REPL — but not when run from a “new process.” (It’s as crazy as it sounds.)- For example, this “clever” idea to convert a C callback with explicit-shape arrays into a modern Fortran callback with assumed-shape arrays runs into the abovementioned issues.
-
The Julia code cannot have closures on the C callback that will be passed into the Fortran library, because Julia’s
cFunctiondoes not support closures on macOS + ARM.- In practice, this means the API of the Fortran library must be (re)designed to allow passing arbitrary data/context into the callback via a
void*(i.e.,type(c_ptr)) argument. This isn’t strictly necessary for Python bindings, but having it does make the code cleaner.
- In practice, this means the API of the Fortran library must be (re)designed to allow passing arbitrary data/context into the callback via a