Skip to content

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 = true in 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.h with the full API declared → Path A (jm bind)
  • Only .c source, no clean header → Path B (--impl lift)
  • 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:

[[engine.methods]]
name = "apply_ctrl"
arg_type = "double"
return_type = "double"

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:

[gain]
impl = """
    return state->gain * 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:

[[engine.state]]
name    = "buf"
type    = "float _Complex[256]"
default = "0.0"

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):

[decimator]
arg_type    = "float _Complex"
return_type = "float"
depends_on  = ["hbfilter", "nco"]

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):

just-makeit object engine \
    --extra-include-dirs /usr/local/include/mylib

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.c is kept as it is; one that lives elsewhere is lifted with an impl_file pointing at it. The component is then declared, and jm apply -- not jm 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:

[engine]
impl_file = "legacy/engine.c::my_process"

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