Adding a New C Extension Module¶
This guide walks through every step required to add a new DSP module to doppler using just-makeit. The scaffold handles boilerplate; you fill in the DSP logic.
For the layout rules that govern each generated file, see Module Layout.
Prerequisites¶
Install just-makeit (one-liner, adds just-makeit to PATH):
The scaffold writes into the doppler source tree, so run all commands from the repo root.
Step 1 — Scaffold the module¶
Use just-makeit module to create the empty subpackage, then add types
and functions with object and function:
# Create the module subpackage (no objects yet)
just-makeit module <name>
# Add a stateful DSP type (step/steps generated by default)
just-makeit object <component> --module <name>
# Add a module-level free function (no type object)
just-makeit function <fn_name> --module <name> \
--param "x:float _Complex[]" --return-type "float _Complex"
object is for stateful DSP types with a create/destroy lifecycle.
The default scaffold generates step() / steps() — single-sample and
block-processing methods. Keeping them is preferred for consistency;
suppress only when the pattern genuinely doesn't fit:
--no-step— no meaningful per-sample operation (e.g. a block-only FFT or FIR whose natural API isexecute)--no-state— stateless object; parameters are passed per call
function is for module-level operations with no persistent state —
window functions, unit conversions, design helpers, anything that takes
inputs and returns a result without a lifecycle. just-makeit function
appends a C stub to <name>_core.c, injects a declaration into
<name>_core.h, and regenerates <name>_ext.c with a Python wrapper.
A module can have any mix of objects and functions.
After just-makeit module <name> + just-makeit object <component> --module <name>
the scaffold has created:
native/inc/<name>/
├── <name>_core.h # module-level function declarations
└── <component>_core.h # per-object C header
native/src/<name>/
├── <name>_core.c # module-level function implementations
├── <name>_ext.c # Python binding (auto-regenerated)
├── <component>_core.c # algorithm skeleton (fill in step())
└── CMakeLists.txt # auto-managed
native/tests/
└── test_<component>_core.c # C test skeleton
native/benchmarks/
└── bench_<component>_core.c # C benchmark skeleton
src/doppler/<name>/
├── __init__.py # re-export stub
└── <name>.pyi # type stub skeleton
tests/ and benchmarks/ under src/doppler/<name>/ are not scaffolded —
you create them in Steps 7 and 8.
Step 2 — Implement the C core¶
Open native/src/<name>/<component>_core.c. The primary thing to implement
is step() — the scaffold generates steps() as an inline loop over
step(), so it comes for free. Any other methods you declared via
just-makeit method also need their bodies filled in.
/* native/src/<name>/<component>_core.c */
#include "<name>/<component>_core.h"
float _Complex
dp_<component>_step(dp_<component>_t *s, float _Complex x)
{
/* DSP logic for one sample */
return x * s->gain;
}
The lifecycle functions (create, destroy, reset) are scaffolded and
only need changes if your object allocates extra memory or has non-trivial
initialization beyond what the generated struct assignment already does.
Keep this file algorithm-only. No Python headers, no NumPy, no
Py_* calls — those belong exclusively in <name>_ext.c.
Step 3 — Add extra methods or properties (optional)¶
The scaffold already wires step() and steps() into the Python binding.
Only run these commands if you need additional methods (e.g. dtype-specific
execute paths) or properties beyond what the scaffold generated.
just-makeit method <component> execute_cf32 --module <name> \
--arg-type "float _Complex[]" \
--out-type "float _Complex"
The tool appends a C stub to <component>_core.c and regenerates
<name>_ext.c with the argument-parsing boilerplate.
For read-only C struct fields exposed as Python properties:
Step 4 — Write C tests¶
Open native/tests/<name>/test_<name>.c (generated skeleton) and add
test cases using the Unity test
framework that the project ships:
void test_execute_passthrough(void) {
dp_mytype_t *s = dp_mytype_create(256);
TEST_ASSERT_NOT_NULL(s);
float _Complex in[256] = { [0] = 1.0f + 0.0f * _Complex_I };
float _Complex out[256];
dp_mytype_execute_cf32(s, in, 256, out);
TEST_ASSERT_FLOAT_WITHIN(1e-5f, 1.0f, crealf(out[0]));
dp_mytype_destroy(s);
}
Run the C suite:
All C tests must pass before moving to the Python layer.
Step 5 — Write <module>.pyi¶
Open src/doppler/<name>/<name>.pyi (generated skeleton) and flesh out
every public type following the module layout rules.
Docstrings use numpy-style format:
# mymod/mymod.pyi — type stubs for the mymod C extension.
import numpy as np
from numpy.typing import NDArray
class <component>:
"""One-line summary.
Parameters
----------
n:
Block size. Must be a power of two.
Examples
--------
Construct and inspect an instance:
>>> import numpy as np
>>> from doppler.mymod import <component>
>>> obj = <component>(256)
>>> obj.n
256
Process a block of CF32 samples:
>>> x = np.ones(256, dtype=np.complex64)
>>> y = obj.execute_cf32(x)
>>> y.dtype
dtype('complex64')
>>> y.shape
(256,)
"""
n: int
"""Block size passed to the constructor."""
def execute_cf32(
self, x: NDArray[np.complex64]
) -> NDArray[np.complex64]:
"""Process one block of CF32 samples."""
...
Verify the examples pass:
Fix any failures before continuing.
Step 6 — Write __init__.py¶
Open src/doppler/<name>/__init__.py and update the re-export:
__all__ is the public API. It controls what from doppler.<name> import * exposes, what IDEs surface in autocompletion, and what users can
reasonably rely on. Make sure every symbol a user should be able to reach
is listed — if it isn't here, it isn't public. Conversely, every name in
__all__ must have a corresponding stub in <name>.pyi; the two lists
must stay in sync.
Nothing else belongs here. See Module Layout.
Step 7 — Write Python tests¶
Open src/doppler/<name>/tests/test_<name>.py and write pytest tests
that exercise the Python API end-to-end through the C extension.
At minimum, cover these categories:
- Construction — valid arguments create the object; invalid arguments
raise the expected exception (
TypeError,ValueError, etc.) - Output shape and dtype — every execute path returns an array of the correct shape and dtype for each supported input type
- Correctness — known input produces known output; verify numerically
against a reference (e.g.
np.allclose) - DSP design requirements — DSP algorithms carry quantitative targets
(filter attenuation, SFDR, passband ripple, decimation accuracy, etc.) that
must be verified, not assumed. Test these thoroughly over repeatable
pseudo-random inputs and/or swept parameter ranges so regressions are caught
automatically. Use a fixed
np.random.default_rng(seed)for reproducibility. step/stepsconsistency — a block processed viasteps()matches the same samples processed one-at-a-time viastep()- Properties — read-only properties return the values passed at construction
import numpy as np
import pytest
from doppler.<name> import <component>
def test_construction_invalid():
with pytest.raises(ValueError):
<component>(-1)
def test_output_shape_and_dtype():
obj = <component>(256)
x = np.ones(256, dtype=np.complex64)
y = obj.execute_cf32(x)
assert y.shape == (256,)
assert y.dtype == np.complex64
def test_correctness():
obj = <component>(256)
x = np.ones(256, dtype=np.complex64)
y = obj.execute_cf32(x)
expected = ... # compute reference result
assert np.allclose(y, expected, atol=1e-5)
@pytest.mark.parametrize("seed", [0, 1, 2, 3, 4])
def test_dsp_design_requirements(seed):
# Example: verify stopband attenuation meets the design target.
# Use a fixed-seed RNG so failures are reproducible.
rng = np.random.default_rng(seed)
obj = <component>(256)
x = (rng.standard_normal(4096) +
1j * rng.standard_normal(4096)).astype(np.complex64)
y = obj.execute_cf32(x)
# Measure stopband power and assert it meets the spec (example: -60 dB).
stopband = np.abs(np.fft.fft(y)[128:384]) ** 2
attenuation_db = 10 * np.log10(stopband.mean())
assert attenuation_db < -60.0
def test_step_steps_consistency():
obj_a = <component>(256)
obj_b = <component>(256)
x = np.random.randn(256).astype(np.float32) + \
1j * np.random.randn(256).astype(np.float32)
via_steps = obj_a.steps(x.astype(np.complex64))
via_step = np.array([obj_b.step(s) for s in x.astype(np.complex64)])
assert np.allclose(via_steps, via_step, atol=1e-6)
Run the Python suite:
Step 8 — Write the Python benchmark¶
Create src/doppler/<name>/benchmarks/bench_<name>.py. Benchmarks use
pytest-benchmark so results are
collected into versioned JSON snapshots in benchmarks/history/ and tracked
in git for regression detection.
For a full description of both the Python and C benchmark pipelines, history file format, and how to compare snapshots, see Benchmarking.
"""bench_<name>.py — throughput benchmarks for doppler.<name>."""
import numpy as np
import pytest
from doppler.<name> import <component>
BLOCK = 1_048_576 # samples per benchmark round
@pytest.fixture(scope="module")
def obj():
return <component>(BLOCK)
@pytest.fixture(scope="module")
def x_cf32():
return np.ones(BLOCK, dtype=np.complex64)
def test_<name>_execute_cf32(benchmark, obj, x_cf32):
benchmark(obj.execute_cf32, x_cf32)
pytest-benchmark handles warmup and repetition automatically. Name each
test test_<name>_<method> so results are identifiable in the JSON history.
CI commits a snapshot automatically on every push to main and on every
release tag — no manual step required. Run locally when you want an
immediate result during development:
make bench-python # saves benchmarks/history/<tag>.json
make bench-python BENCH_TAG=v1.2.3 # version-tagged snapshot (matches CI on tag push)
Compare two snapshots:
uv run pytest-benchmark compare benchmarks/history/2026-05-01-abc1234.json \
benchmarks/history/2026-05-15-def5678.json
Step 9 — Rebuild and verify¶
just-makeit handles all wiring automatically: native/src/<name>/CMakeLists.txt,
the root CMakeLists.txt add_subdirectory, and src/doppler/__init__.py.
Nothing to edit manually — just rebuild:
Checklist¶
Before opening a PR:
- [ ]
make test— all C tests pass - [ ]
python -m doctest -v src/doppler/<name>/<name>.pyi— all examples pass - [ ]
make python-test— all Python tests pass - [ ]
make bench-python— Python benchmarks run and a JSON snapshot is saved - [ ]
make bench-c— C benchmarks run and a-c.jsonsnapshot is saved - [ ]
__init__.pycontains only re-exports and__all__ - [ ] No Python wrapper classes — C extension types are the public API
- [ ]
<name>.pyihas stubs for every exported symbol - [ ]
make docs-build— docs build clean
See also¶
- Module Layout — file layout rules and rationale
- Benchmarking — C and Python benchmark pipelines, history files, comparisons
- just-makeit docs — full command reference
- Build from Source — cmake flags and make targets