| title | C Binding Lowering |
|---|---|
| audience | developers, maintainers, contributors |
| prerequisites | Code Generation Stage guide, completed wrapper plan |
| related | ../../architecture.md, ../codegen.md, ../planning.md, ../printers.md, fortran-bridge.md |
| status | maintained |
| publication | reviewed |
prik/codegen/c/binding.py lowers the
binding and native-entrypoint views of a completed ModulePlan into a
CModule and CHeader. The result is CPython and NumPy C syntax nodes, not
source text and not a compiled extension. CSourcePrinter serializes the
nodes later.
The binding view owns Python extraction, validation, local storage, returned or output C storage, Python result construction, errors, lifecycle actions, extension initialization, and generated Python surfaces. The entrypoint view owns the C ABI prototype and call. The generator may select local names and the necessary C syntax, but never reads adapter-local conversion or original Fortran invocation facts and never chooses ownership, optionality, storage, or conversion policy.
Ordinary functions use their function-owned entrypoint. Every other externally linked generated call is looked up in the generated support procedure registry. That includes constructors, field/member accessors, derived-origin and holder operations, descriptor helpers, and callback trampolines. The C lowerer may create static Python helpers, but it does not invent an external symbol or C prototype when a generated support procedure entrypoint is missing. A binding-implemented callback trampoline uses the same record for its function definition that the Fortran side uses for its interface.
Binding-local derived capsule destructors, holder presence methods, and private field methods follow the explicit module binding-support inventories. The lowerer may join their planned owner paths to namespace-owned derived-type records for emitted names and fields. It does not reconstruct those inventories from results, arguments, constructors, release actions, or holder storage. Any native call made by a local helper still obtains its existence, symbol, and ABI from the generated support procedure registry.
ModulePlan.binding + ModulePlan.entrypoint
+ namespaces + function binding/entrypoint views
-> CBindingGenerator.require_supported()
-> CBindingGenerator.visit()
-> CModule + CHeader
-> CSourcePrinter
-> C binding source + header text
require_supported() checks that the already selected primitive spellings are
available. It is capability preflight, not a second policy pass.
Numeric scalar boundaries retain their exact NumPy contract without using the generic dtype-conversion path on a successful call. The native support helper checks the planned NumPy scalar class, reads its typed payload directly, and allocates the matching typed NumPy scalar for a result. Type mismatches still follow the generated diagnostic path; this fast path changes neither accepted inputs nor returned result types.
_visit_ModulePlan() returns the paired C module and header. binding_module()
collects namespace functions, determines whether the plan requires runtime
helpers, and assembles declarations and functions in emitted dependency order:
shared helpers, class and descriptor support, wrappers, overload dispatchers,
then module initialization. binding_header() lowers prototypes from the
shared entrypoint records.
_visit_FunctionPlan() works in three ordered parts:
- It creates one local-name context and puts declarations before executable statements.
- It applies the plan's
argument_conversion_order; each transfer dispatches on its completed optional, callback, descriptor, or derived facet. - It invokes the planned entrypoint, receives its direct or output-parameter C storage, and applies selected result construction and lifecycle work.
PythonSurfaceEmitter is used only when the plan contains generated classes,
holders, or module proxies. CBindingNames keeps its private C symbols aligned
with the binding helpers. Public names still come from the plan.
This is the smallest complete plan: a public Python ping() that calls the
standalone native PING subroutine. Its empty transfer, result, slot, and
lifecycle tuples are intentional—there are no datatype or ownership decisions
for code generation to infer.
In a normal build, WrapperPlanner constructs the plan after policy completion
and WrapperGenerator freezes and validates it before lowering. Construct a
plan directly only to study an isolated backend mechanism like this one.
This abbreviated, non-runnable sketch shows the records in construction order. Expand the full source to run the complete example.
binding = BindingFunctionPlan(...)
entrypoint = NativeEntrypointFunctionPlan(...)
bridge = BridgeFunctionPlan(...)
function = FunctionPlan(
..., binding=binding, entrypoint=entrypoint, bridge=bridge
)
namespace = NamespacePlan(..., functions=(function,))
plan = ModulePlan(
binding=BindingModulePlan(...),
entrypoint=NativeEntrypointModulePlan(...),
bridge=BridgeModulePlan(...),
namespaces=(namespace,),
)
generator = CBindingGenerator()
c_module, _header = generator.visit(plan)
print(CSourcePrinter().doprint(...))Full runnable source
from prik.codegen.c.binding import CBindingGenerator
from prik.planning.models import (
BindingFunctionPlan, BindingModulePlan, BridgeFunctionPlan,
BridgeModulePlan, FunctionPlan, ModulePlan,
NativeEntrypointFunctionPlan, NativeEntrypointModulePlan,
NativeGeneratedCodeGroupKind, NativeGeneratedCodeGroupPlan, NamespacePlan,
)
from prik.policy.models import (
ExternalDeclarationMode, NativeEntrypointAction, NativeInvocationKind,
)
from prik.printers.c import CSourcePrinter
binding = BindingFunctionPlan(
python_name="ping",
docstring="Call PING.",
release_gil=False,
status_error=None,
argument_conversion_order=(),
)
bridge = BridgeFunctionPlan(
native_name="PING",
native_invocation=NativeInvocationKind.PROCEDURE,
native_operator=None,
standalone=True,
external_declaration=ExternalDeclarationMode.IMPLICIT_EXTERNAL,
native_module=None,
native_is_subroutine=True,
)
entrypoint = NativeEntrypointFunctionPlan(
symbol_name="bind_c_ping",
action=NativeEntrypointAction.GENERATED_FORTRAN_ADAPTER,
parameters=(),
results=(),
projected_slots=(),
)
function = FunctionPlan(
owner_path="demo.ping",
symbol_name="ping",
binding=binding,
entrypoint=entrypoint,
bridge=bridge,
class_call=None,
arguments=(),
results=(),
declaration_callables=(),
available_roles=(),
)
namespace = NamespacePlan(
owner_path="demo",
python_path=(),
functions=(function,),
docstring="Manual codegen demonstration.",
)
plan = ModulePlan(
owner_path="demo",
binding=BindingModulePlan(owner_path="demo"),
entrypoint=NativeEntrypointModulePlan(owner_path="demo"),
bridge=BridgeModulePlan(owner_path="demo"),
namespaces=(namespace,),
native_generated_code_groups=(
NativeGeneratedCodeGroupPlan(
kind=NativeGeneratedCodeGroupKind.FORTRAN_ADAPTERS,
language="fortran",
member_keys=("demo.ping",),
source_paths=("bind_c_demo_wrapper.f90",),
),
),
)
generator = CBindingGenerator()
generator.require_supported(plan)
c_module, _header = generator.visit(plan)
wrapper = next(item for item in c_module.functions if item.name == "wrap_ping")
print(CSourcePrinter().doprint(wrapper))static PyObject * wrap_ping(PyObject * self, PyObject * args, PyObject * kwargs) {
static char * kwlist[] = {NULL};
if (!PyArg_ParseTupleAndKeywords(args, kwargs, "", kwlist)) return NULL;
bind_c_ping();
Py_RETURN_NONE;
}
The binding record's public name produces wrap_ping; the entrypoint record
supplies bind_c_ping. The C generator adds CPython parsing and result
mechanics, but the selected plan remains the reason that call is permitted and
named that way. The bridge record's PING target is deliberately unavailable
to this generator.
binding.py also contains a direct demonstration of its normal input route.
It constructs one scalar semantic function, completes policy, builds its plan,
preflights C scalar support, lowers the binding nodes, and prints the header
and wrapper through CSourcePrinter. Expand Example source on the
published site to see that exact __main__ setup.
python3 prik/codegen/c/binding.pyRendered C header:
#ifndef BINDING_DEMO_WRAPPER_H
#define BINDING_DEMO_WRAPPER_H
#include <Python.h>
static PyObject * wrap_double_value(PyObject * self, PyObject * args, PyObject * kwargs);
#endif /* BINDING_DEMO_WRAPPER_H */
Rendered C binding wrapper:
static PyObject * wrap_double_value(PyObject * self, PyObject * args, PyObject * kwargs) {
static char * kwlist[] = {"value", NULL};
PyObject * bound_value_obj;
double bound_value;
double result;
if (!PyArg_ParseTupleAndKeywords(args, kwargs, "O", kwlist, &bound_value_obj)) return NULL;
if (prik_float64_unpack_exact(bound_value_obj, &bound_value) < 0) { if (!PyErr_Occurred()) { PyErr_Format(PyExc_TypeError, "Expected an argument of type numpy.float64 for argument value. Received <class '%s'>", Py_TYPE(bound_value_obj)->tp_name); } return NULL; };
result = bind_c_double_value(bound_value);
PyObject * result_obj = prik_float64_to_numpy(&result);
if (result_obj == NULL) {
return NULL;
}
return result_obj;
}
The header exposes the planned entrypoint prototype. The wrapper's rendered
body shows the Python-to-entrypoint call and conversion back to a NumPy scalar
result. Policy may route that forward call to an original Fortran bind(C)
symbol or a generated Fortran adapter. Binding-owned callback trampolines are
reverse-call entrypoints used by adapter-local callback procedures.
- Change CPython extraction, Python results, C errors, or binding-side
lifecycle emission in
binding.py. - Change generated class, holder, or proxy Python source in
python_surface.py. - Change cross-backend names used only by C helpers in
naming.py. - If the change needs a new ownership, transfer, or projection decision, stop
at
policy/orplanning/; do not add a binding-local fallback.
| Evidence | What it establishes |
|---|---|
| Binding infrastructure | Invalid NumPy scalar macros fail at the C binding helper boundary. |
| Wrapper-generator handoff | Frozen-plan validation and generated C binding, header, and wrapper assembly. |
| Array lowering | Plan-selected specialized array roles lower through the binding boundary. |
The C backend reports an unsupported completed action, unavailable scalar
spelling, invalid plan reference, or missing node visitor. It delegates missing
semantic decisions to policy/ and planning/, source formatting to
printers/, and compilation to compiler/. Start with the first invalid plan
record, not with the generated C compiler diagnostic.