sofab.binding module¶
Field-id → destination table: decode straight into caller-owned storage.
CORELIB_PLAN §5.3 recommends the visitor pattern “because the primary consumer
of this library is generated code … those objects already exist at decode
time; the visitor pattern lets the decoder write each field straight into the
waiting member without an intermediate representation”. A Binding is
that idea written down once instead of by hand: rather than a handler with a
branch per field id, the table says where every field belongs, and
handler() compiles it into the sofab.Visitor the decoder drives.
It is not a second decode surface, and CORELIB_PLAN §5.3.1 does not allow one: “no convenience wrapper that decodes by another route”, because “every additional surface is a second implementation of every rule in this document”. So a table is a way of saying where a field goes, never a way of getting it there — the same feed, the same header walk, the same hooks and the same verdicts as any other handler.
What it still buys over a hand-written visitor is the elements: an array of any
length lands in the caller’s slots through
sofab.Visitor.on_array_begin() / on_float_array_begin()
without a list and without a Python object per element.
Two pieces of storage, both supplied and sized by the caller — the decoder allocates neither and never sizes anything from the wire (documentation#54 §6.6, CORELIB_PLAN §6.2.1):
wordsOne writable, C-contiguous byte buffer whose length is a multiple of 8 — a
bytearrayis the obvious choice. Every numeric field lands in it as one 64-bit slot: unsigned asuint64, signed asint64,fp32/fp64both widened to a nativedouble, arrays ascapconsecutive slots. Read the slots back through as many typed views over the same buffer as you need —memoryview(buf).cast("q"),.cast("Q"),.cast("d")— which costs no copy and no second buffer.objectsA pre-sized
list, for the two field kinds that have no fixed-width machine representation:stringandblob. Each lands at its own index.A slot takes either of two shapes, and the row says which.
string()andbytes()name a slot to put a value in, and the decoder builds thatstr/bytes— from the only size it has, the wire’s, which is the materialized aggregate CORELIB_PLAN §6.6.3 names.string_into()andblob_into()name a slot that already holds a writable byte buffer the caller put there, and the payload is copied into it: nothing is sized from the wire, a destination too short is refused rather than grown, andcount_atreceives the byte length. That is §6.6.3’s third shape — “into a destination the caller declared before the decode began” — and it is what lets a whole message decode without one allocation the sender chose.
A field the table does not name is not an error: it is dispatched to the
sofab.Visitor the decoder was given, or skipped. So a binding covers
the schema’s hot fields and everything else keeps working. A table built with
closed=True skips such a field even when there is a visitor — which is what
a child table wants, because the visitor was never told the walk descended
into it (see Binding).
Example:
b = Binding()
b.unsigned(1, at=0).signed(2, at=1).string(3, at=0, count_at=2)
b.unsigned_array(4, at=8, cap=16, count_at=3)
b.blob_into(5, at=1, count_at=4) # into a buffer you put there
words = bytearray(b.words_required * 8)
objs = [None] * b.objects_required
objs[1] = bytearray(4096) # field 5's destination
dec = Decoder(binding=b, words=words, objects=objs)
st = dec.feed(chunk)
u = memoryview(words).cast("Q")
u[0] # field 1
objs[0] # field 3, or untouched if it never arrived
u[2] # 1 if field 3 arrived, else untouched
u[8:8 + u[3]] # field 4's elements, u[3] of them
objs[1][:u[4]] # field 5's payload, u[4] bytes of it
- sofab.binding.KIND_TAG: tuple[tuple[WireType, FixlenSubtype | None], ...] = ((WireType.UNSIGNED, None), (WireType.SIGNED, None), (WireType.UNSIGNED, None), (WireType.FIXLEN, FixlenSubtype.FP32), (WireType.FIXLEN, FixlenSubtype.FP64), (WireType.FIXLEN, FixlenSubtype.STRING), (WireType.FIXLEN, FixlenSubtype.BLOB), (WireType.ARRAY_UNSIGNED, None), (WireType.ARRAY_SIGNED, None), (WireType.ARRAY_UNSIGNED, None), (WireType.ARRAY_FIXLEN, FixlenSubtype.FP32), (WireType.ARRAY_FIXLEN, FixlenSubtype.FP64), (WireType.SEQUENCE_START, None))¶
(wire type, fixlen subtype or None). A field whose wire tag contradicts its binding is not an error — it is skipped exactly like an unknown id and the decode stays COMPLETE (MESSAGE_SPEC §7.3, CORELIB_PLAN §6.3). A boolean’s tag is the unsigned one: §4.4 gives booleans no wire type of their own, so nothing distinguishes them here — the difference is what the decoder stores, not what it accepts.- Type:
For each kind, the wire tag it accepts
- class sofab.binding.Entry(kind, field_id, at, cap, count_at, child, elem_lo=0, elem_hi=0, elem_bounded=False, into=False)[source]¶
Bases:
objectOne row of a
Binding. Built by the binder methods, read by the engines; not something callers construct.- Parameters:
kind (int)
field_id (int)
at (int)
cap (int)
count_at (int)
child (Binding | None)
elem_lo (int)
elem_hi (int)
elem_bounded (bool)
into (bool)
- kind¶
- field_id¶
- at¶
- cap¶
- count_at¶
- child¶
- into¶
- elem_lo¶
- elem_hi¶
- elem_bounded¶
- wt¶
- st¶
- declared¶
- class sofab.binding.Binding(closed=False)[source]¶
Bases:
objectWhere each field id’s value belongs. Build once, decode many times.
Every binder method returns
self, so a table reads as one statement.atis a slot index — intowordsfor the numeric kinds, intoobjectsforstring(),bytes(),string_into()andblob_into().count_atis an optionalwordsslot the decoder writes the field’s arrival into:1for a scalar that turned up, the element count for an array, the byte length for astring_into()/blob_into()payload, the number of occurrences for a sequence. Slots the decoder never writes are left exactly as the caller prepared them, which is how a decode reports absence without inventing a sentinel.An array’s
count_atis written when the array’s count header is read, not when its last element lands — that is where the destination is settled (§6.6.3). On a decode that completes the value is the same either way; a decode that ends INCOMPLETE or INVALID inside an array may already have written it.closeddecides what happens to an id this table does not name. Open (the default), it goes to the decoder’s visitor, as it would without a table. Closed, it is skipped exactly as a decoder with no visitor skips it — no hook, nothing materialized, no cap spent, decode stays COMPLETE — and a nested sequence the table does not name is skipped whole.That is what a child table wants. The decoder descends into a bound sequence without telling the visitor, so the visitor still believes the walk is in the parent’s scope, and an id the child does not name would reach it under the parent’s identity: an unknown field a newer sender added inside a struct would land in whichever parent field shares its id. A closed child cannot hand one over. The flag belongs to the table, so a child bound from two places behaves the same in both.
- Parameters:
closed (bool)
- property entries: tuple[Entry, ...]¶
The rows, in the order they were bound. The engines compile this.
- property closed: bool¶
Whether an id this table does not name is skipped rather than handed to the visitor; see
Binding.
- property words_required: int¶
Slots the
wordsbuffer must hold — i.e. it must be at leastwords_required * 8bytes. Counts this table only; a childsequence()binding shares the same buffer, so take the maximum over the whole tree (or give every table disjoint slots, which is what generated code does).
- property objects_required: int¶
Entries the
objectslist must hold.
- property tree_words_required: int¶
words_requiredover this table and every table reachable throughsequence(). A child shares the parent’s storage, so this is the size the one buffer has to have.
- property tree_objects_required: int¶
objects_requiredover the whole tree; seetree_words_required.
- freeze()[source]¶
Close the table — this one and every child — and return the whole reachable set.
A binding is a build-once artifact: a
sofab.Decoderderives its storage requirements and one handler per table from it and holds them, so a table that changed afterwards would decode against a stale copy. Freezing at first use makes that a clear error instead. Called for you — building the decoder is what freezes the table — and idempotent, so calling it yourself is harmless.It is deliberately the whole tree: a child bound into a parent is reachable only downwards, so freezing the root is the only moment at which every table in it can be reached at once.
- Return type:
list[Binding]
- unsigned(field_id, at, count_at=None, max_value=None)[source]¶
Bind an unsigned-integer field to
wordsslotat(uint64).max_valueis the schema’s declared width (0xFFfor au8, or for abitfieldwhose highestposis 7). The slot is 64 bits wide whatever the field declares, so nothing about the storage enforces a narrower width, and MESSAGE_SPEC §1 then requires an explicit check: given, a value above it is INVALID at the value, before it is stored — so a message truncated behind it is INVALID, not INCOMPLETE (§5.2).- Parameters:
field_id (int)
at (int)
count_at (int | None)
max_value (int | None)
- Return type:
- signed(field_id, at, count_at=None, min_value=None, max_value=None)[source]¶
Bind a signed-integer field to
wordsslotat(int64).min_value/max_valueare the schema’s declared width (-128/127for ani8, or for anenumwhose constants all fit one); seeunsigned(). Either side may be given on its own.- Parameters:
field_id (int)
at (int)
count_at (int | None)
min_value (int | None)
max_value (int | None)
- Return type:
- boolean(field_id, at, count_at=None)[source]¶
Bind a boolean field to
wordsslotatas0or1.A boolean has no wire type of its own (§4.4) — it arrives as an unsigned integer and is accepted under that tag — but it is not the unsigned binding, because §4.4 splits the two rules that meet here:
canonical on encode:
sofab.Encoder.write_bool()writestrueas1;tolerant on decode: every value other than
0reads as true. Such a value is not INVALID — there is nothing to reject, only something to normalize — so the slot gets1, never the42the sender happened to write, and a re-encode emits1.
Doing that here is what keeps the rule off every caller: a slot a completed decode filled can be tested for truth or compared to
1, and both agree. (Only a completed one — like every other bound kind, a decode that ends INCOMPLETE or INVALID says nothing about what it has written so far;boolean_array()spells out what that means for an array caught mid-payload.) Note there is deliberately no declared-width argument, the wayunsigned()hasmax_value: §4.4 gives a boolean no width bound at all, unlike anenumor abitfield, so binding one with a ceiling of1— which would make42INVALID — is exactly the reading the clause rules out.- Parameters:
field_id (int)
at (int)
count_at (int | None)
- Return type:
- float32(field_id, at, count_at=None)[source]¶
Bind an
fp32field towordsslotat, widened to a nativedouble(read it back through a.cast("d")view).- Parameters:
field_id (int)
at (int)
count_at (int | None)
- Return type:
- float64(field_id, at, count_at=None)[source]¶
Bind an
fp64field towordsslotatas adouble.- Parameters:
field_id (int)
at (int)
count_at (int | None)
- Return type:
- string(field_id, at, maxlen=0, count_at=None)[source]¶
Bind a UTF-8
stringfield toobjects[at].maxlenis the schema’s declared byte length, or0for a field the schema leaves unbounded. Declaring it makes the field schema-bounded: a longer payload is INVALID (MESSAGE_SPEC §7.1) and the receiver-sidemax_dyn_string_lencap no longer applies to it (§6.2.1). Left at0the cap applies as usual.- Parameters:
field_id (int)
at (int)
maxlen (int)
count_at (int | None)
- Return type:
- bytes(field_id, at, maxlen=0, count_at=None)[source]¶
Bind a
blobfield toobjects[at]; seestring()formaxlen.- Parameters:
field_id (int)
at (int)
maxlen (int)
count_at (int | None)
- Return type:
- string_into(field_id, at, maxlen=0, count_at=None)[source]¶
Bind a UTF-8
stringfield into the buffer already inobjects[at]— nostris built.This is §6.6.3’s third shape: “into a destination the caller declared before the decode began — a field-id → slot table — which is the bullet above with the choice made once instead of per field”.
string()names a slot to put a value in, and the value is one the decoder has to build from the only size it has, the wire’s. This names a slot that already holds the storage: put a writable, contiguous, single-byte buffer (abytearray, amemoryviewover one, anarray("B")) atobjects[at]before decoding, and the decoder validates the payload as UTF-8 and copies the wire bytes into it.Nothing here is sized from the wire. A destination shorter than the announced length is refused with
sofab.SofaArgumentError— §6.3’sInvalidArgument, because “the message is well-formed and within every bound it declares — what does not fit is the storage this caller offered” — and it is never grown.count_atreceives the payload’s byte length, which is how the caller knows how much of its buffer is live; an absent field leaves the slot exactly as it was prepared, as everywhere else. (The arrival flagstring()writes would not be enough here: a caller holding the bytes needs to know how many of them are the message’s.)maxlenis the schema’s declared byte length, exactly as onstring(): declaring it makes a longer payload INVALID (MESSAGE_SPEC §7.1) and takes the receiver-sidemax_dyn_string_lencap off the field (§6.2.1). Left at0the field is schema-unbounded — and the cap does not apply to it either, on the same reasoningsofab.Visitor.on_string_begin()carries: the cap exists to stop the sender dictating the receiver’s allocation, and a caller that put the buffer there sized it itself. What bounds this field is the buffer, and a payload past it is refused at the length word before a byte is copied.- Parameters:
field_id (int)
at (int)
maxlen (int)
count_at (int | None)
- Return type:
- blob_into(field_id, at, maxlen=0, count_at=None)[source]¶
Bind a
blobfield into the buffer already inobjects[at]; seestring_into(). The only difference is the onebytes()has fromstring()— the payload is copied verbatim and not validated as UTF-8.- Parameters:
field_id (int)
at (int)
maxlen (int)
count_at (int | None)
- Return type:
- unsigned_array(field_id, at, cap, count_at=None, elem_max=None)[source]¶
Bind an unsigned-integer array to
words[at:at + cap].capis the schema’s maximum element count. A message declaring more is malformed against that schema, so it is rejected as INVALID (MESSAGE_SPEC §7.1) — the decoder never sizes storage from the wire.elem_maxis the schema’s declared element width (0xFFfor au8array, and so on). Given, it is checked at each element, before the element is stored, so a too-wide value is INVALID at the element that carries it rather than after the array completes.- Parameters:
field_id (int)
at (int)
cap (int)
count_at (int | None)
elem_max (int | None)
- Return type:
- signed_array(field_id, at, cap, count_at=None, elem_min=None, elem_max=None)[source]¶
Bind a signed-integer array to
words[at:at + cap](int64).The two halves of the declared width are independent: either may be given on its own and bounds its own side (see
unsigned_array()).- Parameters:
field_id (int)
at (int)
cap (int)
count_at (int | None)
elem_min (int | None)
elem_max (int | None)
- Return type:
- boolean_array(field_id, at, cap, count_at=None)[source]¶
Bind an array of booleans to
words[at:at + cap]as0/1.The element half of
boolean(): an array of boolean travels as an array of unsigned (§4.4 gives booleans no wire type), and §4.4’s decode rule applies per element, so each slot is normalized as it is stored.capis the schema’s maximum element count and behaves exactly as it does forunsigned_array(). There is noelem_max, for the reasonboolean()takes no width: a boolean element outside0..1is normalized, never rejected.The elements are normalized when the array completes, not one at a time — the pass runs once over the slots the payload filled, so the element loop every other array kind shares carries no test of its own. A decode that ends INCOMPLETE or INVALID inside this array may therefore leave raw wire values in the slots it already wrote, exactly as it may already have written
count_at(seeBinding). The nextsofab.Decoder.feed()refills the array from element zero, so what a completed decode leaves is always0/1.- Parameters:
field_id (int)
at (int)
cap (int)
count_at (int | None)
- Return type:
- float32_array(field_id, at, cap, count_at=None)[source]¶
Bind an
fp32array towords[at:at + cap], widened todoubleper element.- Parameters:
field_id (int)
at (int)
cap (int)
count_at (int | None)
- Return type:
- float64_array(field_id, at, cap, count_at=None)[source]¶
Bind an
fp64array towords[at:at + cap].- Parameters:
field_id (int)
at (int)
cap (int)
count_at (int | None)
- Return type:
- sequence(field_id, child, count_at=None)[source]¶
Descend into a nested sequence with
childas its table (§4.9).The child writes into the same
words/objectsstorage, so a whole message tree decodes into one flat pair of buffers.count_atcounts how many times the sequence occurred, which is what tells a caller whether an optional sub-message was present.A sequence with no binding is skipped whole, sub-tree and all — the auto-skip §5.2 requires — and costs nothing but the walk.