Extend commands¶
These commands add behaviour to objects and modules that already exist in
just-makeit.toml. Run them from the project root after scaffolding.
just-makeit method¶
just-makeit method <object> <method_name>
[--module name]
[--param name:type ...]
[--return-type TYPE]
[--variable-output] [--arg-type TYPE]
[--multi-output TYPE ...]
Add a named execute method to an existing object.
jm method is additive and splice-free: it injects the method's
declaration into <obj>_core.h and appends a fresh C stub to <obj>_core.c,
then regenerates the glue (_ext.c, .pyi) with the new Python binding.
Existing bodies in _core.c are never re-rendered — only the new stub is
appended, ready for you to implement. The C benchmark
native/benchmarks/bench_<obj>_core.c is yours and is left alone too
(gh-1987): time the new method there yourself. On a standalone object you can
instead delete the file and run jm apply, which writes jm's render of it,
timing every declared method it can.
Arguments
| Argument | Description |
|---|---|
object |
Object name (must already exist in just-makeit.toml). |
method_name |
Snake-case name for the new method. |
--module name |
Module the object belongs to. Optional — jm reads it from the manifest (gh-963); pass it to be explicit, and it is validated. |
--param name:type |
Named typed scalar parameter. Repeatable. |
--param name:type=default |
Optional scalar parameter (e.g. gain:double=1.0) — omit it for the default. Makes the method keyword-capable; optional params must follow required ones; plain scalars only (gh-240). |
--param name:type[] |
Named numpy array parameter. Repeatable. Generates const elem_t *name, size_t name_len in C. |
--extra-arg name:type |
Synonym for --param. Repeatable. |
--out-param name:type[] |
Writable array parameter the kernel fills (const dropped). The caller's array must match the dtype exactly and be C-contiguous and writable, or the call raises TypeError. Repeatable. |
--return-type TYPE |
C type of the return value (void for no return). Default float _Complex. |
--arg-type TYPE |
C type of the method's input x: a scalar, or the element type of an input array with --variable-output or --batch. Use void for count-only inputs (the default). |
--variable-output |
Self-sizing output: allocate a NumPy-owned array per call and trim to the returned count. See below. |
--multi-output TYPE |
Add a further output. Repeatable; produces a tuple return. On a --variable-output method each is an array; on a fixed-output one each is a value the kernel writes through a trailing T *outN, returned after the kernel's own return (or after the --out-type array, or alone under --status-return). |
--out-type TYPE |
Allocate a complex64 (or other) output array per call and pass *out to C. The C stub receives (... , elem_t *out) and the Python wrapper allocates and returns the ndarray automatically. The output length equals in_len / out_divisor, where in_len is the first array input's length -- an array --arg-type first -- else the first integer --param (gh-65, gh-1888). |
--out-divisor N |
Divide the input length by N to determine the output array length when --out-type is active (default: 1). Use 2 for methods that interpret the input as interleaved I/Q pairs (e.g. a CI8 buffer where each complex sample is 2 bytes). |
--batch |
Generate a 1:1-rate array transform. The C stub receives (state, const in_t *in, size_t n, out_t *out) (or (state, size_t n, out_t *out) for --arg-type void). The Python wrapper allocates an output array of length n per call and returns it, or writes into an out= buffer of that length. Use when output length equals input length and is unknown at init time. |
--varargs |
Generate a *args/**kwargs Python binding. See below. Mutually exclusive with --arg-type, --param, and --variable-output. |
--error-on-empty |
Treat a zero count from a --variable-output kernel as a REFUSAL and raise, instead of returning an empty array. Its one return value is the length, so it has no status to carry; without this a kernel that validates its input and returns 0 produces a well-formed empty result and the caller carries on. Names the exception with --error and the text with --error-message. Mutually exclusive with none_on_empty, which reads the same zero as "nothing to report". |
--none-on-empty |
The opposite reading of the same emptiness: nothing to report is a NORMAL answer, so the call returns None. On a --borrow a NULL view means "not yet" — the non-blocking twin of a blocking wait() — and without this it raised, while the stub promised a non-optional array (gh-1418). Read by a --variable-output result, on its allocating and out= routes alike, and by a --borrow; on either, the .pyi and help() annotate the return as optional, <array> \| None (gh-2183). Refused on every other shape, which has no empty result to read (gh-2202). |
--count-type TYPE |
The C type a --variable-output kernel returns its count in (default size_t). A signed one (int64_t, int32_t, ...) goes with --error-negative: a negative count is an error code and raises, and 0 stays an ordinary empty result. Refused without --error-negative, since nothing would read the sign. See Refusing a call when zero is a real answer (gh-2012). Persists as count_type = "…". |
--error-sentinel EXPR |
A C constant expression (SIZE_MAX, (size_t)-1) a --variable-output kernel returns in place of its size_t count to REFUSE the call: the binding raises --error with --error-message, and 0 stays an ordinary empty result. For a library that keeps size_t counts; refused on a signed --count-type and beside --error-negative (gh-2012). Persists as error_sentinel = "…". |
--strict |
Refuse an array input whose dtype, rank or contiguity is wrong, rather than casting, copying and flattening it. TypeError names the dtype it wanted; ValueError names the shape, because the type was right and the shape was not. jm already refused rather than reinterpreted for an out= buffer (gh-581) and for a record input; this closes the same gap on the scalar input path. Opt-in, because for a DSP execute() the coercion is a kindness — for a ring buffer it is a hidden copy per call and a (4, 2) array accepted as 8 samples (gh-1426). Refused on a method with no input array to read — no block --arg-type, no T[] one and no array --param (gh-2313). |
--releases A[,B] |
This method releases the named --borrow method(s). jm records each borrow's count where it builds the view, and this method's count param then defaults to it — so view = buf.wait(512); buf.consume() needs the 512 only once. With no count param (a reset()) it simply clears the record. Declared on the releasing method and naming the borrows, so the relationship can be checked: a name that is not a method, or not a borrow, is refused. The default is a default, never a check — consume(k) with k < n stays legal, which is what overlapped frames do. With nothing outstanding and no argument, RuntimeError (gh-1426). |
--release-count NAME |
Which param carries the released count, when --releases is on a method with more than one. The same rule as --borrow-count — defaulted from a sole param, never guessed among several — plus one more: it must be the last param, because an optional parameter cannot precede a required one (gh-1426). |
--status-fn NAME |
C function saying WHY a --borrow returned NULL — dp_f32_wait_status. jm calls it as fn(state, <the borrow's count param>) on the NULL path, after PyErr_CheckSignals(), and matches its answer against --status-error rows. The count is passed because "this n can never be satisfied" is a property of (state, n), not of the state alone. Refused without at least one row: jm would call it and discard the answer (gh-1418). |
--status-error SPEC |
STATUS:ExcName[:message] — map one answer of --status-fn to one exception. Repeatable; STATUS is the C enumerator, emitted as a case label. A status with no row falls through to --none-on-empty if set, else the blanket raise — which is how one ring's blocking wait() and non-blocking peek() share a table and differ only in the row peek declines to write. Without this a NULL borrow raised one blanket ValueError, turning end-of-stream into an error and a Ctrl-C into bad input (gh-1418). |
--pass-capacity |
Emit the 5-arg (…, out, size_t max_out) C form for a --variable-output method (a bounds-checking C API receives the buffer capacity). |
--exact-max-out |
Assert <pkg>_<comp>_<name>_max_out() bounds any call, so the binding allocates exactly that instead of max(max_out, n) (gh-920). Say it when the bound does not depend on the call. |
--short-out |
Accept an out= smaller than _max_out() asks for, down to empty (any number of rows under --out-cols), for a backpressure kernel: it writes what fits, takes only the input that produced it, and the object reports how much, so the caller resumes there. Needs --pass-capacity. Every other method keeps the minimum (gh-2230). |
--max-out N |
The worst-case count <pkg>_<comp>_<name>_max_out() returns. jm writes that body, so it needs no implementing. |
--nogil |
Release the GIL across the pure-C kernel call of any method whose binding jm writes (numpy accessors hoisted out first), so a thread-per-shard worker scales across cores. Opt-in: two calls on one object run at once only if its kernel allows it (one object per stream); a close() from another thread waits for a running call (gh-2272). Refused with --varargs or --manual-stub, whose binding is yours. See below. |
--count-default EXPR |
C expression seeding the synthesized leading count argument of a void-input --variable-output or --batch method, e.g. "state->num_taps". Its value is the zero-arg call's behaviour, and jm cannot derive it — it lives in your C (gh-1051). Refused on a method whose input or params size the call, where there is no count to seed (gh-2313). |
--count-name NAME |
What that synthesized argument is called (default count). See Naming the synthesized count. Refused, like --count-default, where there is no synthesized count (gh-2313). |
--no-count |
Drop that synthesized argument altogether, for a kernel whose result is sized by the object alone: the method is f() (plus the optional out=), the kernel f(state, out[, max_out]), and <pkg>_<comp>_<name>_max_out(state) takes the state alone. See A method with no count (gh-2233). |
--out-cols EXPR |
Make a --variable-output result a matrix: the kernel still fills a flat buffer and returns an element count, and the binding returns (count / EXPR, EXPR). EXPR is an integer or a C expression over the object ("state->nfft"; state is self->handle). A caller-supplied out= must then be 2-D with that many columns. A count that is not a whole number of rows is a RuntimeError. _max_out() and the kernel keep speaking elements (gh-2115). |
--result-field name:T[:doc] |
Declare one field of a returned record struct (repeatable). The method returns a list of these record tuples; pair with --return-type <record_struct> (the record's C type). The optional third component documents that field — it reaches the PyStructSequence field and the record class in the .pyi, so it requires --single (gh-646) or --record-dtype (gh-788). The result-count cap (max_results, default 64) is TOML-only for now — no --max-results CLI flag yet — a header that declares the method's _max_out() sizes the result from it instead, and --consumed-fn re-offers the input a full buffer left (see Record shapes). |
--single |
With --result-field, return one named record (a PyStructSequence: attribute access + unpacking) instead of a list[tuple]. The C kernel returns the --return-type record struct by value (gh-244). |
--record-name NAME |
With --single, the public name of the record type (e.g. ToneMetrics), overriding the name derived from the C --return-type (gh-257). Also settable per method in the manifest as record_name = "…". |
--record-module MOD |
With --single, overrides the __module__ of the record type (e.g. my_pkg.dsp). By default it is the module the object is imported from, <pkg>.<comp> or <pkg>.<module> (gh-1486), so type(r).__module__ and repr(r) already name an importable path. Also settable per method in the manifest as record_module = "…" (gh-261). |
--record-doc "text" |
With --single, the record type's own documentation — what help(ToneMetrics) shows, and the record class's .pyi docstring. Defaults to the CPython-style synopsis (ToneMetrics(enob, sfdr_dbc)), never to nothing. A field with no :doc falls back to the ///< member doc on that field of the record's own struct — the method's return type — in the sacred header or a project header it includes (gh-671, gh-1300). Also settable per method in the manifest as record_doc = "…" (gh-646). |
--impl file::funcname |
Lift the method body from funcname in file instead of emitting a blank <<IMPLEMENT>> stub. |
--impl file::N:M |
Lift lines N..M (inclusive, 1-based) instead of a named function body. Out-of-bounds or inverted ranges error cleanly. |
--replace old::new |
String substitution applied to the body lifted by --impl. Repeatable. |
--doc "text" |
Python docstring for the method. |
--py-return-type STR |
Override the .pyi return-type annotation (the C --return-type still drives the C signature). |
--view ClassName |
Attach the method to a view of the object instead of the object itself — adds a view-only method, or overrides a parent method by reusing its name. What --fn does or does not say decides which override it is; see Overriding a parent method. Views are a module-object feature, so the object must live in a module. |
--fn SYMBOL |
C function this method binds, when it is not the derived <pkg>_<comp>_<name>. For adopting existing C under its own prefix, or binding a validating variant (--fn dp_tlm_emit_checked) under the plain Python name. The Python face is unchanged. On a view it also selects a signature override (below). Persists as fn = "…" on the method. |
--error-negative |
The int return is a value unless it is negative, in which case it is an error code and the method raises. Distinct from --status-return, where the int carries nothing but status. Signed integer return types only; on a --variable-output method the value is the count, so the signed type is --count-type (gh-2012). Persists as error_negative = "true". |
--status-return |
The integer return carries only status (0 = ok): the binding raises on non-zero, so no status code reaches Python, and the method returns its outputs — an --out-type array, --multi-output values (a tuple when several) — or None when it has none. Mutually exclusive with --error-negative, which is for an int that is both a value and an error channel. Needs an integer --return-type, and a fixed-output method: a --variable-output or list-of-records kernel returns its count, a --single one its record and a --borrow its pointer, so those are refused (gh-2250). Persists as status_return = "true". |
--manual-stub |
This method's C binding is already hand-written in a sacred _ext_<obj>_extra.c fragment. jm declares nothing for it and only preserves its .pyi placeholder verbatim across regeneration (gh-428). Persists as manual_stub = "true". Its method-table row comes from an [[<obj>.extra_methods]] row of the same name, whose stub replaces the placeholder (gh-1997). |
--error EXC |
Exception a declared failure raises (--error-negative, --status-return, --error-on-empty, --error-sentinel; default ValueError); one of jm's error categories. Persists as error = "…". |
--error-message TEXT |
Text for that exception; jm appends (rc=%d) where there is a code to append (--error-negative, --status-return), and not for a zero count or a sentinel. Persists as error_message = "…". |
--record-dtype STRUCT |
With --variable-output or --borrow: return one numpy structured array whose dtype is that C struct's own layout, one row per record. Its columns come from [[<obj>.records]] when the struct is declared there (just-makeit record), and from --result-field when it is not — restating them beside a declared record is refused, because a restatement drifts (gh-1407). The dtype is built at runtime by the generated C from offsetof/sizeof — jm never sees the struct, so it cannot guess C's padding (gh-788). Contrast --single, which returns one record; see Record shapes below. |
--consumed-fn NAME |
With --result-field (a list of records) and an --arg-type input: the C function saying how much of its input the kernel's last call took, size_t NAME(const <pkg>_<comp>_state_t *state). A kernel whose output cannot be bounded before the call stops when its result buffer is full; the binding then offers it the rest of the input, call after call, and returns every record in one list (gh-2204). See Record shapes. Persists as consumed_fn = "…". |
--borrow |
Return a zero-copy numpy view of memory the C state already owns. The kernel returns a pointer (<T> *<pkg>_comp_wait(<pkg>_comp_state_t *, size_t n)) instead of filling a buffer, and the view pins the object so its memory outlives any reference to it. Returning NULL raises — --error / --error-message choose which exception. The view is read-only unless --borrow-writeable, and it is a usage contract: valid until the author's own release call, after which it reads whatever the producer has written since. See Array memory ownership. Persists as borrow = "true". |
--borrow-count NAME |
Which param carries the borrowed view's element count. Defaulted from the sole param, and required when there is more than one — sizing a view from the wrong argument reaches past what the state owns and reads as data rather than as an error. Persists as borrow_count = "…". |
--borrow-writeable |
Let the caller write through a borrowed view. Off by default: a consumer holding a view into a producer's region should not be writing through it. Persists as borrow_writeable = "true". |
--no-bench |
Exclude this method from the generated C benchmark. |
Named parameters (--param)¶
Use --param name:type when the method takes multiple distinct typed scalar
inputs. This generates named parameters in both the C stub and the Python
wrapper.
just-makeit method nco configure --module dsp \
--param freq:float \
--param phase:float \
--param mode:int32_t \
--return-type void
Generated C stub:
void
<pkg>_nco_configure(<pkg>_nco_state_t *state, float freq, float phase, int32_t mode)
{
(void)state; (void)freq; (void)phase; (void)mode;
}
Python call:
All scalar types in _CTYPE_META are supported as --param types (float,
double, int, int32_t, uint32_t, size_t, float _Complex, etc.).
Array parameters (--param name:type[]) generate a numpy array input.
The C stub receives (const elem_t *name, size_t name_len) and the Python
wrapper converts whatever the caller passes (a list, an ndarray of another
dtype) the way numpy does, np.asarray(x, dtype=...). jm adds no refusal of
its own: a str converts as numpy converts it, so "0101" into a
uint8_t[] is the one element 101 (gh-1824). A uint8_t[] / int8_t[]
parameter also takes bytes, bytearray or memoryview, one element per
byte (gh-1700):
just-makeit method resamp execute_ctrl --module resample \
--param ctrl:"float _Complex[]" \
--return-type size_t
To refuse a str instead, and say where text goes, give the param a
str_hint in the manifest (gh-1756). Declaring it is the opt-in (gh-1824):
that param refuses a str with a TypeError naming it, the hint appended,
and refuses a bytes numpy would parse as a number (one into an element type
wider than a byte). The same key works on an init param, a method param, a
module-function param and a handle method's array arg:
[[frame.init_params]]
name = "sync"
type = "uint8_t[]"
str_hint = "build bits from text with field_bits()"
The hint is text, never a format: quotes, backslashes and % reach the
message unchanged. It is TOML-only, and load refuses one that is not a
non-empty string, one on a param that is not an array, and one on an out
buffer, a strict method's param or an array of a declared record -- each
refuses anything but an ndarray with its own message first, so the hint could
never be shown. A param
without the key leaves a str to numpy.
An array state field's setter takes the same key (gh-1761). A
[[<obj>.state]] field typed T[N] gets a set_<name> that converts its
argument the same way, so the opt-in and its hint go on the state row:
[[frame.state]]
name = "sync"
type = "uint8_t[8]"
str_hint = "build bits from text with field_bits()"
>>> Frame().set_sync("01010101")
TypeError: sync must be an array of numbers, not str: build bits from text with field_bits()
load refuses one on a scalar or opaque state field (which has no
set_<name>), and one on a [[<obj>.properties]] row: no property setter
converts an array, so that message names the state row instead.
Generated C stub:
size_t
<pkg>_resamp_execute_ctrl(<pkg>_resamp_state_t *state,
const float _Complex *ctrl, size_t ctrl_len)
{
(void)state; (void)ctrl; (void)ctrl_len;
return (size_t)0;
}
Python call: resamp.execute_ctrl(np.zeros(64, dtype=np.complex64))
An array of any rank converts, and name_len is its total element count, so
a (4, 19) array reaches a 1-D contract as 76 flat elements. Two manifest
keys say otherwise (gh-805 §C). rank = 1 refuses any other rank with
ValueError: name must be a 1-D array; elements_per_sample = 2 makes
name_len count samples of two elements, for interleaved I/Q in an
int16_t[] (see Memory ownership). Both work on a
method param, a module-function param and an init param (gh-2004), where
the count is the length create() receives:
Both are TOML-only. load refuses either one on a param that is not an
array, a value that is not an integer of at least 1, and, on an init param,
a rank a T[][] contradicts or an elements_per_sample above 1 where
there is no single length to divide (a T[][], or a dtype-dispatch array).
--param composes with --arg-type: the --arg-type input comes first (as
x), then each --param in order.
Releasing a borrow (--releases)¶
A release's count param defaults to the outstanding borrow's count only when
it is omitted: view = buf.wait(512); buf.consume() releases 512. An
explicit count is passed to C as given, 0 included, because a consumer's
consume(n_processed) computes 0 whenever a frame yields nothing, and the C
release decides what releasing 0 means. Until gh-2191 an explicit 0 was
read as omitted and released the whole borrow.
Varargs methods (--varargs)¶
Use --varargs when a method needs fully flexible Python argument
parsing — for example, configure(rate=48000, mode="fast") where the
parameter set is open-ended or includes types that fall outside the
fixed _CTYPE_META table.
What gets generated:
-
native/src/<comp>/<comp>_<name>_core.c(sacred — never regenerated). A Python-aware C file compiled directly into the Python extension DSO (not the pure-C OBJECT library), so it may include<Python.h>freely. Contains aPyObject *function with an<<IMPLEMENT>>stub: -
<comp>_ext.c(regenerated). Anextern PyObject *declaration pulls the symbol in from the binding file, and thePyMethodDefentry usesMETH_VARARGS | METH_KEYWORDS: -
native/src/<comp>/CMakeLists.txt(surgically updated). The binding.cfile is spliced into thePython3_add_library(...)source list so cmake compiles it into the same DSO. -
.pyistub (regenerated): -
just-makeit.toml:varargs = trueis recorded under[[<comp>.methods]].
Accessing the C state inside the binding:
The self pointer is a <Comp>Object * (the Python object), not the raw
state struct. Cast it to reach the handle:
typedef struct { PyObject_HEAD; <pkg>_filter_state_t *handle; } Obj;
<pkg>_filter_state_t *state = ((Obj *)self)->handle;
The comment at the top of the generated sacred file shows this cast verbatim.
Constraint: --varargs is mutually exclusive with --arg-type,
--param, and --variable-output. Those flags all imply a specific
typed C signature; --varargs bypasses the type system entirely and
gives you raw Python argument access.
Output modes¶
Choosing the right output mode depends on whether the maximum output count is knowable at init time.
--batch — 1:1-rate array transform¶
Use --batch when output length equals input length and is unknown at init
time. The generated C stub receives (state, const in_t *in, size_t n, out_t *out) and the Python wrapper allocates and returns an output array of
length n per call.
just-makeit method nco steps_u32 --module source \
--arg-type void --return-type uint32_t --batch
just-makeit method nco steps_ctrl --module source \
--arg-type float --return-type float --batch
Generated C stubs:
void <pkg>_nco_steps_u32(<pkg>_nco_state_t *state, size_t n, uint32_t *out);
void <pkg>_nco_steps_ctrl(<pkg>_nco_state_t *state, const float *in, size_t n, float *out);
Python calls:
ph = nco.steps_u32(1024) # returns uint32 ndarray of length 1024
out = nco.steps_ctrl(ctrl) # ctrl is float32 ndarray; returns float32 ndarray
nco.steps_ctrl(ctrl, out=buf) # writes into buf (same length) and returns it
Both .pyi faces and the runtime __doc__ say so — an array in (or the
count), an optional out=, an array out — and --nogil releases the GIL
around the kernel on both the allocating and the out= path (gh-2259).
That is the whole binding, so a batch method takes only the flags it reads:
--arg-type, --return-type, --nogil, --strict, --count-default /
--count-name (for --arg-type void), and the ones that name or document it
(--doc, --fn, --impl, --no-bench). Any other — a --param, a
--max-out, --status-return, --variable-output, a record — is refused
before anything is written, by jm method and apply alike, naming the key
(gh-2261). The list is one table, _keys.BATCH_METHOD_KEYS.
--variable-output — self-sizing output¶
Use --variable-output when the method's output count is not simply the
input count — decimators, FIFOs, detectors, anything that returns "however many
it produced". The generated binding allocates a NumPy-owned array of
max(<method>_max_out(state, n), n) per call, lets the kernel write straight
into it, and returns it trimmed to the count the kernel reported.
Each result is independent: it owns its memory, survives destroy(), and never
aliases another call's result. See Array memory
ownership for the policy and the measurements.
The array's element type is --record-dtype when given, else --out-type,
else --return-type, so it cannot be void: a method with no element is
refused before anything is written (gh-1885). A --batch method's element is
its --return-type, under the same rule, and must be a scalar: a record has
no scalar dtype (gh-2261).
just-makeit method hbdecim execute --module resample \
--arg-type "float _Complex" --return-type "float _Complex" \
--variable-output
Generated C stubs:
/* Return maximum output samples possible given current state. */
size_t <pkg>_hbdecim_execute_max_out(<pkg>_hbdecim_state_t *state,
size_t n_in);
/* Process n_in samples; write up to _max_out results; return actual count. */
size_t <pkg>_hbdecim_execute(<pkg>_hbdecim_state_t *state,
const float _Complex *in, size_t n_in,
float _Complex *out);
Python call:
| Use case | _max_out at init |
Use --variable-output? |
|---|---|---|
Decimator, fixed ratio R, block size B |
ceil(B / R) |
Yes |
Buffer / FIFO with fixed capacity C |
C |
Yes |
| FIR filter, output ≤ input length | 0 (unknown) | Yes — lazy-alloc kicks in |
| NCO extended outputs, 1:1 rate | 0 (unknown) | Yes — lazy-alloc kicks in |
max_out() returning 0 is legal and means "unknown" — the binding then
sizes the allocation from the call itself. It is a sizing contract, not a
guarantee: the real bound is n_out <= max(max_out(state, n), n),
because a generator's steps(count) writes exactly count. If your kernel
must know the capacity it was actually given, add pass_capacity = true —
which also lets the binding size the allocation from max_out() alone, but
only once max_out() takes the count and can answer for this call. See
Array memory ownership for the exact rule.
Refusing a call when zero is a real answer¶
The kernel's one return value is the count, so it has no status to carry. If
an empty result always means "refused", say so with --error-on-empty. When
zero is an ordinary answer (a resampler that emits nothing for this block),
the refusal needs a value no count can be, and jm reads one of two (gh-2012):
| Declaration | The kernel returns | The binding |
|---|---|---|
--count-type int64_t --error-negative |
the count, or a negative error code | raises --error with the message and (rc=N) on < 0 |
--error-sentinel SIZE_MAX (any C constant) |
a size_t count, or the sentinel |
raises --error with --error-message on the sentinel |
just-makeit method resampler execute_ctrl --module resample \
--param x:float[] --param ctrl:float[] --return-type float \
--variable-output --count-type int64_t --error-negative \
--error ValueError --error-message "ctrl must be as long as x"
int64_t <pkg>_resampler_execute_ctrl(<pkg>_resampler_state_t *state,
const float *x, size_t x_len,
const float *ctrl, size_t ctrl_len,
float *out);
Pick the one your C library already uses: a signed count gives each failure
its own code, which C callers read too; a sentinel keeps a size_t ABI. Both
are tested on the out= path and the allocate path, before the count is
compared with the buffer, and a refusal returns nothing. 0 stays an empty
array unless --error-on-empty or --none-on-empty also reads it.
Every declaration that could not take effect is refused, by jm method and
by apply alike: --error-negative over a size_t count, a signed
--count-type without --error-negative (a negative count would reach the
length unread), a sentinel on a signed count, both forms together, and either
key on a method whose binding never reads a count (--batch, --varargs, no
--variable-output).
Naming the synthesized count¶
A --variable-output method with --arg-type void and no --param is the
generator shape: there is no input to size the output from, so jm synthesizes
a leading count argument. Its default value is --count-default; its name is
--count-name, and defaults to count.
just-makeit method delay ptr \
--arg-type void --return-type "double _Complex" \
--variable-output --count-default "state->n" --count-name n
Say it when your C API calls the quantity something else. The paired
<pkg>_<comp>_<name>_max_out() already takes its parameter name from the C
signature — deliberately, "rather than inventing a fourth name for the same
concept" (gh-607) — so leaving the method's own kwarg hard-coded put the two
halves of one generated pair at odds: ptr(count=…) beside ptr_max_out(n=…),
for the same number (gh-1074).
Declaring the count as a real --param is not the same thing, even though
it produces a byte-identical C prototype. It leaves the generator shape, and
the default goes with it:
| synthesized count | declared --param |
|
|---|---|---|
| name settable | --count-name |
yes, it is the param name |
| default | yes, --count-default |
no |
For a generator that is not a small loss: the zero-arg call's behaviour is
its default. (Until gh-1079 the declared form also gave up the out= buffer;
that shape has it back, so this table has one row fewer than it used to.)
The name reaches the binding's _kwlist, both .pyi generators, the runtime
docstring and its worked call example — one accessor, so a project cannot end
up with a stub advertising one name and a binding parsing another.
Two names are refused: anything that is not a Python identifier, and out —
which is the other slot in the same _kwlist.
A method with no count¶
Some generator-shaped kernels write at most one result sized by the object
alone — a spectrogram's flush, the one zero-padded row a stream still
owes. A count means nothing to them, and the synthesized one only gave their
callers a keyword to ignore and their C a size_t n to (void). --no-count
removes it on every face (gh-2233):
just-makeit method spec flush \
--arg-type void --return-type float --variable-output \
--pass-capacity --no-count \
--out-cols "state->nfft" --none-on-empty \
--count-type int64_t --error-negative
size_t p_spec_flush_max_out(p_spec_state_t *state);
int64_t p_spec_flush(p_spec_state_t *state, float *out, size_t max_out);
spec.flush() # (1, nfft) row, or None when nothing is owed
spec.flush(out=buf) # into your own (rows, nfft) buffer
spec.flush_max_out() # the bound, from the state alone
<name>_max_out(state) sizes every call, and out= must hold at least that
much, exactly as for any other --variable-output method. Its bound cannot
miss a count the call does not have, so --pass-capacity keeps the exact
allocation here, where a state-only max_out beside a count would not
(gh-920). Without --pass-capacity the kernel is f(state, out), and a
max_out that returns 0 is refused at the call, as it is for an
all---param method: there is nothing else to size the result from.
It composes with --out-cols, --none-on-empty, --count-type with
--error-negative, --exact-max-out and --multi-output (which offers no
out=, so the method then takes no argument at all). It is refused where
there is no synthesized count to drop: without --variable-output, with an
input (--arg-type other than void), with any --param, and beside
--count-default or --count-name, which describe the count it removes. A
header whose _max_out still takes a count — a method scaffolded before it
gained the key — is refused by jm apply with the declaration to write.
Accumulating results is safe. Every call returns an independent NumPy-owned array, so holding them in a list, concatenating them, or passing them on cannot be disturbed by a later call. This used to require a weakref liveness probe over a shared buffer (gh-437); that machinery is gone, and the guarantee is now structural rather than defended at runtime (gh-604).
Record shapes¶
When a method's output is records rather than plain samples, three shapes
are available and --result-field names the fields in all three. What you get
back depends on which of --single / --record-dtype you pass:
| you pass | Python gets | the C kernel writes |
|---|---|---|
| neither | a list[tuple] |
<T> *result, size_t max_results |
--single |
one record, a named PyStructSequence |
the struct by value |
--record-dtype STRUCT |
an array of records, a structured ndarray | <STRUCT> *out |
A plain list of records (the first row) is filled into a buffer the
binding owns, and the kernel can report no more of them than max_results
(TOML, default 64). Its signature has no way to say there were more, so a
call with more to report returns the first max_results and the rest are
gone. When the count is known before the call, declare the method's capacity
function in the sacred header, beside its kernel -- the same function, name
and arity a --variable-output method's _max_out() has:
The binding then asks it for every call, allocates that many records, and
passes the same number as max_results, so a kernel that bounds its output
exactly never saturates (gh-2184). The count is the one the kernel is handed:
n_in, or <param>_len for a method with no input and an array param; with
neither, or when the bound does not depend on the call, it takes only the
state. jm reads the declaration, so there is no flag, and without one the
fixed buffer is unchanged. A module object's binding fragment is not
re-rendered by apply: it reports the method instead, and deleting the
fragment and re-running apply picks the capacity up. The generated
benchmark sizes its buffer from the capacity too when the header declares it
before the benchmark is written (gh-2205). It is create-only, so delete
native/benchmarks/bench_<comp>_core.c and re-run apply to time a kernel
over its whole call.
When the count cannot be known before the call -- a detector's hit count
depends on the data -- let the kernel stop when result is full, record how
much of its input it took, and name the function that says so:
just-makeit method hits push --module det --arg-type "float _Complex" \
--return-type hits_rec_t --result-field idx:size_t \
--result-field mag:float --consumed-fn dp_hits_push_consumed
The binding calls the kernel, appends its records, asks
dp_hits_push_consumed() how much input that call took, and offers the kernel
the rest -- until none is left. The records of every call come back in one
list, in call order, the same shape as without the key (gh-2204). Params ride
along unchanged on every call; only the input block advances. With a declared
_max_out() as well, each call's buffer is sized from it for the input still
left. A call that takes none of the input left raises RuntimeError
naming the method, rather than offering the same input forever, and so does
one claiming more than it was given. jm calls the function and does not
declare it: write it in the sacred header beside the kernel. The key is
refused on a method with no --arg-type input, and on one that does not
return a list of records.
--record-dtype names the element type; who owns the buffer is a separate
question, answered by --variable-output (the kernel fills a buffer sized for
the call) or --borrow (the kernel returns a pointer into memory the state
already owns, <STRUCT> *). It needs one of them — a record element type with
no array to be the element of is refused — and the two are mutually exclusive.
A borrowed record is how integer IQ is returned: numpy has no
complex-integer dtype, so a complex q15 sample is a two-field record
(int16_t i, q) and wait(n) hands back [('i','<i2'),('q','<i2')] — 1-D,
one element per sample, zero-copy, read-only, pinned to the object. See
Types for why the packed-int32 alternative was rejected
(gh-1310).
The two are mutually exclusive — the CLI rejects them together, because "one record" and "an array of records" are different results, not two spellings of one.
# an array of records: one row per telemetry entry, as a structured ndarray
just-makeit method tlm read --module link --arg-type void \
--variable-output --record-dtype dp_tlm_rec_t \
--result-field n:uint64_t --result-field flags:uint8_t
rows = tlm.read(64)
rows["n"] # a column, no per-record Python objects
rows.dtype.itemsize # C's itemsize, padding included
The dtype is built at runtime by the generated C, from offsetof and
sizeof on the author's struct. That is not an optimisation — it is the only
correct source. jm never sees the struct definition (it lives in the sacred
header, which is yours), and numpy packs a field list where C pads it: a
{uint8_t; uint64_t} struct is 16 bytes in C and 9 as a numpy dtype built from
[(name, format), ...]. Deriving the layout from the field list would silently
mis-read every row after the first.
--multi-output¶
Each --multi-output TYPE adds a parallel output array, producing a tuple
return. Combine with --variable-output:
just-makeit method nco steps_u32_ovf --module resample \
--arg-type void --return-type uint32_t \
--variable-output --multi-output uint8_t
--nogil — release the GIL for thread-per-shard scaling¶
For any method whose binding jm writes — --variable-output, --batch,
--single, a list of records, --borrow, or a fixed-size result —
--nogil wraps the pure-C kernel call in Py_BEGIN_ALLOW_THREADS /
Py_END_ALLOW_THREADS. A --varargs or --manual-stub method's CPython
function is yours, so jm has no call to wrap and refuses the flag: release
the GIL in that function instead (gh-2259). A variable-output execute method:
just-makeit method ddc execute --module ddc \
--param x:"float _Complex[]" --variable-output --pass-capacity --nogil
/* generated binding (abridged) */
const float _Complex *_ng0 = (const float _Complex *)PyArray_DATA(x_arr);
size_t _ng1 = (size_t)PyArray_SIZE(x_arr);
size_t n_out;
void *_jm_h = self->handle;
jm_inflight_enter(&self->_jm_inflight);
Py_BEGIN_ALLOW_THREADS
n_out = <pkg>_ddc_execute(_jm_h, _ng0, _ng1, _d0, _cap);
Py_END_ALLOW_THREADS
jm_inflight_leave(&self->_jm_inflight);
Every numpy accessor is hoisted into a local before the block — including
the output data pointer _d0 — so no Python C-API runs while the GIL is
dropped; the allocation and any error-raising stay above it, under the GIL. A
worker that gives each thread its own object then scales across cores
instead of serialising on the GIL.
Closing the object from another thread while a call runs is safe: the
generated destroy() / close() / __exit__ first refuses new calls
(RuntimeError), then waits with the GIL released for the running one to
return, then frees (gh-2272). So a --nogil kernel must return in bounded
time: close() waits for it, and the wait is not interrupted by Ctrl-C
(ending a blocking kernel from close() is gh-2276). A kernel that calls
back into Python which closes the same object deadlocks. A second close()
issued while the first waits returns at once; only the first returns once
the state is freed.
It is opt-in because releasing the GIL is sound only under that one-object-per-stream contract — jm cannot verify it, so you assert it by setting the flag. Generated, not hand-patched: the release is declarative and regenerates with the binding.
just-makeit property¶
just-makeit property <object> <prop_name>
[--module name]
[--type TYPE]
[--writable] [--field] [--enum NAME]
[--error EXC [--error-message TEXT]]
[--buf-field name [--len-field name] [--valid-field name]]
[--expr "C expr"]
[--value-type TYPE] [--count-fn FN] [--key-fn FN] [--value-fn FN]
Add a read-only (or read-write) Python property to an existing object.
just-makeit property nco phase --module source --type uint32_t
just-makeit property nco phase_inc --module source --type uint32_t
just-makeit property buffer capacity --type size_t --writable
just-makeit property reader samples_read --module conv --type uint32_t --field
just-makeit property reader file_type --type int --field --enum ftype
just-makeit property reader stages --type list --value-type "const char *"
just-makeit property reader keywords --type dict --value-type object
Like jm method, a computed property is additive and splice-free: it
injects a get_<prop>() declaration into <obj>_core.h and appends a fresh
stub to <obj>_core.c (plus set_<prop>() if --writable; a container
gets its count, key and value accessors) for you to implement, then
regenerates the Python getter/setter glue in _ext.c. The stub is a marked
no-op returning zero, so the untouched project builds, imports and passes —
replace its body rather than writing a second definition beside it. On a
header-only component it goes static inline into the header instead. With
--field no stub is generated — it injects one TYPE prop_name; member
directly into the state struct and auto-implements the getter as
return state->prop_name. Existing _core.c bodies are never re-rendered.
Arguments
| Argument | Description |
|---|---|
object |
Object name (must already exist in just-makeit.toml). |
prop_name |
Snake-case property name. |
--module name |
Module the object belongs to. Optional — jm reads it from the manifest (gh-963); pass it to be explicit, and it is validated. |
--type TYPE |
C type of the property value. Default size_t. |
--writable |
Also generate a setter. Without this flag the property is read-only. |
--field |
Add a TYPE prop_name; field to the state struct and auto-implement the getter as return state->prop_name. No <<IMPLEMENT>> stub is generated — the field is the implementation. Combine with --writable for a read-write struct field property. Requires the state struct to be a complete type — see Choosing a property kind. |
--buf-field name |
Back the property with a numpy view over the state member name (see Choosing a property kind). Mutually exclusive with --expr. |
--len-field name |
With --buf-field: the state member holding the buffer's element count. Default n. |
--valid-field name |
With --buf-field: a state member that, while zero, makes the property read None. |
--expr "C expr" |
Back the property with an inline C expression, emitted as written. Mutually exclusive with --buf-field. |
--error EXC |
With --writable: the C setter returns int, and a non-zero return refuses the value — the assignment raises EXC (a method's --error names, e.g. ValueError). Refused with --field, which has no C setter to ask. See A setter that refuses a value. Persists as error = "EXC". |
--error-message T |
With --error: the exception's text, with (rc=N) appended. Default "<stem>_set_<prop> failed". Persists as error_message = "…". |
--doc "text" |
Python docstring for the getter (and setter, if --writable). |
--enum NAME |
Present the property as a string from the named [[enum]] SSOT instead of the raw int (gh-519). See below. |
--value-type TYPE |
Element type of a dict/list/tuple property (gh-543). A C type means jm emits the conversion and your accessor stays pure C; object means --value-fn returns a PyObject * itself. See Container properties. |
--count-fn FN |
Entry-count accessor for a container property. Default <stem>_num_<prop>. |
--key-fn FN |
Key accessor for a dict property. Default <stem>_<prop>_key. |
--value-fn FN |
Value accessor for a container property. Default <stem>_<prop>_value. |
--capsule NAME |
With --type capsule: publish a borrowed pointer as a PyCapsule under that name, so a peer extension can consume it by name (gh-788). The capsule does not own the pointer and carries no liveness — the getter checks the object is alive before handing it out, and the capsule's destructor is NULL by contract. Persists as capsule = "NAME". |
--capsule-type T |
With --capsule: the C type the capsule lends, when --expr publishes a member rather than the object's own state (gh-1235). |
--view ClassName |
Attach the property to a view of the object instead of the object itself — adds a property the parent lacks, or overrides a parent property (e.g. its doc) by reusing its name. Views are a module-object feature, so the object must live in a module. |
Choosing a property kind — and what each costs your header¶
A property is backed one of five ways. The flags are not variations on a theme: they decide who writes the C and, crucially, whether your state struct has to be a complete type in the public header.
| Kind | Declared with | Who implements the read | State struct |
|---|---|---|---|
| Computed | no backing flag | you — jm declares <pkg>_<obj>_get_<prop>(const <pkg>_<obj>_state_t *) for the sacred _core.c |
may stay opaque |
| Field | --field |
jm — injects TYPE prop; and returns state->prop |
must be complete |
| Expression | --expr "…" |
jm — emits your C expression inline | complete, if the expr reads a member |
| Buffer | --buf-field name |
jm — returns a numpy view over that member | must be complete |
| Container | --type dict\|list\|tuple |
jm — generates the loop, refcounting and error paths; you supply count/key/value | may stay opaque |
Omitting --field is not "unsupported" — it means "you implement the
accessor". That is the computed kind, and it is the one to reach for when the
struct is deliberately private: a file handle, a scratch buffer, a decoded
header. jm declares
which takes a pointer to the state, so an incomplete type is fine — you can
forward-declare typedef struct <pkg>_rdr_state <pkg>_rdr_state_t; in the header and keep
the definition in your _core.c. The generated binding never touches a member.
The other three kinds read a member directly, so they need the definition. With
an opaque struct a --field property fails at compile time:
If you see that, the property wants to be computed.
A computed property composes with everything else here, including
--enum and its out-of-range check — the
accessor's return value is decoded exactly as a field's would be:
A setter that refuses a value (--error)¶
A writable property's binding calls <stem>_set_<prop>(state, v). Without
--error it discards what that returns, so a value the core rejects is
dropped silently: the assignment succeeds and the property goes on reading
its old value. --error (manifest error, spelled like a method's) makes
the return a verdict:
[[acc.properties]]
name = "alpha"
type = "double"
writable = true
expr = "self->handle->alpha"
error = "ValueError"
error_message = "alpha must lie in (0, 1]"
int <pkg>_acc_set_alpha(<pkg>_acc_state_t *state, double alpha)
{
if (!(alpha > 0.0 && alpha <= 1.0))
return -1; /* refuse: the state keeps its old value */
state->alpha = alpha;
return 0;
}
The setter must return int: 0 accepts, anything else refuses. Where jm
declares the setter (a computed property) it declares it int, with an
int stub. Where you declare it yourself (an --expr, --buf-field or
capsule property), declare it int; a void one does not compile against
the binding. The .pyi and help() both document the exception in a
Raises section.
--error needs a C setter call whose return jm can test, so it is refused
on a read-only property, with --field (the setter assigns the member
directly: drop --field and declare the setter returning int), and on a
property named for a state field (its setter is that field's accessor, which
jm declares void). --error-message without --error is refused too.
The converse is refused as well (gh-2302). Without error the binding calls
the setter as a bare statement, so a header that already declares it
returning int would have its status discarded: the assignment would
succeed while C refused the value. jm apply and jm status --check name
each such setter. Declare error on the property, or make the setter
void.
On a module object whose
fragment is sacred
(the default), a setter already in the fragment keeps its body, as any
member's does: declare error when you add the property, or let jm own the
fragment with fragment = "generated", whose setters follow the manifest.
Enum-valued properties (--enum)¶
A property whose C value is an index into a [[enum]] table can present that
value to Python as its string, exactly as a kind = "handle" module's
getters have always done. C still stores the int; only the Python face
changes.
[[enum]]
name = "ftype"
values = ["raw", "wav", "blue"] # order IS the C int: raw=0, wav=1, blue=2
[[reader.properties]]
name = "file_type"
type = "int"
field = true
enum = "ftype"
>>> r.file_type
'blue'
>>> r.file_type = "wav" # only when the property is --writable
>>> r.file_type
'wav'
>>> r.file_type = "nope"
ValueError: invalid file_type 'nope' (choices: raw, wav, blue)
The stub types it as Literal["raw", "wav", "blue"], so a typo is caught by
mypy rather than at runtime.
Two things worth knowing:
- The value is range-checked. If the C side holds an index the table
cannot contain — say a format code decoded from a file header that names a
variant you do not support — reading the property raises
ValueError: file_type holds out-of-range ftype value 99 (valid: 0..2)rather than reading past the table (gh-521). enumis not the same asstring_enum:.enum = "name"references the shared[[enum]]SSOT and is what properties, handle getters, and--param name:enum:<ename>use. The inlinestring_enum:a,b,cform spells its choices in the type itself and applies to constructor init-params. Prefer the SSOT whenever the same choice set is used in more than one place — that is the whole reason it exists.
--enum cannot be combined with a buf_field property: an array of enum
strings has no decoded form, and jm rejects it with a diagnostic rather than
generating something misleading.
Container properties — dict, list and tuple¶
A property whose --type is dict, list or tuple is backed by a small
iteration protocol your core implements. jm generates the loop, the container,
the refcounting and every error path; you supply the accessors.
[[reader.properties]]
name = "keywords"
type = "dict"
count_fn = "<pkg>_reader_num_keywords" # size_t (const state *)
key_fn = "<pkg>_reader_keyword_tag" # const char * (const state *, size_t)
value_fn = "<pkg>_reader_keyword_value" # <value_type> (const state *, size_t)
value_type = "object"
All three accessor names default from the object's C symbol stem and the
property name (<stem>_num_<prop>, <stem>_<prop>_key,
<stem>_<prop>_value), so the common declaration names none of them. The stem
is the object's name prefixed with [project] c_prefix, which jm new
defaults to the package name
(c-library); a name you declare
is used as written. key_fn applies to dict only — a list or
tuple is keyed by position, and passing it there is an error rather than a
silently ignored flag.
Container properties are read-only. jm builds the container fresh on every read, so a setter would mutate a copy the caller never sees; expose a method that mutates the core instead.
Choosing a value_type¶
This is the one real decision, and it is about who owns the conversion.
value_type |
Conversion | Where value_fn lives |
|---|---|---|
a C type (double, const char *, …) |
jm emits it | the sacred _core.c — pure C |
object |
you emit it | a hand-written *_ext_extra.c |
Prefer a C type. Your accessor returns an ordinary C value, jm converts it, and
your core never includes Python.h:
Reach for object when the value's Python type is data-dependent and so
cannot be annotated statically. The motivating case is a BLUE extended header,
where each keyword's type comes from a code stored in the file itself — one
keyword yields a str, the next an int, the next a list[float]. There,
value_fn returns a PyObject * directly:
It must return a new reference, or NULL with an exception set. Because it
needs Python.h it cannot live in the pure-C core — put it in a hand-written
<obj>_ext_extra.c (standalone) or <module>_ext_<obj>_extra.c (module),
which jm wires in and never modifies. jm forward-declares it above the getter,
so the #include order works out.
Stubs, and why this beats hand-writing it¶
The generated .pyi annotates as dict[str, T], list[T] or tuple[T, ...]
— Any for the object escape hatch. That is half the point: a property
hand-written into a sacred fragment never reaches the stub at all, so it stays
invisible to a type checker even though the runtime is correct.
Errors you get for free¶
A key_fn or a pointer-valued value_fn that returns NULL raises a
RuntimeError naming the property, the accessor and the index. Both are real
guards, not decoration: PyUnicode_FromString(NULL) reaches strlen(NULL) and
crashes, so the unchecked form segfaults rather than raising. A partially built
container is released on every failure path.
Removing a method or property¶
just-makeit remove <kind> <name> --object <obj> [--module <mod>] [--force],
where kind is method or property (also object, module, function,
state, warning, error or app for those respectively —
--object/--module only apply where relevant; warning and error are
addressed differently, see their own sections below, and app is
removed by its name):
just-makeit remove method configure --object nco --module dsp
just-makeit remove property phase --object nco --module dsp
This regenerates the glue (_ext.c, .pyi) without the entry, so the
binding stops exposing it, and the files jm derives from the binding with it:
the link-check table native/tests/test_<obj>_symbols.c stops naming the
removed function, and an element contract (test_<obj>_invariants.py) the
entry was one face of goes (gh-1978). It is splice-free, so it leaves the
orphaned _core.c body (and the _core.h declaration, or the field-backed
struct member) in place with a "delete by hand" note — your code is never
silently rewritten. Remove the stub yourself once you're sure. Your C
benchmark is left as it is as well (gh-1987): if it still calls the method, a
second note says so — update it before you delete the body, or it stops
linking. (Removing
state via just-makeit remove state <name> --object <obj> is structural and
rebuilds the object via the regenerate path instead.)
An object, module or function that an app is built from is not
removed while the app is there (gh-2075): jm remove refuses, names each
such app and its route, jm remove app <name>, and changes nothing. Removing
the component does not silently delete an app you may have shipped.
just-makeit record¶
Name an element type once, so both directions can reference it. Two kinds, told apart by which flag you give — a struct, or a plain scalar:
just-makeit record ring iq16_t --field i:int16_t --field q:int16_t
just-makeit record ring sample --type "float _Complex"
A scalar element's name is jm's, not C's: the element a width family carries
is usually a bare float _Complex with no typedef to point at, so the name
is an alias jm substitutes. A struct element's name is the C struct.
Declaring both --field and --type is refused — they are two elements
under one name.
[[ring.records]]
name = "iq16_t"
fields = [
{ name = "i", type = "int16_t" },
{ name = "q", type = "int16_t" },
]
The struct is yours, declared in the sacred _core.h; this says which
fields are exposed to Python and under what names. jm never sees the
definition, so the numpy dtype is built at runtime from offsetof/sizeof —
the compiler's own layout, never numpy's packing rules, so a padded struct
cannot be described wrongly.
| Flag | Description |
|---|---|
--field name:type |
A column; repeatable, and the order given is the exposed order. The type must be a registered scalar. |
--type <scalar> |
A SCALAR element (gh-1404). Mutually exclusive with --field. |
--doc TEXT |
One line describing what a row is. |
Re-declaring a record replaces its field list, so a column added to the struct
reaches the manifest by running the command again rather than by hand-editing.
The members that speak it are re-rendered then -- the binding, the stub and the
contract below -- so status --check stays clean; give the struct in _core.h
the same columns, since the binding reads each one's offset from it (gh-2055).
A duplicate column is refused: numpy takes the field names as a set and would
silently drop one.
Changing the C type an element stands for -- a scalar's --type, or a switch
between scalar and struct -- is refused while any member speaks it. It
changes their C prototypes, and jm never rewrites your definitions of them, so
re-declared underneath them the header and your _core.c would disagree. The
refusal names the members and prints the commands that make the change instead
(gh-2055):
$ just-makeit record ring sample --type float
error: 'sample' on 'ring' is spoken by write and wait, so changing its C type
(float _Complex -> float) changes their C prototypes, and jm never
rewrites your definitions of them: re-declared under them, the header
and your _core.c would disagree, and the tree would not build.
To change it, take them out, re-declare, and add them back:
just-makeit remove method write --object ring
just-makeit remove method wait --object ring
# then delete dsp_ring_write() and dsp_ring_wait() by hand, as each remove's note says
just-makeit record ring sample \
--type float
just-makeit method ring write \
--arg-type "sample[]" \
--return-type size_t
just-makeit method ring wait \
--param n:size_t \
--arg-type void \
--return-type sample \
--borrow
A switch between scalar and struct stops after the re-declare and says how the
new kind is spoken instead, since a struct is read back through
--record-dtype. An element no member speaks yet is re-declared freely.
Referencing it¶
| direction | declaration | Python |
|---|---|---|
| rows in | arg_type = "iq16_t[]" on a method |
write(x) takes a 1-D structured array |
| rows out | record_dtype = "iq16_t" with variable_output |
wait(n) returns one |
A scalar element is read back through return_type instead —
--return-type sample with --borrow or --variable-output — because its
name is jm's alias and no C type carries it. --record-dtype sample would
spell the prototype sample *out, which does not compile, so it is refused
(by jm method, by apply and status on a manifest row, and by
just-makeit record declaring a scalar under a member already reading the
name that way), naming the spelling to use instead (gh-2068).
Declaring the record once is the point: a ring buffer whose write() takes
exactly what wait() returns used to restate the columns on each face, and a
restatement drifts — doppler measured three hand-written faces disagreeing
about what one method returned.
Neither direction restates the columns. Both read them from the
declaration, so a method referencing a declared record carries no
result_fields at all, and adding one is refused rather than ignored:
$ just-makeit method ring wait --arg-type void --return-type int16_t \
--variable-output --record-dtype iq16_t --result-field i:int16_t
error: method 'wait': record 'iq16_t' already declares its columns.
Drop the result_fields here -- `[[<obj>.records]]` is where they live,
so both directions describe the same bytes. Change the columns with
`just-makeit record <obj> iq16_t --field <name>:<type> ...`.
A record_dtype naming a struct that is not declared keeps taking its
columns from --result-field, which is how every project spelled it before
just-makeit record existed. That spelling is already a complete
declaration — a name plus its columns — so nothing had to change to keep
working (gh-1407).
The generated contract¶
A component that declares an element with both a writer (arg_type = "sample[]") and a reader (return_type = "sample" with borrow or
variable_output) also gets src/<pkg>/tests/test_<comp>_invariants.py
(for an object in a module, the tests/ of the module's package: its
package when it declares one) — jm's file, written
by the jm method that completes the pair and rewritten on every apply,
beside test_<comp>.py — which is jm's too until you delete its
# jm:generated line, and never written again after that.
It asserts what the family promises: what you read is exactly what you can
write. The input face — the declared width is accepted, a foreign one is
refused rather than reinterpreted — is generated always and passes on a
fresh scaffold. "Foreign" means one numpy will not cast into the element
safely: complex128 for a real element, a string dtype for a complex one. A
widening such as int8 into a double element is a lossless cast the
binding performs, so it cannot stand in for a foreign width (gh-2067). The full round trip (same dtype, same rank, bytes identical)
needs a working kernel, since a borrowing reader's stub returns NULL, so
it appears once you have implemented the pair. There is no skipif: a test
that passes while covering nothing is worse than one that is absent.
The length reaching C is in records, not scalars. A two-sample write
passes x_len == 2, never 4; the 2 * factor that hand-written bindings get
wrong never appears.
An input's dtype must equal the record's, and is refused otherwise —
including a dtype with the same fields declared in the other order.
PyArray_FromAny accepts that one and hands C the bytes unchanged, which
delivered i and q transposed; there is no meaningful cast between
structured layouts, so jm refuses rather than reinterprets (the same rule
out= buffers follow).
just-makeit view¶
just-makeit view <object> <ViewClassName>
--module name
--create-fn fn
[--init-param name:type[:default] ...]
[--exclude-property name ...] [--exclude-method name ...]
[--doc "text"]
Add a second Python class over the same generated C core (gh-504). The view
shares the object's <pkg>_<obj>_state_t, its _core.c, and its step(); it
differs only in the C constructor it calls, the constructor arguments it takes,
and the Python surface it exposes. Use it when one algorithm has two front doors — a
continuous mode and a burst mode, an empty accumulator and a pre-seeded one —
and a second object would mean a second copy of the C.
just-makeit view acc SeededAcc --module bank \
--create-fn acc_create_seeded \
--init-param seed:double:0.0 \
--exclude-method total
This records a [[<obj>.views]] entry, injects
<pkg>_acc_state_t *acc_create_seeded(double seed); into <obj>_core.h, appends an
<<IMPLEMENT>> stub for it to the sacred <obj>_core.c (so the module still
compiles before you have written a line), and regenerates the module glue with
the extra class registered. The view lands in its own binding fragment,
<mod>_ext_<viewclass>.c, next to the parent's — no second core library and
no second state struct.
Views are a module-object feature: the multi-type module machinery is what
registers the extra class, so --module is required. --create-fn is also
required and must differ from the parent's own constructor
(<pkg>_<obj>_create, or the --create-fn the object declared) — a view
exists precisely to build from a different constructor.
Arguments
| Argument | Description |
|---|---|
object |
Object the view sits over (must already exist in just-makeit.toml). |
ViewClassName |
Python class name for the view. Must be unique across every class the module exposes — each object's class name and every existing view. |
--module name |
Module the object belongs to. Required. |
--create-fn fn |
C constructor the view's __init__ calls (e.g. acc_create_seeded). Required; must differ from the parent's own constructor (<pkg>_<obj>_create, or its --create-fn). Scaffolded as a stub in the shared core. |
--init-param name:type[:default] |
The view's own constructor parameter. Repeatable; same syntax as jm object --init-param. Omit entirely to inherit the parent's constructor shape. |
--exclude-property name |
Parent property to omit from the view's Python surface. Repeatable; must name an existing property of the parent. |
--exclude-method name |
Parent method to omit. Repeatable; must name an existing [[<obj>.methods]] entry. The builtins step/steps/reset are not excludable. |
--doc "text" |
Docstring for the view class. |
Diverging, not only trimming¶
--exclude-property / --exclude-method take members away. To add a
member the parent lacks, or override one it has, pass --view <ClassName>
to jm property, jm method,
or jm warning — reusing a parent member's name
overrides it, a new name adds it:
# `runs` exists on SeededAcc only
just-makeit property acc runs --module bank --type size_t --field \
--doc "reseed count" --view SeededAcc
# same name as the parent's property -> overrides its docstring on the view
just-makeit property acc depth --module bank --type size_t --field \
--doc "seed depth" --view SeededAcc
These persist as [[<obj>.views.properties]], [[<obj>.views.methods]], and
[[<obj>.views.warnings]] under the matching view, and are merged over the
parent's when the glue is generated. See
Configuration → Schema reference.
Excluding a method drops only the view's Python wrapper and its PyMethodDef
entry — the shared C function stays in the core, so nothing dangles and the
parent keeps working.
Overriding a parent method¶
A view method that reuses a parent method's name is an override, and there
are two kinds. --fn decides which (gh-1012, gh-1011):
| you pass | you get | the view calls |
|---|---|---|
--doc only (or a matching signature) |
a doc-only override | the parent's <pkg>_<obj>_<name> |
--fn <symbol> |
a signature override | its own <symbol>, scaffolded into the shared core |
This is not a flag standing in for a second question. The parent's C symbol
carries the parent's prototype, so a different signature is only callable
through a different symbol — which is exactly what --fn supplies:
# doc-only: same signature, the view just words it differently
just-makeit method acc plain --module bank --view SeededAcc \
--doc "seeded plain"
# -> View 'SeededAcc' overrides the doc of 'plain'
# (shares the parent's <pkg>_acc_plain)
# signature override: its own arg_type, so its own C function
just-makeit method acc scaled --module bank --view SeededAcc \
--fn <pkg>_acc_scaled_seeded --arg-type float --return-type double \
--doc "seeded scale"
# -> Implement <pkg>_acc_scaled_seeded() in acc_core.c
Both prototypes then stand side by side in the sacred header, and only the view's fragment calls the override:
double <pkg>_acc_scaled(<pkg>_acc_state_t *state, double x); /* parent */
double <pkg>_acc_scaled_seeded(<pkg>_acc_state_t *state, float x); /* the view's */
A differing signature without --fn is refused, rather than silently
ignored as it was before 0.62.0 — the view would have called the parent's
symbol, so the declaration could only ever have been dropped:
error: view 'SeededAcc' redeclares parent method 'scaled' with a different arg_type.
Without --fn the view calls the parent's <pkg>_acc_scaled, which has the parent's
signature, so this could only be ignored.
Pass --fn <symbol> to bind its own C function (gh-1012), or drop the
differing key(s) for a doc-only override.
Only the keys you actually state are compared, so a hand-written manifest
entry carrying nothing but doc is a doc-only override and stays legal. And
--fn naming the parent's own symbol is refused up front, rather than left for
the C compiler to report as a conflicting redefinition:
error: view 'SeededAcc' overrides method 'scaled' with fn '<pkg>_acc_scaled', which is
the symbol the parent already binds.
A signature override needs its OWN C function — give --fn a different name, or
drop it for a doc-only override.
Properties and warnings have no second kind: reusing a parent's name overrides
how it reads, as in the depth example above.
Limitation: a view cannot yet sit over a parent whose init-params use
per-array dtype-dispatch or optional-array forms (real_type, real_create_fn,
create_fn, optional); those paths embed <pkg>_<obj>_create directly and
would silently ignore the view's create_fn, so jm view rejects them with a
diagnostic.
The Views example builds the whole thing end to end.
just-makeit warning¶
just-makeit warning <object>
--condition FIELD
--message TEXT
[--module name] [--category NAME] [--stacklevel N]
Declare a warning that fires after construction when a boolean state field
is set — a way to flag a degenerate-but-legal configuration without failing the
constructor. The generated __init__ glue checks the field once the object is
built and, if set, calls PyErr_WarnEx with your message and category.
just-makeit warning agc --condition underpowered \
--message "AGC gain floor reached; output may clip" \
--category RuntimeWarning
This is declarative: the condition, message, and category live in
just-makeit.toml as a [[<object>.warnings]] entry, and the check is
regenerated into _ext.c — you write no C. --condition names a bool-ish
field on the object's state struct (emitted as self->handle-><field>), so it
must already exist as state (add it with just-makeit add if needed).
Arguments
| Argument | Description |
|---|---|
object |
Object name (must already exist in just-makeit.toml). |
--condition FIELD |
Bool-valued state field that triggers the warning. Required. |
--message TEXT |
Warning text shown to the Python caller. Required. |
--category NAME |
Warning class — a Python built-in warning (UserWarning, DeprecationWarning, RuntimeWarning, …). Default UserWarning. |
--module name |
Module the object belongs to. Optional — jm reads it from the manifest (gh-963); pass it to be explicit, and it is validated. |
--stacklevel N |
PyErr_WarnEx stacklevel, so the warning points at the caller's frame. Default 1. |
--after POINT |
Where the check runs. Only __init__ (the default) is supported — anything else is refused rather than accepted and ignored, so the flag is a declared extension point, not a knob with hidden values. |
--view ClassName |
Attach the warning to a view rather than the object, so a second front door over the shared core gets its own PyErr_WarnEx. Requires --module (gh-509). |
An object may carry more than one warning — each just-makeit warning call on
a distinct --condition adds another. Re-running with the same condition
replaces that entry.
Remove one with just-makeit remove warning <condition> --object <obj> — a
warning has no name of its own, so its condition is what identifies it.
just-makeit error¶
Translate a create() failure into a specific Python exception. By default a
failed constructor raises a blanket MemoryError; this replaces that with an
exception class and message of your choosing.
just-makeit error biquad \
--category ValueError \
--message "biquad coefficients unstable (|poles| >= 1)"
Also declarative: the choice is stored as create_error /
create_error_message in just-makeit.toml and regenerated into the _ext.c
failure path. There is one failure channel — a NULL from create() cannot
say why it failed — so this applies to every create() failure, a genuine
allocation failure included. Choose a category that reads sensibly for the most
likely cause.
Arguments
| Argument | Description |
|---|---|
object |
Object name (must already exist in just-makeit.toml). |
--category NAME |
Exception class — a Python built-in exception (ValueError, RuntimeError, OverflowError, …). Required. |
--message TEXT |
Text for the raised exception. Required. |
--module name |
Module the object belongs to. Optional — jm reads it from the manifest (gh-963); pass it to be explicit, and it is validated. |
--view ClassName |
Give a view its own translation instead of the object's. Views are a module-object feature, so the object must live in a module. |
Each object has a single failure translation, so re-running replaces it rather
than accumulating. Remove it with just-makeit remove error <obj> --object <obj> — error takes no name because there is only ever one per object.
Views inherit their parent's translation¶
A view is a second Python class over the same C core, so declaring the error on the parent covers both front doors — you do not need to repeat it:
just-makeit error rateconv --module resample \
--category ValueError \
--message "RateConverter: invalid parameter (need rate > 0)"
# RateConverter(...) -> ValueError
# MatchedRateConverter(...) -> ValueError, same message
This matters because a view exists precisely when the constructor takes different — usually more — parameters, so it is the constructor with the most ways to be handed something invalid.
Add --view when the view's extra parameters deserve their own wording:
just-makeit error rateconv --module resample --view MatchedRateConverter \
--category ValueError \
--message "MatchedRateConverter: invalid pulse/beta/span combination"
Only an explicit --view declaration is written to the manifest; an inherited
translation keeps tracking the parent rather than being frozen into a copy.
Note the contrast with just-makeit warning, where a
view does not inherit: a warning describes a condition the view may not
have, while an error describes the same object refusing to construct.
just-makeit function¶
just-makeit function <name> --module <mod>
[--param name:type ...]
[--return-type TYPE]
[--doc "text"]
Add a stateless C function to an existing module — no struct, no lifecycle, no persistent state.
Writes a C stub to the function's own sacred source file
native/src/<module>/<name>.c (never regenerated — your implementation is
safe) and injects the declaration into native/inc/<pkg>/<module>/<module>_core.h.
Each function thus owns one translation unit, which the module's CMakeLists
compiles into the module's OBJECT library. Then regenerates <module>_ext.c
to add a _bind_<name> Python wrapper and wire it into the PyMethodDef
array.
With --inline the function instead lives entirely as a static inline body
in <module>_core.h and gets no .c file.
The generated _bind_<name> wrapper is positional-or-keyword
(METH_VARARGS | METH_KEYWORDS): callers may pass arguments positionally or by
name (fn(input=x, n=8)). A no-parameter function stays METH_NOARGS. Keyword
capability is ~free unless keywords are actually used — see
Arguments: positional vs keyword.
Arguments
| Argument | Description |
|---|---|
name |
Snake-case function name. |
--module mod |
Module the function belongs to (required). |
--param name:type |
Named typed scalar parameter. Repeatable. |
--param name:type=default |
Optional scalar parameter — omitting it yields default (e.g. gain:double=1.0). Optional params must come after required ones; plain scalars only (gh-240). |
--param name:type[] |
Named numpy array parameter. Repeatable. Generates const elem_t *name, size_t name_len in C. |
--param name:path |
Filesystem path parameter. Python accepts str \| os.PathLike; C receives const char * via PyUnicode_FSConverter (gh-353). |
--param name:enum:<ename>[=d] |
String-choice parameter validated against the named [[enum]] SSOT; C receives the int index; optional default d is the string value (gh-353). |
--out-param name:type[] |
Writable output array parameter (const dropped). Repeatable. |
--return-type TYPE |
C return type (default: void). |
--check-return |
Treat a non-zero int return as failure: raises RuntimeError(rc), returns None on success. Requires an integer --return-type (gh-363). |
--why |
Manifest why = true. The C function takes a trailing const char **why, and a refusal raises the sentence it writes there as ValueError; with none written the --check-return error is unchanged. Requires --check-return (gh-1706). |
--status-error SPEC |
STATUS:ExcName[:message] — raise ExcName when the function returns STATUS, a C constant emitted as a case label. Repeatable; a status with no row keeps the --check-return error. Manifest status_errors, the same table a borrow method declares. Requires --check-return on a status-returning function (gh-1614). |
--result-field name:type |
Return a list of records, one field per flag (repeatable); --return-type names the record's C struct, and the C function fills <struct> *result up to max_results: --out-size when declared (gh-2205), else the TOML-only max_results (default 64). |
--out-type TYPE |
Allocate a 1-D output array of this element type per call and pass it to C as TYPE *out, after the array params (before any trailing scalars). With --variable-output it goes last instead. Its length is the first array param's; TYPE[n] ('float[n]') names the integer param that holds it instead. The C function is never told the length, so a function with neither (and, with --variable-output, no --out-size) is refused (gh-1888). |
--variable-output |
With --out-type: the function allocates its own 1-D output rather than returning a scalar — no caller buffer and no cached instance buffer. out is appended last to the C call, and the binding returns the ndarray (gh-335). A size_t-returning function is trimmed to the count it reports; a void one returns the full allocation. |
--out-size EXPR |
Length of that output, as a verbatim C expression over the function's own arguments — including each array param's generated <name>_len (e.g. x_len * factor, or a call like wfm_rrc_ntaps(sps, span)). Omit it and the length falls back to --out-type TYPE[n]'s n, then to the first array parameter's length; with none of the three the function is refused (gh-1888). With --result-field it is the number of records jm allocates and hands the C as max_results -- --out-size x_len for one record per input element -- so a call due more than 64 returns them all (gh-2205). |
--inline |
Emit a static inline body in <module>_core.h instead of a separate <name>.c. |
--doc "text" |
Python docstring for the function. |
--impl file::funcname |
Lift the function body from funcname in file instead of emitting a blank <<IMPLEMENT>> stub. |
--impl file::N:M |
Lift lines N..M (inclusive, 1-based) instead of a named function body. Ranges error cleanly. |
--replace old::new |
String substitution applied to the body lifted by --impl. Repeatable. |
Example — no parameters:
native/src/fft/fft_global_setup.c (yours to implement):
/*
* fft_global_setup.c — fft module-level function.
*/
#include "<pkg>/fft/fft_core.h"
/* <<IMPLEMENT: <pkg>_fft_global_setup>> */
void
<pkg>_fft_global_setup(void)
{
}
fft_core.h (declaration injected automatically):
Example — with parameters:
just-makeit function compute_window \
--module fft \
--param n:size_t \
--param beta:float \
--return-type float
native/src/fft/compute_window.c:
/*
* compute_window.c — fft module-level function.
*/
#include "<pkg>/fft/fft_core.h"
/* <<IMPLEMENT: <pkg>_compute_window>> */
float
<pkg>_compute_window(size_t n, float beta)
{
(void)n; (void)beta;
return (float)0.0f; /* placeholder */
}
Python call:
Array parameters work identically to jm method: append [] to the type.
just-makeit function apply_window \
--module fft \
--param data:"float _Complex[]" \
--return-type void
Path parameters (name:path) accept a str | os.PathLike from Python,
coerce it to bytes via PyUnicode_FSConverter, and forward const char * to C
— the same coercion used by the handle generator:
Python call:
from pathlib import Path
import my_pkg.dsp as dsp
dsp.load_calibration(Path("/data/cal.bin"))
dsp.load_calibration("/data/cal.bin") # str also works
Enum parameters (name:enum:<ename>[=default]) accept a choice string,
validate it against the [[enum]] SSOT in just-makeit.toml, and forward
the int index to C. Requires a [[enum]] with the matching name to be
declared in just-makeit.toml first.
just-makeit function convert_image \
--module img \
--param path:path \
--param dst_cs:enum:color_space \
--param src_cs:enum:color_space=rgb \
--return-type int \
--check-return
--check-return makes the generated binding treat a non-zero int
return value as a failure: it captures the result, raises RuntimeError on
a non-zero code (or the exception a --status-error row names for that
code, below), and returns None on success. Requires --return-type to
be an integer type (int, size_t, …). It is the module-function analog of
the handle generator's close_returns and composes naturally with path and
enum args.
from my_pkg import img
img.convert_image("input.png", dst_cs="lab") # → None, or raises RuntimeError
--why (manifest why = true) carries the C function's own reason into
the exception (gh-1706). jm appends const char **why as the last C
parameter, after out and everything else it adds, and the binding passes
the address of a local initialised to NULL. On a refusal -- a non-zero
status, or a zero count from a --variable-output function -- the exception
is ValueError(<what the function wrote>); a refusal that writes nothing
keeps the --check-return text. The parameter is invisible from Python: it
is in neither the signature nor the .pyi. The sentence should be a static
string, since jm reads it after the call returns and never frees it.
int
<pkg>_parse_rate(const char *spec, const char **why)
{
if (!*spec) {
if (why) *why = "RATE must not be empty";
return -1;
}
return 0;
}
--status-error STATUS:ExcName[:message] (manifest status_errors)
lets the returned status choose the exception (gh-1614). The binding
switches on the status before its generic raise: a row raises its exception,
and a status with no row keeps the --check-return RuntimeError. STATUS
is the author's C constant -- jm knows none -- so it must be visible to the
module's _ext.c, for instance from the module's _core.h. A message may
name the function's scalar params as {name}. It is the table a
--borrow method declares with --status-fn; on a function
the status is the return value, so there is no status function to name.
just-makeit function simpson_weights --module util \
--param 'w:double[]' --return-type int --check-return \
--status-error 'DP_ERR_INVALID:ValueError:w must have odd length' \
--status-error DP_ERR_MEMORY:MemoryError
util.simpson_weights(np.ones(4)) # ValueError: w must have odd length
util.simpson_weights(np.ones(1)) # MemoryError: ... failed (DP_ERR_MEMORY)
With --why too, the row picks the class and the sentence the function
wrote is the text, so a reason no longer has to mean ValueError; a status
with no row raises a written sentence as ValueError, as before.
A composer's delegated JSON reader has the same shape, declared per factory
as from_json_why / from_file_why under [module.X.json]
(object-of-objects).
C stub (native/src/img/convert_image.c — yours to implement):