Skip to content

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

. <(curl -fsSL https://just-buildit.github.io/just-makeit/install.sh)

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 is execute)
  • --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:

just-makeit property <component> n --module <name> --type size_t --field

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:

make test

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:

python -m doctest -v src/doppler/<name>/<name>.pyi

Fix any failures before continuing.


Step 6 — Write __init__.py

Open src/doppler/<name>/__init__.py and update the re-export:

from .<name> import <component>

__all__ = ["<component>"]

__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 / steps consistency — a block processed via steps() matches the same samples processed one-at-a-time via step()
  • 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:

make python-test

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:

make pyext        # compile the new .so
make python-test  # full pytest suite

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.json snapshot is saved
  • [ ] __init__.py contains only re-exports and __all__
  • [ ] No Python wrapper classes — C extension types are the public API
  • [ ] <name>.pyi has stubs for every exported symbol
  • [ ] make docs-build — docs build clean

See also