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.
@nogilcalls 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,
staticsymbols, unsupported calling conventions,volatile, and_Atomicvalues; - 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.