sofab.encoder module

SofaBuffers encoder (OStream equivalent).

One buffer-ownership model (CORELIB_PLAN §5.1): the encoder writes into a fixed output buffer and drains it through a flush sink when that buffer fills. It never grows or reallocates the buffer it writes into — “what was handed over is what gets written”. Three construction shapes express that one model:

  • Encoder.over_buffer(buf, offset, flush) — the primitive, and the only one that takes a caller-supplied buffer. Writes into buf, reserving offset bytes at the front for a lower-layer header, draining via the flush sink when full; without a sink a full buffer reports SofaBufferError, which is the shape a caller sizes from a generated MAX_SIZE.

  • Encoder(writer) — the same primitive over a scratch buffer of _SCRATCH_SIZE bytes installed with a sink that forwards to writer.write. This is §5.1’s “unbounded schema” shape, so a message of any size streams out through bounded memory as it is written, rather than accumulating until Encoder.flush().

  • Encoder() — the same again, with the sink appending into the result the encoder hands back from Encoder.getvalue(). What grows there is the message being returned, not a buffer the encoder writes into.

The scratch buffer of the latter two is one allocation, made once at construction and never resized; §5.1 puts even that in the generated layer, which knows the schema — so generated code that can bound its message should prefer over_buffer with a MAX_SIZE-sized buffer and no sink.

Sequences are framed lazily: Encoder.write_sequence_begin_lazy() holds the header back until the sequence receives content, so a sequence-typed field whose value equals its declared default is omitted rather than emitted as an empty begin/end frame (MESSAGE_SPEC §2). The closer picks the outcome — Encoder.write_sequence_end() drops a contentless sequence, Encoder.write_sequence_end_keep() forces the frame out (wrapper-array elements, explicit empty arrays). Held-back ids are encoder state, never buffer content, so a flush cannot split a pending run by construction: a pending header occupies no buffer space, and the buffer only fills through a write — which commits the whole run before its first byte goes out. A tiny output buffer therefore produces exactly the one-shot bytes.

The run itself has no fixed window: it is sofab.MAX_DEPTH slots wide, so the hold-back reaches the full depth and every depth is canonical (CORELIB_PLAN §6.0.1 — only a heap-free profile may bound the run and frame eagerly beyond the bound). It is sized at construction and never grows, which §6.6 requires of every piece of the codec’s bounded working state.

class sofab.encoder.Encoder(writer=None, *, sticky=False)[source]

Bases: object

Encodes SofaBuffers fields to a byte stream.

Parameters:
  • writer (Writer | None)

  • sticky (bool)

classmethod over_buffer(buffer, offset=0, flush=None, *, sticky=False)[source]

Create an encoder that writes into a fixed caller-owned buffer.

Rust/C/Java-style construction: bytes are written directly into buffer, reserving offset bytes at the front for a lower-layer header. When the buffer fills, the encoder calls flush with the bytes written so far; flush is expected to drain them and (via buffer_set()) hand back a fresh buffer so encoding continues. With no flush sink a full buffer raises SofaBufferError. Pass sticky=True to latch the first error instead of raising per call (inspect it via error).

With a flush sink the buffer must leave at least MIN_OUTPUT_BUFFER bytes past offset; without one there is no minimum. See buffer_set(), which enforces both.

Parameters:
  • buffer (bytearray)

  • offset (int)

  • flush (Callable[[bytes], None] | None)

  • sticky (bool)

Return type:

Encoder

buffer_set(buffer, offset=0)[source]

Install a new fixed output buffer mid-stream.

Mirrors C sofab_ostream_buffer_set / Rust buffer_set / Java bufferSet: typically called from inside the flush sink to hand the encoder a fresh buffer so encoding continues without interruption. offset bytes are reserved at the front (e.g. for a framing header).

The offset belongs to this installation, not to the buffer (CORELIB_PLAN §5.1): the cursor starts at offset and the offset is then consumed, so a later flush the sink returns from without installing anything resumes at 0. Re-installing — even the same buffer — is what re-arms the reservation, which is how a sink gets fresh header room in every flushed unit rather than only in the first.

MIN_OUTPUT_BUFFER binds here, and only for a buffer that is installed with a flush sink: len(buffer) - offset must be at least that many bytes, checked at installation and at every mid-stream set, so an unusable buffer is refused where it is handed over rather than partway through a message. Without a sink no flush can occur and no minimum applies — the buffer holds the message or reports SofaBufferError — which is what keeps a caller sizing from a generated MAX_SIZE exact, down to a zero-byte remainder.

Parameters:
  • buffer (bytearray)

  • offset (int)

Return type:

None

property error: SofaError | None

The first error recorded in sticky mode, or None.

bytes_used()[source]

Bytes standing in the output buffer, i.e. written since it was installed and not yet drained.

The buffer is fixed, so this never exceeds its size — for the convenience models, _SCRATCH_SIZE. It is not the length of the message: bytes already drained to the writer/sink are no longer here.

Return type:

int

flush()[source]

Drain buffered bytes to the writer / flush sink; return the count.

Return type:

int

getvalue()[source]

Return the encoded message (in-memory model only).

Only Encoder() retains one: with a writer the bytes have already been handed over, and with over_buffer() they are in the caller’s buffer — returning the undrained tail of either would be partial output dressed up as a whole message (CORELIB_PLAN §5.1), so both raise SofaArgumentError.

Return type:

bytes

write_unsigned(field_id, value)[source]

Write an unsigned integer field as a base-128 varint.

value must be an integer in 0..UNSIGNED_MAX (64-bit), else SofaArgumentError. “Integer” is Python’s own rule — anything with __index__ (int, bool, IntEnum, NumPy integers). A float is refused rather than truncated, 3.0 included; write int(x) if that is what you mean.

Parameters:
  • field_id (SupportsIndex)

  • value (SupportsIndex)

Return type:

None

write_signed(field_id, value)[source]

Write a signed integer field, ZigZag-encoded into a varint.

value must be an integer in SIGNED_MIN..SIGNED_MAX (64-bit), else SofaArgumentError — see write_unsigned() for what counts as an integer.

Parameters:
  • field_id (SupportsIndex)

  • value (SupportsIndex)

Return type:

None

write_bool(field_id, value)[source]

Write a boolean as an unsigned field (1/0).

Parameters:
  • field_id (SupportsIndex)

  • value (bool)

Return type:

None

write_float32(field_id, value)[source]

Write a 32-bit IEEE-754 float as a little-endian fixlen field.

Parameters:
  • field_id (SupportsIndex)

  • value (float)

Return type:

None

write_float32_bits(field_id, bits)[source]

Write a 32-bit float from its raw wire bits, verbatim.

bits is an unsigned 32-bit integer — the little-endian payload as it lay on the wire, which is what sofab.Visitor.on_float32_bits() hands back. The four bytes go out unchanged: no float is constructed and no conversion happens, so nothing can quiet a signaling NaN on the way.

This is CORELIB_PLAN §6.5’s other half. A double-only target “MUST NOT re-encode an fp32 from the widened value”, because the IEEE widening a Python float performs sets the quiet bit and destroys a signaling NaN’s payload. A transcoder, a round-trip or any re-encode pairs this with on_float32_bits; a producer that has a value rather than bytes writes write_float32() as before.

Out of 0..0xFFFFFFFF is SofaArgumentError (§6.3).

Parameters:
  • field_id (SupportsIndex)

  • bits (SupportsIndex)

Return type:

None

write_float64(field_id, value)[source]

Write a 64-bit IEEE-754 float as a little-endian fixlen field.

Parameters:
  • field_id (SupportsIndex)

  • value (float)

Return type:

None

write_string(field_id, text)[source]

Write a UTF-8 string as a fixlen field (STRING subtype).

Encoding is strict UTF-8 (str.encode("utf-8") with no errors=). Python str is a Unicode string type, so per CORELIB_PLAN §6.4 it is always strict: SOFAB_STRICT_UTF8 is a no-op for it and is omitted entirely (documented as always-ON). A str that cannot be encoded as valid UTF-8 — a lone/unpaired surrogate such as '\ud800' — is refused with SofaArgumentError (the encode-side InvalidArgument outcome, MESSAGE_SPEC §8 producer-side MUST NOT), never silently replaced. Embedded U+0000 is valid UTF-8 and round-trips unchanged.

Parameters:
  • field_id (SupportsIndex)

  • text (str)

Return type:

None

write_bytes(field_id, data)[source]

Write a raw byte blob as a fixlen field (BLOB subtype).

A blob longer than sofab.FIXLEN_MAX is refused with SofaArgumentError (see _write_fixlen()) — on the declared length, before the copy, so an oversized payload is never duplicated just to be rejected.

Parameters:
  • field_id (SupportsIndex)

  • data (bytes | bytearray | memoryview)

Return type:

None

write_unsigned_array(field_id, values)[source]

Write an array of unsigned integers, each as a varint.

The element count must be 0..ARRAY_MAX and every element an integer in 0..UNSIGNED_MAX, else SofaArgumentError (see write_unsigned() for what counts as an integer). A zero-count array is a valid, fully-specified empty array on the wire ([header][count=0]).

Parameters:
  • field_id (SupportsIndex)

  • values (Iterable[SupportsIndex])

Return type:

None

write_signed_array(field_id, values)[source]

Write an array of signed integers, each ZigZag-encoded into a varint.

The element count must be 0..ARRAY_MAX and every element an integer in SIGNED_MIN..SIGNED_MAX, else SofaArgumentError (see write_unsigned() for what counts as an integer). A zero-count array is a valid, fully-specified empty array ([header][count=0]).

Parameters:
  • field_id (SupportsIndex)

  • values (Iterable[SupportsIndex])

Return type:

None

write_bool_array(field_id, values)[source]

Write an array of booleans, each as the varint 1 or 0.

The array half of write_bool(), and canonical for the same reason (§4.4): an element is tested for truth and true goes out as 1, so a re-encode of a tolerantly decoded array is the canonical form of it. On the wire this is an array of unsigned — booleans have no wire type of their own — so a zero-count array is the same empty array write_unsigned_array() writes.

Any object may be an element: it is tested for truth exactly as if would test it, which is what write_bool() does for a scalar. The test runs per element, inside the loop rather than up front, so a __bool__ that raises leaves the same partial write the other array writers leave — the native engine reaches that state too, and the two have to be indistinguishable.

Parameters:
  • field_id (SupportsIndex)

  • values (Iterable[object])

Return type:

None

write_float32_array(field_id, values)[source]

Write an array of 32-bit floats as a packed little-endian fixlen array.

The element count must be 0..ARRAY_MAX, else SofaArgumentError. A zero-count array emits [header][count=0][fixlen_word] — the fixlen_word is always present (so empty fp32/fp64 arrays stay distinguishable) but there is no payload (§4.8).

Parameters:
  • field_id (SupportsIndex)

  • values (Iterable[float])

Return type:

None

write_float32_array_bits(field_id, payload, count=None)[source]

Write an fp32 array from its raw wire bytes, verbatim.

payload is any bytes-like object holding the array’s little-endian payload — 4 * count bytes, which is what sofab.Visitor.on_float32_array_bits() hands back. Pass count to state it, or leave it out and it is len(payload) // 4; a length that is not a multiple of four is SofaArgumentError.

The array half of write_float32_bits(), and for the same reason: §6.5’s bit-exactness requirement is stated over “every fp32 position … and each element of an fp32 array”.

Parameters:
  • field_id (SupportsIndex)

  • payload (Any)

  • count (int | None)

Return type:

None

write_float64_array(field_id, values)[source]

Write an array of 64-bit floats as a packed little-endian fixlen array.

The element count must be 0..ARRAY_MAX, else SofaArgumentError. A zero-count array emits [header][count=0][fixlen_word] — the fixlen_word is always present (so empty fp32/fp64 arrays stay distinguishable) but there is no payload (§4.8).

Parameters:
  • field_id (SupportsIndex)

  • values (Iterable[float])

Return type:

None

write_sequence_begin_lazy(field_id)[source]

Open a nested sequence (sub-message) under field_id, holding its header back until the sequence turns out to have content.

MESSAGE_SPEC §2 omits a sequence-typed field whose value equals its declared default, and “not one child was written” is exactly that condition — evaluated per child field, recursively, for free, because the message layer already omits every child equal to its own default. A sequence closed with nothing in it therefore emits nothing instead of a two-byte empty frame, and an all-default message becomes the empty byte string. The predicate is never a byte image of the object, so in-memory padding cannot influence it.

This is the only way to open a sequence. How it closes decides whether a contentless one survives: write_sequence_end() drops it, write_sequence_end_keep() forces the frame out.

Must be balanced by a later write_sequence_end() / write_sequence_end_keep(). Refuses to open a sequence nested deeper than sofab.MAX_DEPTH (255), raising SofaArgumentError.

Parameters:

field_id (SupportsIndex)

Return type:

None

write_sequence_end()[source]

Close the innermost open sequence, letting it vanish if it received no content.

Use it wherever absence encodes the same value as an empty frame: a struct/union field, and an array field whose declared default is the empty collection (MESSAGE_SPEC §2). Where the frame must be visible, close with write_sequence_end_keep() instead.

Raises SofaArgumentError if no sequence is currently open.

Return type:

None

write_sequence_end_keep()[source]

Close the innermost open sequence, keeping its frame even when it received no content.

Behaves like a write: it first emits any held-back headers — this frame’s and every enclosing one’s — and then the end marker, so an empty sequence reaches the wire as begin + end.

Required wherever the frame carries information beyond its contents:

  • a wrapper-array element (struct/union/nested row): element presence is what carries a dynamic array’s length — highest present id + 1 (MESSAGE_SPEC §5.1) — so dropping an all-default element would change the decoded length, not just the bytes;

  • an array field already known to differ from a non-empty declared default: absence would reconstruct that default, so the empty frame is the only encoding of “explicitly empty” (§2, §3).

The two failure directions are not symmetric, which is why this is the safe choice when in doubt: using it where write_sequence_end() would do costs one non-canonical empty frame that every decoder normalizes away, while the reverse silently changes an array’s length.

Raises SofaArgumentError if no sequence is currently open.

Return type:

None