"""Wire-format constants, enums, the :class:`Field` descriptor, and errors.
These mirror the shared SofaBuffers definitions used by ``corelib-c-cpp``,
``corelib-rs``, ``corelib-go``, ``corelib-java`` and ``corelib-cs`` so the
Python runtime produces byte-identical output.
"""
from __future__ import annotations
from dataclasses import dataclass
from enum import IntEnum
# --- limits (from corelib-c-cpp/src/include/sofab/sofab.h) -------------------
#: SofaBuffers API version (mirrors C ``SOFAB_API_VERSION``). Callers and the
#: code generator use this to verify wire compatibility.
API_VERSION = 1
#: Highest valid field ID (``INT32_MAX``).
ID_MAX = 0x7FFF_FFFF
#: Largest unsigned wire value (``UINT64_MAX``).
UNSIGNED_MAX = (1 << 64) - 1
#: Signed wire value range (``INT64_MIN`` .. ``INT64_MAX``).
SIGNED_MIN = -(1 << 63)
SIGNED_MAX = (1 << 63) - 1
#: Largest fixlen payload length in bytes (``INT32_MAX``).
FIXLEN_MAX = 0x7FFF_FFFF
#: Largest array element count (``INT32_MAX``).
ARRAY_MAX = 0x7FFF_FFFF
#: Maximum nested-sequence depth. An encoder must not open more than this many
#: nested sequences; a decoder rejects a message nesting deeper.
MAX_DEPTH = 255
#: Smallest output buffer this port accepts **for streaming** (CORELIB_PLAN
#: §5.1). It is ``1`` because the encoder splits every atomic unit — a header
#: varint, a ``fixlen_word``, an element count, a scalar, one float — at any byte
#: boundary, so a one-byte scratch buffer already produces exactly the one-shot
#: bytes. The declaration binds a buffer installed **with** a flush sink, at
#: installation and at every mid-stream :meth:`~sofab.Encoder.buffer_set`:
#: ``len(buffer) - offset`` must be at least this. A buffer installed **without**
#: a sink is subject to no minimum — no flush can occur, so it simply holds the
#: message or reports ``SofaBufferError``, and a caller sizing from a generated
#: ``MAX_SIZE`` gets an exact fit.
MIN_OUTPUT_BUFFER = 1
#: The smallest reassembly buffer a :class:`sofab.Decoder` accepts.
#:
#: A construct split across fed chunks has to be joined somewhere, and
#: CORELIB_PLAN §6.6.2 puts that somewhere in the caller's hands: "A codec
#: **MUST NOT** grow a private accumulator instead." So the decoder holds one
#: buffer, sized **once at construction** and never grown — a sender cannot make
#: it bigger by sending different bytes, which is the property §6.6 protects —
#: and a construct that does not fit it is refused with
#: :class:`SofaArgumentError` rather than accommodated.
#:
#: There is deliberately no *default* size to go with this minimum. §6.2.1 says
#: a codec "**MUST NOT** hold a limit of its own" and "**MUST NOT** supply a
#: default for one it was not given", and a reassembly size is exactly such a
#: number: it decides which well-formed messages this receiver can stream. The
#: library used to answer 4096 for every caller who did not ask, which made the
#: number the corelib's rather than the deployment's — the same defect the three
#: ``max_dyn_*`` caps had before #137. ``reassembly`` is now required, and
#: generated code derives it from the schema.
#:
#: The floor is what a single spanning construct's framing needs: an id header
#: and a length/count word are ten bytes each at the outside (§4.1). A buffer
#: that cannot hold those is not a smaller buffer, it is a broken one.
MIN_REASSEMBLY = 16
#: 64-bit mask used by varint/zigzag wrap-around to match the C ``uint64_t``.
MASK64 = (1 << 64) - 1
[docs]
class WireType(IntEnum):
"""The 3 low bits of a field header."""
UNSIGNED = 0x0
SIGNED = 0x1
FIXLEN = 0x2
ARRAY_UNSIGNED = 0x3
ARRAY_SIGNED = 0x4
ARRAY_FIXLEN = 0x5
SEQUENCE_START = 0x6
SEQUENCE_END = 0x7
[docs]
class Status(IntEnum):
"""The three-valued decode outcome CORELIB_PLAN §5.2 requires.
Returned by every :meth:`sofab.Decoder.feed`, describing the bytes consumed
**so far** — not a verdict on the message as a whole:
* :attr:`COMPLETE` — the consumed bytes end exactly at a field boundary. A
valid message *may* end here; more fields may also still follow.
* :attr:`INCOMPLETE` — the bytes end *inside* a construct. **This is not an
error.** The partial tail is retained and the next ``feed`` continues from
it. Whether an incomplete message is acceptable is the caller's decision:
only its framing (a length prefix, a datagram boundary, EOF) knows whether
more bytes can still come.
* :attr:`INVALID` — the bytes are malformed regardless of what follows.
Terminal; the reason is on :attr:`sofab.Decoder.error`.
There is deliberately **no** ``finish``/``end`` step that could reclassify
:attr:`INCOMPLETE` as an error (§5.2): the status ``feed`` returned *is* the
answer. A receiver-side limit rejection (§6.2.1) is not one of these three —
it is a well-formed message the receiver declined, so it arrives on the error
channel as :class:`SofaLimitError`, never as :attr:`INVALID` (§6.3).
"""
COMPLETE = 0
INCOMPLETE = 1
INVALID = 2
[docs]
class FixlenSubtype(IntEnum):
"""The 3 low bits of a fixlen length header."""
FP32 = 0x0
FP64 = 0x1
STRING = 0x2
BLOB = 0x3
[docs]
@dataclass
class Field:
"""Describes the field the decoder is currently positioned on.
Mirrors the C field callback's ``(id, size, count)`` plus the wire type.
``size`` is the fixlen byte length (or the per-element size of a fixlen
array); ``count`` is the element count of an array; ``subtype`` is set for
fixlen and fixlen-array fields.
"""
id: int
type: WireType
size: int = 0
count: int = 0
subtype: FixlenSubtype | None = None
# --- errors -----------------------------------------------------------------
[docs]
class SofaError(Exception):
"""Base class for all SofaBuffers errors."""
[docs]
class SofaDecodeError(SofaError):
"""Malformed input — invalid *regardless* of what bytes might follow: an
overflowing (>64-bit) varint, a bad fixlen subtype, an out-of-range
id/count/length, invalid UTF-8, nesting past ``MAX_DEPTH``, or a dangling
sequence end (``MESSAGE_SPEC`` §7 INVALID).
This is deliberately **not** raised for truncation — bytes that simply end
inside a field are :class:`SofaIncompleteError` (§7 INCOMPLETE), a distinct
non-error outcome that is not a subclass of this class, so
``except SofaDecodeError`` does not catch it.
"""
[docs]
class SofaIncompleteError(SofaError):
"""Truncated input — the bytes end *inside* a field (``MESSAGE_SPEC`` §7
INCOMPLETE): an unterminated varint, a fixlen/array payload shorter than its
declared length, an array element that runs off the end, or a nested
sequence that is never closed.
This is **not** malformed: more bytes could complete the message, and the
caller owns end-of-input. It is a sibling of :class:`SofaDecodeError` under
:class:`SofaError`, *not* a subclass of it, so callers can tell "need more
bytes" apart from "these bytes are garbage".
"""
[docs]
class SofaLimitError(SofaError):
"""A wire-declared array count or fixlen (string/blob) length exceeded a
**receiver-configured** decode limit (``Decoder(max_dyn_array_count=…,
max_dyn_string_len=…, max_dyn_blob_len=…)``).
This is a *policy* rejection, not wire malformation: the bytes are perfectly
well-formed and would decode fine under a looser limit — the receiver simply
declined to allocate for them. It is **never** what a decoder raises for a
limit that was not stated at all: there is no limit to raise then, so an
omitted ``max_dyn_*`` argument is :class:`SofaArgumentError` (§6.2.1, §6.3).
It is therefore a sibling of
:class:`SofaDecodeError` under :class:`SofaError`, **not** a subclass of it,
so ``except SofaDecodeError`` does not catch it and differential fuzzing does
not see a limit rejection as a conformance divergence from another engine.
It is raised only for a field the **schema** leaves unbounded: where the
schema states a ``count:``/``maxlen:`` the handler says so — by declaring
that bound on its :class:`sofab.Binding` entry, or by answering
:meth:`sofab.Visitor.on_schema_bound` — and that bound governs instead, an
over-bound value being :class:`SofaDecodeError` (CORELIB_PLAN §6.2.1/§6.3,
MESSAGE_SPEC §7.1). Nor is it raised for a field nothing materializes — a
skipped one, or one read into storage the handler returned from
:meth:`sofab.Visitor.on_blob_begin` / :meth:`sofab.Visitor.on_array_begin`.
"""
[docs]
class SofaArgumentError(SofaError):
"""The caller's own request is invalid — the ``InvalidArgument`` outcome of
CORELIB_PLAN §6.3, which is the *only* code that taxonomy has for a caller
mistake (every remaining malformed input is :class:`SofaDecodeError`).
Named after the code it carries. §6.3 lets a port "adapt casing and idiom",
and this class used to take that as far as ``SofaRangeError`` — which read
narrower than the code is: a destination too short for what a hook was told
is a caller mistake, not a value out of range. Every other port keeps the
word (``Error::Argument`` in Rust, ``Error::InvalidArgument`` in C++), so
this one does too. ``SofaRangeError`` remains as an alias.
On construction it covers a receiver cap the caller did not state. §6.2.1
fixes the provenance of the three ``max_dyn_*`` numbers even where the codec
performs the comparison: a codec "**MUST NOT** hold a limit of its own,
**MUST NOT** supply a default for one it was not given, **MUST NOT** read an
omitted argument as *unlimited*, and **MUST NOT** clamp to one". So all three
are required, on :class:`sofab.Decoder` and on the
:mod:`sofab.collectors` helpers alike, and omitting one is a mistake in the
**call** rather than a property of the message or of the deployment —
which is exactly what this code is for. :class:`SofaLimitError` would say
something untrue about it: it promises a limit to raise that was never
configured.
On encode, a value (or id/count) is not writable: either it is outside the
permitted range, or it is not an integer at all: integer fields accept
whatever Python considers losslessly an integer (any object with
``__index__`` — ``int``, ``bool``, ``IntEnum``, NumPy integers), and refuse
everything else rather than silently truncating it. A ``float`` is therefore
rejected, ``3.0`` included; convert explicitly with ``int(x)`` if that is
what you mean. The same code covers the encoder's other invalid calls:
:meth:`~sofab.Encoder.getvalue` on a caller-owned fixed buffer, and a
sequence end without a matching begin.
On decode it covers the caller's own storage not fitting what the message
announced — a **destination that cannot hold what was announced**: a buffer handed back from
:meth:`sofab.Visitor.on_blob_begin` or :meth:`sofab.Visitor.on_array_begin`
shorter than the size those hooks were told, or a ``reassembly=`` buffer too
small for a construct spanning a chunk. The handler was given the count or
length first and answered with storage that does not fit it, so the mistake
is the caller's; §6.6.3 has the codec refuse such a destination "rather than
growing it", and §6.2.1 forbids clamping into it.
That second case is the *only* ceiling left on a destination the caller
supplies. A receiver's configured ``max_dyn_*`` limit does not also apply to
it: the limit exists to stop the sender dictating the receiver's allocation,
and a handler that returns a buffer has sized that buffer itself
(:class:`SofaLimitError`).
It is *not* raised when a field's wire type merely contradicts the type a
binding declares for it: that is MESSAGE_SPEC §7.3, which the decoder answers
by skipping the field (see :class:`sofab.Decoder`).
"""
#: Deprecated alias for :class:`SofaArgumentError`, kept so existing
#: ``except SofaRangeError`` and ``isinstance`` checks keep working. It is the
#: same class, not a subclass, so either name catches what the other raises.
SofaRangeError = SofaArgumentError
[docs]
class SofaBufferError(SofaError):
"""A fixed encoder buffer filled up and no flush sink was provided."""