Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,26 @@ format changes.

## [Unreleased]

### Added

- **`protowire.pb`, a PB codec for dataclasses** (#36, #34): a port of
protowire-go's `encoding/pb`. Fields are declared with
`Annotated[T, pb.Field(number, kind)]`; `pb.marshal`, `pb.unmarshal` and the
length-prefixed `pb.Encoder` / `pb.Decoder` write and read protowire-go's
bytes, big numbers included (`decimal.Decimal` for `pxf.Decimal`,
`pb.BigFloat` for `pxf.BigFloat`, `pb.BIGINT` for `pxf.BigInt`). Decoding
takes Go's per-call limits as keyword arguments (`max_message_size`,
`max_nesting_depth`, `max_numeric_literal_digits`, `max_repeated_count`,
`max_big_float_exponent`; 0 for the default), and `marshal` refuses a value
nested past `max_nesting_depth`. Go's `encoding/pb` tests are ported with
their byte vectors, and the conformance tests check the bytes against the
protobuf runtime both ways.
- **`check_decode --format pb` decodes through `protowire.pb`**, into
dataclasses mirroring `adversarial.proto`, as Go's harness decodes through
its `encoding/pb`. It takes every `--limit` but `MaxBytesLiteralLength`. All
29 of the corpus's `pb` rows pass, the 16 that skip Python today included,
with the same verdict as protowire-go's harness on each.

### Changed

- **A row read as cells keeps its list and block values and its precision
Expand Down
41 changes: 41 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,42 @@ e = envelope.Err(400, "VALIDATION", "bad input").error.with_field(
)
```

### PB for plain classes

`protowire.pb` is a port of protowire-go's `encoding/pb`: protobuf binary for
dataclasses, with no `.proto` and no generated code. Each field names its
number, and its protobuf type where the Python type does not say it. The bytes
are protowire-go's, and decoding is bounded by the same per-call limits.

```python
from dataclasses import dataclass, field
from decimal import Decimal
from typing import Annotated, Optional
from protowire import pb

@dataclass
class Order:
id: Annotated[int, pb.Field(1, pb.UINT64)] = 0
symbol: Annotated[str, pb.Field(2)] = ""
qty: Annotated[int, pb.Field(3, pb.SINT32)] = 0 # zigzag, Go's `protowire:"3,zigzag"`
price: Annotated[Optional[Decimal], pb.Field(4)] = None # pxf.Decimal
tags: Annotated[list[str], pb.Field(5)] = field(default_factory=list)

data = pb.marshal(Order(id=7, symbol="ACME", qty=-100, price=Decimal("12.50")))
order = pb.unmarshal(data, Order, max_message_size=1 << 20)

# Length-prefixed streams, as Go's pb.Encoder / pb.Decoder frame them.
pb.Encoder(out).encode(order)
dec = pb.Decoder(inp)
while (o := dec.decode(Order)) is not None:
...
```

`int` is int64 unless the field says otherwise (`pb.INT32`, `pb.UINT32`,
`pb.UINT64`, `pb.SINT32`, `pb.SINT64`, or `pb.BIGINT` for `pxf.BigInt`);
`float` is double unless `pb.FLOAT`. `decimal.Decimal` is `pxf.Decimal` and
`pb.BigFloat` is `pxf.BigFloat`. See the module docstring for the rest.

## Build from source

```sh
Expand Down Expand Up @@ -123,6 +159,11 @@ cross-port equivalence guarantees.
- **No standalone Python CLI.** The shared CLI lives in
[trendvidia/protowire/cmd/protowire](https://github.com/trendvidia/protowire/tree/main/cmd/protowire);
Python callers either invoke that binary or use the in-process API.
- **`protowire.pb` is pure Python.** protowire-cpp's PB codec is C++
templates over C++ structs, so there is nothing native to call on a
dataclass. It is slower than the native PXF and SBE paths. A message nested
past Python's recursion limit is a `ValueError`, so a `max_nesting_depth`
raised past a few hundred needs `sys.setrecursionlimit` raised with it.
- **Free-threaded Python (PEP 703 / 3.13t)** is untested. nanobind supports
it but the build hasn't been validated against `--disable-gil` interpreters.

Expand Down
77 changes: 65 additions & 12 deletions scripts/check_decode.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,9 @@

- pxf and sbe go through ``protowire.pxf`` and ``protowire.sbe``, that is
through the protowire-cpp decoder this port wraps.
- pb goes through the upstream protobuf runtime. This port has no PB decoder
of its own, so these rows test what a caller gets from
``Message.ParseFromString``, and a fix for one of them cannot land here.
- pb goes through ``protowire.pb``, this port's own PB codec, as the Go
reference's goes through its encoding/pb: the bytes decode into the
hand-declared dataclasses below, which mirror adversarial.proto.
- pb-render writes the bytes as PXF through ``pxf.marshal_bytes``, so these
rows test the protowire-cpp encoder. The bridge reads the bytes one level
deeper than the encoder's limit, leaving the encoder to refuse. A read that
Expand All @@ -62,24 +62,27 @@
``--limit MaxMessageSize=1024`` sets one HARDENING limit for the row, under
the Go name the manifest uses. pxf rows take all six, sbe rows
MaxMessageSize and MaxRepeatedCount, and pb-render MaxNestingDepth. pxf-pb
takes the six, as pxf does, and pb-text none, as in the Go reference. A limit
this port cannot apply to the row's format exits 2: running the row without
it would not test the limit. pb rows take none, since the protobuf runtime
has no such settings.
takes the six, as pxf does, and pb-text none, as in the Go reference. pb takes
the five protowire.pb has: all but MaxBytesLiteralLength, which bounds a PXF
literal. A limit this port cannot apply to the row's format exits 2: running
the row without it would not test the limit.
"""

from __future__ import annotations

import argparse
import struct
import sys
from dataclasses import dataclass, field
from decimal import Decimal
from pathlib import Path
from typing import Annotated, Optional

try:
from google.protobuf import descriptor_pb2, descriptor_pool, message_factory
from google.protobuf.message import DecodeError

from protowire import pxf, sbe
from protowire import pb, pxf, sbe
except ImportError as e: # A broken build must not read as a reject of every row.
print(f"check_decode: {e}", file=sys.stderr)
sys.exit(2)
Expand Down Expand Up @@ -117,7 +120,7 @@ def _message_class(fds_bytes: bytes, schema: str):
"pxf-pb": set(_LIMITS.values()),
"sbe": {"max_message_size", "max_repeated_count"},
"pb-render": {"max_nesting_depth"},
"pb": set(),
"pb": set(_LIMITS.values()) - {"max_bytes_literal_length"},
"pb-text": set(),
"envelope": set(),
}
Expand Down Expand Up @@ -254,11 +257,61 @@ def _pb_text(fds_bytes: bytes, schema: str, data: bytes, limits: dict[str, int])
return 0


# Hand-declared mirrors of adversarial.proto for protowire.pb, which reads
# dataclasses rather than descriptors. Drift from the .proto shows up as a
# corpus row failing its expectation. Unlike the Go reference's mirror, Tree
# keeps `children`, so the mirror is the whole message.


@dataclass
class Tree:
child: Annotated[Optional[Tree], pb.Field(1)] = None
label: Annotated[str, pb.Field(2)] = ""
children: Annotated[list[Tree], pb.Field(3)] = field(default_factory=list)
branches: Annotated[dict[str, Optional[Tree]], pb.Field(4)] = field(default_factory=dict)
tags: Annotated[dict[str, str], pb.Field(5)] = field(default_factory=dict)


@dataclass
class StringHolder:
value: Annotated[str, pb.Field(1)] = ""


@dataclass
class BytesHolder:
value: Annotated[bytes, pb.Field(1)] = b""


@dataclass
class BigIntHolder:
value: Annotated[int, pb.Field(1)] = 0


@dataclass
class ListHolder:
values: Annotated[list[int], pb.Field(1, pb.INT32)] = field(default_factory=list)


@dataclass
class BigNumHolder:
big_int: Annotated[Optional[int], pb.Field(1, pb.BIGINT)] = None
decimal: Annotated[Optional[Decimal], pb.Field(2)] = None
big_float: Annotated[Optional[pb.BigFloat], pb.Field(3)] = None


_PB_TYPES = {
"adversarial.v1." + c.__name__: c
for c in (Tree, StringHolder, BytesHolder, BigIntHolder, ListHolder, BigNumHolder)
}


def _decode_pb(fds_bytes: bytes, schema: str, data: bytes, limits: dict[str, int]) -> int:
msg = _message_class(fds_bytes, schema)()
cls = _PB_TYPES.get(schema)
if cls is None:
raise SystemExit(f"check_decode: no protowire.pb mirror for {schema}")
try:
msg.ParseFromString(data)
except DecodeError as e:
pb.unmarshal(data, cls, **limits)
except ValueError as e:
return _reject(f"pb: {e}")
return 0

Expand Down
6 changes: 3 additions & 3 deletions src/protowire/__init__.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# SPDX-License-Identifier: MIT
# Copyright (c) 2026 TrendVidia, LLC.
"""protowire — PXF/SBE/envelope codecs, Python wrapper around protowire-cpp."""
"""protowire — PXF/SBE/envelope codecs, Python wrapper around protowire-cpp, and a PB codec for dataclasses."""

from . import envelope, pxf, sbe
from . import envelope, pb, pxf, sbe

__all__ = ["pxf", "sbe", "envelope"]
__all__ = ["pxf", "sbe", "envelope", "pb"]
__version__ = "1.1.0"
Loading
Loading