The object-of-objects pattern: capsule, composer & handle generators¶
A comprehensive guide to kind = "capsule", kind = "composer", and
kind = "handle" — the generators that turn jm from a one-off binding
scaffolder into a templating engine that composes objects of objects. From one
declarative manifest jm emits a self-contained extension module whose ergonomic
API lives in the .so (no forced pure-Python wrapper), with an optional
take-it-or-leave-it .pyi, JSON (de)serialization, and a command-line tool —
leaving only the DSP kernels and resource logic hand-written.
Index¶
- 1. Executive summary
- 2. Mental model
- 3. The capsule generator (
kind = "capsule") - 4. The composer generator (
kind = "composer") - 5. The handle generator (
kind = "handle") - 6. Lifecycle & memory invariants
- 7. Validation discipline: reference-first
- 8. Manifest reference
- 9. When to use which
- 10. Gaps & roadmap
- 11. Appendix: the build arc
1. Executive summary¶
1.1 The problem¶
A DSP algorithm is written once in C. Exposing it is where the cost piles up — and for composition subsystems it explodes. A waveform composer, for example, historically carried the same source/segment/spec model across three hand-written faces:
- a C command-line tool (
wfmgen.c, ~700 lines); - a hand-written CPython extension over opaque capsules (~1250 lines);
- a pure-Python OO layer (
Synth/Segment/Timeline/Composer, ~1200 lines).
Two failure modes follow. First, the string-enum tables ("tone", "bpsk",
…) were duplicated in four places, kept in sync only by tests — the number
one drift risk. Second, the ergonomic API was forced into pure Python, which
violates the "the C-extension type is the public API; __init__.py is
re-export only" rule and the "drop a .so on a target and go" goal: a bare
.so import gave only flat capsule functions, never Composer.
1.2 The solution¶
Two layered generators, each driven entirely by the manifest:
kind = "capsule"— generate "free functions over an opaquePyCapsulestate" extensions (create/execute/reset/destroy/get_*/set_*). The runtime skeleton.kind = "composer"— built on the capsule skeleton, generate the ergonomic CPython OO types (Synth/Segment/Timeline/Composer+ factories), the JSON (de)serializer from the enum single-source-of-truth, and an optional command-line tool. The kernels (accumulation, synthesis, noise resolution) stay hand-written; everything around them is generated.
1.3 What it enables¶
- A self-contained
.so. A bareimportgives the full typed OO surface — no forced Python wrapper. The.pyiis take-it-or-leave-it. - One enum definition. Every face (OO validation, JSON, CLI flags) reads the
one
[[enum]]table. The four-way drift risk collapses to a single source. - Generic, not bespoke. Nothing is waveform-specific. The same generator composes any "object of objects" — a future DDC composer drops in with a manifest and zero hand-written glue.
- Three faces from one model. OO types, JSON spec round-trip, and a CLI tool
all fall out of the same
source.fields/segment.fields+ enum SSOT. - Hand-write only the algorithm. The DSP kernels remain hand-owned; the
binding, marshalling, types, serialization, build wiring, and stubs are
generated and reconciled by
jm apply/ guarded byjm status --check.
1.4 The feature set at a glance¶
| Issue | Feature | Role |
|---|---|---|
| gh-285 | [[enum]] SSOT |
one string-enum table feeding every face |
| gh-286 | kind = "capsule" |
free-functions-over-PyCapsule skeleton |
| gh-287 | kind = "composer" |
OO types + JSON + CLI, built on the capsule |
| gh-306 | kind = "handle" |
one typed class over an opaque resource handle |
2. Mental model¶
2.1 Three shapes: capsule, composer, handle¶
All three expose opaque hand-C state rather than copying it into Python. They differ in the surface they present:
-
A capsule module presents free functions. State is a
PyCapsulehandle passed as the first argument:state = <backing>_create(...); y = <backing>_execute(state, x, out); <backing>_destroy(state). This is the lift of a hand-written "functions-over-a-capsule" extension. -
A composer module presents CPython types. The backing
<backing>_state_t *lives inside theComposerobject (no PyCapsule); the user manipulatesSynth/Segment/Timelineobjects and calls methods. The composer is "an object (the Composer) made of objects (Segments, each made of Synths)" — hence object of objects. -
A handle module presents one CPython type over a single opaque resource handle — a file writer, a socket, a clock, a session. It is the intersection of the other two: the capsule's opaque backing and lifecycle, wearing the composer's typed-class face (constructor, methods, properties, context-manager). It is the RAII resource shape, distinct from the capsule's free functions and the composer's object-of-objects.
2.2 The generated-vs-hand-written seam¶
The dividing line is deliberate and stable:
| Generated by jm | Hand-written (stays in _core.c / app-side) |
|---|---|
| Enum int↔string tables (the SSOT) | The accumulation kernel |
Capsule mechanics + execute/reset/destroy/get_/set_ + GIL + numpy marshal |
The per-source resolution (e.g. shared noise floor) |
Source/segment marshalling + a bytes buffer |
The synthesis kernels |
The Synth/Segment/Timeline/Composer types + factories |
The writer / socket / clock C logic (BLUE/SigMF/zmq) |
The Plan/SampleClock/StreamSink handle types (over that C logic) |
— |
JSON to/from, the CLI face, CMake, .pyi |
— |
The rule of thumb: jm generates everything that is a mechanical projection of
the manifest; you hand-write only what encodes the algorithm — the DSP kernels
and the resource logic (a file writer, a socket). The handle generator moved
the transport binding across the seam: doppler's SampleClock and
StreamSink types are now generated, leaving only the socket/clock C behind
them hand-written.
2.3 The enum SSOT¶
A top-level [[enum]] block names a string-enum once; the order is the C
int (append-only), unless the enum names its C constants with enumerators
(Named enums):
The composer generator emits one C table per referenced enum
(_enum_wfm_type[]), only for the enums the module's fields actually
reference, plus a shared _enum_index() lookup whenever some face parses a
name back to its int (gh-1863). Every face — type-attribute
validation, JSON ser/de, CLI choice flags — resolves names↔ints through these
tables. There is no second copy to drift against.
3. The capsule generator (kind = "capsule")¶
3.1 What it generates¶
For a capsule module, jm apply materializes three glue files:
native/src/<cname>/<cname>_ext.c— the binding (<cname>is the module id with dots as underscores:dsp.mix→dsp_mix): capsule wrapper struct, a use-after-destroy guard,<backing>_create, a variable-outputexecute(exact-dtype numpy in/out, zero-copyout[:n]view, optional GIL release), bare void methods (reset),destroy, andget_/set_accessors; plus thePyMethodDeftable andPyInit.native/src/<cname>/CMakeLists.txt— a Python-extension target linking thelink = truedependency cores.src/<pkg>/<package>/<leaf>.pyi— a typed stub.
The kernels stay in the backing object's _core.c.
3.2 Manifest schema¶
[module.ddc_fn]
kind = "capsule"
backing = "ddcr" # wraps ddcr_state_t, calls ddcr_*
capsule_name = "doppler.ddc.ddcr_state" # the PyCapsule name string
package = "ddc" # .so/.pyi land in a sibling package
header = "doppler/ddc/ddc_core.h" # backing API header (override)
depends_on = [{ name = "ddc", link = true }] # cores linked onto the .so
extra_link_libs = ["m"]
[[module.ddc_fn.init_params]] # -> ddcr_create(norm_freq, rate)
name = "norm_freq"
type = "double"
[[module.ddc_fn.methods]] # variable-output execute
name = "execute"
arg_type = "float[]"
return_type = "float _Complex[]"
caller_out = true
nogil = true
[[module.ddc_fn.properties]] # -> ddcr_get_/set_norm_freq
name = "norm_freq"
type = "double"
writable = true
Key points:
backingis the symbol prefix and the<backing>_state_tit wraps. When it names a jm component, the symbols follow that component's C stem, so under[project] c_prefix = "dp"abacking = "lo"callsdp_lo_createand wrapsdp_lo_state_t; a hand-written core (ddcr) is spelled exactly as written. The header path, capsule name and Python function names always usebackingas written (gh-1685). The same holds for a composer'sbacking.packagelets the.so/.pyibuild into a sibling package directory (doppler's formerddc_fnbuilt into theddcpackage sodoppler.ddccouldfrom .ddc_fn import ddcr_*). When unset, the module's own path is used.capsule_nameis thePyCapsulename string; the default is<pkg>.<module>.<backing>_state.depends_onentries withlink = trueadd each<name>_coreto the.so's link line (CMake does not pull OBJECT-lib objects transitively into a final.so, so the link must be direct).depends_onentries withtest_only = truelink the component's C test and bench and nothing else — not the core'sPUBLIClink line, the.so, the aggregate library, or the public core header. For a component whose test round-trips through a sibling (a reader that writes the captures it reads back), this keeps the manifest from asserting a dependency the shipped artifact does not have. It wins overlink = trueif both are set.
3.3 Capsule mechanics & lifetime¶
The generated binding wraps the state in a small struct with a destroyed flag:
createallocates the wrapper, calls<backing>_create, and returns aPyCapsulewhose destructor frees the state if it was not already explicitly destroyed (so the GC reclaims a forgotten handle)._get_wrapraisesRuntimeErrorif the handle was already destroyed — a clean use-after-destroy guard rather than a crash.executerequires the exact output dtype (no silent cast — a cast would write into a temp copy instead of the caller's buffer), runs the kernel with the GIL released whennogil = true, and returns a zero-copyout[:n]slice of the caller's buffer.
3.4 Worked example: ddc_fn¶
Historical: doppler's ddc_fn (the functional DDCR down-converter) was the
pilot; doppler has since retired it. Migrating
it from a 400-line hand-written no_generate extension to kind = "capsule"
deleted the hand code; jm apply regenerated a byte-equivalent binding that
compiled clean, passed all existing tests, and kept the
from doppler.ddc import ddcr_* re-exports working.
4. The composer generator (kind = "composer")¶
4.1 Overview: the four OO types¶
A composer module emits three CPython types into the .so, plus a
fourth (Timeline) when [module.X.timeline] type_name is set:
Synth(the source type) — one source's configuration (waveform fields, enums and an optionalbytespattern), plus factory functions (tone()/bpsk()/…).Segment— a list of sources summed over a span, plus segment scalars (fs/num_samples/off_samples). Built inline from one source's kwargs, or via theSegment.sum(*sources, …)classmethod.Timeline— an ordered, iterable run of segments played back-to-back (add/iter/len/subscript).Composer— holds the backing<backing>_state_t; built from a segment list / a singleSegment/ aTimeline/ single-segment kwargs;execute,compose,segments/repeat/continuous, JSON, context manager.
4.2 Manifest schema¶
[module.wfm_compose]
kind = "composer"
backing = "wfm_compose" # dp_wfm_compose_state_t + dp_wfm_compose_*
package = "wfm"
header = "doppler/wfm/wfm_compose.h"
depends_on = [{ name = "wfm_compose", link = true }] # and the other cores
extra_link_libs = ["wfm_cjson", "m"]
[module.wfm_compose.source]
object = "wfm_synth"
struct = "wfm_source_t" # the C struct the type wraps
type_name = "Synth" # the Python class name
fields = [
{ name = "type", type = "int", enum = "wfm_type", default = "tone" },
{ name = "freq", type = "double", default = "0.0" },
{ name = "bits", type = "uint8_t*", bytes = true },
# ... one row per source field
]
[module.wfm_compose.segment]
type_name = "Segment"
struct = "wfm_segment_t"
fields = [
{ name = "fs", type = "double", default = "1e6" },
{ name = "num_samples", type = "size_t", default = "1024" },
{ name = "off_samples", type = "size_t", default = "0" },
]
[module.wfm_compose.timeline]
type_name = "Timeline" # set it to emit a Timeline type
[module.wfm_compose.oo]
factories = ["tone", "noise", "pn", "bpsk", "qpsk", "chirp", "bits"]
discriminant = "type" # the enum field a factory presets
composer_type_name = "Composer"
[module.wfm_compose.json]
enabled = true # generate to_json/from_json/from_file
[module.wfm_compose.cli]
enabled = true # opt-in c-face command-line tool
name = "wfmgen"
# gh-1190: a method jm cannot express. The body lives in the hand-written
# native/src/wfm_compose/wfm_compose_ext_extra.c, which jm `#include`s after
# the generated types and never modifies; this row is what makes it reachable
# and typed. `type` picks which of the module's types it lands on (default:
# the composer type).
[[module.wfm_compose.extra_methods]]
name = "draws"
fn = "Composer_draws"
flags = "METH_NOARGS" # default METH_NOARGS
doc = "Per-instance draw records."
returns = "list[dict[str, object]]" # raw Python, for the .pyi
jm forward-declares each row's fn above the method table that names it, with
the signature its flags imply. METH_NOARGS, METH_O and METH_VARARGS
take (PyObject *self, PyObject *arg), and METH_KEYWORDS adds a
PyObject *kwds; METH_FASTCALL rows get CPython's fastcall signature. So
define the function with exactly that signature, self
as a PyObject * too. A declared row always includes the file, so it can be
written before or after the apply that declares it; if it is missing, the
build fails naming it. The composer_seams example builds one end to end.
apply refuses a row with no name or fn, an fn given two different
flags, and an fn the generated <cname>_ext.c (or its seam header)
already declares — Composer_dealloc, a getter, PyInit_<leaf> — since the
row's prototype would conflict with jm's own (gh-2005).
An ordinary object takes the same key, row for row, minus type (it has one
type): see
[[<component>.extra_methods]]
(gh-1997).
extra_methods is the composer's escape hatch, and it is deliberately not
spelled methods: on a kind = "handle" or kind = "capsule" module that
word means "generate the wrapper from this signature", while here the wrapper
already exists and only needs a row. A composer methods table is reported as
an unknown key naming the tables it is valid on (an object, a handle module, a
capsule module).
The fields list is the keystone: one ordered list of
{name, type, enum?, default?, bytes?} per source/segment determines the C
struct marshalling, the type's getset slots, the JSON shape, and the CLI flags —
all from a single declaration. (type is the C type; enum tags a field as a
string-enum resolved through the SSOT; bytes marks an owned byte buffer.)
A field's docstring comes from the header. Each row is a member of the
table's struct, so the member's own doc (a trailing /**< ... */ or a
/** ... */ block above it) is the field's documentation, on the .pyi and
on the runtime getset doc (help(Synth.freq)). A row's doc key is the
fallback for a member the header leaves undocumented. jm reads the headers the
binding includes (the module's header and whatever project headers it
includes) and asks only the field's own struct, so a same-named member of
another struct never documents it. An owned buffer is looked up by its
pointer member, or by the first step of a c_ptr path. If a row's doc and
the member's doc both exist and differ, jm status lists the row under DOC
and jm status --check fails (gh-1703).
A type's docstring is its struct's, on both faces. The /** ... */
block above the source or segment struct gives the class summary (its
@brief) and description, and the fields follow as Parameters. That one
docstring is the .pyi class docstring and the runtime tp_doc, so
help(Synth) lists the same keywords the stub does. A factory says what it
makes the same way on both faces: the source type it returns, the
discriminant value it presets and, when that value's [[enum]] declares
enumerators, the enumerator's header doc (WFM_SYNTH_TONE = 0, /**< ... */).
Every other method jm writes for the composer types has one docstring too,
and its runtime text is the stub's after a leading signature line (gh-2283).
A bytes field is owned: jm generates the coercion from a Python bytes,
the getter, the setter, the free in dealloc, the deep-copy when a source is
rebuilt from a resolved segment, and both halves of the JSON codec. By default
it lives in two flat members of the source struct, <name> and n_<name>.
c_ptr / c_len say otherwise, and may name a path into a nested struct
(gh-1184) — so a project whose C already has a type for the thing can carry
that type instead of flattening its parameters into the source:
# `wfm_seq_t` names a run of bits however it was produced (LITERAL / PN /
# GOLD, with the generator's poly and seed), so the source carries one
# instead of ten flat fields per sequence.
{ name = "sync", type = "uint8_t*", bytes = true,
c_ptr = "sync.bits", c_len = "sync.len" },
Naming c_ptr also makes the two sites that own the buffer cast — the
attach, which needs a uint8_t **, and the free. A relocated member is
commonly const-qualified, because the type it belongs to is written for the
borrowing consumer while the source is the owner.
coerce = "bit_pattern" widens what the field takes from Python: bytes, a
sequence of ints (a list, a NumPy array; any nonzero is a 1), or a str. jm
stores one bit per byte. By default jm reads a str itself: 0/1 digits,
or 0x hex expanded MSB first.
A project that already has a text form for a bit pattern names its own reader
with coerce_str_fn (gh-1709), so the str face uses that grammar instead of
a second one from jm:
{ name = "payload", type = "uint8_t*", bytes = true,
coerce = "bit_pattern", coerce_str_fn = "pat_parse" },
/* Declared by jm in <cname>_bridge.h; written by the project. */
size_t pat_parse(const char *text, uint8_t *out, size_t max_out,
const char **why);
jm calls it twice. The first call passes out = NULL, max_out = 0 to get the
size, and the second passes a buffer of that many bytes to fill. Each call
returns the number of bits it read, one per byte. A return of 0 is a refusal,
and *why becomes the ValueError message. The empty string is refused only
if the project's reader refuses it. This happens on every face that takes text
for the field: the constructor, the property setter, a segment's
single-source keywords, and the c-face CLI's --<field> flag, which exits 2
with the reason. A refused value leaves the field as it was. A bytes value
or an int sequence is taken as before. The key belongs to one field, so a
sibling bit_pattern field without it keeps jm's grammar. When every
bit_pattern field names a reader, jm emits no grammar of its own, and
fields naming the same reader share one generated helper (gh-1736). On a
field that does not coerce, the key would have no effect, so jm refuses it.
An owned-pointer field (gh-1711) holds a pointer to a host-owned
description object, such as a frame description a source is framed by. The
source owns a copy of it, not a reference to a Python object:
Composer.segments rebuilds sources from the kernel's structs and
from_json builds one from text, and neither has a Python object to keep
alive. The field names the pointed-to type and four host functions:
[[module.wfm_compose.source.fields]]
name = "frame"
object = "frame.FrameDesc" # resolves type, capsule, header and .pyi class
# ...or, for a pointer no jm object publishes (the gh-790 spelling):
# type = "wfm_frame_desc_t *"; capsule = "p.frame.desc"; header = "..."
c_ptr = "frame" # optional; default <name>
copy_fn = "dp_wfm_frame_desc_copy"
free_fn = "dp_wfm_frame_desc_free"
parse_fn = "dp_wfm_frame_desc_from_json"
format_fn = "dp_wfm_frame_desc_to_json"
parse_why = true # optional: parse_fn names its refusal (gh-1735)
/* Declared by jm in <cname>_bridge.h; written by the project. T is the
* pointee: the member may be `const T *`, but the source owns a `T *`. */
T *copy_fn(const T *); /* borrowed -> owned; NULL on failure */
void free_fn(T *); /* NULL-safe */
T *parse_fn(const char *); /* text -> owned; NULL on refusal */
char *format_fn(const T *); /* owned text, released with free() */
All four are required together, because each face calls one of them.
parse_why = true (gh-1735) declares the reason-naming reader instead, the
shape of [X.json] from_json_why (gh-1706):
T *parse_fn(const char *, const char **why). On a refusal the reader
points *why at a static sentence naming the cause; every face that reads
text passes it and reports that sentence -- the constructor keyword and the
setter raise ValueError(<sentence>), the generic from_json / from_file
raise it too, and the CLI prints bad --<name> TEXT: <sentence>. A reader
that refuses without writing one keeps the generic message. It is a switch:
a function name there is refused at load.
Fields may share a host function, and the bridge header declares it once.
A C function has one prototype, so every key naming it must agree on it:
two fields naming one parse_fn with parse_why on only one of them, or
one copy_fn over two types, are refused naming the function, both
fields and both prototypes (gh-1739), as is any other seam colliding with
one of these names.
object and capsule are both optional, so a field bound only from its text
form is valid; writing both is refused, as is a key of another field shape
(bytes, enum, default, c_len, ...), a type that is not a pointer,
and the field on a segment.
| face | behaviour |
|---|---|
| constructor kwarg / setter | None clears it. A capsule, or an object with ._capsule, is passed to copy_fn; a str to parse_fn, and a refusal raises ValueError. The old value is freed only once the new one is built. |
| getter | the text from format_fn, or None. It round-trips through the setter. |
| dealloc | free_fn |
Composer(...) |
shallow, like a bytes field: the backing create deep-copies the value |
Composer.segments |
each rebuilt source owns a copy from copy_fn, so nothing is freed twice |
generic to_json |
format_fn's text, nested as a JSON value when it parses as JSON and a string otherwise; a NULL pointer writes nothing |
generic from_json |
either spelling, through parse_fn; a value the host refuses fails the whole record, and the record's teardown calls free_fn. A delegated to_json_fn / from_json_fn is unaffected |
| c-face CLI | --<name> TEXT through parse_fn, freed after create; text the host refuses exits 2 |
.pyi |
the keyword and the property setter take FrameDesc \| str \| None (object \| str \| None for a bare capsule, str \| None for text only); the property reads str \| None |
The getter returns text rather than a FrameDesc by constraint: jm cannot
build a host class from a raw pointer in a separately compiled .so, the
same ABI hazard object references avoid elsewhere. The text form is the one
generic value that round-trips through the setter and the record.
4.3 The source type¶
render_source_type emits a PyTypeObject wrapping the backing C struct
(source.struct) plus a standalone fs:
- a keyword
tp_initparsing every field (defaults from the manifest); - per-field getset: enum fields cross as validated strings (int↔name via the
SSOT table; an invalid value raises
ValueErroron both init and assignment), scalars as numbers, thebytesfield as Pythonbytes; deallocfrees the owned bits buffer;- factory module functions: each preset the
oo.discriminantenum field to the factory name and forward the rest (sotone(freq=…)isSynth(type="tone", freq=…)).
Straight-C seams (gh-998)¶
A source can hand work back to the project as plain C, with no CPython in it:
[module.X.source.generates]gives the source type its ownstep()/steps()/reset(), delegating to a composed generator object (generator). The project writes the bridge that builds one from a source:<state_t> *bridge_fn(const <struct> *, double fs). The other keys (state_type,steps_fn,step_fn,reset_fn,destroy_fn,header,output_type) default to the generator's own C stem and header, andfloat _Complex. A NULL frombridge_fnraisesRuntimeError, unless the optionalbridge_error_fn,const char *fn(const <struct> *, double fs), returns a reason, which is raised asValueError(reason)(gh-1307).methods = ["prod", ...]in the same table (gh-2259) puts named methods of the generator object on the source type too:Synth(...).prod(x)instead of building the generator yourself. Each runs against the same generatorsteps()uses, built on the first call (with the same refusal) and continued after, so the two interleave on one state. The binding, its docstring and its.pyimember are the generator's own, written by the same emitter, with one addition: theirRaisesalso lists the refusal of that first build, assteps()'s does. An error the binding raises names the source (Synth.prod: ...). Only the names listed are delegated. A name is refused at load when the generator does not declare it, when it is already a source member (a field,fs, a computed property,steps,step,reset), or when the source cannot carry it: a built-in member of the generator, avarargsormanual_stubmethod (hand-written bindings), a borrow or its release (they lend the generator's memory), asinglerecord, or acodecmethod (its docstring has noRaisesto list the build in).[[module.X.source.computed]](name,type,fn,doc) is a read-only property computed in C on every read:<type> fn(const <struct> *).
jm writes the prototype of every seam (these, a field's coerce_str_fn and
an owned-pointer field's four host functions) into a generated
native/inc/<pkg>/<cname>/<cname>_bridge.h, which the binding includes. The
file is written only when at least one seam exists, so a C test or benchmark
includes it instead of re-declaring a signature jm owns. The
composer_seams example builds one end to end.
4.4 The segment type¶
A Segment holds a Python list of source objects plus the segment scalars — it
needs no backing struct (the wfm_segment_t[] is built later by the
Composer), which keeps it fully generic. Two construction faces match the
hand-written original:
- inline single-source:
Segment(type="tone", num_samples=…)forwards the source fields to the source type and wraps the one result; - multi-source:
Segment.sum(*sources, num_samples=…)(a classmethod that type-checks each positional source).
Segment.add(*others) -> Timeline sequences segments in time.
4.5 The timeline type¶
A thin sequence wrapper — add (chainable), __iter__, __len__, subscript —
the fluent face of the segment list the composer already sequences.
4.6 The composer type¶
render_composer_type is where the OO objects drive the real kernel:
__init__dispatches single-segment-from-kwargs / a loneSegment/ aTimeline-or-list, builds a transient<segment_struct>[]from the OO objects, and calls<backing>_create.execute(n)returns a zero-copy cf32 slice (GIL released across the kernel).compose(block=4096)drains a finite spec viaPyArray_Concatenate(raises on acontinuousspec).segments/repeat/continuousreflect the resolved spec back as rebuilt OO objects.close/__enter__/__exit__/deallocdestroy the backing state.
The backing provides four functions, spelled through its C stem (§3.2):
<state_t> *<backing>_create(const <seg> *segs, size_t n, int repeat, int continuous)(or thecreate_fnbelow);size_t <backing>_execute(<state_t> *, float _Complex *out, size_t max);const <seg> *<backing>_segments(const <state_t> *, size_t *n, int *repeat, int *continuous);void <backing>_destroy(<state_t> *).
A [module.X.composer] ergonomics table adds optional in-.so conveniences so
no hand-Python wraps the composer: stream = true generates
stream(block=4096) — an iterator that drains execute() into blocks — and
to_dict = true generates to_dict() (the resolved composition as a plain
nested dict, the generic primitive any sidecar format is built from). With a
realtime = {clock_create, pace, destroy, header} sub-table the iterator also
paces to an fs-Hz clock in C (for blk in c.stream(4096, realtime=1e6):),
so a project drops its hand-written paced() helper (gh-317).
The transient <segment_struct>[] aliases each source's bits pointer;
<backing>_create deep-copies (see §6), so
the transient arrays are freed straight after and ownership stays with the
Synth objects.
The create every face calls is <backing>_create unless the module names
another: [module.X] create_fn = "fn" (gh-1758), with the same four
arguments. It is the author's name, used exactly as written -- a
c_prefix never respells it -- and the Composer constructor, the
generated from_json / from_file and the C CLI's flag path all call it.
A create that can say why it refused a composition declares it with
[module.X] create_why = true (gh-1755), the constructor's form of
[X.json] from_json_why (§4.7):
the create -- create_fn, or the <backing>_create default -- takes a
trailing const char **why,
<state_t> *fn(const <segment_struct> *segs, size_t n, int repeat,
int continuous, const char **why);
and every face passes it: the constructor raises ValueError(<the reason>),
the generated from_json / from_file raise it instead of
invalid composer spec, and the C CLI's flag path prints it. A refusal that
writes no reason keeps the old ValueError("<fn> failed"). A backing that
keeps a plain four-argument create for its own C callers names the
reason-naming one in create_fn (doppler: create_fn = "dp_wfm_compose_create_why"). A create_why that is not true/false is
refused at load.
4.7 JSON faces (generated vs delegated)¶
With [module.X.json] enabled = true the composer gets
from_json/from_file/to_json. There are two modes:
- Generated (default) — a generic, SSOT-driven ser/de built from
source.fields/segment.fields. One uniform schema:{version, repeat, continuous, segments: [ {<scalar fields>, sources: [{<source fields>}]} ]}; enum fields serialize as their SSOT string, abytesfield as a JSON int array, everything else numeric.from_json/from_fileread an integer field narrower than 64 bits only as a whole number within its type's range, and abyteselement only from 0 to 255; anything else is refused with aValueErrornaming the field (gh-2247). A field of a 64-bit integer type is a JSON number while its value is within ±(2^53 - 1), the range a double holds exactly (I-JSON, RFC 7493), and a decimal string such as"18446744073709551615"past it (gh-2139).from_json/from_fileread either form exactly, and refuse with aValueErrornaming the field a number outside that range or with a fraction, a string that is not a decimal integer or is out of the type's range, and a sign on an unsigned field. The JSON face round-trips by construction, as the Python face does: a ranged field's bounds parse and read back at the field's own type, so a 64-bit pair is exact there too (gh-2224); reusable by any composer with zero hand-written wire code. (Uses cJSON; the project provides the header viajson.include_dirand links its json lib viaextra_link_libs.) - Delegated (escape hatch) — set
json.to_json_fn(plus optionalfrom_json_fn/from_file_fn/to_json_trailing) to call a hand-written C serializer instead. This exists for projects that need a specific, pre-existing wire format byte-for-byte (e.g. a domain schema with conditional field emission that a generic generator cannot reproduce).
A delegated reader that can say why it refused declares it per factory
(gh-1706): from_json_why = true means from_json_fn is
<state_t> *fn(const char *json, const char **why), and from_file_why = true the same for from_file_fn. The generated factory passes the address
of a NULL-initialised local, and a refusal raises
ValueError(<the reason>); a refusal that writes none keeps the old
ValueError("<fn> failed") / OSError("<fn> failed"). The C CLI's
--from-file passes it too and prints it. Both keys belong to the delegated
mode only -- the generated reader has no C factory to ask -- and apply
refuses them without to_json_fn. The same carriage on a module function is
why = true (--why).
4.8 The CLI face¶
With [module.X.cli] enabled = true the composer gets an opt-in standalone C
command-line tool (render_cli): a pure-C main() — no Python — that
- builds the composer from source/segment-field flags (
--type,--freq,--num_samples, …) or from a JSON spec via--from-file, which calls the project's C reader[X.json] from_file_fn(default<backing>_from_file(const char *path); underfrom_file_whyit takes a trailingconst char **why). jm does not generate that reader, and the generated JSON ser/de lives in the extension only, so a composer with the CLI enabled supplies it; - takes
--out FILE,--repeatand--continuous; - streams samples in the chosen wire format, reusing
jm app's output axes verbatim (--sample_type/--file-type/--endian); - validates enum flags against the SSOT
_enum_*tables — no hand-written flag tables; - reads a numeric flag whole at its field's declared type (decimal,
0xhex or leading-0octal for an integer), or refuses it: text that is not a number, overflows, or is outside the type's range prints<prog>: --<field>: <reason>and exits 2, through the same checked parser asjm app(gh-2225).
The CMake add_executable target is emitted outside the BUILD_PYTHON
guard (it is a C tool, not a Python module).
4.9 apply materialization¶
jm apply routes a composer module to the composer materializer (no
object-group scaffold). It writes <cname>_ext.c (the assembled module: enum
tables + the source, segment and composer types, the timeline type when
declared, the factory table and PyInit), CMakeLists.txt, <leaf>.pyi,
(when enabled) <cname>_cli.c, and (when the source declares a seam, §4.3)
native/inc/<pkg>/<cname>/<cname>_bridge.h, then splices the top-level
add_subdirectory. These are glue: jm apply reconciles them on every run and
jm status --check guards them. The manifest round-trips through save/load
so a project is reproducible from the manifest plus the hand-written kernels
alone.
5. The handle generator (kind = "handle")¶
5.1 What it generates¶
A handle module is the intersection of the capsule and composer generators
(§2.1): the capsule's opaque hand-C backing and lifecycle, wearing the composer's
typed-class face. Where a capsule presents free functions and a composer presents
an object of objects, a handle presents one typed PyTypeObject over a single
opaque resource handle.
jm apply materializes the same three glue files as a capsule —
native/src/<cname>/<cname>_ext.c, CMakeLists.txt, <leaf>.pyi — recognized at
all four dispatch sites in _apply.py (the import block, the materialize
dispatch, the _mods_need_update exclusion filter, and _sync_aggregates glue
reconciliation; miss any and jm apply / jm status --check break silently).
The generated type carries:
- a constructor — either
create_fn(allocates and returns the handle) or, for an init-in-place C API,init_fn(jmmallocssizeof(handle_type), callsinit_fn(self->h, …), andfrees on close; gh-315). It coercescreate_args— enum-string→index via the SSOT,os.fspathfor apatharg, a borrowed(const void *, size_t)for abytesarg (y#, gh-565), scalar casts — and runs an optional conditionalcreate_postsetter. A NULL return raisesRuntimeError: "<create_fn> failed"unless the module declarescreate_error/create_error_message(gh-514), which is worth doing: a handle module is the shape that opens external resources, and "no such file, unrecognised container, or an unsupported format" tells the caller what to fix where an internal C symbol name does not; - methods mapping
name → fn(self->h, …), in six shapes: scalar args (honoringdefault/ keyword args, gh-319); an array-in arg (numpy-marshaled like the capsule path), optionally followed by trailing scalars (send(iq, fs, fc), gh-308); a count-in→array-out shape returning an independent numpy-owned array, whose arguments after the count pass through behind it (fill(n, gain)→fn(h, out, n, gain), gh-2211); an array-in + writable array-out execute (execute(x, out)→ the zero-copyout[:n_out]view, gh-311), which may itself carry trailing scalars for a control port (execute_ctrl(x, out, rate, freq=0.0)→fn(h, in, n_in, rate, freq, out, max_out), gh-582 — noteoutstays second in Python so it remains required ahead of any defaulted scalar); a scalar/string-args → handle-length array-out shape (out_len_fnsizes the result); and a scalar/string-args → handle-lengthbytesshape (returns = "bytes"+out_len_fn: a temp buffer filled byfn(self->h, …, void *out), copied into an immutablebytes—save(), the write half of §5.5 save/restore, gh-565). On every shape, and on a factory, an arg declaringenumtakes that enum's choice string and reaches C as its int, as a create-arg does (gh-2228); - module-level factories (§5.5) — alternate constructors that build a fresh handle from a blob or file;
- decoded-getter properties (§5.2), including writable scalar properties — the genuinely new code;
- the RAII protocol (§5.3): an always-generated idempotent
close()and atp_deallocthat closes a forgotten handle, plus__enter__/__exit__whencontext_manageris set; - an optional weak-symbol backend guard (§5.3).
Almost everything is reused: the enum SSOT tables + _enum_index (composer), the
scalar format-char machinery and numpy marshaling (capsule / _types), and the
context-manager + idempotent-close pattern (composer). The only genuinely new C
is the decoded-getter property and the weak-symbol guard.
5.2 The decoded-getter property¶
A composer getset reads a struct field directly; a handle property decodes the
output of a shared C getter. One getter fn(self->h, &tmp) fills an
out-struct; each declared property decodes one named field with a transform:
| transform | example |
|---|---|
| plain | _to_py(tmp.frac) |
enum |
_enum_<e>[tmp.idx] → string (the SSOT) |
scale |
tmp.ns * 1e-9 |
expr |
verbatim C: tmp.peak > 0 ? 20*log10(tmp.peak) : -INFINITY |
An expr may also read a constructor value stashed into the object struct
(self->sample_type >= 2). A getter marked cache = true is resolved once in
tp_init (fixed metadata — a reader's sample rate); otherwise it is called
live on each access (a running clock's counters).
Three variations cover the C APIs that don't fill a struct (gh-311/gh-314):
- a getter whose
outis a scalar C type returns by value (tmp = fn(self->h)) and its single field decodestmpdirectly; - each field may instead name its own scalar getter via
getter = "T fn(h)"(no sharedfn/out), so a project drops the hand-C struct shim that bundled per-property getters into a*_stats_tpurely to fit the decode; - a field that also names a
writable_fnbecomes a read/write property — the getset gains a(setter)that coerces the value (PyArg_Parse) and callsset_fn(self->h, v).
[[module.wfm_writer.getters]]
fn = "wfm_writer_stats"
out = "wfm_writer_stats_t"
cache = false
[[module.wfm_writer.getters.fields]]
name = "clip_fraction"
from = "frac"
type = "double"
[[module.wfm_writer.getters.fields]]
name = "peak_dbfs"
type = "double"
expr = "tmp.peak > 0 ? 20*log10(tmp.peak) : -INFINITY"
5.3 RAII, optional backends & the UAF rule¶
close() (calling close_fn, default <backing>_close) and tp_dealloc are
always generated: close() is idempotent
(if (!self->closed) { close_fn(self->h); self->closed = 1; }) and tp_dealloc
closes a still-open handle, so even a forgotten handle releases. Setting
context_manager additionally emits __enter__/__exit__ (the latter calls
close()), so a with block releases cleanly too. A POSIX-only backend is
declared optional_backend = "<symbol>": the symbol is a weak extern, and
tp_init raises NotImplementedError when it resolves to NULL.
The use-after-free rule (§6) applies to tp_init: a path arg crosses as a
borrowed PyBytes (from PyUnicode_FSConverter); the backing *_open copies it,
so the borrow is Py_DECREF'd only after create_fn returns. A method
returning data from a grow-on-demand buffer returns an independent numpy-owned
array, never a dangling view.
5.4 Worked example: doppler's transport layer¶
The handle generator existed to retire doppler's hand-written wfmcompose_py.c
(~960 lines of CPython — the four transport types plus segment-tuple parsing and
free functions that moved to jm module functions). Writer, Reader, ZmqSink,
and SampleClock were
one archetype — a capsule-backed resource handle — instantiated four times
over the existing wfm_writer.c / wfm_reader.c / wfm_sink.c C API (whose
wfm_reader_info() already filled a struct, ideal for the decoded-getter path).
Reader used cache = true info getters; Writer / ZmqSink exposed their
stats as per-field scalar getters (gh-314, no *_stats_t shim);
SampleClock was built in place via init_fn (gh-315, no create/destroy
shim); ZmqSink / SampleClock used the weak-symbol guard (POSIX-only);
ZmqSink.send(iq, fs, fc) was the array+scalar method shape. doppler has
since moved Writer / Reader to object modules and renamed ZmqSink to
StreamSink. The validation was reference-first (§7): the first real compile of
generated handle output — scaffold → jm apply → compile + a real C backing →
import → exercise — caught a codegen bug a string-assertion missed, and now
guards the marshaling end-to-end in CI.
5.5 Zero-binding save / restore (gh-565)¶
A handle often wants to persist and reconstruct — a "prepare once, materialize
many" plan that skips an expensive setup on reload. jm generates the whole
round-trip, no _ext.c and no Python: a handle serializes to bytes and a
module-level factory rebuilds a fresh handle from that blob (or a file).
Write — returns = "bytes". A method whose output length comes from the
handle declares an out_len_fn; jm sizes the blob with it, fills a temp buffer
via fn(self->h, …, void *out), copies the bytes into an immutable bytes
object, and frees the temp. Because the return is an owned copy there is no
aliasing — none of the array shapes' deferred-free / view machinery applies.
[[module.wfm_plan.methods]]
name = "save"
fn = "wfm_plan_save" # size_t (const h*, void *out) -> bytes written
out_len_fn = "wfm_plan_save_bytes" # size_t (const h*)
returns = "bytes"
Restore — [[module.<name>.factories]]. A factory is a module-level
function (not a method, not a classmethod) that parses an init-param, calls an
alternate create_fn to build a fresh handle, wraps it in the module's typed
class, and returns it. The primary constructor is untouched and the type stays
@final.
[[module.wfm_plan.factories]]
name = "PlanFromBlob"
create_fn = "wfm_plan_restore" # h* (const void *blob, size_t n)
init_params = [{ name = "blob", type = "bytes" }]
[[module.wfm_plan.factories]]
name = "PlanFromFile"
create_fn = "wfm_plan_load" # h* (const char *path)
init_params = [{ name = "path", type = "path" }]
from doppler.wfm import Plan, PlanFromBlob, PlanFromFile
blob = p.save() # bytes
p2 = PlanFromBlob(blob) # a fresh, independent Plan
p3 = PlanFromFile("plan.bin") # ditto, from a file
| Factory key | Notes |
|---|---|
name |
The module-level function name (e.g. PlanFromBlob) |
create_fn |
Alternate constructor h* (…) that returns a fresh handle |
init_params |
{name, type} — a bytes blob, a path, or scalars |
Restore semantics. A factory
tp_allocs the instance and bypassestp_init, so any expr-stashed init scalars (Python-sidecreate_argsreferenced by anexprgetter, §5.2) stay zero — the blob reconstructs the handle wholesale and those Python values aren't available on restore.cache = truegetters are resolved from the rebuilt handle. If a factory needs a stashed scalar for anexprgetter, pass it as an extrainit_param.
6. Lifecycle & memory invariants¶
The hard-won rules that keep the generated C correct:
-
Deep-copy, then free.
<backing>_createmakes its own copy of the segment list including each source'sbits. So a caller (the composer__init__, the JSON parser, the CLI) may build a transient array that aliases the source buffers, call create, then free the transient — the composer owns its own copy. -
Alias-then-copier ordering (the use-after-free lesson). When you alias a Python object's owned buffer into a transient struct and then call a C copier, the owner must stay alive until after the copier returns. The composer
__init__originally dropped the only reference to the freshly-builtSegment→Synthchain beforecreateran; in the single-segment-kwargs path that freed thebitsout from under create's deep-copy. The fix keeps the segment list alive acrosscreate. Generalize: any "alias into a Python object's owned buffer, then call a C copier" pattern must keep the owner alive past the copier. -
Zero-copy numpy views pin the buffer's owner, not the buffer. A returned
out[:n]view keeps the array object alive; if a later call couldreallocthat array's data, return an independent numpy-owned array instead. -
bytesownership is single. The source type ownssrc.bits(freed indealloc). Rebuilt sources (from the resolved spec or JSON) deep-copy so each object owns its own. The CLI frees its flag-built buffer aftercreate(create deep-copied) so the tool is Valgrind-clean;free(NULL)is a no-op when the flag is absent. -
GIL release across the pure-C kernel (
nogil) is safe under the one-state-per-call contract: the kernel touches only this stream's state and the caller's buffers, references to the numpy arrays are held, and pointers are fetched before the block.
7. Validation discipline: reference-first¶
Unlike the capsule (which cloned a known-good hand-written extension), the composer OO types had no existing C reference — the behaviour lived in validated Python. So the discipline was:
- Compile and run, don't assert-on-string. Each generated C type was compiled against the project's real structs and exercised, not merely matched against expected substrings. (Structural string tests still exist as a fast, compiler-free unit gate.)
- Byte-exact against the reference. Output was compared sample-for-sample to
the hand-written Python reference across every spec shape (single waveform,
multi-segment timeline, multi-source sum, a bits pattern, a chirp), plus JSON
round-trip and block-wise
execute == compose. - The full chain in the real project. The end-state proof ran
manifest → jm apply → the project's own CMake build → import → samples, and for the CLI→ build the executable → run it, confirming byte-exactness at every layer. - Pilot link recipe. When linking a standalone probe against a large
project's object tree, link the
*_coreobjects (minus test/bench mains and any core that drags an unused transport seam — use the no-op stub core instead), plus the vendored json objects, plus-lm. Undefined Python/numpy symbols resolve at import.
8. Manifest reference¶
Shared (capsule, composer & handle):
| key | meaning |
|---|---|
kind |
"capsule", "composer", or "handle" |
backing |
symbol prefix; wraps <backing>_state_t, calls <backing>_* |
package |
package dir the .so/.pyi build into (default: module path) |
header |
backing C API header, included verbatim (default <pkg>/<backing>/<backing>_core.h; spell an override "<pkg>/...") |
depends_on |
[{name, link=true}, …] — cores linked onto the .so |
extra_link_libs |
non-core link targets (e.g. "m", a vendored json lib) |
doc |
the module's docstring, on its m_doc and its re-export __init__.py (gh-645) |
platforms |
the platforms the module's extension is built on (gh-1463) |
Module-level [[module.X.functions]] are a plain module's. On any of the three
kinds the table is refused with a warning, and jm function --module on one
exits naming the plain-module route. Each face reads only the tables listed for
it below; one that belongs to another face (a handle properties, a capsule
getters, a composer init_params, ...) warns and names the table that face
reads instead.
A capsule's init_params and properties, and a handle's create_args,
factory init_params and getter fields, each cross as one C scalar. A
type there that is an array ("float[]") or a spelling jm does not know is
refused before anything is written, with one error: line naming the module,
the table and the row (gh-2009). A constructor array is supported on an
object's [[<object>.init_params]].
A composer's settings, segment.fields, source.computed, source fields
and serializer params convert each value by its declared type, through
the same conversion an object's property and method use: a double keeps
its fraction, a bool reads back as a bool, a complex keeps both parts,
and the .pyi says so (gh-2035). The same refusal covers them. Beyond it,
a settings, segment.fields or source.computed row holds a number, so a
string type is refused there; and a complex segment field is refused when
the JSON or CLI face is on, since each carries a segment field as one real
number.
Capsule only: capsule_name (the PyCapsule name string; default
<pkg>.<module>.<backing>_state), [[module.X.init_params]]
(name/type), [[module.X.methods]] (name, arg_type?,
return_type?, caller_out?, nogil?), [[module.X.properties]]
(name/type/writable?).
Composer only:
| table / key | meaning |
|---|---|
create_fn |
the create every face calls, <state_t> *fn(segs, n, repeat, continuous); default <backing>_create, used as written (gh-1758) |
create_why |
the create takes a trailing const char **why; every face that creates raises or prints its reason (gh-1755) |
[X.source] |
object, struct, type_name, fields[]; optional computed[] and generates (the straight-C seams, §4.3) and ranged |
[X.segment] |
type_name, struct, fields[]; optional sources_member/count_member (default sources/n_sources), flat_sources (a one-source segment proxies that source's fields as read-only attributes), ranged |
ranged |
on [X.source] / [X.segment]: [{name, flag}, …], fields that also take a (lo, hi) pair, redrawn uniformly each repeat; the struct carries a <name>_hi companion. Each bound converts at the field's own type, so an integer one keeps every bit and is refused out of range (gh-2224) |
[X.timeline] |
type_name (the Timeline type is emitted only when it is set) |
[X.oo] |
factories[], discriminant, composer_type_name |
[[X.serializers]] |
an extra delegated serializer {name, fn, returns?, params?[], header?}: a <Composer>.<name>(<params>) method calling the project's C fn(<params>, segs, n) over the resolved segments, for a domain wire format jm does not generate (gh-317) |
[X.json] |
enabled; optional to_json_fn/from_json_fn/from_file_fn/to_json_trailing/from_json_why/from_file_why (delegation), header/include_dir (generated path) |
[X.composer] |
stream, to_dict; optional realtime = {clock_create, pace, destroy, header} to pace stream() in C (gh-317) |
[[X.settings]] |
a post-construction setting {name, setter_fn, getter_fn, type, enum?} — a scalar the backing exposes through a setter/getter pair and that is set once, after create_fn returns and before the first execute(). Becomes a constructor kwarg AND a read/write attribute; a string-valued one resolves through its [[enum]] SSOT (gh-1126) |
[X.cli] |
enabled, name |
A field entry (source.fields/segment.fields):
{ name, type, enum?, default?, bytes?, complex?, c_ptr?, c_len?, coerce?, coerce_str_fn?, aliases?, doc? }
— one declaration drives the marshalling, the type slots, the JSON shape, and
the CLI flag. complex marks an owned complex64 array, as bytes marks an
owned byte buffer; c_ptr / c_len relocate either into a nested struct
(§4.2); coerce = "bit_pattern" and coerce_str_fn widen what a bytes
field takes (§4.2); aliases are constructor keywords accepted in place of
the field's name; doc is the fallback docstring for a member its header
leaves undocumented. A source field may instead be an owned pointer (§4.2,
gh-1711):
{ name, object? | type + capsule? + header?, c_ptr?, copy_fn, free_fn, parse_fn, format_fn, parse_why? }.
A project that generates its own faces from these rows (a CLI table, a JSON schema) may put its own keys on them, and declare them once so jm does not warn that each one has no effect (gh-1752):
[project]
composer_field_keys = ["cli", "json", "metavar"]
[[module.wfm_compose.source.fields]]
name = "freq"
type = "double"
cli = "--freq" # read by the project's generator, not by jm
metavar = "HZ"
Any other unknown key on a field row still warns, so a misspelt jm key stays visible; a declared name on any other table still warns too. A name jm itself reads on a field row is refused.
Handle only:
| table / key | meaning |
|---|---|
handle_type |
the opaque C handle type (default <backing>_t) |
type_name |
the generated CPython class name (Plan) |
create_fn |
the backing constructor; create_args[] are {name, type, enum?, default?, kwonly?, doc?} (type = "path" → os.fspath) |
init_fn |
init-in-place ctor over a caller-allocated struct (jm mallocs + frees); mutually exclusive with create_fn (gh-315) |
[[X.create_post]] |
conditional post-create setter {fn, when?, arg?} |
create_error / create_error_message |
exception + message raised when create_fn returns NULL; undeclared → RuntimeError: "<create_fn> failed" (gh-514) |
[[X.methods]] |
{name, fn, args[], returns?, nogil?, error?, error_message?, status_return?, out_len_fn?, doc?} — scalar (args honor default); array-in (+ trailing scalars); count-in→array-out (+ trailing scalars, gh-2211); array-in + a writable=true array-out execute (gh-311/319); out_len_fn sizes a handle-length array or bytes result (§5.5) |
[[X.factories]] |
module-level alternate constructors {name, create_fn, init_params[]} that build a fresh handle (§5.5, gh-565) |
close_returns |
"int": close() raises RuntimeError on a non-zero close_fn return; tp_dealloc still ignores it |
serializable |
true: state_bytes() / get_state() / set_state() over the handle, from the backing's <backing>_state_bytes / _get_state / _set_state (gh-403) |
capsule (or capsule_name) |
publish a borrowed _capsule property lending the handle's pointer, so another component can take it as a capsule parameter (gh-794) |
error / error_message (on a method) |
over an int returns, a non-zero rc raises error with error_message (the rc appended) instead of crossing as an int; undeclared message → "<fn> failed". The .pyi says -> None and documents a numpy Raises section, from the same pair the binding raises with (gh-565/gh-1111/gh-1116). Needs a status return: on an array or bytes result the C return is the payload length, so there is no rc to check and the declaration is refused (gh-1118); with an array argument jm reads the return as a count unless the method says status_return = true |
status_return (on a method) |
true: the int returns is a status, as on an object method: non-zero raises error (ValueError when undeclared) and the method returns None. The one way an array-argument method such as send(iq, fs, fc) raises on a refused block; refused over a non-integer returns or an array / bytes result (gh-2186) |
[[X.getters]] |
a shared struct getter {fn, out, cache?, fields[]}, or per-field scalar getters (each field a getter); field {name, from?, type, enum?, scale?, expr?, getter?, writable_fn?} (gh-311/314) |
close_fn |
the idempotent close() / tp_dealloc destructor (always generated; default <backing>_close) |
context_manager |
also emit __enter__/__exit__ (__exit__ calls close()) |
optional_backend |
a weak-symbol backend; absent → NotImplementedError |
9. When to use which¶
-
Reach for
kind = "capsule"when the natural API is free functions over an opaque handle — a streaming processor whose state you create once and feed blocks (a down-converter, a filter chain), especially when you want a flat binding that a sibling package re-exports. -
Reach for
kind = "composer"when you are composing objects of objects — building a higher-level object out of a list of configured source objects, sequenced and serialized: waveform composition today; equally a multi-stage channelizer, a scenario/timeline builder over any generator object, or a DDC composer. If you want the ergonomic OO surface to live in the.so(not a pure-Python wrapper), plus JSON round-trip and a CLI, this is the shape. -
Reach for
kind = "handle"when the natural API is one typed object over an opaque resource handle with RAII — a file writer, a socket sink, a sample clock, a session: a constructor, a few methods, read-only decoded-from-a-getter properties, and awith-block /close(). It's the typed-class counterpart to a capsule (which gives the same backing as flat free functions instead). -
Stay with a plain object-group module /
jm appwhen there is a single object with a simple scalar/blockwise/generator I/O shape and no composition.
10. Gaps & roadmap¶
- JSON delegation vs generation. The generated JSON ser/de is generic and
SSOT-driven. A project that must reproduce a domain-specific wire schema
byte-for-byte (conditional field emission, bespoke layouts) uses the
json.to_json_fndelegation hatch — at the cost of keeping that one hand-written serializer (and its enum copy). - The last enum copy. The CLI's
--from-filealways calls a backing C reader ([X.json] from_file_fn, default<backing>_from_file), and that reader keeps its own enum table. Collapsing the final copy to zero needs a standalone generated C ser/de (enum tables + to/from JSON over the backing structs) shared by both the extension and the CLI — a deferrable follow-up. - CLI faces. The c-face CLI is generated; console/pep723 faces are not (the
OO
Composeralready gives Python users the API directly).
11. Appendix: the build arc¶
| Issue / PR | Slice |
|---|---|
| gh-285 | [[enum]] SSOT |
| gh-286 | capsule generator; ddc_fn pilot |
| gh-287 | composer: schema + Synth/Segment |
| gh-287 | composer: Timeline/Composer + JSON faces + Segment.add |
| gh-287 | composer: jm apply materialization |
| gh-287 | composer: generic SSOT-driven JSON ser/de |
| gh-287 | composer: generic c-face CLI generator |
| gh-306 | handle generator: typed class, decoded-getters, RAII |
| gh-308 | handle: array+scalar method args + real-compile CI harness |
| gh-311 | handle: array-in→writable-array-out execute + writable prop |
| gh-314 | handle: per-field scalar getters (drop the struct shim) |
| gh-315 | handle: init_fn init-in-place constructor |
| gh-319 | handle: keyword / default args on methods |
| gh-318 | module functions: stateless variable_output (self-sizing) |
| gh-317 | composer: realtime-paced stream() (in-.so pacing) |
Each slice was validated by compiling and running the generated C against a real
project and comparing byte-for-byte to the hand-written reference, culminating in
the full manifest → apply → build → import → samples chain in situ.