nativegate
← Blog

How to wrap legacy Fortran with F2PY

If you're wrapping Fortran with f2py by hand — a reasonable decision, as established in our evaluations — this walkthrough covers the actual sequence for legacy code specifically, where the problems live. For a library you'd write today (free-form, module-based, explicit intents), the f2py documentation's own quickstart is better than anything we could paraphrase. This post is about machines that predate those assumptions.

Step 0: scope what you're wrapping

On a multi-hundred-deck legacy model, wrap a chosen surface: the routines a caller would actually want, and everything they transitively call. Establish that list before generating a single signature line.

Step 1: reconcile the source the compiler will see

Fixed-form F77 decks are typically composed with INCLUDE statements and macros, so the file f2py parses is not the file the compiler compiles. Expand the INCLUDEs into a generated copy (never edit originals), resolve parameterized kind aliases (real(dp) with a local integer, parameter :: dp will otherwise fail f2py's wrapper in ways gfortran reports far from the cause), and strip column-1 comment conventions your shop added.

Step 2: decide directions you can't infer safely

Old code rarely declares intent. For each argument of each exposed routine, decide: input-only, output-only, or in-place? Record that decision either as f2py directives in the source copy, or in a side manifest if you want the source untouched. The silent-integer-string case is the classic: an assumed-length CHARACTER*(*) argument can fall through to implicit integer typing and bind as an int — a binding that compiles and then hands a pointer where a string descriptor is expected.

Step 3: generate and build

f2py on the expanded copy
$ f2py -c _expanded/model.f90 -m model

Expect the build to name errors far from their causes when something in step 1 was wrong — the resolution is almost always back in the expansion, not the generated wrapper.

Step 4: test against known answers, immediately

As soon as the module imports, capture the outputs of known configurations and commit them. Every change after this — regenerating the wrapper, upgrading gfortran, changing flags — is measured against that baseline. Without it, step 5 is unfalsifiable.

Step 5: everything downstream

Packaging (scikit-build-core, wheel locks, CI builds), concurrency policy for COMMON-block state, and — if other teams will consume the model — HTTP service, container, manifests. Those are real weeks on your calendar, and they're the part nativegate generates so that days 2–30 don't repeat day 1.

The walkthrough makes the honest point explicitly: wrapping via f2py is a fine one-time exercise. The modernization is the work after the module compiles; generating that work is the tool.

Where nativegate fits → View on GitHub