sofab.visitor module¶
The decode surface. CORELIB_PLAN §5.3.1 makes it the only one:
- “A corelib exposes exactly one decode surface: the visitor. The decoder
calls typed visitor methods on a caller-supplied object; pull-reading becomes ‘the visitor writes the decoded value into one of the object’s own members and skips what it does not recognise’.”
Visitor is a base class whose hooks all default to no-op (the value
is still consumed, so an unhandled field is transparently skipped). Subclass it
and override only the fields you care about, then pass it to
sofab.Decoder as visitor=.
Two control hooks let a visitor decline work before the value is decoded — so skipping a 10k-element array or a deep sub-tree costs nothing:
Visitor.on_field()— returnFalseto skip a scalar/fixlen/array field instead of decoding it.Visitor.on_sequence_begin()— returnFalseto skip the entire nested sequence (its matching end is consumed too, soon_sequence_endis not called for a skipped sequence).
A third hook carries the one fact the codec cannot know and the schema does:
Visitor.on_schema_bound() names the count/maxlen the schema puts
on a field, which is what takes the receiver-side max_dyn_* cap off it
(§6.2.1) and makes exceeding it INVALID rather than a policy rejection. It
is told the wire’s tag alongside the id, because it is the only hook that spans
more than one kind — every other one fires for a single wire type, so the
decoder has already matched the tag before calling it.
A fourth hook is asked once, when the decoder is built, and never again:
Visitor.destinations() names the slots the handler wants its fields
written into, as a sofab.Binding plus the storage it addresses. It is
the same bargain Visitor.on_array_begin(), on_string_begin()
and on_blob_begin() already strike — name a destination and the
codec writes there instead of calling you back — declared once for the whole
message instead of per field.
Because this is the only decode surface (§5.3.1), a table is reached through it and never beside it: there is one handler object, one walk, and one implementation of every rule, so nothing can be right on one route and wrong on another.
Which hooks a handler overrides is read off its type, once, when the
decoder binds it — so a hook nobody overrides costs nothing per field, and a
child handler returned from Visitor.on_sequence_begin() is measured on its
own type rather than its parent’s.
- class sofab.visitor.Visitor[source]¶
Bases:
objectBase visitor: override the hooks for the fields you handle.
Every hook is keyed by the wire type the decoder recovered.
field_idis the decoded field id. Unhandled hooks default to a no-op, which still consumes the value (so unknown fields are skipped safely).- destinations()[source]¶
The slots this handler wants its fields written into, or
None.Asked once, when the
sofab.Decoderis built, and never again — so nothing the wire says can change the answer, which is what §6.6 asks of a decode’s storage. Return(binding, words, objects): asofab.Bindingmapping field ids to slots, a writable 8-byte-aligned buffer for the scalar and array slots, and a list forstring/blobslots (orNonewhen the table names none).A field the table names is written straight into its slot and no typed hook fires for it — the same bargain
on_array_begin(),on_string_begin()andon_blob_begin()strike per field, made once for the whole message. A field the table does not name reaches this visitor’s hooks exactly as it would have without a table, and thecount/maxlena table entry declares is answered from the table rather than fromon_schema_bound().This is not a second decode surface (§5.3.1). The decoder still drives, the walk is the same walk, and every rule — the receiver cap, the schema bound, the §7.3 tag test, the UTF-8 check, the declared element width, the resume transaction — has one implementation that runs for a mapped field and an unmapped one alike. The table says where a value goes; it never says how it is decoded.
Decoder(binding=…, words=…, objects=…)is the constructor shorthand for a handler that declares exactly this and nothing else.- Return type:
tuple[Binding, Any, list[Any] | None] | None
- on_field(field)[source]¶
Called for every non-sequence field before its value is decoded. Return
Falseto skip the value entirely; any other return proceeds to decode it and dispatch to the typed hook below.- Parameters:
field (Field)
- Return type:
bool | None
- on_schema_bound(field_id, n, wtype, subtype)[source]¶
The count or length the schema declares for this field, or
-1.Asked once, at the count/length header — after
on_field(), before a payload byte is read or any storage is written — for astring, ablobor an array this handler has accepted, and for nothing else. A scalar carries neither a count nor a length, so none is asked for, and a field the handler skipped is never asked (§6.7.2).nis what the wire announced: the byte length for astringorblob, the element count for an array.wtype/subtypeare the tag the wire carried, and they are here so that a handler can apply MESSAGE_SPEC §7.3 to its own declaration before answering. This is the only hook that spans more than one kind —on_string_begin()fires for astringand nothing else,on_array_begin()for an integer array and nothing else, so the decoder has already matched the tag for them. Here it has not, and an id the schema bounds can arrive under a tag the schema never declared for it. §7.3 says such a field is skipped like an unknown id, so a handler must answer-1for a tag it did not declare — a bound answered for someone else’s field is a bound applied to a length that was never the handler’s, and theINVALIDthat follows contradicts §7.3. A table entry (destinations()) gets the same test run for it by the decoder, which is why the two routes agree.subtypeis the fixlen subtype for astring, abloband a fixlen array, andNonefor an integer array, which carries none. Both are enum members recovered by index, not built, so overriding this hook still costs no allocation per field.Returning
n >= 0:a wire count/length above it is
INVALID—sofab.SofaDecodeError(MESSAGE_SPEC §7.1): the message contradicts the schema;the receiver-side
max_dyn_*cap stops applying to the field (§6.2.1) — a schema bound is a statement about validity, a receiver limit about capacity.
Returning
-1(the default) leaves the field to the receiver caps, unchanged.A handler that declares destinations (
destinations()) answers this from its table for every field the table names; this hook is what the rest go through, and both reach the same rule in the same place.def on_schema_bound(self, field_id, n, wtype, subtype): if (field_id == 0 and wtype is WireType.FIXLEN and subtype is FixlenSubtype.STRING): return 32 # the schema's maxlen for this field return -1 # not the field the schema declared
- Parameters:
field_id (int)
n (int)
wtype (WireType)
subtype (FixlenSubtype | None)
- Return type:
int
- on_sequence_begin(field_id)[source]¶
A nested sequence is opening; nothing inside it has been decoded.
Three answers:
Falseskip the whole sub-tree — its end marker is consumed and
on_sequence_end()is not called.- another
Visitor descend into it: every field of that sub-tree goes to the visitor returned, its
on_sequence_end()fires when the scope closes, and this visitor resumes afterwards. That is how a hand-written object hands a nested message to the object that models it. Generated code is flat and does not descend; it grows a wrapper array’s list with the helpers insofab.collectors.- anything else
decode the sub-tree into this same visitor, as a flat event stream.
A sub-tree opens a fresh id scope (§4.9), so the ids inside it mean what the nested schema says, not what the enclosing one does.
- Parameters:
field_id (int)
- Return type:
bool | TypeAliasForwardRef(‘sofab.Visitor’) | None
- on_array_begin(field_id, wtype, count)[source]¶
An integer array’s header has been read; no element has been decoded.
This is the only place a handler can say anything about the array’s elements, because the typed hook below receives them already decoded. Return
Noneto take the default — a list, handed toon_unsigned_array()/on_signed_array()— or a(dst, elem_min, elem_max)triple:dstSomewhere to put the elements, or
Noneto keep the list. A writable buffer of at leastcountslots: anarrayof the right typecode, amemoryviewover one, or any object supporting the buffer protocol. The decoder writes into it and does not call the typed hook — the handler already has the values where it wanted them, and none of them was ever a Python object. A buffer too short issofab.SofaArgumentError; the decoder never grows one (CORELIB_PLAN §6.6).elem_min/elem_maxThe element width the schema declares, or
Nonefor an open side. The decoder applies it at each element, so a value outside it is INVALID whether the array completes or is truncated behind it (§7.1), which is also §5.2’s INVALID-over-INCOMPLETE for free. A handler cannot do this itself: by the time it holds the list, an array that never arrived is indistinguishable from one that did.
Called again for the same array if a chunk boundary suspends the read, so return the same answer each time; the decoder restarts the array from its first element and fills
dstfrom the beginning.Not called for float arrays, which carry no declared width to state. Their destination hook is
on_float_array_begin().A configured
max_dyn_array_countdoes not gate this hook, on the same reasoning ason_blob_begin(): it is asked first and toldcount, and adstit hands back is storage it sized itself. The cap governs the list the decoder would otherwise build — theNoneanswer — not a destination of the handler’s own.- Parameters:
field_id (int)
wtype (WireType)
count (int)
- Return type:
tuple[Any, int | None, int | None] | None
- on_blob_begin(field_id, size)[source]¶
A blob’s length has been read; no payload byte has been copied yet.
Return
Noneto take the default — abytes, handed toon_bytes(). Return a writable, contiguous buffer of at leastsizebytes and the decoder copies the payload straight into it and does not callon_bytes(). One too short issofab.SofaArgumentError; the decoder never grows one (CORELIB_PLAN §6.6), and the refusal comes at the length word, before a byte is written.This is §6.6.3’s second shape for an aggregate: a callback carrying a whole blob obliges the codec to build one, and the only size available to build it from is the wire’s. A megabyte blob costs a megabyte allocation per message that way; into a destination it costs none.
Called again for the same blob if a chunk boundary suspends the copy, so return the same answer each time; the decoder restarts the payload from its first byte.
The string twin is
on_string_begin().A configured
max_dyn_blob_lendoes not gate this hook. It is asked first, and asked whatever the announced size is. The limit is there to stop the sender dictating the receiver’s allocation (§6.2.1), and a handler that hands back a buffer has sized that buffer itself — there is no allocation of the decoder’s left to prevent. It is toldsizebefore a byte is copied precisely so that a receiver unwilling to take that many can refuse it here, which is its call to make. ReturnNoneand the cap applies again, because then thebytesis the decoder’s to build and the wire is its only size.- Parameters:
field_id (int)
size (int)
- Return type:
Any
- on_string_begin(field_id, size)[source]¶
A string’s byte length has been read; no payload byte has been copied yet, and none has been validated.
Return
Noneto take the default — astr, handed toon_string(). Return a writable, contiguous buffer of at leastsizebytes and the decoder validates the payload as UTF-8, copies the wire bytes straight into it, and does not callon_string(). One too short issofab.SofaArgumentError; the decoder never grows one (CORELIB_PLAN §6.6), and the refusal comes at the length word, before a byte is written.sizeis the wire byte length, which is what a schemamaxlenbounds (MESSAGE_SPEC §1) — not a character count. What lands in the buffer is UTF-8, so a target that wants Python text decodes it itself; what this saves is thestrthe decoder would otherwise have had to build, sized by the wire.This is §6.6.3’s second shape for the third aggregate, and it is the sharpest of the three: with a caller
reassembly=buffer and aBinding, a 1 MiB string still cost a 1 MiB allocation inside the codec, because there was no third opt-out to take.The payload is still validated (§6.7.2: a field the handler reads is materialized and validated). Validation walks the bytes — §6.4.3’s
utf8_validprimitive — so nothing the wire sizes is built to check them. Invalid UTF-8 isINVALIDand the destination is left untouched.Called again for the same string if a chunk boundary suspends the copy, so return the same answer each time; the decoder restarts the payload from its first byte.
A configured
max_dyn_string_lendoes not gate this hook, on the same reasoning ason_blob_begin().- Parameters:
field_id (int)
size (int)
- Return type:
Any
- on_float_array_begin(field_id, subtype, count)[source]¶
A fixlen (
fp32/fp64) array’s count has been read; no element has been decoded.Return
Noneto take the default — alist, handed toon_float32_array()/on_float64_array()— or a writable buffer of at leastcount8-byte slots (anarray("d"), amemoryviewover one, a NumPyfloat64array). The decoder widens each element into it and does not call the typed hook. A buffer too short issofab.SofaArgumentError; the decoder never grows one (CORELIB_PLAN §6.6).subtypeissofab.FixlenSubtype.FP32orFP64, so one hook serves both and a handler that only wants one returnsNonefor the other.Slots are 8 bytes for both subtypes because a Python
floatis a double and that is what the values become. A consumer that needs anfp32’s wire bits intact takeson_float32_array_bits()instead (§6.5).Called again for the same array if a chunk boundary suspends the read, so return the same answer each time.
A configured
max_dyn_array_countdoes not gate this hook, on the same reasoning ason_blob_begin().- Parameters:
field_id (int)
subtype (FixlenSubtype)
count (int)
- Return type:
Any
- on_unsigned(field_id, value)[source]¶
Handle a decoded unsigned-integer field.
- Parameters:
field_id (int)
value (int)
- Return type:
None
- on_signed(field_id, value)[source]¶
Handle a decoded signed-integer field.
- Parameters:
field_id (int)
value (int)
- Return type:
None
- on_float32(field_id, value)[source]¶
Handle a decoded 32-bit float field.
The value is a Python
float— a Cdouble— because Python has no other float. CORELIB_PLAN §6.5 permits that for a value consumer; a consumer that has to reproduce the wire bytes takeson_float32_bits()instead.- Parameters:
field_id (int)
value (float)
- Return type:
None
- on_float32_bits(field_id, bits)[source]¶
The raw wire bits of a 32-bit float field, as an
int.Override this and the decoder calls it instead of
on_float32()for every scalarfp32.bitsis the little-endian payload read as an unsigned 32-bit integer, exactly as it lay on the wire, andsofab.Encoder.write_float32_bits()puts it back verbatim.This is CORELIB_PLAN §6.5’s required channel for a double-only target, which Python is. IEEE widening
fp32to a double sets the quiet bit, so a signaling NaN’s payload is destroyed the instant the value passes through the wider float — and no later code can recover it. A port on such a target therefore “MUST provide a raw-wire-bytes path for bit-exact consumers (transcode, round-trip, any re-encode) that re-emits those bytes verbatim” and “MUST NOT re-encode anfp32from the widened value”.(This port also preserves an sNaN through the widened
float, by doing the conversion on the bit pattern by hand rather than letting the hardware quiet it — soon_float32()is bit-exact here today. That is a property of this implementation on this platform; the raw channel is the guarantee.)The array twin is
on_float32_array_bits().- Parameters:
field_id (int)
bits (int)
- Return type:
None
- on_float64(field_id, value)[source]¶
Handle a decoded 64-bit float field.
- Parameters:
field_id (int)
value (float)
- Return type:
None
- on_string(field_id, value)[source]¶
Handle a decoded UTF-8 string field.
- Parameters:
field_id (int)
value (str)
- Return type:
None
- on_bytes(field_id, value)[source]¶
Handle a decoded raw byte-blob field.
- Parameters:
field_id (int)
value (bytes)
- Return type:
None
- on_unsigned_array(field_id, values)[source]¶
Handle a decoded unsigned-integer array field.
- Parameters:
field_id (int)
values (list[int])
- Return type:
None
- on_signed_array(field_id, values)[source]¶
Handle a decoded signed-integer array field.
- Parameters:
field_id (int)
values (list[int])
- Return type:
None
- on_float32_array(field_id, values)[source]¶
Handle a decoded 32-bit float array field.
- Parameters:
field_id (int)
values (list[float])
- Return type:
None
- on_float32_array_bits(field_id, count, payload)[source]¶
The raw wire bytes of a 32-bit float array, undecoded.
Override this and the decoder calls it instead of
on_float32_array().payloadis a read-onlymemoryviewof exactly4 * countlittle-endian bytes — the array’s payload as it lay on the wire — andsofab.Encoder.write_float32_array_bits()puts it back verbatim.§6.5’s requirement is stated over “every
fp32position — a scalarfp32(§4.6) and each element of anfp32array (§4.8)”, so the scalar channel alone would not meet it.The bytes do not outlive the call. They are the caller’s own input, borrowed for the duration of this callback exactly as a fed chunk is (§6, chunk lifetime), and a handler that still needs them afterwards copies them. That is §6.7’s second route — “the codec passes the value through the callback … and the caller copies it. The second route is not a view” — and it is why nothing is allocated to deliver an array of any length.
- Parameters:
field_id (int)
count (int)
payload (Any)
- Return type:
None