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):

words

One writable, C-contiguous byte buffer whose length is a multiple of 8 — a bytearray is the obvious choice. Every numeric field lands in it as one 64-bit slot: unsigned as uint64, signed as int64, fp32/fp64 both widened to a native double, arrays as cap consecutive 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.

objects

A pre-sized list, for the two field kinds that have no fixed-width machine representation: string and blob. Each lands at its own index.

A slot takes either of two shapes, and the row says which. string() and bytes() name a slot to put a value in, and the decoder builds that str/bytes — from the only size it has, the wire’s, which is the materialized aggregate CORELIB_PLAN §6.6.3 names. string_into() and blob_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, and count_at receives 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: object

One 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: object

Where each field id’s value belongs. Build once, decode many times.

Every binder method returns self, so a table reads as one statement. at is a slot index — into words for the numeric kinds, into objects for string(), bytes(), string_into() and blob_into(). count_at is an optional words slot the decoder writes the field’s arrival into: 1 for a scalar that turned up, the element count for an array, the byte length for a string_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_at is 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.

closed decides 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 words buffer must hold — i.e. it must be at least words_required * 8 bytes. Counts this table only; a child sequence() 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 objects list must hold.

property tree_words_required: int

words_required over this table and every table reachable through sequence(). A child shares the parent’s storage, so this is the size the one buffer has to have.

property tree_objects_required: int

objects_required over the whole tree; see tree_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.Decoder derives 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 words slot at (uint64).

max_value is the schema’s declared width (0xFF for a u8, or for a bitfield whose highest pos is 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:

Binding

signed(field_id, at, count_at=None, min_value=None, max_value=None)[source]

Bind a signed-integer field to words slot at (int64).

min_value/max_value are the schema’s declared width (-128 / 127 for an i8, or for an enum whose constants all fit one); see unsigned(). 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:

Binding

boolean(field_id, at, count_at=None)[source]

Bind a boolean field to words slot at as 0 or 1.

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() writes true as 1;

  • tolerant on decode: every value other than 0 reads as true. Such a value is not INVALID — there is nothing to reject, only something to normalize — so the slot gets 1, never the 42 the sender happened to write, and a re-encode emits 1.

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 way unsigned() has max_value: §4.4 gives a boolean no width bound at all, unlike an enum or a bitfield, so binding one with a ceiling of 1 — which would make 42 INVALID — is exactly the reading the clause rules out.

Parameters:
  • field_id (int)

  • at (int)

  • count_at (int | None)

Return type:

Binding

float32(field_id, at, count_at=None)[source]

Bind an fp32 field to words slot at, widened to a native double (read it back through a .cast("d") view).

Parameters:
  • field_id (int)

  • at (int)

  • count_at (int | None)

Return type:

Binding

float64(field_id, at, count_at=None)[source]

Bind an fp64 field to words slot at as a double.

Parameters:
  • field_id (int)

  • at (int)

  • count_at (int | None)

Return type:

Binding

string(field_id, at, maxlen=0, count_at=None)[source]

Bind a UTF-8 string field to objects[at].

maxlen is the schema’s declared byte length, or 0 for 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-side max_dyn_string_len cap no longer applies to it (§6.2.1). Left at 0 the cap applies as usual.

Parameters:
  • field_id (int)

  • at (int)

  • maxlen (int)

  • count_at (int | None)

Return type:

Binding

bytes(field_id, at, maxlen=0, count_at=None)[source]

Bind a blob field to objects[at]; see string() for maxlen.

Parameters:
  • field_id (int)

  • at (int)

  • maxlen (int)

  • count_at (int | None)

Return type:

Binding

string_into(field_id, at, maxlen=0, count_at=None)[source]

Bind a UTF-8 string field into the buffer already in objects[at] — no str is 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 (a bytearray, a memoryview over one, an array("B")) at objects[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’s InvalidArgument, 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_at receives 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 flag string() writes would not be enough here: a caller holding the bytes needs to know how many of them are the message’s.)

maxlen is the schema’s declared byte length, exactly as on string(): declaring it makes a longer payload INVALID (MESSAGE_SPEC §7.1) and takes the receiver-side max_dyn_string_len cap off the field (§6.2.1). Left at 0 the field is schema-unbounded — and the cap does not apply to it either, on the same reasoning sofab.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:

Binding

blob_into(field_id, at, maxlen=0, count_at=None)[source]

Bind a blob field into the buffer already in objects[at]; see string_into(). The only difference is the one bytes() has from string() — 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:

Binding

unsigned_array(field_id, at, cap, count_at=None, elem_max=None)[source]

Bind an unsigned-integer array to words[at:at + cap].

cap is 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_max is the schema’s declared element width (0xFF for a u8 array, 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:

Binding

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:

Binding

boolean_array(field_id, at, cap, count_at=None)[source]

Bind an array of booleans to words[at:at + cap] as 0/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.

cap is the schema’s maximum element count and behaves exactly as it does for unsigned_array(). There is no elem_max, for the reason boolean() takes no width: a boolean element outside 0..1 is 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 (see Binding). The next sofab.Decoder.feed() refills the array from element zero, so what a completed decode leaves is always 0/1.

Parameters:
  • field_id (int)

  • at (int)

  • cap (int)

  • count_at (int | None)

Return type:

Binding

float32_array(field_id, at, cap, count_at=None)[source]

Bind an fp32 array to words[at:at + cap], widened to double per element.

Parameters:
  • field_id (int)

  • at (int)

  • cap (int)

  • count_at (int | None)

Return type:

Binding

float64_array(field_id, at, cap, count_at=None)[source]

Bind an fp64 array to words[at:at + cap].

Parameters:
  • field_id (int)

  • at (int)

  • cap (int)

  • count_at (int | None)

Return type:

Binding

sequence(field_id, child, count_at=None)[source]

Descend into a nested sequence with child as its table (§4.9).

The child writes into the same words / objects storage, so a whole message tree decodes into one flat pair of buffers. count_at counts 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.

Parameters:
  • field_id (int)

  • child (Binding)

  • count_at (int | None)

Return type:

Binding