errors_warnings example¶
The four ways a component can tell Python that something is wrong — and they are four, not one, because C has more failure modes than it has channels to report them on.
| what happened | how C says it | how Python hears it | declared by |
|---|---|---|---|
create() refuses |
returns NULL |
an exception, at construction | just-makeit error |
create() succeeded, with a caveat |
a bool field on the state struct |
a warning, after construction |
just-makeit warning |
| a call fails | an int status, non-zero is bad |
an exception; the method returns None |
--status-return --error |
| a call fails, but usually returns a value | an int that is a value unless negative |
an exception, or the int |
--error-negative --error |
The first two are create()'s problem and the last two are a method's. All
four are pure glue — no sacred file is touched by declaring them, which is
the point: they are a translation layer over signals your C already emits.
TL;DR — see it work first¶
. <(curl -fsSL https://just-buildit.github.io/just-makeit/install.sh)
just-makeit example errors_warnings
# errors_warnings: PASSED
Prerequisites¶
Pass a custom path to keep the venv somewhere persistent:
Or with pip if just-makeit is already installed:
1. Scaffold an allocator¶
just-makeit new budget
cd budget
just-makeit object allocator \
--init-param "capacity:size_t:required" \
--init-param "slots:size_t:required" \
--state "n_slots:size_t:0" \
--state "remaining:size_t:0" \
--state "degraded:bool:false" \
--arg-type size_t \
--return-type size_t
capacity and slots are --init-params: they are what the caller passes,
and create() consumes them without storing them. The three --state fields
are what the object derives and keeps — declaring init-params is what keeps
them out of the constructor signature, so Allocator(capacity=9, slots=3) is
the whole public API.
degraded is the one to watch. It is an ordinary bool on the state struct
that create() sets, and in step 3 it becomes the entire mechanism behind a
Python warning.
2. Declare the four channels¶
# ── channel 1: create() refuses ─────────────────────────────────────────────
just-makeit error allocator \
--category ValueError \
--message "capacity must cover at least one unit per slot"
# ── channel 2: create() succeeded, with a caveat ────────────────────────────
just-makeit warning allocator \
--condition degraded \
--category RuntimeWarning \
--message "capacity is not divisible by slots; the remainder is unusable"
# ── channel 3: an int that carries nothing but status ───────────────────────
just-makeit method allocator take \
--arg-type size_t \
--return-type int \
--status-return \
--error ValueError \
--error-message "requested more than remains"
# ── channel 4: an int that is a value unless it is negative ─────────────────
just-makeit method allocator peek \
--arg-type size_t \
--return-type int \
--error-negative \
--error IndexError \
--error-message "no such slot"
What each one is really doing:
just-makeit errorreplaces jm's default translation of aNULLfromcreate(). Without it every refusal surfaces asMemoryError, which is true only for an actual allocation failure and misleading for every other reason — and uncatchable the way a caller would naturally reach for it. The command prints the limit of the design as it runs: NULL is NULL, so every failure now reports asValueError, a genuine out-of-memory included.just-makeit warningexists because C has no channel for "succeeded, but".create()returns a pointer or it does not. So the component writes aboolon its state, and--condition degradedtells jm to emit aPyErr_WarnExreading that field right after a successful construction.--status-returnsays theintis only a status. The method returnsNonein Python, and any non-zero raises.--error-negativesays theintis a value unless it is negative.peek()returns anintin Python, and only a negative raises.
Both method flags need --return-type int. That is not cosmetic:
--status-return with --return-type size_t is accepted and generates
int _rc = allocator_take(...) against a size_t prototype, which compiles
and silently truncates.
3. Implement — the C only ever sets flags and returns codes¶
"""Implement create(), take() and peek().
Every anchor below is asserted before it is replaced. A silent `str.replace`
that matches nothing leaves the scaffold's own placeholder in place, the
build still succeeds, and the demo then fails somewhere unrelated -- which is
exactly how this script was wrong the first time it was written.
"""
from pathlib import Path
CORE = Path("native/src/allocator/allocator_core.c")
CTEST = Path("native/tests/test_allocator_core.c")
CREATE_OLD = """\
allocator_state_t *obj = calloc(1, sizeof(*obj));
if (!obj)
return NULL;
obj->n_slots = 0;
obj->remaining = 0;
obj->degraded = false;
return obj;"""
CREATE_NEW = """\
allocator_state_t *obj;
/* Channel 1 -- refuse. NULL is the ONLY failure signal create() has, so
* every reason to refuse funnels through it and arrives in Python as the
* single category `just-makeit error` declared.
*/
if (slots == 0 || capacity < slots)
return NULL;
obj = calloc(1, sizeof(*obj));
if (!obj)
return NULL;
obj->n_slots = slots;
obj->remaining = (capacity / slots) * slots;
/* Channel 2 -- succeeded, but not with what was asked for. Construction
* is complete and valid; this is a "here is the best I could do" flag,
* which is why it is a warning and not a refusal.
*/
obj->degraded = (capacity % slots) != 0;
return obj;"""
TAKE_OLD = """\
allocator_take(allocator_state_t *state, size_t x)
{
(void)state; (void)x;
return (int)0;
}"""
TAKE_NEW = """\
allocator_take(allocator_state_t *state, size_t x)
{
/* Channel 3 -- status only. 0 is success; the value of a non-zero code
* is reported in the exception message but carries no result.
*/
if (x > state->remaining)
return 1;
state->remaining -= x;
return 0;
}"""
PEEK_OLD = """\
allocator_peek(allocator_state_t *state, size_t x)
{
(void)state; (void)x;
return (int)0;
}"""
PEEK_NEW = """\
allocator_peek(allocator_state_t *state, size_t x)
{
/* Channel 4 -- a value, unless it is negative. A valid slot returns a
* count the caller keeps; an invalid one returns a negative code that
* never reaches the caller as a number.
*/
if (x >= state->n_slots)
return -1;
return (int)(state->remaining / state->n_slots);
}"""
# The C test is the author's file, and a contract change is exactly when it has
# to move: covering the refusal here keeps channel 1 tested at the C layer,
# where it is implemented, and not only through the binding.
#
# This patch used to do more. Before gh-1105 the params were `required` with no
# seed, so jm emitted `create(0, 0)` behind a skip-and-bail it did not trust —
# the zero being precisely what this ctor refuses — and the patch had to
# replace that whole block plus a `get_remaining(obj) == 0` line that a
# deriving create() makes false. Declaring `example_value` (see test.py) fixed
# both at the source: jm now constructs with valid arguments, and the accessor
# test no longer asserts a post-construction value it cannot know. So all that
# is left here is ADDING the refusal cases, which is the part that was ever
# about this example.
CTEST_OLD = """\
allocator_state_t *obj = allocator_create(1024, 4);
REQUIRE(obj != NULL);"""
CTEST_NEW = """\
/* create() refuses what it cannot serve -- 2 units over 3 slots. */
CHECK(allocator_create(2, 3) == NULL);
CHECK(allocator_create(9, 0) == NULL);
allocator_state_t *obj = allocator_create(9, 3);
REQUIRE(obj != NULL);
/* An exact fit is not degraded; 10 over 3 would be. */
CHECK(allocator_get_degraded(obj) == false);
CHECK(allocator_get_n_slots(obj) == 3);
CHECK(allocator_get_remaining(obj) == 9);"""
def _replace(text: str, old: str, new: str, what: str) -> str:
assert old in text, (
f"anchor for {what} not found -- did the scaffold change?"
)
return text.replace(old, new, 1)
def main() -> None:
s = CORE.read_text(encoding="utf-8")
s = _replace(s, CREATE_OLD, CREATE_NEW, "create()")
s = _replace(s, TAKE_OLD, TAKE_NEW, "take()")
s = _replace(s, PEEK_OLD, PEEK_NEW, "peek()")
CORE.write_text(s, encoding="utf-8")
t = CTEST.read_text(encoding="utf-8")
t = _replace(t, CTEST_OLD, CTEST_NEW, "C test construction")
CTEST.write_text(t, encoding="utf-8")
if __name__ == "__main__":
main()
Nothing in this file mentions Python. create() returns NULL or sets a
bool; the two methods return an int. Every exception and warning the next
step shows is jm's glue reading those, which is why none of this needed a
#include <Python.h> and why the declarations in step 2 touched no sacred
file.
4. Build¶
cmake -B build -S . -DCMAKE_BUILD_TYPE=Release
cmake --build build --parallel 4
ctest --test-dir build --output-on-failure
5. All four, from Python¶
"""Drive all four failure channels from Python."""
import sys
import warnings
sys.path.insert(0, "src")
from budget import Allocator # noqa: E402
# ── channel 1: create() refuses ─────────────────────────────────────────
# 2 units cannot cover 3 slots, so create() returns NULL.
try:
Allocator(capacity=2, slots=3)
raise AssertionError("expected a refusal")
except ValueError as exc:
print(f"1. refuse -> ValueError: {exc}")
# ── channel 2: succeeded, with a caveat ─────────────────────────────────
# 10 over 3 slots leaves a remainder, so construction succeeds and warns.
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
lossy = Allocator(capacity=10, slots=3)
assert len(caught) == 1, caught
assert caught[0].category is RuntimeWarning
print(f"2. caveat -> RuntimeWarning: {caught[0].message}")
# The object is fully usable -- a warning is not a failure.
assert lossy.get_remaining() == 9
# An exact fit warns about nothing.
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
a = Allocator(capacity=9, slots=3)
assert not caught, caught
print(f" (exact fit) -> {len(caught)} warnings")
# ── channel 3: a status-only int ────────────────────────────────────────
# Success is None, not 0: the int carried nothing worth returning.
assert a.take(5) is None
print(f"3. take(5) -> None (remaining now {a.get_remaining()})")
try:
a.take(100)
raise AssertionError("expected a refusal")
except ValueError as exc:
print(f" take(100) -> ValueError: {exc}")
# ── channel 4: an int that is a value unless negative ───────────────────
# The same call shape returns a number here, because the int means something.
assert a.peek(0) == 1
print(f"4. peek(0) -> {a.peek(0)}")
try:
a.peek(99)
raise AssertionError("expected a refusal")
except IndexError as exc:
print(f" peek(99) -> IndexError: {exc}")
print("errors_warnings demo: PASSED")
1. refuse -> ValueError: capacity must cover at least one unit per slot
2. caveat -> RuntimeWarning: capacity is not divisible by slots; the remainder is unusable
(exact fit) -> 0 warnings
3. take(5) -> None (remaining now 4)
take(100) -> ValueError: requested more than remains (rc=1)
4. peek(0) -> 1
peek(99) -> IndexError: no such slot (rc=-1)
errors_warnings demo: PASSED
Two details worth keeping:
take()returnsNoneon success, not0.--status-returnmeans theintcarried nothing but status, so there is no result to hand back. Its neighbourpeek()returns a real number from an identically-shaped C function, and the only reason they differ is the flag.- The failing code reaches the message —
(rc=1),(rc=-1). Your--error-messagesays what went wrong in prose; jm appends what the kernel actually returned, which is the part you need when several codes share one category.