Porting existing C extensions to just-makeit¶
A complete guide for bringing hand-rolled, fragmented Python C extensions into the consistent just-makeit scaffold. Covers assessment, three porting paths, the sacred/glue model, type mapping, and verification.
Assess your extension¶
Before choosing a path, answer four questions about your existing code.
What is the API shape?¶
Map your step/process function signature to a just-makeit shape:
| Your signature looks like | Shape | Preset flag |
|---|---|---|
T process(State*, T in) |
processor | (default) |
T generate(State*) |
generator | --preset generator |
void consume(State*, T in) |
consumer | --preset consumer |
void process(State*, const T *in, n, U *out) |
blockwise | --preset blockwise |
| state init from filepath, no step at all | reader | --preset reader |
| no state struct at all | any | --no-state |
| no step function at all | any | --no-step |
What state does it carry?¶
List every field in your state struct. For each field determine:
- Is it a supported scalar type? (see Type mapping)
- Is it a fixed-length array? (
float buf[256]→float[256]) - Is it an opaque pointer to internal memory? (use
opaque = truein TOML) - Is it a constructor-only parameter that doesn't live in the struct?
Does it have extra methods or properties?¶
Beyond the core step/steps, does your extension expose:
- Named processing variants (e.g.
process_ctrl,apply_gain)? →jm method - Getter/setter pairs for state fields? →
jm property - Module-level utility functions? →
jm function
Do you have a clean header, or only a .c file?¶
- Clean
_core.hwith the full API declared → Path A (jm bind) - Only
.csource, no clean header → Path B (--impllift) - Algorithm only in Python / pseudocode → Path C (fresh scaffold)
Three porting paths¶
Do you have a C header that declares the full API?
YES → Path A: jm bind (synthesises binding from header; fastest)
NO → Do you have working C source?
YES → Path B: --impl lift (scaffold + lift body from file)
NO → Path C: fresh scaffold, transplant algorithm by hand
All three end at the same place: make && make test green and
jm status showing no drift.
Path A — you have the header (jm bind)¶
jm bind reads a _core.h and synthesises _ext.c + .pyi. It handles
every shape that just-makeit can represent.
What the parser handles¶
Below, S is the state type <pkg>_<comp>_state_t: every symbol carries the
C stem <pkg>_<comp>, the project's c_prefix (which jm new sets to the
package name) joined to the component name.
| Header construct | Becomes in Python |
|---|---|
T <pkg>_<comp>_step(S *s, T in) |
.step(x) method |
void <pkg>_<comp>_step(S *s, T in) (consumer) |
.step(x) → None |
T <pkg>_<comp>_step(S *s) (generator) |
.step() → T |
void <pkg>_<comp>_steps(s, const T *in, n, U *out) (blockwise) |
.steps(in) → ndarray |
T <pkg>_<comp>_get_field(const S *s) / void <pkg>_<comp>_set_field(S *s, T v) |
property |
T <pkg>_<comp>_verb(S *s, T in) (custom verb) |
.verb(x) method |
size_t <pkg>_<comp>_verb(s, T *out, n) + <pkg>_<comp>_verb_max_out(s, n) |
variable-output .verb() |
typedef struct <pkg>_<comp>_state_t <pkg>_<comp>_state_t; (opaque forward decl) |
opaque state |
| constructor params not in the struct body | __init__ keyword args |
Declarations the parser cannot handle are skipped with a warning — add
those by hand via [[comp.methods]] in TOML after binding. The manifest then
declares the component, so its binding is jm apply's from there on.
Running jm bind¶
# A project to bind into. It must not declare engine: jm bind refuses a
# component the manifest declares, whose binding jm apply renders.
just-makeit new myproject
# Your header and source, where jm reads them:
# native/inc/myproject/engine/engine_core.h
# native/src/engine/engine_core.c
# jm bind reads the existing header; it does NOT touch _core.h / _core.c
just-makeit bind engine # writes engine_ext.c + engine.pyi
# Verify the binding is in sync with the header (use as a CI gate)
just-makeit bind engine --check # exit 0 = in sync; exit 1 = drift
jm bind writes the binding and its stub, and no build wiring: nothing adds
native/src/engine to the root CMakeLists.txt for you.
When bind is not enough¶
For each skipped declaration, add a [[engine.methods]] block to
just-makeit.toml and run jm apply — from then on the manifest declares
engine, so jm apply renders its binding and jm bind refuses it:
jm apply injects the declaration into _core.h and appends a stub to
_core.c. Fill in the stub body.
Path B — you have the C source (--impl lift)¶
Use this when you have working C but no clean, public header.
Scaffold the object¶
Match the jm object flags to your API shape and list the state fields:
just-makeit new myproject
just-makeit object gain \
--arg-type float \
--return-type float \
--state gain:float:1.0 \
--state clip:float:1.0
This generates a passing scaffold before you write a single line.
Lift the step body with --impl¶
# Lift by function name
just-makeit object gain \
--arg-type float \
--return-type float \
--state gain:float:1.0 \
--impl legacy/dsp.c::apply_gain
# Lift by line range (when the source isn't a named function)
just-makeit object gain \
--impl legacy/dsp.c::42:67
# Rename identifiers while lifting (repeatable)
just-makeit object gain \
--impl legacy/dsp.c::apply_gain \
--replace apply_gain_state::myproject_gain_state_t \
--replace apply_gain::myproject_gain_step
--impl extracts the body between the outermost braces of the target
function and injects it into the stub: the inline step() in _core.h, or,
for the create::/reset::/destroy:: slots and methods, the
/* <<IMPLEMENT>> */ placeholder in _core.c. --replace old::new
substitutes before injection: as a whole word when old is letters and
digits only, otherwise as a literal substring.
Lift lifecycle bodies¶
just-makeit object gain \
--impl create::legacy/dsp.c::gain_init \
--impl reset::legacy/dsp.c::gain_reset \
--impl destroy::legacy/dsp.c::gain_teardown
Record in TOML for reproducibility¶
Instead of passing --impl flags on every run, record the lift in
just-makeit.toml so jm apply can re-derive the scaffold from scratch:
[gain]
arg_type = "float"
return_type = "float"
impl_file = "legacy/dsp.c::apply_gain"
create_impl_file = "legacy/dsp.c::gain_init"
replace = { "apply_gain_state" = "myproject_gain_state_t" }
[[gain.state]]
name = "gain"
type = "float"
default = "1.0"
Inline impl placeholder¶
Write the body inline in TOML using these interpolated placeholders:
| Placeholder | Expands to |
|---|---|
{component} |
component name, exactly as declared |
{Component} |
title-cased component name |
{module} |
module name (empty when standalone) |
{Module} |
title-cased module name |
{arg_type} |
C type of step() argument |
{return_type} |
C type of step() return value |
{method} |
method name (in [[methods]]) |
{component} is the name as declared, not the C symbol stem: under
[project] c_prefix the derived names are <c_prefix>_<component>_…, which
no placeholder spells. The body sees step()'s own parameters, state and
x:
Path C — fresh scaffold, transplant algorithm¶
When you only have a Python prototype or pseudocode:
just-makeit new myproject
just-makeit object engine \
--arg-type float \
--return-type float \
--state coeff:float:0.5
# Build and test before writing a single line of algorithm
make -C /path/to/myproject && make -C /path/to/myproject test
Open native/inc/myproject/engine/engine_core.h, fill in the inline
myproject_engine_step() body, and run the tests again. The scaffold
already passes CTest — you're implementing into green.
The sacred/glue model¶
Sacred files (yours — never overwritten)¶
| File | Protected content |
|---|---|
native/inc/<pkg>/<c>/<c>_core.h |
state struct body + inline step() body |
native/src/<c>/<c>_core.c |
all function bodies (steps, lifecycle) |
native/src/<mod>/<fn>.c |
module-level function bodies |
native/tests/test_<c>_core.c |
your C tests (the scaffold is a seed) |
_core.h is hybrid: jm apply may inject new method/property declarations,
but the struct body and step() inline body are never touched.
Glue files (always regenerated — never hand-edit)¶
| File | Regenerated by |
|---|---|
native/src/<c>/<c>_ext.c |
jm apply / jm bind |
src/<pkg>/<c>.pyi |
every mutating command |
native/src/<c>/CMakeLists.txt |
jm apply (your own CMake goes in <c>_extra.cmake) |
native/tests/test_<c>_symbols.c |
jm apply, derived from your header and binding |
src/<pkg>/tests/test_<c>.py and src/<pkg>/benchmarks/bench_<c>.py are
jm's while their first line is # jm:generated; delete that line to make
them yours. The root CMakeLists.txt is shared: jm splices only its marked
blocks. Every file, by owner:
Who owns each file.
The three maintenance commands¶
| Command | Effect |
|---|---|
jm apply |
Additive — injects missing declarations, regenerates glue, never deletes |
jm regenerate COMP |
Delete all COMP files and rebuild from manifest; by default splices hand-written _core.c bodies back in (--discard for a clean reset) |
jm status |
Read-only: what apply would create (MISSING) or rewrite (STALE), plus jm's create-only files a newer jm renders differently (OUTDATED) |
Use jm apply for day-to-day additions. Use jm regenerate when a component
needs to be rebuilt cleanly from its manifest — after a sweeping signature
change, for example. It preserves what it can by splicing your old bodies
back in by function name; pass --discard when you want a truly clean reset
(e.g. you have a new impl_file that provides the replacement).
Type mapping reference¶
Supported scalar types¶
| C type | NumPy dtype | Notes |
|---|---|---|
float |
float32 |
|
double |
float64 |
|
float _Complex |
complex64 |
|
double _Complex |
complex128 |
|
long double _Complex |
clongdouble |
platform width |
int |
int32 |
|
int8_t … int64_t |
int8 … |
|
uint8_t … uint64_t |
uint8 … |
|
size_t |
uintp |
pointer width (64-bit: uint64) |
ptrdiff_t |
intp |
pointer width (64-bit: int64) |
bool |
bool |
|
const char * |
str |
return, param and init-param |
Array types¶
Append [] to any scalar key for variable-length arrays (blockwise preset,
--multi-output): float _Complex[], float[], double[], …
Fixed-length state arrays use TYPE[N] syntax:
Unsupported constructs and workarounds¶
| C construct | Workaround |
|---|---|
| Struct return value | --single --result-field … (one record, by value) or --variable-output --record-dtype STRUCT (rows) |
void * pointer |
Mark state field opaque = true in TOML |
| Multi-scalar in/out | Use blockwise (T[] / U[]) or extra methods |
| Nested structs | Opaque pointer + accessor methods |
| Variadic args | Not supported — wrap in a fixed-signature fn |
Shape mapping reference¶
| API shape | Flags | Preset shorthand |
|---|---|---|
T step(s, T in) |
--arg-type T --return-type T |
(default) |
T step(s, T in) mutable state |
--arg-type T --return-type T --mutable |
|
T step(s) generator |
--arg-type void --return-type T |
--preset generator |
void step(s, T in) consumer |
--arg-type T --return-type void |
--preset consumer |
void steps(s, T*in, n, U*out) |
--arg-type T[] --return-type U[] |
--preset blockwise |
| No step at all | --no-step |
--preset reader |
| No state struct | --no-state |
|
| Opaque state pointer | opaque = true on the state field in TOML |
|
| Variable-length output | --variable-output --method-name verb --max-out N |
Adding extra methods, properties, and functions¶
just-makeit method engine execute_ctrl \
--arg-type double --return-type double \
--impl legacy/dsp.c::engine_ctrl
just-makeit property engine gain --type float --writable
just-makeit module mymod # a function lives in a module
just-makeit function normalize --module mymod --param x:float --return-type float
If a method needs extra scalar control parameters beyond the standard x
input, use --extra-arg name:type (or the synonymous --param):
just-makeit method engine execute_ctrl \
--arg-type double --return-type double \
--extra-arg bypass:bool \
--extra-arg gain:float
Or declare them in TOML and run jm apply:
[[engine.methods]]
name = "execute_ctrl"
arg_type = "double"
return_type = "double"
extra_args = [{name = "bypass", type = "bool"}, {name = "gain", type = "float"}]
impl_file = "legacy/dsp.c::engine_ctrl"
[[engine.properties]]
name = "gain"
type = "float"
writable = true
Multi-component and module layout¶
Standalone objects (default)¶
Each object gets its own .so: from myproject import Engine.
just-makeit object fir --arg-type float --return-type float
just-makeit object biquad --arg-type float --return-type float
# → myproject/fir.so + myproject/biquad.so
Module objects (shared .so)¶
Objects in the same module share one .so subpackage:
just-makeit object fir --module filter --arg-type float --return-type float
just-makeit object biquad --module filter --arg-type float --return-type float
# → myproject/filter.so (Fir and Biquad both imported from myproject.filter)
Component dependencies¶
When one component's C code calls another's, declare depends_on in its
fragment (TOML — there is no --depends-on CLI flag):
jm apply then wires the hbfilter and nco OBJECT libraries into
decimator's OBJECT lib and its test/bench link, and injects
#include "myproject/hbfilter/hbfilter_core.h" into decimator_core.h (for any dep
whose header exists). The C code can use the dependency's types directly.
Wiring external dependencies¶
[project]
# pkg-config libraries
pkg_modules = ["fftw3f", "samplerate"]
# CMake find_package() packages, each with its pkg-config module name --
# the two differ, and only you know the second
find_packages = [
{ name = "ZLIB", pkg_config = "zlib" },
{ name = "OpenSSL", pkg_config = "openssl" },
]
# Pure-C subproject deps (add_subdirectory)
c_deps = ["resamp", "fft"]
For raw header+lib pairs, pass --extra-include-dirs on the CLI (recorded
automatically in TOML):
Post-port verification¶
# 1. Drift report
just-makeit status
# 2. Binding in sync with header (if jm bind was used)
just-makeit bind engine --check
# 3. Refresh any stale glue
just-makeit apply
# 4. Build and test
make && make test
# 5. Smoke-test the Python side
python -c "from myproject import Engine; e = Engine(1.0); print(e.step(0.5))"
The jm status sections you meet first when porting:
| Status | Meaning |
|---|---|
| OK | File exists and matches what apply would generate |
| MISSING | File declared in manifest but not on disk — run jm apply |
| STALE | File differs from what the manifest would generate; apply rewrites |
| STALE — yours | _core.c / _core.h lacks a definition the manifest declares; apply appends it |
| OUTDATED | A create-only file of jm's (Makefile, clib_common.h, …) behind this jm; never counted |
The rest are in just-makeit status.
Your _core.c is never rewritten, so its algorithm never shows as drift. A
STALE glue file means a manual edit that will be overwritten on the next
jm apply — move the change into the manifest instead.
Troubleshooting¶
jm bind skipped declarations¶
The regex parser couldn't handle those signatures. For each:
- Simplify the C signature where possible, or
- Add a
[[comp.methods]]TOML block with the correct types. A definition already in the component's_core.cis kept as it is; one that lives elsewhere is lifted with animpl_filepointing at it. The component is then declared, andjm apply-- notjm bind-- renders its binding.
jm apply regenerated _ext.c and lost my hand edits¶
_ext.c is glue — never hand-edit it. Move any customisation into a
[[comp.methods]] or [[comp.properties]] TOML entry, or into the
sacred _core.c. A binding the manifest cannot express goes in
native/src/<comp>/<comp>_ext_extra.c, which the generated _ext.c
#includes and jm never writes (a module object's is
<mod>_ext_<obj>_extra.c; see
Hand-written C in a module's binding).
jm regenerate lost my step() body¶
By default jm regenerate splices hand-written bodies back in by function
name, but the splice is best-effort text matching: it loses if you passed
--discard, or if a changed signature defeats the name match. Record the
implementation in TOML so apply/regenerate can always re-derive it,
regardless of the splice outcome — inline as impl = """…""", or in a file
outside the component's own:
jm apply and jm regenerate will then re-inject it automatically. The file
must not be the component's own _core.c / _core.h: those are the files
regenerate rebuilds.
CMake can't find my external library¶
Check whether the library ships a .pc file (pkg_modules), a CMake
config (find_packages), or only raw headers+lib (--extra-include-dirs).
Windows¶
Build with clang-cl from a Developer PowerShell. MinGW is no longer supported. See Does it work on Windows?.
step() signature mismatch after porting¶
Use --replace to rename identifiers during the lift:
just-makeit object engine \
--arg-type "float _Complex" \
--impl legacy.c::my_process \
--replace my_complex_t::"float _Complex" \
--replace my_state_t::myproject_engine_state_t
Quick reference¶
# Assess
just-makeit status # drift table
# Bootstrap
just-makeit new PROJ # empty project
just-makeit object COMP [flags] # add standalone object
just-makeit object COMP --module MOD # add module object
# Bind from header (Path A)
just-makeit bind COMP # synthesise _ext.c from _core.h
just-makeit bind COMP --check # CI gate: exit 1 if drift
# Lift from existing C (Path B)
just-makeit object COMP --impl file::func # lift step() body by name
just-makeit object COMP --impl file::N:M # lift by line range
just-makeit object COMP --impl create::file::fn # lift create() body
just-makeit object COMP --replace old::new # rename identifiers on lift
# Extend
just-makeit method COMP VERB # add named method
just-makeit property COMP FIELD # add getter/setter property
just-makeit function FUNC --module MOD # add module-level function
just-makeit add --object COMP --state N:T:D # add state field (structural)
# Maintain
just-makeit apply # sync glue from manifest (additive)
just-makeit regenerate COMP # rebuild component from scratch
just-makeit script # emit CLI history from TOML
# Build and test
make && make test