Configuration — just-makeit.toml¶
Every project scaffolded by just-makeit new contains a just-makeit.toml
file at the project root. It is the single source of truth for the project's
structure: what objects exist, what state they carry, what types and flags were
used, and how the build system is configured.
just-makeit reads this file before every object, add, method,
property, and script command — you never need to pass the project name or
repeat earlier choices on the command line.
What is stored¶
| Category | Stored in TOML |
|---|---|
| Project name and version | Yes |
Build system (--build-system) |
Yes |
Performance annotations (--perf) |
Yes |
Test runner (--pytest, --pytest-benchmark) |
Yes |
| Objects and their state variables | Yes |
arg-type, return-type, --mutable, --no-state, --no-step |
Yes |
Constructor parameters (--init-param) |
Yes |
| Extra methods, properties, module-level functions | Yes |
| Module subpackage structure | Yes |
--impl / --replace lifted code |
Yes — stored as impl / replace |
A lifted --impl body is stored in the manifest (as impl, create_impl,
reset_impl, destroy_impl, …), so jm regenerate and jm apply can
re-stamp it. Edits you make afterwards to the sacred _core.c are yours and
live only in source — the manifest holds the original lift, not your later
changes.
Project layout and schema¶
After just-makeit new my_project followed by just-makeit object engine.
just-makeit.toml sits at the project root — every command reads it from
there, no flags required.
By default (the fragment layout — see jm new),
just-makeit.toml itself holds only [project] plus an include glob; each
object's and module's section lives in its own objects/<name>.toml /
modules/<name>.toml fragment instead of being inlined. The schema — every
key and table shown below — is identical either way; only which physical file
holds it changes. Pass --no-fragments to jm new to inline everything into
a single just-makeit.toml, as older projects (and the "combined schema" tab
below) do.
my_project/
├── just-makeit.toml
├── objects/
│ └── engine.toml
├── CMakeLists.txt
├── Makefile
├── pyproject.toml
├── bootstrap.toml
├── Doxyfile
├── zensical.toml
├── .gitignore
├── README.md
├── docs/
│ ├── index.md
│ └── api.md
├── cmake/
│ ├── my_project-config.cmake.in
│ └── my-project.pc.in
├── native/
│ ├── inc/
│ │ ├── my_project.h
│ │ ├── clib_common.h
│ │ ├── pyex_common.h
│ │ └── engine/
│ │ └── engine_core.h
│ ├── src/
│ │ ├── my_project_lib.c
│ │ └── engine/
│ │ ├── engine_core.c
│ │ ├── engine_ext.c
│ │ └── CMakeLists.txt
│ ├── tests/
│ │ └── test_engine_core.c
│ └── benchmarks/
│ ├── bench_engine_core.c
│ └── jm_bench.h
└── src/
└── my_project/
├── __init__.py
├── engine.pyi
├── tests/
│ └── test_engine.py
└── benchmarks/
└── bench_engine.py
# One section per object, named after the object.
[engine]
arg_type = "float _Complex"
return_type = "float _Complex"
mutable = "false"
no_state = "false"
no_step = "false"
# One entry per --state declaration.
[[engine.state]]
name = "gain"
type = "double"
default = "1.0"
# One entry per --init-param.
[[engine.init_params]]
name = "order"
type = "int"
default = "4"
# One entry per --array-arg.
[[engine.array_args]]
name = "coeffs"
type = "float32"
# One entry per `just-makeit method`.
[[engine.methods]]
name = "normalize"
return_type = "void"
params = [{name = "scale", type = "double"}]
# One entry per `just-makeit property`.
[[engine.properties]]
name = "peak"
type = "double"
writable = true
field = true
Everything above, inlined into one just-makeit.toml — this is what
jm new --no-fragments produces, and what every fragment ultimately
means regardless of which file it lives in:
[project]
name = "my_project"
version = "0.1.0"
build = "cmake"
perf = "false"
pytest = "false"
pytest_benchmark = "false"
[engine]
arg_type = "float _Complex"
return_type = "float _Complex"
mutable = "false"
no_state = "false"
no_step = "false"
[[engine.state]]
name = "gain"
type = "double"
default = "1.0"
[[engine.init_params]]
name = "order"
type = "int"
default = "4"
[[engine.array_args]]
name = "coeffs"
type = "float32"
[[engine.methods]]
name = "normalize"
return_type = "void"
params = [{name = "scale", type = "double"}]
[[engine.properties]]
name = "peak"
type = "double"
writable = true
field = true
# Module subpackage, named after the module.
[module.filter]
objects = ["fir", "biquad"]
[[module.filter.functions]]
name = "design_lowpass"
return_type = "void"
doc = "Compute FIR coefficients for a lowpass filter."
params = [{name = "cutoff", type = "double"}]
[fir]
arg_type = "float _Complex"
return_type = "float _Complex"
mutable = "false"
no_state = "false"
no_step = "false"
[[fir.state]]
name = "coeffs"
type = "float[16]"
default = "0.0f"
Module dependencies & external libraries¶
jm apply regenerates each module's CMakeLists.txt, so dependency wiring must
be declared in the manifest — a hand-edited link line is clobbered on the
next apply. Four keys cover every case; none of them require post-apply patching,
and jm status --check stays clean with no allowlist.
| You need… | Key | Scope | Effect |
|---|---|---|---|
| link an external/system library | extra_link_libs |
[module.<m>] |
appended verbatim to target_link_libraries; generator expressions allowed |
| link a sibling module's core | extra_link_libs (name the <obj>_core) |
[module.<m>] |
names the target on the link line |
| call into another object's API (link and include its header) | depends_on |
[<object>] |
links <dep>_core and injects #include "<dep>/<dep>_core.h" (gh-170) |
| add a hand-written C support dir other modules link | c_deps |
[project] |
emits add_subdirectory(native/src/<dir>); that dir's CMakeLists.txt is hand-owned (never regenerated) |
| add a hand-written Python extension module | no_generate |
[module.<m>] |
emits the add_subdirectory, leaves the module's _ext.c / .pyi / CMake alone |
| keep a module's functions in one TU | functions_in_core |
[module.<m>] |
appends every function body to <m>_core.c (shared static helpers, one TU) instead of one .c per function; CMake lists only <m>_core.c (gh-247). Also jm module <m> --functions-in-core. |
extra_link_libs — external and sibling libraries¶
[module.source]
objects = ["nco", "lo", "awgn"]
# system libs and generator expressions are fine:
extra_link_libs = ["$<$<PLATFORM_ID:Linux>:mvec>", "${MY_STATIC_LIB}", "m"]
[module.wfm]
objects = ["waveform_engine"]
# link sibling cores from other modules by their <obj>_core target name:
extra_link_libs = ["source_core", "lfsr_core", "m"]
Set it from the CLI with jm module <m> --extra-link-libs TARGET (repeatable).
depends_on — link and include a dependency¶
When an object actually calls another object's C API (not just links it), use
depends_on on the object, not the module. It links the dependency's core
and auto-includes its header, so opaque-typed fields compile:
[waveform_engine]
depends_on = ["source", "lfsr"] # links source_core/lfsr_core + includes both headers
Prefer this over naming the cores in extra_link_libs whenever the dependency's
types or functions are referenced from your _core.c.
c_deps — hand-written C support directories¶
For a pure-C directory (object libraries, vendored code) that has no Python binding but that modules link against:
Each listed dir owns its CMakeLists.txt (define add_library(<name>_core …),
tests, etc.); modules then link it via extra_link_libs = ["io_core", …]. CLI:
jm new --c-dep DIR (repeatable).
no_generate — hand-written extension modules¶
For a module whose binding is written by hand (e.g. a free-function API over an opaque capsule):
jm apply emits the add_subdirectory(native/src/io) and otherwise leaves the
module untouched — io_ext.c, io.pyi, and its CMakeLists.txt are yours.
Pair with reexports on a sibling module to fold its symbols
into a generated package __init__.py.
Worked example: three interdependent modules + a C support dir¶
[project]
c_deps = ["io"] # hand-written native/src/io (io_core, …)
[module.source]
objects = ["source", "lfsr"]
extra_link_libs = ["${DOPPLER_STATIC_LIBRARY}", "io_core", "m"]
[module.wfm]
objects = ["waveform_engine"]
extra_link_libs = ["source_core", "lfsr_core", "m"]
# …or, if waveform_engine calls source/lfsr APIs, drop those two libs and use:
# [waveform_engine]
# depends_on = ["source", "lfsr"]
This is exactly the wiring doppler uses (c_deps = ["hbdecim", "resamp", "wfmcompose"]; [module.ddc] extra_link_libs = ["lo_core", …];
[synth] depends_on = ["lo", "awgn", "pn"]) — fully declarative, idempotent
across jm apply.
Complete CLI ↔ TOML mapping¶
Every TOML key the schema accepts maps to a CLI flag. This is a standing design bar: no feature should require a TOML edit before it can be used.
Status legend: ✅ on main · 🟡 CLI flag pending (TOML works today).
CLI and TOML are both first-class authoring paths. The CLI is the recommended way (presets, validators, errors); TOML editing is a fully supported alternative for power users or for knobs the CLI hasn't yet exposed. The 🟡 rows below are TOML-only today and are tracked for CLI parity — but unlike previous phrasing, this is no longer "by design"; the goal is parity.
[project] keys¶
| TOML key | CLI flag | Status | Notes |
|---|---|---|---|
name |
jm new <NAME> |
✅ | Required positional. |
version |
jm config version X |
✅ | Bumped by jm app / release tooling. |
build |
jm new --build-system cmake\|make |
✅ | |
perf |
jm new --perf / jm perf |
✅ | Retrofit available via jm perf. |
pytest |
jm new --pytest |
✅ | |
pytest_benchmark |
jm new --pytest-benchmark |
✅ | |
find_packages |
jm new --find-package NAME (repeatable) |
✅ (0.13.23) | CMake find_package(NAME REQUIRED). |
pkg_modules |
jm new --pkg-module NAME (repeatable) |
✅ (0.13.23) | pkg-config via pkg_check_modules. |
c_deps |
jm new --c-dep DIR (repeatable) |
✅ (0.13.23) | Vendored C subdir (no Python wrapper). |
schema |
(managed by jm upgrade) |
✅ | Migrated; no user-facing flag. |
c_style |
jm new --c-style clang-format |
✅ (0.36.0) | Reformat generated C — see below. |
c_format_command |
(manifest only) | ✅ (0.43.3) | Which formatter binary — see below. |
Generated-C house style — c_style and c_format_command¶
jm emits its own canonical 4-space C. A project with a different committed
style otherwise sees permanent drift: jm regenerates the *_ext.c binding in
4-space, the project's formatter rewrites it to house style, and
jm status --check calls it stale forever.
c_style decides whether to format; c_format_command decides which
binary does it, and the second is what makes the result reproducible. Left
unset it defaults to ["clang-format"] — a bare PATH lookup, which is fine
on one machine and wrong across two: clang-format 21 and 22 format the same
input differently, so a project whose developers and CI resolve different
versions gets a drift gate that flips red on a tree nobody touched. Point it
at whatever already pins the version (a uvx version specifier, a pre-commit
mirror, an absolute path) and the committed bytes stop depending on the
machine.
Turning it on in an existing project needs one jm apply
There is no jm config key for this — you add the lines above to
just-makeit.toml by hand. Formatting happens after a command that emits
C, so declaring it changes nothing on its own: the committed *_ext.c is
still 4-space while the tree jm status compares it against is now
house-styled, and the next jm status --check reports it stale and exits
1 on a tree you did not touch.
jm apply reconciles it, once, permanently. Nothing is lost — the binding
is generated glue, not your code. Do it in the same commit as the manifest
change so no one else meets the red gate.
The command must not depend on the working directory
jm formats its temp scaffold — the tree jm status compares against
— from outside your project. A command that resolves a different binary
depending on where it runs therefore formats the two compared sides with
two different formatters, and no number of jm apply runs clears the
resulting drift.
["uv", "run", "--group", "dev", "clang-format"] is exactly this trap.
Outside a project uv prints warning: --group dev has no effect when used outside of a project and falls back to whatever is on PATH.
Prefer uvx clang-format==<version> or an absolute path. jm status
checks for this and names it when there is drift to explain.
It is an argv list, never a shell string — splitting a string would have to
guess about quoting, and the first thing that goes here is a path that may
contain spaces. jm appends -i --style=file --fallback-style=LLVM and the file
list, so the committed .clang-format still decides the layout.
A project that formats its C gets a .clang-format (gh-960).
--style=file with no file falls back to LLVM silently, so declaring the
formatter and shipping no style file was a project asking for a layout nobody
had chosen. jm apply now writes jm's house style file when it is absent, and
jm status reports one that is behind jm's current render as OUTDATED — a
file it could never compare before, because the tree it compares against was
seeded from the project's own copy. It is create-only like the rest: yours to
edit, and [project] status_allow says so once.
Scope: only the wholesale-regenerated *_ext.c glue is reformatted. *_core.c
and the splice-patched native/inc/** headers are left to the project's own
formatter — reformatting those breaks jm apply convergence.
A missing binary is a soft failure: one warning, and the command still
succeeds with generated C in jm's default style. When jm status reports
drift on a c_style project it also prints the formatter's version, so
"stale in CI, clean locally" names its own cause.
Authored @code examples — the 79-column budget¶
An @code block in a sacred header becomes the Examples section of the
generated docstring. jm strips the * comment decoration and re-indents the
line to sit inside the docstring, so the line gets shorter than it looks:
| where the docstring lands | stub indent | your @code line may be |
|---|---|---|
| a method or property | 8 | 71 columns |
a class docstring (create) |
4 | 75 columns |
| a module-level function | 4 | 75 columns |
A line wrapped to the header's own 79 columns is 74 columns of content once
* is counted — which fits the header and overflows the stub by 3. That
is why jm apply reports the concrete figure per site rather than a rule:
native/inc/cvt/cvt_core.h: cvt_step(): @code line will be 82 columns in the
stub; wrap at <= 71.
>>> c.step(2.0) # beyond +1.0 -> saturates to int16 max
jm never rewrites the line. A >>> is executable, and the overflow is
usually a trailing comment whose column you aligned deliberately. Three ways
to bring one back under budget, none of which changes what the example does:
- Trim the comment text, keeping the
#column. Most overflows are a few words of comment. - Continue the statement across a
...continuation line — a doctest is one logical statement, so this is behaviour-preserving. - Move the note above the example. A doctest block runs prose, then the
>>>code, then its output, then a blank line, then prose again — and the prose wraps freely, so a long aside reads better there anyway.
jm status prints the outstanding count, so a project sweeping them has a
burn-down number.
Generated Python style — py_format_command¶
The Python twin of c_format_command. jm emits its own layout for the
generated .pyi stubs; a project that wants them in its own pinned style
hands jm the command:
Unset, nothing runs and output is byte-identical to before. There is no separate on/off key — declaring the command is the opt-in.
Why jm runs it rather than your pre-commit hook. A .pyi is drift-gated:
jm status --check regenerates and compares byte-for-byte. A formatter run
outside jm therefore creates drift — your hook formats the file, jm
regenerates it unformatted, and no number of apply runs converges. Once jm
runs the formatter itself, it runs it on both the real tree and the throwaway
scaffold apply compares against, so the two sides are formatted by the same
command and compare equal.
That symmetry is also why jm's own emission does not need to match your formatter: the formatted output is the fixed point, because formatters are idempotent.
Scope: .pyi stubs only. A package __init__.py is deliberately excluded
— apply merges those, so they carry hand-written Python alongside the
generated re-exports, and reformatting a hybrid file rewrites your code. The
same reasoning keeps c_style off native/inc/**.
As with c_format_command: an argv list, never a shell string; only argv[0]
is resolved on PATH, so uv run … works when the formatter itself is not on
PATH; and a missing binary is a soft failure — one warning, the command
still succeeds, and neither tree is formatted, so they still compare equal.
[<component>] keys¶
| TOML key | CLI flag | Status | Notes |
|---|---|---|---|
arg_type |
jm object --arg-type T |
✅ | |
return_type |
jm object --return-type T |
✅ | |
mutable |
jm object --mutable |
✅ | |
no_state |
jm object --no-state |
✅ | |
no_step |
jm object --no-step |
✅ | |
no_reset |
jm object --no-reset |
✅ | Removes reset() entirely — binding, C function, .pyi entry, tests. |
class_name |
jm object --class-name NAME |
✅ | |
depends_on |
(inferred from --module) |
✅ | Set automatically when an object lives in a module. |
extra_link_libs |
(component scope: TOML only) | 🟡 | Per-module is jm module --extra-link-libs; per-component still TOML-only (rare case). |
extra_include_dirs |
jm object --extra-include-dirs DIR (repeatable) |
✅ (0.13.23) |
[[<component>.state]] entries¶
| TOML field | CLI flag | Status |
|---|---|---|
name, type, default |
jm object --state name:type[:default] (repeatable) |
✅ |
name, type, opaque = true |
(TOML only) | 🟡 |
name, type, no_ctor = true |
(TOML only) | 🟡 |
name, type, controllable = true |
(TOML only) | 🟡 |
The three rare modifiers (opaque, no_ctor, controllable) currently
require editing just-makeit.toml directly. CLI flags are pending
(syntax under discussion: --state name:type:opaque,
--state name:type:no-ctor, --state name:type:controllable). Until
those land, hand-editing the manifest is the workaround.
controllable = true turns a state field into an optional per-call
override on step() / steps() — see
Arguments — Default / optional arguments
for the full semantics.
[[<component>.init_params]] entries¶
| TOML field | CLI flag | Status |
|---|---|---|
name, type, default |
jm object --init-param name:type[:default] (repeatable) |
✅ |
optional = true |
jm object --init-param 'name:type[]:optional' |
✅ (syntax extension) |
default_raw = "<C constant>" (a default jm must not evaluate) |
(TOML only) | ✅ (gh-1099) |
real_type, real_create_fn, create_fn |
(TOML only) | 🟡 |
capsule = "<name>", header = "path/hdr.h" |
jm object --init-param 'name:type:capsule:<name>[:<header>]' |
✅ (0.47.0) |
object = "<comp>[.<Class>]" (derives type/capsule/header) |
jm object --init-param 'name:object:<comp>[.<Class>][:optional]' |
✅ (gh-1224) |
required = false on a capsule param (nullable handle) |
jm object --init-param 'name:type:capsule:<name>[:<header>]:optional' |
✅ (gh-805 §H) |
derived = "<name>" (name a 1-D array's length parameter) |
(TOML only) | ✅ (gh-900) |
derived = ["<n0>", "<n1>"] (name a 2-D array's extents) |
(TOML only) | ✅ (gh-1097) |
c_type = "<typedef>" (declare an integer param's C type) |
(TOML only) | ✅ (gh-1096) |
example_value = "<literal>" (a value generated tests construct with) |
(TOML only) | ✅ (gh-1105) |
compose with [[state]] |
--init-param + --state together |
✅ (0.13.23) (gate dropped) |
example_value — constructing a required param in generated tests¶
jm seeds a generated smoke test and doctest with the type's zero. For a constructor that validates — rejecting a zero rate, span or size — that is the one value it refuses.
Whether yours does is a fact about C, and jm does not read your _core.c. So
the generated tests ask rather than assume (gh-1109): each makes the
zero-seeded call once, and skips only if it is rejected, carrying your
constructor's own message.
SKIPPED: required constructor parameter(s) capacity, slots have no default;
seed valid arguments to enable this smoke test:
allocator_create returned NULL
The decision is made when the test runs, not when it is written, which is
what makes it safe: adding validation to a create() whose tests were
generated before you did turns them into skips on the next run, never into
failures. Until you add it, jm's own scaffolded create() ignores the
parameter, so the suite runs and asserts.
A skip is still a suite that asserts nothing, and that is what example_value
is for — it gives jm something valid to build with:
It is not a default. The parameter stays required, the Python signature is
unchanged, and Allocator() is still a TypeError — which matters, because a
validating constructor is usually one you want to be mandatory. One
declaration feeds the generated pytest, the generated C smoke test and the
docstring examples, so both faces exercise the same construction.
It is also the only answer for the docstring examples, which have no
runtime probe available to them: a doctest is executable prose with nowhere to
put a fallback, so the Examples block stays suppressed until a value jm can
show is declared. The same is true of an init-param with no seed at all — a
path, a bytes blob, a capsule handle — where there is no call to attempt
and the generated tests skip unconditionally.
It takes effect when the object is created. The generated test files are
create-only — jm writes them once and never rewrites them — so adding
example_value to an existing project changes nothing already on disk.
One thing changes with it. For an init-params constructor the generated accessor test asserts the set/get round-trip only, not the value a field holds after construction. jm generates the state-var constructor whole, so there it knows the initial value and still asserts it; with init-params the constructor is the author's, and any state it derives from its arguments makes that assertion a guess. "Reset restores the declared defaults" is still asserted by the reset test on both faces, where the code under test is jm's.
A default is a literal; a constant is default_raw¶
default is rendered verbatim into four places — the C local, both .pyi
writers, and the generated app's argparse flags — so it has to be a literal
of the type declared beside it. jm refuses anything else, naming the type and
the value:
A C constant is a real and common case, and it has its own key.
default_raw means "this is C, not a literal": the text goes into the C
unchanged and the Python side gets ..., which is the honest answer for a
value jm cannot evaluate.
The refusal names default_raw in its message, so the remedy arrives with the
error. Before gh-1099 that key was read only for types carrying a parse
intermediate — it worked on a size_t and was silently dropped on an int,
which fell back to the type's zero.
const char * is exempt: its value is text. bool accepts true/false
(and 0/1) and says so in its own words when it does not.
Naming what the constructor declares¶
jm derives the create() prototype from init_params, and two parts of it
used to have no spelling. Both keys below change only the declaration jm
injects into the sacred _core.h — the Python face, the parse block and the
call are untouched, which is what makes them safe.
They matter because jm status --check's CTOR comparison (gh-1076) is not
suppressible. A C signature the manifest could not describe was reported
forever, and jm apply "resolved" it by rewriting the author's header down to
jm's rendering.
c_type — an enum typedef in the prototype. A string_enum:/enum:
init-param renders int, because jm's type vocabulary has no enum typedef.
When the C really takes one, say so:
[[detector.init_params]]
name = "noise_mode"
type = "string_enum:mean,median,min,max"
default = "mean"
c_type = "det_noise_mode_t"
The binding still parses the choice string, validates it to an index and
passes an int; C converts at the call. That interchangeability is also the
limit, so it is enforced: c_type is accepted only on a parameter jm
declares as an integer. Over a double it would be a silent ABI mismatch that
still compiles, and jm refuses it by name.
derived — naming an array's extents. By default a 1-D array init-param
appends a trailing <name>_len, and a 2-D one (type = "T[][]") appends
<name>_dim0, <name>_dim1. A string moves the 1-D length before the data
pointer and names it; a list names a 2-D array's extents in place:
The Python face still takes one 2-D array, the binding still requires
ndim == 2, and it still passes both dimensions — only the declared names
change. The list must name every extent; a shorter one is refused, because
it would drop an extent from the declaration while the binding kept passing
it.
A capsule-typed init-param: constructing from a foreign handle¶
The constructor counterpart of the method params above — the object is built
around a pointer another module published. header injects the #include
that declares the foreign type into the sacred _core.h, because the type
appears in the create() prototype.
A capsule init-param is mandatory by default: there is usually no object
to build around a handle that is not there. Declare it optional when NULL
is a value that means something:
[[capture.init_params]]
name = "clock"
type = "dp_sample_clock_t *"
capsule = "doppler.clk"
header = "clk.h"
# required omitted -> nullable
required = true (the default, and what the CLI writes without :optional)
rejects None up front with a TypeError naming what to pass, rather than
letting a NULL reach create() and surfacing the failure a layer away from
its cause. Either way a wrong object — an int, say — gets a TypeError
naming the capsule, not the AttributeError from the internal ._capsule
lookup.
The stub annotates a nullable handle object | None, without a = None
default: the argument still has to be passed. Being omittable is a separate
axis, and a stub advertising a default the binding does not honour is the
gh-611 defect this project ships a checker for.
The generated create()'s Doxygen says May be NULL (Python: None). on a
nullable handle, so the contract is visible where the author writes the body
that has to honour it.
object: naming another generated class instead of its capsule¶
When the pointer comes from another jm-generated object, say so directly rather than restating the capsule string:
[[wfm_compose.init_params]]
name = "frame"
object = "frame.FrameDesc" # <component>[.<ClassName>]
required = false
jm object seg --init-param 'frame:object:frame.FrameDesc'
jm object seg --init-param 'frame:object:frame.FrameDesc:optional'
object resolves to the capsule form above — it derives type
(<component>_state_t *), capsule (read from the referenced component's own
capsule property) and header, so all three are omitted. The generated C is
byte-for-byte the capsule path; what changes is the declaration and the stub:
capsule = "..." |
object = "frame.FrameDesc" |
|
|---|---|---|
| the capsule name | written at both ends | read from the producer |
| a typo | fails at runtime | refused at generation |
the .pyi |
frame: object |
frame: FrameDesc, with its import |
The name is read, not derived, because the producer already owns that
string; deriving a second one from the component id would be a second opinion
about it, and the two would drift the first time either changed. Declaring
object and capsule together is refused for the same reason.
A view is a legal target (frame.FrameDesc above is one), and resolves to
the same capsule — it is the same C core. Naming a component that publishes no
capsule is refused with the jm property line that would fix it.
A kind = "handle" module is a legal target too (gh-1227), and it is the
better-founded half of the feature. gh-794 exists precisely so a handle can
hand its pointer to another module, and a handle declares what that pointer
is:
[module.wfm_writer]
kind = "handle"
backing = "wfm_writer"
handle_type = "wfm_writer_t" # the capsule lends a `wfm_writer_t *`
capsule = "proj.wfm.writer" # gh-794's module-level key
type_name = "Writer"
[[seg.init_params]]
name = "w"
object = "wfm_writer" # -> wfm_writer_t *, from the declaration
Every slot is read: handle_type gives the C type (the generated struct
stores exactly <handle_type> *h, and the capsule lends it), capsule the
name, header the include, type_name the class. A handle generates one
class, so wfm_writer.Writer is accepted and any other suffix is refused. A
capsule or composer module is still not a target.
The type is read when the producer states it and inferred otherwise.
Undeclared, it is <component>_state_t *, which is right for a producer
publishing the default self->handle. A capsule property may instead carry an
expr reaching a member — and then the pointer is something no consumer can
name, so the producer says so with capsule_type (gh-1235):
[[frame.properties]]
name = "_capsule"
type = "capsule"
capsule = "p.frame.desc"
expr = "&self->handle->d" # publishes a member...
capsule_type = "const wfm_frame_desc_t *" # ...and this is what it IS
jm property frame _capsule --type capsule --capsule p.frame.desc \
--expr '&self->handle->d' --capsule-type 'const wfm_frame_desc_t *'
It is deliberately not ctype, which on a property is a legacy synonym for
type — and a capsule property's type is already the word capsule, so
reusing it would be one key answering two questions.
An expr-publishing producer that declares no capsule_type is refused
rather than resolved to a type the capsule does not carry, naming both fixes:
declare it on the producer, or write the type out at the consumer the gh-790
way.
This is sugar, not a new mechanism
Two generated objects could always be wired constructor-to-constructor: the binding accepts the producing object itself, not just its capsule, and has since gh-790. What was missing was a way to say so — and the ergonomics mattered, because nothing checked that the two ends named the same string.
It resolves to a capsule deliberately. Type-checking the object and
reading its handle straight out of the struct would need the producer's
object layout inside a consumer .so compiled separately, and possibly by
a different jm version — the ABI hazard the capsule exists to avoid.
[[<component>.methods]] entries¶
| TOML field | CLI flag | Status |
|---|---|---|
name, arg_type, return_type |
jm method <obj> <method> --arg-type T --return-type T |
✅ |
doc = "..." |
jm method --doc "text" |
✅ |
fn = "SYMBOL" |
jm method --fn SYMBOL |
✅ (0.49.0) |
params = [{name, type}] |
jm method --param name:type (repeatable) |
✅ |
varargs = true |
jm method --varargs |
✅ |
extra_args = [{name, type}] |
jm method --extra-arg name:type (alias for params) |
✅ (0.14.2) |
variable_output = true |
jm method --variable-output |
✅ |
pass_capacity = true |
jm method --pass-capacity |
✅ (0.14.4) |
exact_max_out = true |
jm method --exact-max-out |
✅ (0.55.0) |
count_default = "EXPR" |
jm method --count-default EXPR |
✅ (0.34.0) |
nogil = true |
jm method --nogil |
✅ (0.15.2) |
max_out = N (sibling stub) |
jm method --max-out N |
✅ (0.13.23) |
multi_output = ["T", ...] |
jm method --multi-output T (repeatable) |
✅ |
out_type = "T" |
jm method --out-type T |
✅ |
out_divisor = N |
jm method --out-divisor N |
✅ |
batch = true |
jm method --batch |
✅ |
bench = false |
jm method --no-bench |
✅ |
result_fields = [{name, type, doc?}] |
jm method --result-field name:type[:doc] (repeatable) |
✅ (0.13.23) |
single = true |
jm method --single |
✅ (0.19.6) |
record_name = "..." |
jm method --record-name NAME (with --single) |
✅ (0.19.8) |
record_module = "..." |
jm method --record-module MOD (with --single) |
✅ (0.19.14) |
record_doc = "..." |
jm method --record-doc "text" (with --single) |
✅ (0.41.0) |
record_dtype = "STRUCT" |
jm method --record-dtype STRUCT (with --variable-output) |
✅ (0.47.0) |
max_results = N |
(TOML only; default 64) | 🟡 |
none_on_empty = true |
(TOML only) return None rather than an empty result |
🟡 |
status_return = true |
(TOML only) the int return carries status only |
🟡 |
error_negative = true |
jm method --error-negative |
✅ (0.49.0) |
error = "EXC" |
jm method --error EXC |
✅ (0.49.0) |
error_message = "..." |
jm method --error-message TEXT |
✅ (0.49.0) |
py_return_type = "..." |
jm method --py-return-type STR |
✅ |
manual_stub = "..." |
(TOML only) hand-written .pyi signature, kept verbatim |
🟡 |
codec = "..." |
(TOML only) variant codec applied to the result | 🟡 |
sink_fn = "SYMBOL" |
(TOML only) C sink the method feeds instead of returning | 🟡 |
impl = "..." body |
jm method --impl file::funcname |
✅ |
impl_file, replace |
jm method --impl file::funcname / --replace old::new |
✅ |
[[<component>.properties]] entries¶
| TOML field | CLI flag | Status |
|---|---|---|
name, type |
jm property <obj> <prop> --type T |
✅ |
writable = true |
jm property --writable |
✅ |
field = true |
jm property --field |
✅ |
buf_field, len_field, valid_field, expr |
jm property --buf-field / --len-field / --valid-field / --expr |
✅ (0.30.2) |
[[<component>.views]] entries¶
| TOML field | CLI flag | Status |
|---|---|---|
class_name, create_fn |
jm view <obj> <Class> --module <mod> --create-fn <fn> |
✅ (0.31) |
doc = "..." |
jm view --doc "text" |
✅ (0.31) |
init_params = [{name, type, ...}] |
jm view --init-param name:type[:default] (repeatable) |
✅ (0.31) |
exclude_properties = ["..."] |
jm view --exclude-property name (repeatable) |
✅ (0.31) |
exclude_methods = ["..."] |
jm view --exclude-method name (repeatable) |
✅ (0.31) |
properties = [{...}] |
jm property <obj> <prop> --view <Class> |
✅ (0.32) |
methods = [{...}] |
jm method <obj> <meth> --view <Class> |
✅ (0.32) |
warnings = [{...}] |
jm warning <obj> --view <Class> |
✅ (0.33) |
[<component>] lifecycle impl bodies¶
| TOML field | CLI flag | Status |
|---|---|---|
impl = "..." (step body) |
jm object --impl file::funcname |
✅ |
impl_file = "path::N:M" |
jm object --impl file::N:M (line range) |
✅ (0.14) |
create_impl = "..." |
jm object --impl create::file::funcname |
✅ (0.13.23) |
reset_impl = "..." |
jm object --impl reset::file::funcname |
✅ (0.13.23) |
destroy_impl = "..." |
jm object --impl destroy::file::funcname |
✅ (0.13.23) |
init_post_parse = "..." |
(TOML only) | 🟡 |
The --impl file::N:M form lifts source lines N..M (inclusive, 1-based)
instead of a named function body; it composes with the slot prefixes
(create::file::N:M) and out-of-bounds ranges error cleanly.
[module.<name>] keys¶
| TOML key | CLI flag | Status |
|---|---|---|
objects (list) |
(auto-populated by jm object --module <mod>) |
✅ |
extra_link_libs |
jm module --extra-link-libs TARGET (repeatable) |
✅ (0.13.23) |
extra_include_dirs |
jm module --extra-include-dirs DIR (repeatable) |
✅ (0.13.23) |
extra_types |
jm module --extra-types NAME (repeatable) |
✅ (0.13.23) |
no_generate = "true" |
(TOML only) | 🟡 |
functions |
(auto-populated by jm function --module <mod>) |
✅ |
reexports = { sub = ["name", ...] } |
(TOML only) | 🟡 (0.15.1) |
reexports folds names from a sibling extension (typically a no_generate
module whose binding/.pyi are hand-written) into this module's generated
__init__.py — both the import block and __all__ — so the re-export glue
regenerates from the manifest instead of being a hand-edit jm apply would
clobber. Output is single-line, matching the rest of the package.
[[module.<name>.functions]] entries¶
| TOML field | CLI flag | Status |
|---|---|---|
name, return_type, doc |
jm function <fn> --module <mod> --return-type T --doc STR |
✅ |
params = [{name, type, out?}] |
jm function --param name:T + --out-param name:T[] |
✅ (0.13.22) |
params … {mutable = true} |
(synonym for out — writable array param) |
✅ (0.15.3) |
inline = true |
jm function --inline |
✅ |
out_type = "T" |
jm function --out-type T |
✅ (0.13.23) |
out_type = "str" |
(TOML only) | 🟡 (0.71.2) |
result_fields = [{name, type}] |
jm function --result-field name:type (repeatable) |
✅ (0.13.23) |
max_results_param |
(TOML only) | 🟡 |
impl = "..." body |
jm function --impl file::funcname |
✅ |
A function that returns a string¶
out_type = "str" is the one out_type that is not an array of a C scalar
(gh-1180). On a variable_output function it makes jm allocate the buffer,
call C, and hand back a Python str — the caller allocates nothing:
[[module.cvt.functions]]
name = "bin_to_hex"
return_type = "size_t" # the number of characters written
variable_output = true
out_type = "str"
out_size = "bits_len * 2" # the capacity, as C over the args
[[module.cvt.functions.params]]
name = "bits"
type = "uint8_t[]"
str names the Python shape; the C you implement takes a char *. The
integer return is required and jm refuses without one — a void function
cannot say how much it wrote, and hunting for a NUL the callee may never have
written is a read past the end waiting to happen. char is deliberately not a
supported scalar type: char[] in a param position is refused with a message
naming uint8_t[] for a byte buffer and this key for text.
Counts¶
- ✅ on main: ~66 keys (every common path; Phase 2 stack shipped in 0.13.23)
- 🟡 CLI flag pending: 11 keys — rare modifiers (
opaque,no_ctor,controllable,init_post_parse,default_raw/real_typeinit-param details,no_generatemodule,max_results/max_results_param). These are foot-guns to close: TOML is the persistence layer, not the user interface. Each will get a CLI flag in Phase 3.
Phase 2 acceptance bar — "every TOML field has a 'Reachable via CLI' column ✓" — is met for the common path. The remaining 🟡 rows are tracked Phase 3 work, not by-design exceptions.
Schema reference¶
[project]¶
| Key | Type | Default | Set by |
|---|---|---|---|
name |
string | — | just-makeit new <name> |
version |
string | "0.1.0" |
just-makeit new / just-makeit config version X |
build |
"cmake" or "make" |
"cmake" |
--build-system make |
perf |
"true" or "false" |
"false" |
--perf |
pytest |
"true" or "false" |
"false" |
--pytest |
pytest_benchmark |
"true" or "false" |
"false" |
--pytest-benchmark |
[<object>]¶
One section per standalone object or module-member object. The section name
is whatever you passed to just-makeit object <name>.
| Key | Type | Default | Set by |
|---|---|---|---|
arg_type |
string | "float _Complex" |
--arg-type |
return_type |
string | same as arg_type |
--return-type |
mutable |
"true" or "false" |
"false" |
--mutable |
no_state |
"true" or "false" |
"false" |
--no-state |
no_step |
"true" or "false" |
"false" |
--no-step |
no_reset |
"true" (only when set) |
(absent) | --no-reset |
[[<object>.state]]¶
One entry per --state declaration.
| Key | Type | Notes |
|---|---|---|
name |
string | ASCII letters/digits/underscores, no leading digit |
type |
string | C type; append [N] for fixed arrays |
default |
string | C initialiser expression |
[[<object>.array_args]]¶
Fixed-size array constructor arguments added with --array-arg.
| Key | Type | Notes |
|---|---|---|
name |
string | Argument name |
type |
string | Stored as NumPy dtype name (float32, float64, complex64, …); C types (float, double, float _Complex, …) are also accepted on input and normalised |
[[group]] and [[<object>.init_groups]]¶
A field group is a repeat declared once and instantiated under a prefix
(gh-999). jm's type vocabulary has no struct in either direction, so a C
descriptor built from N copies of the same small field set had to be flattened
into one long name-prefixed constructor list — written out once per repeat,
with every default, doc and enum binding duplicated N times and free to
drift, since jm saw N unrelated params.
[[group]]
name = "wfm_seq"
[[group.fields]]
name = "kind"
type = "int"
enum = "wfm_seq_kind"
default = "literal"
doc = "Which sequence family this leg uses."
[[group.fields]]
name = "len"
type = "size_t"
[[frame.init_groups]]
group = "wfm_seq"
prefix = "preamble" # -> preamble_kind, preamble_len
[[frame.init_groups]]
group = "wfm_seq"
prefix = "sync" # -> sync_kind, sync_len
| Key | Table | Notes |
|---|---|---|
name |
[[group]] |
The group's name, referenced by init_groups.group. |
fields |
[[group]] |
[[group.fields]] entries. Every key an init_params entry accepts is accepted here — type, default, doc, required, c_type, derived, the array and capsule forms. (enum is not one of them: it is a method-param, property and function-param key. A constructor parameter spells its enum in type, as enum:<name> or string_enum:a,b,c.) |
group |
[[<obj>.init_groups]] |
Which group to instantiate. |
prefix |
[[<obj>.init_groups]] |
Prepended as <prefix>_<field>. Omit it to use the bare field names. |
This is not jm learning structs, and the expansion is exactly the
hand-written list. The C prototype, the kwlist, the .pyi and the docstrings
are byte-identical to writing the params out yourself — which is the point: it
declares the repeat, it does not add a type.
[[group]] is a top-level SSOT table, like [[enum]]. A group is
referenced by name from component tables in any fragment, so it lives in
just-makeit.toml rather than in one of them.
The declaration is what round-trips. The expansion happens when the
manifest is read and is folded back when it is written, so jm apply,
jm property and every other mutating command leave the two init_groups rows
in place and never write the expanded params out beside them.
Groups instantiate after the object's explicit init_params, which is the
order the manifest reads in — so a hand-written param and a grouped one coexist
predictably:
[[frame.init_params]]
name = "crc"
type = "int"
# -> crc, preamble_kind, preamble_len, sync_kind, sync_len
A row naming a group that does not exist is left alone rather than raised on:
jm reports the unrecognised declaration the way it reports any other typo,
instead of turning it into a traceback out of every command at once.
[[<object>.init_params]]¶
Constructor-only parameters added with --init-param (no getter/setter, no
reset). Same name / type / default keys as state. Besides the scalar /
array types, two opaque pseudo-types are accepted (both required-positional, no
default): type = "path" (an os.fspath coerced to a borrowed const char *,
gh-515) and type = "bytes" (a read-only bytes-like coerced to a borrowed
(const void *, size_t) pair via y#, gh-565). The C constructor must copy
either borrow before returning.
An array init-param is a required positional by default. To make one
omittable, give it default = "[]" (gh-611):
Omitted — or passed None — it reaches create() as NULL with length 0,
which is the convention C already uses for "no array". Any number of arrays
compose: they share one create() call with a NULL/0 pair each, so a
constructor describing a composite of independently-absent parts is spelled
directly:
"[]" is the only accepted default (an array has no other zero jm could
invent), and it is 1-D only.
This is not optional. That key is array dispatch: the array's presence
selects a different create_fn instead of <component>_create. Dispatch
picks one constructor, so it does not compose — jm refuses optional on more
than one array, and refuses it with no create_fn, pointing here in both
cases (gh-1004 / gh-1005). An init-param may also not be named
<array>_len, which is the length parameter jm derives for the array beside
it (gh-1002).
[[<object>.methods]]¶
One entry per just-makeit method call.
| Key | Type | Notes |
|---|---|---|
name |
string | Method name |
arg_type |
string | Array-style input type |
return_type |
string | C return type |
params |
array of {name, type} |
Named scalar / array parameters |
variable_output |
bool | --variable-output |
pass_capacity |
bool | --pass-capacity (5-arg (…, out, max_out) C form) |
exact_max_out |
bool | --exact-max-out: max_out bounds any call, so allocate exactly |
count_default |
string | C expression seeding count for a void-input method (gh-657) |
nogil |
bool | --nogil (release the GIL across the kernel; see below) |
status_return |
bool | int return is a status: -> None, ValueError on non-0 (gh-432) |
batch |
bool | --batch |
multi_output |
array of strings | --multi-output types |
out_type |
string | --out-type |
out_divisor |
int | --out-divisor (default 1; omitted from TOML when 1) |
nogil wraps the pure-C kernel of a variable_output execute method in
Py_BEGIN_ALLOW_THREADS / Py_END_ALLOW_THREADS (numpy accessors hoisted out
first), so a thread-per-shard worker — one object + output buffer per thread —
scales across cores instead of serialising on the GIL. Opt-in: it is sound
only when the object is not shared across threads concurrently (one object per
stream).
Capsule-typed params (gh-432)¶
A param may carry capsule = "<name>" (and optionally
header = "path/hdr.h"): its C type is a foreign pointer that crosses
the Python boundary as a named PyCapsule. Manifest-authored (no CLI flag
yet):
[[agc.methods]]
name = "set_telemetry"
arg_type = "void"
return_type = "int"
status_return = true
params = [
{ name = "tlm", type = "dp_tlm_t *",
capsule = "doppler.telemetry.dp_tlm",
header = "telemetry/telemetry.h" },
{ name = "prefix", type = "const char *" },
{ name = "decim", type = "uint32_t", default = "1" },
]
Generated binding semantics: None maps to NULL (the C-side detach
idiom); a PyCapsule is name-checked with PyCapsule_GetPointer; any other
object is unwrapped through its _capsule attribute first, so callers pass
the friendly wrapper (obj.set_telemetry(tlm, "agc")), not the capsule.
The .pyi annotates the param object | None. The header key injects
#include "path/hdr.h" into the object's _core.h alongside the gh-170
depends_on includes (skipped when the file doesn't exist under
native/inc). status_return = true binds the C int status as
-> None, raising ValueError on non-zero — the same contract as the
serializable set_state glue. Module functions accept capsule params
too (same parse builder); status_return is methods-only for now.
[[<object>.properties]]¶
One entry per just-makeit property call.
| Key | Type | Notes |
|---|---|---|
name |
string | Property name |
type |
string | C type of the value |
writable |
bool | --writable |
field |
bool | --field (adds struct member, auto-implements getter) |
[[<object>.views]]¶
One entry per just-makeit view call — a second Python class over the same
generated C core (gh-504). The view shares <object>_state_t and the
object's _core.c; only its constructor and its Python surface differ. Views
are a module-object feature, so the object must belong to a [module.<name>].
| Key | Type | Notes |
|---|---|---|
class_name |
string | Python class name for the view. Required; unique across every class the module exposes. |
create_fn |
string | C constructor the view's __init__ calls. Required; must differ from <object>_create. Scaffolded as a stub in the shared _core.c. |
doc |
string | Docstring for the view class. |
init_params |
array | The view's own constructor params, same shape as [[<object>.init_params]]. Omit to inherit the parent's constructor shape. |
exclude_properties |
array of strings | Parent property names the view omits from its Python surface. |
exclude_methods |
array of strings | Parent method names the view omits. Only the view's Python wrapper and PyMethodDef entry are dropped; the shared C function stays. |
properties |
array | The view's own properties, same shape as [[<object>.properties]]: a new name ADDS a property the parent lacks, a parent's name OVERRIDES it. Merged over the parent's. |
methods |
array | The view's own methods, same shape as [[<object>.methods]]: ADD a new method (scaffolds a shared C stub) or OVERRIDE a parent method's doc. |
warnings |
array | The view's own post-construction warnings, same shape as [[<object>.warnings]] (gh-509). A view carries no parent warnings, so this is its only source. |
[[acc.views]]
class_name = "SeededAcc"
create_fn = "acc_create_seeded"
exclude_methods = ["total"]
[[acc.views.init_params]]
name = "seed"
type = "double"
default = "0.0"
[[acc.views.properties]]
name = "runs" # a property the parent does not have
type = "size_t"
doc = "reseed count"
field = true
The nested tables are written by just-makeit property|method|warning <obj> --view <ClassName>; see
Extend commands → just-makeit view.
[module.<name>]¶
| Key | Type | Notes |
|---|---|---|
objects |
array of strings | Objects in declaration order |
functions |
array | Module-level functions (see below) |
reexports |
table {sub = [names]} |
Re-export sibling symbols into __init__.py (0.15.1) |
extra_link_libs / extra_include_dirs / extra_types |
array | Extra CMake wiring |
no_generate |
string "true" |
Hand-written module: jm apply only wires the CMake add_subdirectory |
[[module.<name>.functions]]¶
One entry per just-makeit function call.
| Key | Type | Notes |
|---|---|---|
name |
string | Function name |
return_type |
string | C return type |
doc |
string | Python docstring |
params |
array of {name, type, out?} |
Parameters; an array param with out = true (or its synonym mutable = true) is writable — generated T *name, not const T *name |
Variant codecs ([codec.<name>])¶
A variant codec (gh-554) maps a runtime discriminant value — a small tag,
e.g. a BLUE/SigMF keyword's char type code — to a C element width, so one
value can be encoded and decoded as any of a fixed set of C types chosen at call
time. The same declared table drives both the input pack (Python → bytes) and
the output decode (bytes → Python), so the two directions cannot drift —
zero hand-written binding on either side.
Declared once at the top level, keyed by name (like [module.<name>]):
[codec.blue_keyword]
discriminant = "char" # C type of the tag that selects a branch
scalar_collapse = true # decode: count == 1 -> a scalar, else a list
entries = [
{ code = "A", ctype = "char", bytes = true }, # raw bytes -> str
{ code = "B", ctype = "int8_t" }, # -> int
{ code = "I", ctype = "int16_t" },
{ code = "L", ctype = "int32_t" },
{ code = "X", ctype = "int64_t" },
{ code = "F", ctype = "float" }, # -> float
{ code = "D", ctype = "double" },
]
| Codec key | Type | Notes |
|---|---|---|
discriminant |
string | C type of the tag (char, or an int-family _CTYPE_META scalar) |
scalar_collapse |
bool | Decode a lone element as a scalar rather than a 1-element list |
entries |
array | { code, ctype, bytes? } — one branch per discriminant value |
Each entry's numeric ctype must be a scalar in _CTYPE_META; the Python type
it crosses as is derived from the ctype (int / float), so an entry never
declares a redundant py. A bytes = true entry is packed raw and decoded as
str.
Write — a codec method ([[<obj>.methods]])¶
A method with codec + sink_fn packs a variant argument into a host-order
buffer and calls the sink. Among its params, one carries role = "discriminant" (the tag), one role = "variant" (the value jm packs); the rest
are fixed passthroughs.
[[wfm_writer.methods]]
name = "add_keyword"
codec = "blue_keyword"
sink_fn = "wfm_writer_add_keyword" # int (state, <fixed...>, <disc>, const void *, size_t)
params = [
{ name = "tag", type = "const char *" }, # fixed passthrough
{ name = "type", role = "discriminant" }, # the char code
{ name = "value", role = "variant" }, # jm packs per codec
]
jm generates the whole binding: parse, the per-code pack (a Python scalar or
any sequence → the coded C width), the sink_fn call, and a precise .pyi
union (str | int | float | Sequence[int] | Sequence[float]). jm does not
declare sink_fn — that stays your pure-C contract.
Read — a codec container property ([[<obj>.properties]])¶
A container property with codec decodes each entry back to Python. It reuses
the container cursor (count_fn / key_fn for a dict) and adds an entry_fn
that returns a pointer to one entry struct, whose fields the codec decodes.
[[wfm_reader.properties]]
name = "keywords"
codec = "blue_keyword"
count_fn = "wfm_reader_num_keywords"
key_fn = "wfm_reader_keyword_tag"
entry_fn = "wfm_reader_keyword"
entry_type = "wfm_keyword_t" # see the default-derivation note below
type_field = "type" # struct fields the decode reads
count_field = "count"
value_field = "value"
| Property key | Default | Notes |
|---|---|---|
codec |
— | The [codec.<name>] to decode with |
entry_fn |
<obj>_<prop>_entry |
Returns const <entry_type> *(state, i) |
entry_type |
<obj>_<prop>_t |
The entry struct type — often needs setting |
type_field / count_field / value_field |
type / count / value |
The discriminant / length / payload members |
scalar_collapse |
codec's value | Per-property override |
header |
— | #include the .pyi/ext needs for the struct |
jm generates the decode helper (bytes → str; a numeric branch decodes count
elements to int/float, collapsing to a scalar when count == 1 if
scalar_collapse) and the dict[str, str | int | float | list[int] | list[float]] .pyi. As on the write side, jm declares neither entry_fn
nor the entry struct — both are your pure-C contract.
entry_typeusually needs setting. It defaults to<obj>_<prop>_t(propertykeywordsonwfm_reader→wfm_reader_keywords_t), but a shared, element-named struct (wfm_keyword_t) will not match that guess and the decode helper won't compile. Setentry_typeexplicitly whenever the struct isn't named after the property.
Error surfaces¶
Codec errors are intentionally generic: a non-zero sink_fn return raises
ValueError: <method> failed; an unknown discriminant raises ValueError: unsupported code '<c>'; a multi-character discriminant string is a
PyArg-level TypeError. If you need a domain-specific message, wrap the call
in Python.
Inspecting config¶
Prints a summary of the project and every object's state variables:
To update the version:
Reconstructing a project¶
just-makeit script reads just-makeit.toml and prints the exact sequence of
CLI commands that would recreate the project from scratch:
just-makeit script # print to stdout
just-makeit script | sh # pipe directly into a shell to rebuild
Example output for a two-object project:
#!/usr/bin/env sh
# Reconstructed from just-makeit.toml
just-makeit new my_project
cd my_project
just-makeit object engine \
--state "gain:double:1.0" \
--state "center_freq:double:1000.0"
just-makeit object detector \
--arg-type "float _Complex" \
--state "threshold:float:0.5f"
When is this useful?¶
Moving a project. Copy just-makeit.toml to a new machine, run
just-makeit script | sh, and the full scaffold is regenerated. Your own
code (business logic in *_core.c, tests, customisations) travels with the
project directory as normal; the script just recreates the generated
boilerplate if it was ever lost or corrupted.
Starting fresh after a breaking change. If a just-makeit update changes
generated file layouts, just-makeit script | sh in an empty directory
produces a clean scaffold at the current version.
Documentation / reproducibility. Commit just-makeit.toml to record
exactly how the project was built. Anyone can reproduce the generated
structure without needing to remember the original command sequence.
Note
The original --impl / --replace lift is stored in the manifest
(impl, replace, …), so script | sh reproduces it. Any edits you
made to the sacred _core.c afterwards live only in source — keep that
in version control.
Editing TOML by hand¶
The file is plain TOML — you can edit it directly. just-makeit will read
your changes on the next command. The rules:
- Order matters for state variables:
[[<object>.state]]entries are emitted in the order they appear, which controls constructor argument order in both C and Python. - Keys must come before sub-table arrays: all scalar keys on an object
section (
impl,create_impl,reset_impl,destroy_impl,arg_type,mutable, …) must appear before the first[[<object>.state]]or[[<object>.methods]]entry. TOML parses bare keys after an array-of-tables header as part of that entry, not the parent section, so keys placed after a[[…]]line are silently dropped by the parser. - Editing a signature in TOML (removing a state variable, changing a
method's return type) propagates to the glue files (
_ext.c,.pyi,CMakeLists.txt) and the public declarations in_core.hon the next command, but the sacred_core.cbody is left as you wrote it. Runjm regenerate <obj>to rebuild that component cleanly from the manifest (this discards the_core.cbody —git stashfirst). - Don't rename the file —
just-makeitalways looks forjust-makeit.tomlat the project root.