C Support

PRIK builds a supported subset of C APIs as importable Python extensions. The generated binding calls your exported C symbol without ABI conversion. Every extension has a generated CPython binding translation unit. The only optional additional C translation unit is the opt-in forwarder for a symbol that your headers and Python.h both declare, described in Symbols your binding's own headers declare.

C wrapping is best for standalone numerical functions with primitive values, NumPy buffers, and explicit output storage. It is deliberately fail-closed: parsing a declaration does not promise that it can be wrapped, and an unsupported form stops the build before native compilation.

Requirements

Install PRIK and NumPy, then make sure a C compiler and the development headers for the Python that will import the extension are available. cc is the default compiler; use --compiler when the native project requires another one.

To see the C types and NumPy dtypes selected for a particular compiler target, run:

python3 -m prik probe --language c --compiler cc

Build a scalar C function

This first example is source-driven: PRIK reads the C declaration, builds the extension, and writes an editable contract alongside it.

Create native_math.c:

double add(double left, double right) {
    return left + right;
}

Build it with an explicit language selection:

python3 -m prik --language c native_math.c \
  --compiler cc \
  --out native_math \
  --out-dir build

To inspect the contract without compiling, write native_math.pyi:

python3 -m prik generate --pyi --language c native_math.c --out native_math.pyi

The file contains:

from prik.contracts import Float64

def add(left: Float64, right: Float64) -> Float64: ...

C contract extraction writes one file rather than the Fortran package layout; the .pyi format reference shows both forms.

Then import and call the extension:

import sys

import numpy as np

sys.path.insert(0, "build")
import native_math

print(native_math.add(np.float64(3.0), np.float64(2.5)))
5.5

PRIK validates arithmetic arguments at the native boundary. Pass the matching NumPy scalar—for example, np.float64 for a C double.

The source build writes an editable semantic .pyi contract beside the extension. Use that contract when a pointer needs a more precise Python meaning than the C declaration can express.

Author a contract for pointers and arrays

C syntax cannot tell whether double * means one scalar or the first element of an array. A source-generated contract therefore starts conservatively. When the parameter is a NumPy buffer, state the shape and native call order in an authored .pyi contract.

Create scale.c:

#include <stddef.h>

void scale(size_t count, double *values) {
    for (size_t index = 0; index < count; ++index) {
        values[index] *= 2.0;
    }
}

Create scale.pyi:

from prik.contracts import Arg, Float64, native_call

@native_call([Arg(0).shape[0], Arg(0)])
def scale(values: Float64[:]) -> None: ...

Arg(0).shape[0] provides count; Arg(0) passes the NumPy buffer to double *values.

python3 -m prik --language c scale.pyi \
  --native-c-sources scale.c \
  --compiler cc \
  --out scale \
  --out-dir build
import sys

import numpy as np

sys.path.insert(0, "build")
import scale

values = np.array([1.0, 2.0, 3.0], dtype=np.float64)
scale.scale(values)
print(values)
[2. 4. 6.]

Supported arrays have ranks 1 through 15, primitive non-Boolean elements, and C-contiguous NumPy storage. PRIK validates dtype, rank, shape, layout, and writeability before calling C.

Use Float64[()] when the caller should provide one writable scalar slot.

Choose the pointer contract

Arg(i) uses the annotation's normal C representation: a bare numeric scalar crosses by value, while rank-zero and array storage cross by address. Use Addr(Arg(i)) only when a bare scalar must become a C pointer.

C parameter Python contract @native_call entry Native effect
double value value: Float64 Arg(0) (or omit @native_call) Passes double by value.
double *value value: Float64 Addr(Arg(0)) Passes the address of call-local scalar storage; mutation is discarded unless returned.
double *value value: Float64[()] Arg(0) (or omit @native_call) Passes the caller's zero-dimensional NumPy storage address; mutation is visible in place.
double *values values: Float64[:], Float64[4], or Float64[n] Arg(0) Passes the validated C-contiguous NumPy data address.

For an authored scalar read-back, write the address projection and return the call-local value explicitly:

from prik.contracts import Addr, Arg, Float64, Returns, native_call

@native_call([Addr(Arg(0))])
def scale_scalar(value: Float64) -> Returns["value", Float64]: ...

A source-generated contract for double *value already contains this Addr(Arg(0)) projection. Do not wrap Float64[()] or an array in Addr(...): their normal native representation is already an address.

Do not leave a pointer as a scalar when C indexes it as an array. A generated source contract is conservative; promote the parameter to a shaped NumPy array before calling a buffer API.

An authored contract is authoritative. If the source C declaration is const T *, do not author writable storage or write-back through it: writing through a const-qualified C pointer is undefined behavior.

Rename, reorder, and address arguments

An authored contract can present an existing C ABI under a better Python name and argument order. It names the real C symbol, then states each native argument explicitly.

When the Python declaration and C symbol have the same name, omit @bind: that name is the default native target. Use @bind("native_name") only for a different C symbol. The same default applies to Fortran semantic contracts.

Create projected.c:

int combine_native(int right, int *left, int bias) {
    return 100 * right + 10 * *left + bias;
}

void read_status(int value, int *output) {
    *output = value + 1;
}

Create projected.pyi:

from prik.contracts import Addr, Arg, Int32, Return, bind, native_call

@bind("combine_native")
@native_call([Arg(1), Addr(Arg(0)), Int32(5)])
def combine(left: Int32, right: Int32) -> Int32: ...

@bind("read_status")
@native_call([Arg(0), Return("output", 0)])
def status(value: Int32) -> Int32: ...

combine is the Python name, combine_native is the linked C symbol, Addr(Arg(0)) passes the address of left, and Int32(5) supplies the literal third native argument. Return(...) turns the output pointer into the Python result.

python3 -m prik --language c projected.pyi \
  --native-c-sources projected.c \
  --compiler cc \
  --out projected \
  --out-dir build
import sys

import numpy as np

sys.path.insert(0, "build")
import projected

print(projected.combine(np.int32(2), np.int32(3)))
print(projected.status(np.int32(7)))
325
8

Return several C outputs

Use a named Return(...) slot for every native output pointer that should become part of the Python return value.

Create stats.c:

#include <stddef.h>

void stats_compute(size_t count, const double *values, double *mean, double *total) {
    double sum = 0.0;
    for (size_t index = 0; index < count; ++index) {
        sum += values[index];
    }
    *total = sum;
    *mean = count ? sum / (double)count : 0.0;
}

Create stats.pyi:

from prik.contracts import Arg, Float64, Return, Returns, bind, native_call

@bind("stats_compute")
@native_call([Arg(0).shape[0], Arg(0), Return("mean", 0), Return("total", 1)])
def summarize(values: Float64[:]) -> tuple[Returns["mean", Float64], Returns["total", Float64]]: ...
python3 -m prik --language c stats.pyi \
  --native-c-sources stats.c \
  --compiler cc \
  --out stats \
  --out-dir build
import sys

import numpy as np

sys.path.insert(0, "build")
import stats

mean, total = stats.summarize(np.array([1.0, 2.0, 3.0, 4.0], dtype=np.float64))
print(mean, total)
2.5 10.0

See Calls and Results for the full shared contract vocabulary.

Pass C strings

Choose the string contract from what the C function does with the pointer:

C parameter Contract Python value
Read-only const char * String Python str
Writable char * String[n][()] or String[...][()] Rank-zero NumPy S array

String borrows the UTF-8 buffer of the Python str, which CPython NUL-terminates. The native function must not write through it. For writable storage, use a caller-owned NumPy bytes array. A stated capacity such as String[32][()] also checks the array itemsize; String[...][()] accepts the itemsize the caller supplies.

Create text.c:

#include <stddef.h>
#include <string.h>

int name_length(const char *text) {
    return (int)strlen(text);
}

void shout(const char *text, char *out) {
    size_t index = 0;
    for (; text[index]; ++index) {
        char value = text[index];
        out[index] = (value >= 'a' && value <= 'z') ? (char)(value - 32) : value;
    }
    out[index] = '\0';
}

Create text.pyi:

from prik.contracts import Int32, String

def name_length(text: String) -> Int32: ...

def shout(text: String, out: String[32][()]) -> None: ...
python3 -m prik --language c text.pyi \
  --native-c-sources text.c \
  --compiler cc \
  --out text \
  --out-dir build
import sys

import numpy as np

sys.path.insert(0, "build")
import text

print(text.name_length("hello"))
buffer = np.array(b"", dtype="S32")
text.shout("hello", buffer)
print(buffer[()])
5
b'HELLO'

When C uses an explicit byte length, pass it with Len(Arg(i)) in @native_call(...). The contract does not impose a terminator convention of its own.

Hide native outputs and raise Python exceptions

Use Hidden(name, T) for C output storage that Python never returns. This is particularly useful for status values and diagnostic messages consumed by @raises.

Create checked.c:

#include <string.h>

void checked_sqrt(double value, double *root, int *status, char *message) {
    if (value < 0.0) {
        *status = -1;
        *root = 0.0;
        strcpy(message, "value must not be negative");
        return;
    }
    *status = 0;
    message[0] = '\0';
    *root = value == 4.0 ? 2.0 : value;
}

Create checked.pyi:

from prik.contracts import Arg, Float64, Hidden, Int32, Return, Returns, String, native_call, raises

@raises(status="status", message="message", success=0)
@native_call([Arg(0), Return("root", 0), Hidden("status", Int32), Hidden("message", String[64])])
def checked_sqrt(value: Float64) -> Returns["root", Float64]: ...
python3 -m prik --language c checked.pyi \
  --native-c-sources checked.c \
  --compiler cc \
  --out checked \
  --out-dir build
import sys

import numpy as np

sys.path.insert(0, "build")
import checked

print(checked.checked_sqrt(np.float64(4.0)))
try:
    checked.checked_sqrt(np.float64(-1.0))
except RuntimeError as error:
    print(error)
2.0
value must not be negative

The function returns only root; status and message become a RuntimeError on failure. A hidden message needs a fixed capacity because PRIK allocates the native buffer.

A visible message buffer is also valid when the caller owns it:

@raises(status="status", message="message", success=0)
@native_call([Arg(0), Arg(1), Hidden("status", Int32)])
def checked(value: Float64, message: String[64][()]) -> None: ...

Here message is a rank-zero np.ndarray with dtype S64; the caller can inspect it after the exception. String can also name a visible message when the C API declares const char *; that borrows a Python str. If that native code writes through the borrowed pointer, handling that unsafe contract is the C API author's responsibility. Prefer NumPy storage for a writable message.

Present several C symbols as one Python name

An authored contract can dispatch supported dtype/rank variants behind one Python name. Mark the concrete candidates @private, then name them with @overload(...).

Create overloads.c:

int scale_integer(int value) { return value * 2; }

double scale_real(double value) { return value * 2.0; }

Create overloads.pyi:

from prik.contracts import Float64, Int32, overload, private

@private
def scale_integer(value: Int32) -> Int32: ...

@private
def scale_real(value: Float64) -> Float64: ...

@overload("scale_integer")
def scale(value: Int32) -> Int32: ...

@overload("scale_real")
def scale(value: Float64) -> Float64: ...
python3 -m prik --language c overloads.pyi \
  --native-c-sources overloads.c \
  --compiler cc \
  --out overloads \
  --out-dir build
import sys

import numpy as np

sys.path.insert(0, "build")
import overloads

print(overloads.scale(np.int32(21)))
print(overloads.scale(np.float64(1.5)))
print([name for name in dir(overloads) if not name.startswith("_")])
42
3.0
['scale']

Candidates must remain distinguishable by their supported dtype and rank.

What is supported

  • Externally linked functions with void, arithmetic scalars, and C99 complex values whose ABI the selected compiler can probe.
  • One-level primitive pointer parameters, expressed as a scalar address, rank-zero NumPy storage, a projected result, or a C-contiguous NumPy array.
  • Rank-zero C string inputs and storage, hidden outputs, status projection, symbol renaming, reordered arguments, typed literals, and derived lengths or shapes.
  • Overload sets whose candidates are distinguishable by supported dtype and rank.
  • @nogil calls that do not access Python state.
  • Ordinary compiler preprocessing, including standard includes and macros.

Qualifiers and compiler attributes

Use C qualifiers as constraints when authoring a contract: const T * must not be presented as writable NumPy storage. const and restrict themselves do not add a separate Python type or calling convention.

Common non-ABI attributes, such as deprecated and warn_unused_result, do not change a wrapper. An attribute that may change the ABI, symbol identity, or layout—such as a calling convention or alignment attribute—stops the build instead of being ignored.

Compiler-preprocessed system headers may define an unavailable extended floating spelling, such as _Float32, through a compatibility typedef. PRIK accepts those declarations as parsing context so that an unrelated private header declaration does not block a reviewed public surface. This tolerance does not add direct-wrapper support for the extended floating type itself. Prototype parameters may also omit their names: a declaration such as long rinttol(double) remains a modern prototype and is not treated as a K&R definition. Actual K&R definitions remain unsupported.

Exact native scalar identities

Generated C contracts are target-specific and representation-based. Distinct C types such as long and long long may therefore use the same public NumPy contract type. When their exact identity matters to the call, generation keeps it as a sparse operator inside @native_call(...):

from prik.contracts import Arg, CLongLong, Float64, Int64, Return, native_call

@native_call([Arg(0)], result=CLongLong(Return(0)))
def llround(value: Float64) -> Int64: ...

The public signature continues to use ordinary NumPy contract types. Scalars and scalar addresses accept exactly that public dtype and are converted directionally; the native C spelling does not add a second accepted Python scalar type. Ranked arguments instead require the corresponding exact NumPy element storage so the pointer path remains zero-copy. See Calls and Results: Preserve an Exact C Scalar at the Native Call for arguments, addresses, results, arrays, and the supported exact-storage rules.

Symbols your binding's own headers declare

Exact native scalar casts make compatible duplicate declarations harmless, but they cannot resolve a genuine identifier collision: a header included by the binding may already declare the same name for a different API. Name that symbol to isolate it from Python.h:

python3 -m prik --language c vendor.pyi \
  --native-library vendor \
  --collision-adapter evaluate \
  --out vendor_api --out-dir build

The build writes a separate adapter translation unit that includes no Python header. Its signature is reconstructed from the completed exact native C types and it only forwards to the original symbol:

long long evaluate(double x);

long long prik_collision_adapter_evaluate(double x) {
    return (evaluate)(x);
}

The adapter targets a real function symbol; PRIK does not expose macros. The forwarder has hidden visibility, so it is not part of the extension's exported ABI. It is correct with or without the shared --lto build optimization. Use --collision-adapter-all to adapt every eligible function instead of naming each one. Only functions declared by C source inputs are eligible.

This isolates a declaration collision inside the binding translation unit. It does not choose between two different linked libraries that both export the same external symbol; normal target linker and loader resolution must already select the intended implementation.

A source-free .pyi must preserve every exact native scalar identity needed by the declaration. A target-generated contract does this automatically; an edited contract uses the same @native_call operators explicitly. See CLI Commands for complete selection, validation, and LTO behavior.

Current limits

PRIK rejects these forms rather than guessing their ABI or memory contract:

  • callbacks and function pointers; struct, union, and C global-state wrappers; and enum constants;
  • variadic functions, static symbols, unsupported calling conventions, volatile, and _Atomic values;
  • pointer results, multi-level pointers, raw or nullable pointers, and APIs with retained or ownership-sensitive pointers;
  • arrays of strings, Boolean arrays, native C array declarators, arrays outside ranks 1–15, and Fortran-ordered C arrays.

For a feature-by-feature view, see the language support matrix. The C parser can inspect a broader set of declarations than the supported wrapper subset; use its output to understand source, not as a build promise.

Build and inspect APIs

The examples above use the CLI. For application and test code, use build_c_extension() for source builds or build_pyi_extension() for authored contracts, then import the returned WrapperBuildResult. See the Python API for those calls and CLI Commands for build, generation, Makefile, and inspection options.

Native dependencies

Pass public C source files as positional inputs. Add implementation-only C files with --native-c-sources, compiler flags with --native-c-compile-flags, existing objects with --native-objects, and libraries with --native-library and --native-library-dir. These complete the native link without becoming Python API declarations.

For headers and conditional source, pass the same preprocessing information as the native project: -I, -D, --std, and, when available, --compile-commands build/compile_commands.json.

To wrap a reviewed subset of a broad or system header, keep included files private and select the exact reachable functions from a file:

python3 -m prik generate --pyi --language c api_probe.h \
  --include-exposure roots-only \
  --export-symbols reviewed_functions.txt \
  --out contracts/api.pyi

The export file names the reviewed functions that become public, including functions declared by an otherwise-private system header. Every unlisted declaration is excluded. This selects the semantic API rather than linker exports: selected functions still need native link inputs and a signature supported by the C wrapper. See CLI Commands: C include exposure for the file format and fail-closed validation rules.

The Python build API accepts the already-resolved names instead of a CLI text file:

build = build_c_extension(
    "api_probe.c",
    export_symbols=("evaluate", "normalize"),
    native_libraries=("vendor",),
)

Inspect a broader C API

The C parser and contract generator accept more syntax than the supported wrapper subset. Use them to examine declarations, not as a promise that each declaration can be built:

python3 -m prik parse --language c include/library.h --json
python3 -m prik semantics --language c include/library.h
python3 -m prik generate --pyi --language c include/library.h --out contracts/library.pyi

For a project header that needs its normal preprocessing configuration:

python3 -m prik parse --language c include/library.h \
  -I include \
  -D LIBRARY_ENABLE_FAST=1 \
  --std c11 \
  --compile-commands build/compile_commands.json

Only declarations in the wrapped translation unit become a source build's public API; headers supply declarations and preprocessing context. For the broader Fortran wrapper surface, start with the User Guide.