Summary
turf-python is a tool and a Turf package in their own repository. It works in two directions:
- Python imports Turf. From a Turf library built by
turf build --lib(TDP-2-030),turf-python buildgenerates a CPython extension module and a wheel. A function marked@exportbecomes a Python function, an error becomes an exception, a handle becomes a Python object, and a buffer such as a numpy array is passed without a copy.turf-pythonis also a PEP 517 build backend, sopip install .builds a Turf project the way maturin builds a Rust one. - Turf calls Python. A
pythonpackage wraps the CPython API using TDP-2-029’s contracts, so a Turf program can import a Python module and call it.
The compiler knows nothing about Python. The tool reads only the export description and the built library. It ships after the compiler’s 2.9.0, at its own pace.
Motivation
A fast core imported from Python is how much of Python’s speed reaches its users: numpy, Polars and Pydantic’s core are written in C, C++ and Rust. Turf can be that core language with memory safety by default and no ownership rules to learn. The reverse direction matters too. Python’s libraries for data, science and machine learning are reachable from almost any language that embeds CPython, and a Turf program should be one of them.
Design
Python imports Turf
A Turf package:
// src/lib.tr in package `fastsum`
/// The sum of every element.
@export
pub fn total(xs: Slice<double>): double {
mut sum: double = 0.0;
for x in xs { sum += x; }
return sum;
}
/// Parses `key=value` lines.
@export
pub fn parse(text: string): !&Config error { BadLine } { ... }
# pyproject.toml
[build-system]
requires = ["turf-python>=1.0"]
build-backend = "turf_python.backend"
import fastsum, numpy as np
fastsum.total(np.arange(5.0)) # 10.0; a contiguous, aligned float64 array is not copied
cfg = fastsum.parse("a=1\nb=2") # a fastsum.Config object
try:
fastsum.parse("nonsense")
except fastsum.BadLine as e: # a subclass of fastsum.Error
print(e)
Conversions, read from the export description:
| Turf | Python | At the boundary |
|---|---|---|
int, int32 |
int |
OverflowError if it does not fit |
double |
float |
|
bool |
bool |
|
char |
int 0–255, or a one-byte bytes |
|
string |
str (UTF-8) |
Copied |
Slice<T> |
any object with the buffer protocol and a matching item format (bytes, bytearray, array.array, memoryview, numpy arrays) |
Not copied when the buffer meets the contract below; otherwise refused or copied, never silently reinterpreted |
&T handle |
an instance of a generated class T |
Released when Python frees the object |
!T error |
an exception class per error, under one Error base per module |
Handles. A handle becomes an object whose deallocation calls the release function, so Python’s reference counting decides when the Turf object can be collected. A class whose Turf type has a method marked @ownership(releases: self) also supports with, which calls that method on exit, and a call after it raises ValueError. A dead handle that reaches the library anyway comes back as TURF_E_DEAD_HANDLE (TDP-2-030, Boundary errors) and is raised as ValueError too, so a caller’s mistake never ends the interpreter.
The buffer contract. A Slice<T> parameter takes a Python buffer without a copy only when all of these hold. The glue checks each before releasing the GIL:
| Condition | How it is checked | If it fails |
|---|---|---|
The item format is T’s exactly (d for double, q for int, i for int32, B or c for char) |
The buffer’s format string |
TypeError |
| The byte order is the machine’s | The format’s byte-order prefix (<, >, !, =) |
TypeError, or a copy with the bytes swapped if the function is marked @export(copy_mismatched) |
| The buffer is C-contiguous | PyBUF_C_CONTIGUOUS |
TypeError, or a copy under the same mark |
The data pointer is aligned for T |
The address modulo T’s alignment |
A copy into an aligned buffer, since a view taken at an odd byte offset is legitimate Python |
| The buffer is writable, when the Turf function may write to the slice | PyBUF_WRITABLE; a Slice the function never writes to is marked read-only in the export description |
TypeError (bytes and read-only numpy arrays are refused for writable slices) |
Lifetime. The glue holds the buffer with PyObject_GetBuffer for the whole call and releases it with PyBuffer_Release after Turf returns. While a buffer is held, bytearray, array.array and numpy refuse to resize or reallocate it, so its address and length cannot change during the call.
Writes from other threads. While the GIL is released, another Python thread can still write to the array’s contents. A slice from a buffer holds only numbers, never pointers, so this cannot break memory safety. It does make the values Turf reads unspecified during that time. A function that needs a stable snapshot is marked @export(snapshot), and the glue copies its slices before releasing the GIL.
The GIL. These rules hold for every call:
- Every argument is converted, and every buffer checked and acquired, while the GIL is held.
- The glue releases the GIL before it waits for the library’s entry lock, and keeps it released while Turf code runs. This is what stops the entry lock and the GIL from deadlocking against each other.
- Turf code never touches a Python object without the GIL. A Turf library called from Python holds no
PyObjectat all; arguments reach it already converted. - A Turf callback into Python (a Python function passed as a callback, or the
pythonpackage used inside a library) takes the GIL withPyGILState_Ensureand gives it back withPyGILState_Releasebefore returning to Turf. - After Turf returns, the glue takes the GIL back, releases the buffers, and converts the results.
On free-threaded Python (3.13t and later) there is no GIL. Rules 1, 3 and 5 still hold, the entry lock alone serializes calls into Turf, and the paragraph on writes from other threads applies to every call.
Panics. Caller mistakes are exceptions (rule above). A panic is reserved for a bug inside the library, and it ends the process, Python included (F-PANIC-1). The generated docs say so, and point library authors to !T for every expected failure.
The build.
- One wheel per platform, built against CPython’s limited API (abi3) so it serves every supported Python version.
- On Linux the wheel is tagged
manylinuxwith the glibc baseline of the build machine, andmusllinuxwhen built against musl. The Turf library links the host’s C library dynamically (TDP-2-030). - A
.pyistub file with every function’s types and doc comment, so editors and type checkers see the module.
Turf calls Python
import python as py;
fn main(): int {
py::start();
defer py::stop();
immut np: &py::Object = py::import("numpy");
defer np.close();
immut a: &py::Object = np.call("arange", py::int(5));
defer a.close();
printline(a.call("sum").as_int()); // 10
return 0;
}
py::Objectis a handle over aPyObject *reference, withclose()marked@ownership(releases: self)(TDP-2-029). Closing twice, or using an object after closing it, is a compile error inside a function and a panic through a copy.- An unclosed object leaks a reference, since Turf has no finalizers.
deferis the idiom, and AN-5’s leak warning sees a missing one. - A Python exception raised by a call becomes a Turf error value (
py::Errorwith the exception’s type name and message), never a panic. - The package holds the GIL from
py::start()on, since the Turf program has one mutator. - It links the system’s
libpython3, found throughpython3-config, from the package’s[native] libs.
Constitution check
- I. Safe by default, unsafe by declaration. The glue and the
pythonpackage hold theunsafecode. A Turf or Python user of either writes none. Buffers are checked for format and contiguity before Turf sees them. - II. Native performance, with costs you can read. Buffers that meet the contract are not copied; strings, misaligned buffers and
snapshotslices are, as the tables say. Turf code runs with the GIL released. - III. The compiler is the authority. Not affected; the compiler only produces the library and its description.
- IV. Nothing crosses a boundary implicitly. Only
@exportfunctions appear in Python. Python objects reach Turf only as handles. - V. A few general mechanisms, not many special cases. The glue is generated from the general export description. The
pythonpackage uses the same contracts as any C binding. - VI. It works when you install it.
pip installof a wheel needs no Turf toolchain.pip install .on a project needsturf, which the build backend says when it is missing. - VII. Stability is a promise. All additive.
turf-pythonversions itself and states which export-description schema versions it reads. - VIII. A claim is not true until it is demonstrated. See Verification.
The Non-Goals hold: no borrow checker, no no-runtime target, no AI in the compilation path.
Migration
Nothing breaks.
Verification
- Wheels for every Tier 1 platform installed into clean virtual environments for the oldest and newest supported CPython, and for a free-threaded build; the test suite run under
python -X dev. - Every conversion row, including a numpy array passed without a copy (its memory is written by Turf and read back by numpy), and each row of the buffer contract: a wrong format, a big-endian array, a non-contiguous view, a view at an odd byte offset (copied),
bytespassed to a writable slice, and an integer that overflows. - Another Python thread writing to an array during a call: no crash and no sanitizer report, and
@export(snapshot)sees the values from before the call. - A dead handle passed in raises
ValueError, and the interpreter keeps running. - A handle released by Python’s garbage collector while Turf collections are forced (
TURF_GC_STRESS);withclosing it; a call after closing raises. - Ten Python threads calling one library at once: results correct, Python threads not blocked while Turf code runs, no deadlock with a Turf callback into Python.
- The
pythonpackage: import, call, an exception turned intopy::Error, and the compile errors for double close.
Drawbacks
- A panic ends the interpreter. The answer is
!T, but a bug in a library still takes the process down. - Serialized Turf calls until 3.1: Python threads run in parallel with Turf, not with each other inside Turf.
- Another repository to release and keep in step with CPython.
Alternatives
- Glue written by hand with
ctypesorcffi. It works from day one and stays possible. It loses types, exceptions and handle lifetimes, which is what this proposal is for. - Use PyO3’s approach of a library inside the language’s compiler. Rejected: the compiler stays free of any host language.
- Keep the GIL during Turf calls. Simpler, but it stops every Python thread while Turf runs.
Unresolved questions
- Whether
charmaps tointor to one-bytebytesby default. - Whether the
pythonpackage should also offer a thin layer for numpy arrays asSlice<T>views, which needs the buffer protocol on the Turf side.