ADR 0008: Python binding over the C ABI

  • Status: Accepted

  • Date: 2026-09-20

Decision

The Python package will consume only the installed public C ABI, use explicit native-library discovery and cleanup, and duplicate no domain validation. The binding technology is the Python standard library’s ctypes module.

The ctypes signature and struct table is generated from the authoritative header by tools/generate_abi.py, committed, and regenerated in CI so a stale file fails the build. The header remains the single source of truth; the generator encodes no ABI knowledge of its own, runs only at development and CI time, and fails loudly on a declaration it cannot represent. Public struct sizes and field offsets are additionally asserted against the values the C compiler reports for the installed header, because a mirrored struct whose fields are reordered or padded differently cannot be detected by symbol checks.

The wrapper requires an explicit native-library path, either as an API argument or through POSTPROJECT_LIBRARY. Paths are expanded and resolved before loading; the wrapper does not search the current working directory or mutate the platform loader path. Packagers may supply an absolute bundled-library path from their own installation layout.

Every owned opaque C handle has one Python owner with deterministic close() and context-manager cleanup plus an idempotent finalizer fallback. Borrowed strings and result-set values are copied before the owning native handle is released. Error codes become structured Python exceptions.

Consequences

Python validates the same integration boundary used by non-Rust applications. PyO3 and direct internal Rust access are excluded from the primary binding.

Using ctypes avoids an additional runtime or extension-compilation dependency and works on Linux, macOS, and Windows. It also preserves the explicit library-path policy above, which a build-time-linked extension module such as CFFI in API mode would not: that approach buys compiler-verified declarations at the cost of per-platform wheels, a build toolchain, and runtime discovery.

The residual tradeoff is that the binding restates the header in a second language, where a mistake corrupts memory rather than raising. Generating the table and cross-checking layout against the compiler removes the hand-maintained step; the ABI version check remains the guard against a mismatched library.

This constrains the public header to remain mechanically parseable — fixed-width typedefs, opaque handle pointers, flat structs, no unions, varargs, or function pointers. A declaration the generator cannot represent is a reason to simplify the header rather than to special-case the generator.