proto 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
proto/__init__.py ADDED
@@ -0,0 +1,47 @@
1
+ """proto: schema-first, protobuf wire-compatible messages in pure Python.
2
+
3
+ Declare messages as annotated classes, encode them to bytes that any
4
+ Protocol Buffers implementation can read, and decode them back::
5
+
6
+ import proto
7
+
8
+ @proto.message
9
+ class Point:
10
+ x: int = proto.field(1, "sint32")
11
+ y: int = proto.field(2, "sint32")
12
+
13
+ data = proto.encode(Point(3, -4))
14
+ assert proto.decode(Point, data) == Point(3, -4)
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from . import wire
20
+ from .errors import DecodeError, EncodeError, ProtoError, SchemaError
21
+ from .message import FieldInfo, decode, encode, field, fields, from_dict, is_message, message, to_dict
22
+ from .schema import to_proto
23
+ from .stream import iter_delimited, read_delimited, write_delimited
24
+
25
+ __version__ = "0.1.0"
26
+
27
+ __all__ = [
28
+ "message",
29
+ "field",
30
+ "encode",
31
+ "decode",
32
+ "fields",
33
+ "is_message",
34
+ "FieldInfo",
35
+ "to_dict",
36
+ "from_dict",
37
+ "to_proto",
38
+ "write_delimited",
39
+ "read_delimited",
40
+ "iter_delimited",
41
+ "wire",
42
+ "ProtoError",
43
+ "SchemaError",
44
+ "EncodeError",
45
+ "DecodeError",
46
+ "__version__",
47
+ ]
proto/errors.py ADDED
@@ -0,0 +1,21 @@
1
+ """Exception hierarchy for :mod:`proto`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __all__ = ["ProtoError", "SchemaError", "EncodeError", "DecodeError"]
6
+
7
+
8
+ class ProtoError(Exception):
9
+ """Base class for every error raised by :mod:`proto`."""
10
+
11
+
12
+ class SchemaError(ProtoError, TypeError):
13
+ """A message class is declared incorrectly (bad field number, type, ...)."""
14
+
15
+
16
+ class EncodeError(ProtoError, ValueError):
17
+ """A value cannot be encoded (out of range, wrong Python type, ...)."""
18
+
19
+
20
+ class DecodeError(ProtoError, ValueError):
21
+ """Input bytes are not a valid encoding of the requested message."""
proto/message.py ADDED
@@ -0,0 +1,535 @@
1
+ """Message definitions (``@message`` / ``field``) and the encoder/decoder."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import dataclasses
6
+ import enum
7
+ import inspect
8
+ import sys
9
+ import types
10
+ import typing
11
+ from dataclasses import dataclass
12
+ from typing import Any, TypeVar, Union
13
+
14
+ from .errors import DecodeError, EncodeError, SchemaError
15
+ from .scalars import INFERRED, SCALARS, ScalarType
16
+ from .wire import (
17
+ MAX_FIELD_NUMBER,
18
+ WireType,
19
+ decode_tag,
20
+ decode_varint,
21
+ encode_tag,
22
+ encode_varint,
23
+ skip_field,
24
+ )
25
+
26
+ __all__ = [
27
+ "FieldInfo",
28
+ "field",
29
+ "message",
30
+ "is_message",
31
+ "fields",
32
+ "encode",
33
+ "decode",
34
+ "to_dict",
35
+ "from_dict",
36
+ ]
37
+
38
+ M = TypeVar("M")
39
+
40
+ _META_KEY = "proto"
41
+ _RESERVED_NUMBERS = range(19000, 20000)
42
+ _ENUM_SCALAR = SCALARS["int32"]
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class _FieldDecl:
47
+ """What the user wrote in ``field(...)``; resolved later into FieldInfo."""
48
+
49
+ number: int
50
+ type: str | type | None
51
+ packed: bool | None
52
+
53
+
54
+ @dataclass(frozen=True)
55
+ class FieldInfo:
56
+ """Fully resolved description of one message field.
57
+
58
+ ``kind`` is ``"scalar"``, ``"enum"`` or ``"message"``. ``type_name`` is
59
+ the ``.proto`` spelling of the type (``"sint32"``, ``"Color"``, ...).
60
+ ``target`` is the enum or message class for non-scalar kinds.
61
+ """
62
+
63
+ name: str
64
+ number: int
65
+ kind: str
66
+ type_name: str
67
+ repeated: bool
68
+ optional: bool
69
+ packed: bool
70
+ scalar: ScalarType | None
71
+ target: type | None
72
+
73
+ @property
74
+ def wire_type(self) -> WireType:
75
+ """Wire type of a single (unpacked) element of this field."""
76
+ if self.kind == "message":
77
+ return WireType.LEN
78
+ if self.kind == "enum":
79
+ return WireType.VARINT
80
+ assert self.scalar is not None
81
+ return self.scalar.wire_type
82
+
83
+
84
+ # --------------------------------------------------------------------------
85
+ # Declaration
86
+ # --------------------------------------------------------------------------
87
+
88
+
89
+ def field(
90
+ number: int,
91
+ type: str | type | None = None,
92
+ *,
93
+ default: Any = dataclasses.MISSING,
94
+ default_factory: Any = dataclasses.MISSING,
95
+ packed: bool | None = None,
96
+ ) -> Any:
97
+ """Declare a message field with protobuf field ``number``.
98
+
99
+ ``type`` overrides the type inferred from the annotation. It may be a
100
+ scalar name (``"sint32"``, ``"fixed64"``, ...), an ``IntEnum`` subclass,
101
+ or a ``@message`` class. ``packed`` controls packed encoding of repeated
102
+ numeric fields (default ``True``, as in proto3). When no default is given
103
+ the proto3 zero value is used (``0``, ``""``, ``[]``, ``None``, ...).
104
+ """
105
+ if isinstance(number, bool) or not isinstance(number, int):
106
+ raise SchemaError(f"field number must be an int, got {number!r}")
107
+ if not 1 <= number <= MAX_FIELD_NUMBER:
108
+ raise SchemaError(f"field number out of range: {number}")
109
+ if number in _RESERVED_NUMBERS:
110
+ raise SchemaError(f"field numbers 19000-19999 are reserved: {number}")
111
+ if isinstance(type, str) and type not in SCALARS:
112
+ raise SchemaError(f"unknown scalar type {type!r}")
113
+ meta = {_META_KEY: _FieldDecl(number, type, packed)}
114
+ return dataclasses.field(default=default, default_factory=default_factory, metadata=meta)
115
+
116
+
117
+ def message(cls: type[M] | None = None, /, *, name: str | None = None) -> Any:
118
+ """Class decorator turning an annotated class into a protobuf message.
119
+
120
+ The class becomes a regular :func:`dataclasses.dataclass` and gains
121
+ ``to_bytes()`` and ``from_bytes(data)`` helpers. ``name`` sets the message
122
+ name used by :func:`proto.to_proto` (defaults to the class name).
123
+ """
124
+
125
+ # Remember the defining scope so annotations naming classes local to a
126
+ # function (or defined later in it) can still be resolved lazily.
127
+ scope = sys._getframe(1).f_locals
128
+
129
+ def wrap(c: type[M]) -> type[M]:
130
+ c.__proto_scope__ = scope # type: ignore[attr-defined]
131
+ _fill_defaults(c)
132
+ dc = dataclasses.dataclass(c)
133
+ dc.__proto_message__ = True # type: ignore[attr-defined]
134
+ dc.__proto_name__ = name or c.__name__ # type: ignore[attr-defined]
135
+ if "to_bytes" not in c.__dict__:
136
+ dc.to_bytes = encode # type: ignore[attr-defined]
137
+ if "from_bytes" not in c.__dict__:
138
+ dc.from_bytes = classmethod(decode) # type: ignore[attr-defined]
139
+ return dc
140
+
141
+ return wrap if cls is None else wrap(cls)
142
+
143
+
144
+ def is_message(obj: Any) -> bool:
145
+ """True if ``obj`` is a ``@message`` class or an instance of one."""
146
+ cls = obj if isinstance(obj, type) else type(obj)
147
+ return cls.__dict__.get("__proto_message__", False) is True
148
+
149
+
150
+ def _fill_defaults(cls: type) -> None:
151
+ """Give every proto field without a default its proto3 zero value."""
152
+ annotations = inspect.get_annotations(cls)
153
+ hints = _resolve_hints(cls, strict=False)
154
+ for attr, annotation in annotations.items():
155
+ value = cls.__dict__.get(attr, dataclasses.MISSING)
156
+ if _is_classvar(hints.get(attr, annotation)):
157
+ continue
158
+ decl = _decl_of(value)
159
+ if decl is None:
160
+ raise SchemaError(f"{cls.__name__}.{attr} must be declared with proto.field(number)")
161
+ if value.default is not dataclasses.MISSING or value.default_factory is not dataclasses.MISSING:
162
+ continue
163
+ hint = hints.get(attr)
164
+ if hint is None: # unresolvable forward reference
165
+ text = annotation if isinstance(annotation, str) else ""
166
+ if text.replace(" ", "").lower().startswith(("list[", "typing.list[")):
167
+ value.default_factory = list
168
+ elif isinstance(decl.type, str) and "None" not in text:
169
+ value.default = SCALARS[decl.type].default
170
+ else:
171
+ value.default = None
172
+ continue
173
+ base, repeated, optional = _unwrap(hint, f"{cls.__name__}.{attr}")
174
+ if repeated:
175
+ value.default_factory = list
176
+ elif optional:
177
+ value.default = None
178
+ else:
179
+ value.default = _zero_value(decl.type if decl.type is not None else base)
180
+
181
+
182
+ def _zero_value(tp: Any) -> Any:
183
+ if isinstance(tp, str):
184
+ return SCALARS[tp].default
185
+ if isinstance(tp, type) and issubclass(tp, enum.IntEnum):
186
+ return _enum_zero(tp)
187
+ if tp in INFERRED:
188
+ return SCALARS[INFERRED[tp]].default
189
+ return None # message fields (and anything we cannot classify yet)
190
+
191
+
192
+ def _enum_zero(tp: type[enum.IntEnum]) -> enum.IntEnum:
193
+ try:
194
+ return tp(0)
195
+ except ValueError:
196
+ raise SchemaError(f"enum {tp.__name__} must define a member with value 0") from None
197
+
198
+
199
+ def _decl_of(value: Any) -> _FieldDecl | None:
200
+ if isinstance(value, dataclasses.Field):
201
+ decl = value.metadata.get(_META_KEY)
202
+ if isinstance(decl, _FieldDecl):
203
+ return decl
204
+ return None
205
+
206
+
207
+ def _is_classvar(hint: Any) -> bool:
208
+ if isinstance(hint, str):
209
+ return hint.startswith(("ClassVar", "typing.ClassVar"))
210
+ return hint is typing.ClassVar or typing.get_origin(hint) is typing.ClassVar
211
+
212
+
213
+ def _resolve_hints(cls: type, strict: bool) -> dict[str, Any]:
214
+ """Evaluate the class annotations; forward refs may resolve to the class itself."""
215
+ module = sys.modules.get(cls.__module__)
216
+ globalns = dict(vars(module)) if module else {}
217
+ scope = cls.__dict__.get("__proto_scope__")
218
+ localns = dict(scope) if scope is not None and scope is not globalns else {}
219
+ localns[cls.__name__] = cls
220
+ try:
221
+ return typing.get_type_hints(cls, globalns=globalns, localns=localns)
222
+ except (NameError, AttributeError, TypeError):
223
+ if strict:
224
+ raise
225
+ hints: dict[str, Any] = {}
226
+ for attr, annotation in inspect.get_annotations(cls).items():
227
+ if isinstance(annotation, str):
228
+ try:
229
+ annotation = eval(annotation, globalns, localns) # noqa: S307 - same as typing does
230
+ except Exception:
231
+ continue
232
+ hints[attr] = annotation
233
+ return hints
234
+
235
+
236
+ def _unwrap(hint: Any, where: str) -> tuple[Any, bool, bool]:
237
+ """Split an annotation into ``(base_type, repeated, optional)``."""
238
+ optional = False
239
+ origin = typing.get_origin(hint)
240
+ if origin is Union or origin is types.UnionType:
241
+ args = [a for a in typing.get_args(hint) if a is not type(None)]
242
+ if len(args) != 1 or len(typing.get_args(hint)) != 2:
243
+ raise SchemaError(f"{where}: only `T | None` unions are supported")
244
+ hint, optional = args[0], True
245
+ origin = typing.get_origin(hint)
246
+ if origin is list:
247
+ if optional:
248
+ raise SchemaError(f"{where}: repeated fields cannot be optional")
249
+ (inner,) = typing.get_args(hint) or (Any,)
250
+ return inner, True, False
251
+ if hint is list:
252
+ return Any, True, False
253
+ return hint, False, optional
254
+
255
+
256
+ # --------------------------------------------------------------------------
257
+ # Schema resolution
258
+ # --------------------------------------------------------------------------
259
+
260
+
261
+ def fields(cls: type) -> tuple[FieldInfo, ...]:
262
+ """Return the resolved fields of a message class, ordered by number."""
263
+ if not is_message(cls):
264
+ raise SchemaError(f"{cls!r} is not a @proto.message class")
265
+ cached = cls.__dict__.get("__proto_fields__")
266
+ if cached is None:
267
+ cached = _build_fields(cls)
268
+ cls.__proto_fields__ = cached # type: ignore[attr-defined]
269
+ return cached
270
+
271
+
272
+ def _build_fields(cls: type) -> tuple[FieldInfo, ...]:
273
+ try:
274
+ hints = _resolve_hints(cls, strict=True)
275
+ except (NameError, AttributeError, TypeError) as exc:
276
+ raise SchemaError(f"cannot resolve annotations of {cls.__name__}: {exc}") from exc
277
+ result: list[FieldInfo] = []
278
+ seen: dict[int, str] = {}
279
+ for f in dataclasses.fields(cls):
280
+ decl = _decl_of(f)
281
+ if decl is None:
282
+ continue
283
+ where = f"{cls.__name__}.{f.name}"
284
+ if decl.number in seen:
285
+ raise SchemaError(f"{where}: field number {decl.number} already used by {seen[decl.number]}")
286
+ seen[decl.number] = f.name
287
+ base, repeated, optional = _unwrap(hints[f.name], where)
288
+ target = decl.type if decl.type is not None else base
289
+ result.append(_make_info(f.name, decl, target, repeated, optional, where))
290
+ result.sort(key=lambda info: info.number)
291
+ return tuple(result)
292
+
293
+
294
+ def _make_info(
295
+ name: str, decl: _FieldDecl, target: Any, repeated: bool, optional: bool, where: str
296
+ ) -> FieldInfo:
297
+ if isinstance(target, str):
298
+ scalar = SCALARS[target]
299
+ packed = repeated and scalar.packable and decl.packed is not False
300
+ return FieldInfo(name, decl.number, "scalar", target, repeated, optional, packed, scalar, None)
301
+ if target in INFERRED:
302
+ scalar = SCALARS[INFERRED[target]]
303
+ packed = repeated and scalar.packable and decl.packed is not False
304
+ return FieldInfo(name, decl.number, "scalar", scalar.name, repeated, optional, packed, scalar, None)
305
+ if isinstance(target, type) and issubclass(target, enum.IntEnum):
306
+ _enum_zero(target)
307
+ packed = repeated and decl.packed is not False
308
+ return FieldInfo(name, decl.number, "enum", target.__name__, repeated, optional, packed, None, target)
309
+ if isinstance(target, type) and is_message(target):
310
+ if decl.packed:
311
+ raise SchemaError(f"{where}: message fields cannot be packed")
312
+ type_name = target.__proto_name__ # type: ignore[attr-defined]
313
+ return FieldInfo(name, decl.number, "message", type_name, repeated, True, False, None, target)
314
+ raise SchemaError(f"{where}: unsupported field type {target!r}")
315
+
316
+
317
+ # --------------------------------------------------------------------------
318
+ # Encoding
319
+ # --------------------------------------------------------------------------
320
+
321
+
322
+ def encode(msg: Any) -> bytes:
323
+ """Serialize a message instance to protobuf wire-format bytes.
324
+
325
+ Fields are written in field-number order. Singular non-optional fields
326
+ holding their default value are omitted, exactly as proto3 does.
327
+ """
328
+ if not is_message(msg) or isinstance(msg, type):
329
+ raise EncodeError(f"expected a @proto.message instance, got {type(msg).__name__}")
330
+ out = bytearray()
331
+ for info in fields(type(msg)):
332
+ value = getattr(msg, info.name)
333
+ try:
334
+ _encode_field(out, info, value)
335
+ except EncodeError as exc:
336
+ raise EncodeError(f"{type(msg).__name__}.{info.name}: {exc}") from None
337
+ return bytes(out)
338
+
339
+
340
+ def _encode_one(info: FieldInfo, value: Any) -> bytes:
341
+ if info.kind == "message":
342
+ if not isinstance(value, info.target): # type: ignore[arg-type]
343
+ raise EncodeError(f"expected {info.type_name}, got {type(value).__name__}")
344
+ payload = encode(value)
345
+ return encode_varint(len(payload)) + payload
346
+ if info.kind == "enum":
347
+ if isinstance(value, bool) or not isinstance(value, int):
348
+ raise EncodeError(f"expected {info.type_name} or int, got {type(value).__name__}")
349
+ return _ENUM_SCALAR.encode(int(value))
350
+ assert info.scalar is not None
351
+ payload = info.scalar.encode(value)
352
+ if info.scalar.wire_type is WireType.LEN:
353
+ return encode_varint(len(payload)) + payload
354
+ return payload
355
+
356
+
357
+ def _encode_field(out: bytearray, info: FieldInfo, value: Any) -> None:
358
+ if info.repeated:
359
+ if not isinstance(value, (list, tuple)):
360
+ raise EncodeError(f"repeated field expects a list, got {type(value).__name__}")
361
+ if not value:
362
+ return
363
+ if info.packed:
364
+ body = b"".join(_encode_one(info, item) for item in value)
365
+ out += encode_tag(info.number, WireType.LEN) + encode_varint(len(body)) + body
366
+ else:
367
+ tag = encode_tag(info.number, info.wire_type)
368
+ for item in value:
369
+ if item is None:
370
+ raise EncodeError("repeated fields cannot contain None")
371
+ out += tag + _encode_one(info, item)
372
+ return
373
+ if value is None:
374
+ if info.optional:
375
+ return
376
+ raise EncodeError("non-optional field is None")
377
+ if not info.optional:
378
+ if info.kind == "enum" and int(value) == 0:
379
+ return
380
+ if info.kind == "scalar" and info.scalar is not None and info.scalar.is_default(value):
381
+ # Still validate the type so `0` in a str field is rejected.
382
+ info.scalar.encode(value)
383
+ return
384
+ out += encode_tag(info.number, info.wire_type) + _encode_one(info, value)
385
+
386
+
387
+ # --------------------------------------------------------------------------
388
+ # Decoding
389
+ # --------------------------------------------------------------------------
390
+
391
+
392
+ def decode(cls: type[M], data: bytes | bytearray | memoryview) -> M:
393
+ """Parse wire-format ``data`` into a new instance of message class ``cls``.
394
+
395
+ Unknown fields are skipped. If a singular field appears more than once
396
+ the last occurrence wins; repeated fields accept packed and unpacked
397
+ encodings interchangeably.
398
+ """
399
+ by_number = {info.number: info for info in fields(cls)}
400
+ view = memoryview(data) if not isinstance(data, memoryview) else data
401
+ values: dict[str, Any] = {}
402
+ pos, end = 0, len(view)
403
+ try:
404
+ while pos < end:
405
+ number, wire_type, pos = decode_tag(view, pos)
406
+ info = by_number.get(number)
407
+ if info is None:
408
+ pos = skip_field(view, pos, wire_type, number)
409
+ continue
410
+ if info.repeated and info.kind != "message" and wire_type is WireType.LEN and info.wire_type is not WireType.LEN:
411
+ length, pos = decode_varint(view, pos)
412
+ stop = pos + length
413
+ if stop > end:
414
+ raise DecodeError("truncated packed field")
415
+ items = values.setdefault(info.name, [])
416
+ while pos < stop:
417
+ item, pos = _decode_one(info, view, pos, stop)
418
+ items.append(item)
419
+ if pos != stop:
420
+ raise DecodeError("packed field length mismatch")
421
+ continue
422
+ if wire_type is not info.wire_type:
423
+ raise DecodeError(
424
+ f"{cls.__name__}.{info.name}: wire type {wire_type.name} does not match {info.type_name}"
425
+ )
426
+ item, pos = _decode_one(info, view, pos, end)
427
+ if info.repeated:
428
+ values.setdefault(info.name, []).append(item)
429
+ else:
430
+ values[info.name] = item
431
+ except RecursionError:
432
+ raise DecodeError("message nesting too deep") from None
433
+ # Absent fields take their proto3 zero value, not the Python-side default,
434
+ # so a value equal to the zero value (and hence omitted) round-trips.
435
+ for info in by_number.values():
436
+ if info.name not in values:
437
+ values[info.name] = _absent_value(info)
438
+ return cls(**values)
439
+
440
+
441
+ def _absent_value(info: FieldInfo) -> Any:
442
+ if info.repeated:
443
+ return []
444
+ if info.optional:
445
+ return None
446
+ if info.kind == "enum":
447
+ return _enum_zero(info.target) # type: ignore[arg-type]
448
+ assert info.scalar is not None
449
+ return info.scalar.default
450
+
451
+
452
+ def _decode_one(info: FieldInfo, view: memoryview, pos: int, end: int) -> tuple[Any, int]:
453
+ wire = info.wire_type
454
+ if wire is WireType.VARINT:
455
+ raw, pos = decode_varint(view, pos)
456
+ if pos > end:
457
+ raise DecodeError("truncated varint")
458
+ if info.kind == "enum":
459
+ number = _ENUM_SCALAR.decode(raw)
460
+ try:
461
+ return info.target(number), pos # type: ignore[misc]
462
+ except ValueError:
463
+ return number, pos # open enum: keep unknown values as ints
464
+ assert info.scalar is not None
465
+ return info.scalar.decode(raw), pos
466
+ if wire is WireType.LEN:
467
+ length, pos = decode_varint(view, pos)
468
+ stop = pos + length
469
+ else:
470
+ stop = pos + (4 if wire is WireType.I32 else 8)
471
+ if stop > end:
472
+ raise DecodeError(f"truncated {info.type_name} field")
473
+ chunk = view[pos:stop]
474
+ if info.kind == "message":
475
+ return decode(info.target, chunk), stop # type: ignore[arg-type]
476
+ assert info.scalar is not None
477
+ return info.scalar.decode(bytes(chunk)), stop
478
+
479
+
480
+ # --------------------------------------------------------------------------
481
+ # dict conversion
482
+ # --------------------------------------------------------------------------
483
+
484
+
485
+ def to_dict(msg: Any) -> dict[str, Any]:
486
+ """Convert a message to a plain ``dict``.
487
+
488
+ Nested messages become dicts, enum members become their names (unknown
489
+ enum values stay ints), unset optional/message fields are omitted.
490
+ """
491
+
492
+ def convert(info: FieldInfo, value: Any) -> Any:
493
+ if info.kind == "message":
494
+ return to_dict(value)
495
+ if info.kind == "enum":
496
+ return value.name if isinstance(value, enum.IntEnum) else value
497
+ return value
498
+
499
+ out: dict[str, Any] = {}
500
+ for info in fields(type(msg)):
501
+ value = getattr(msg, info.name)
502
+ if value is None:
503
+ continue
504
+ out[info.name] = [convert(info, v) for v in value] if info.repeated else convert(info, value)
505
+ return out
506
+
507
+
508
+ def from_dict(cls: type[M], data: dict[str, Any]) -> M:
509
+ """Build a message from a ``dict`` produced by :func:`to_dict`.
510
+
511
+ Enum values may be given by name or number. Unknown keys raise
512
+ :class:`SchemaError`.
513
+ """
514
+ infos = {info.name: info for info in fields(cls)}
515
+ unknown = set(data) - set(infos)
516
+ if unknown:
517
+ raise SchemaError(f"unknown fields for {cls.__name__}: {sorted(unknown)}")
518
+
519
+ def convert(info: FieldInfo, value: Any) -> Any:
520
+ if info.kind == "message" and isinstance(value, dict):
521
+ return from_dict(info.target, value) # type: ignore[arg-type]
522
+ if info.kind == "enum" and isinstance(value, str):
523
+ return info.target[value] # type: ignore[index]
524
+ if info.kind == "enum" and isinstance(value, int):
525
+ try:
526
+ return info.target(value) # type: ignore[misc]
527
+ except ValueError:
528
+ return value
529
+ return value
530
+
531
+ kwargs = {
532
+ name: [convert(infos[name], v) for v in value] if infos[name].repeated else convert(infos[name], value)
533
+ for name, value in data.items()
534
+ }
535
+ return cls(**kwargs)
proto/py.typed ADDED
File without changes
proto/scalars.py ADDED
@@ -0,0 +1,140 @@
1
+ """Codecs for the protobuf scalar value types (``int32``, ``string``, ...)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ import struct
7
+ from dataclasses import dataclass
8
+ from typing import Any, Callable
9
+
10
+ from .errors import DecodeError, EncodeError
11
+ from .wire import WireType, encode_varint, zigzag_decode, zigzag_encode
12
+
13
+ __all__ = ["ScalarType", "SCALARS", "INFERRED"]
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class ScalarType:
18
+ """Describes how one protobuf scalar type maps to Python and to bytes.
19
+
20
+ ``encode`` turns a Python value into the field payload (without a tag).
21
+ ``decode`` turns the raw payload into a Python value: the raw payload is an
22
+ ``int`` for ``VARINT`` types and ``bytes`` for every other wire type.
23
+ """
24
+
25
+ name: str
26
+ wire_type: WireType
27
+ default: Any
28
+ encode: Callable[[Any], bytes]
29
+ decode: Callable[[Any], Any]
30
+
31
+ @property
32
+ def packable(self) -> bool:
33
+ """Whether repeated fields of this type use packed encoding."""
34
+ return self.wire_type is not WireType.LEN
35
+
36
+ def is_default(self, value: Any) -> bool:
37
+ """True if ``value`` is the proto3 default and should not be emitted."""
38
+ if isinstance(self.default, float):
39
+ # -0.0 is distinguishable from 0.0 and protobuf emits it.
40
+ return value == 0 and math.copysign(1.0, value) > 0
41
+ return value == self.default
42
+
43
+
44
+ def _require(value: Any, types: tuple[type, ...], name: str) -> None:
45
+ if not isinstance(value, types):
46
+ raise EncodeError(f"{name} field expects {types[0].__name__}, got {type(value).__name__}")
47
+
48
+
49
+ def _varint_int(name: str, bits: int, signed: bool, zigzag: bool = False) -> ScalarType:
50
+ lo = -(1 << (bits - 1)) if signed else 0
51
+ hi = (1 << (bits - 1)) - 1 if signed else (1 << bits) - 1
52
+ mask = (1 << bits) - 1
53
+
54
+ def encode(value: Any) -> bytes:
55
+ _require(value, (int,), name)
56
+ if not lo <= value <= hi:
57
+ raise EncodeError(f"{name} value out of range: {value}")
58
+ return encode_varint(zigzag_encode(value, bits) if zigzag else value)
59
+
60
+ def decode(raw: int) -> int:
61
+ raw &= mask
62
+ if zigzag:
63
+ return zigzag_decode(raw)
64
+ if signed and raw >> (bits - 1):
65
+ return raw - (1 << bits)
66
+ return raw
67
+
68
+ return ScalarType(name, WireType.VARINT, 0, encode, decode)
69
+
70
+
71
+ def _fixed(name: str, fmt: str, is_float: bool) -> ScalarType:
72
+ packer = struct.Struct(fmt)
73
+ wire = WireType.I32 if packer.size == 4 else WireType.I64
74
+ types: tuple[type, ...] = (float, int) if is_float else (int,)
75
+
76
+ def encode(value: Any) -> bytes:
77
+ _require(value, types, name)
78
+ try:
79
+ return packer.pack(value)
80
+ except (struct.error, OverflowError) as exc:
81
+ raise EncodeError(f"{name} value out of range: {value!r}") from exc
82
+
83
+ def decode(raw: bytes) -> Any:
84
+ return packer.unpack(raw)[0]
85
+
86
+ return ScalarType(name, wire, 0.0 if is_float else 0, encode, decode)
87
+
88
+
89
+ def _encode_bool(value: Any) -> bytes:
90
+ _require(value, (bool, int), "bool")
91
+ return b"\x01" if value else b"\x00"
92
+
93
+
94
+ def _encode_string(value: Any) -> bytes:
95
+ _require(value, (str,), "string")
96
+ return value.encode("utf-8")
97
+
98
+
99
+ def _decode_string(raw: bytes) -> str:
100
+ try:
101
+ return raw.decode("utf-8")
102
+ except UnicodeDecodeError as exc:
103
+ raise DecodeError(f"string field is not valid UTF-8: {exc}") from None
104
+
105
+
106
+ def _encode_bytes(value: Any) -> bytes:
107
+ _require(value, (bytes, bytearray, memoryview), "bytes")
108
+ return bytes(value)
109
+
110
+
111
+ #: All supported scalar types, keyed by their ``.proto`` name.
112
+ SCALARS: dict[str, ScalarType] = {
113
+ s.name: s
114
+ for s in (
115
+ _varint_int("int32", 32, signed=True),
116
+ _varint_int("int64", 64, signed=True),
117
+ _varint_int("uint32", 32, signed=False),
118
+ _varint_int("uint64", 64, signed=False),
119
+ _varint_int("sint32", 32, signed=True, zigzag=True),
120
+ _varint_int("sint64", 64, signed=True, zigzag=True),
121
+ ScalarType("bool", WireType.VARINT, False, _encode_bool, lambda raw: raw != 0),
122
+ _fixed("fixed32", "<I", is_float=False),
123
+ _fixed("fixed64", "<Q", is_float=False),
124
+ _fixed("sfixed32", "<i", is_float=False),
125
+ _fixed("sfixed64", "<q", is_float=False),
126
+ _fixed("float", "<f", is_float=True),
127
+ _fixed("double", "<d", is_float=True),
128
+ ScalarType("string", WireType.LEN, "", _encode_string, _decode_string),
129
+ ScalarType("bytes", WireType.LEN, b"", _encode_bytes, bytes),
130
+ )
131
+ }
132
+
133
+ #: Scalar type inferred from a bare Python annotation when ``type=`` is omitted.
134
+ INFERRED: dict[type, str] = {
135
+ bool: "bool",
136
+ int: "int64",
137
+ float: "double",
138
+ str: "string",
139
+ bytes: "bytes",
140
+ }
proto/schema.py ADDED
@@ -0,0 +1,57 @@
1
+ """Render ``@message`` classes as ``.proto`` (proto3) source text."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import enum
6
+
7
+ from .errors import SchemaError
8
+ from .message import fields, is_message
9
+
10
+ __all__ = ["to_proto"]
11
+
12
+
13
+ def to_proto(*classes: type, package: str | None = None) -> str:
14
+ """Return a proto3 ``.proto`` file declaring ``classes``.
15
+
16
+ Every enum and message reachable from ``classes`` is emitted once, enums
17
+ first, then messages with dependencies before the messages using them.
18
+ The result can be fed to ``protoc`` to interoperate with other languages.
19
+ """
20
+ enums: list[type[enum.IntEnum]] = []
21
+ messages: list[type] = []
22
+
23
+ def visit(cls: type, stack: tuple[type, ...]) -> None:
24
+ if cls in messages or cls in stack:
25
+ return
26
+ for info in fields(cls):
27
+ if info.kind == "enum" and info.target not in enums:
28
+ enums.append(info.target) # type: ignore[arg-type]
29
+ elif info.kind == "message":
30
+ visit(info.target, stack + (cls,)) # type: ignore[arg-type]
31
+ messages.append(cls)
32
+
33
+ for cls in classes:
34
+ if not is_message(cls):
35
+ raise SchemaError(f"{cls!r} is not a @proto.message class")
36
+ visit(cls, ())
37
+
38
+ lines = ['syntax = "proto3";', ""]
39
+ if package:
40
+ lines += [f"package {package};", ""]
41
+ for e in enums:
42
+ lines.append(f"enum {e.__name__} {{")
43
+ lines += [f" {member.name} = {member.value};" for member in e]
44
+ lines += ["}", ""]
45
+ for cls in messages:
46
+ lines.append(f"message {cls.__proto_name__} {{") # type: ignore[attr-defined]
47
+ for info in fields(cls):
48
+ label = "repeated " if info.repeated else ("optional " if info.optional and info.kind != "message" else "")
49
+ options = " [packed = false]" if info.repeated and info.kind != "message" and not info.packed and _packable(info) else ""
50
+ lines.append(f" {label}{info.type_name} {info.name} = {info.number}{options};")
51
+ lines += ["}", ""]
52
+ return "\n".join(lines)
53
+
54
+
55
+ def _packable(info: object) -> bool:
56
+ scalar = getattr(info, "scalar", None)
57
+ return scalar is None or scalar.packable
proto/stream.py ADDED
@@ -0,0 +1,62 @@
1
+ """Length-delimited framing for writing many messages to one byte stream.
2
+
3
+ Each frame is a varint length followed by the encoded message, the same
4
+ format as Java's ``writeDelimitedTo`` / ``parseDelimitedFrom``.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Any, BinaryIO, Iterator, TypeVar
10
+
11
+ from .errors import DecodeError
12
+ from .message import decode, encode
13
+ from .wire import encode_varint
14
+
15
+ __all__ = ["write_delimited", "read_delimited", "iter_delimited"]
16
+
17
+ M = TypeVar("M")
18
+
19
+
20
+ def write_delimited(stream: BinaryIO, msg: Any) -> int:
21
+ """Write ``msg`` to ``stream`` with a varint length prefix.
22
+
23
+ Returns the number of bytes written.
24
+ """
25
+ payload = encode(msg)
26
+ frame = encode_varint(len(payload)) + payload
27
+ stream.write(frame)
28
+ return len(frame)
29
+
30
+
31
+ def read_delimited(cls: type[M], stream: BinaryIO) -> M | None:
32
+ """Read one length-prefixed ``cls`` message from ``stream``.
33
+
34
+ Returns ``None`` on a clean end of stream (no bytes left); raises
35
+ :class:`DecodeError` if the stream ends in the middle of a frame.
36
+ """
37
+ length = 0
38
+ shift = 0
39
+ first = True
40
+ while True:
41
+ byte = stream.read(1)
42
+ if not byte:
43
+ if first:
44
+ return None
45
+ raise DecodeError("stream ended inside a length prefix")
46
+ first = False
47
+ length |= (byte[0] & 0x7F) << shift
48
+ if not byte[0] & 0x80:
49
+ break
50
+ shift += 7
51
+ if shift >= 64:
52
+ raise DecodeError("length prefix longer than 10 bytes")
53
+ payload = stream.read(length)
54
+ if len(payload) != length:
55
+ raise DecodeError(f"stream ended inside a frame ({len(payload)}/{length} bytes)")
56
+ return decode(cls, payload)
57
+
58
+
59
+ def iter_delimited(cls: type[M], stream: BinaryIO) -> Iterator[M]:
60
+ """Yield length-prefixed ``cls`` messages from ``stream`` until it is exhausted."""
61
+ while (msg := read_delimited(cls, stream)) is not None:
62
+ yield msg
proto/wire.py ADDED
@@ -0,0 +1,160 @@
1
+ """Low-level Protocol Buffers wire-format primitives.
2
+
3
+ Everything here operates on plain ``int`` and ``bytes`` values and is
4
+ byte-for-byte compatible with the official protobuf encoding described at
5
+ https://protobuf.dev/programming-guides/encoding/.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from enum import IntEnum
11
+
12
+ from .errors import DecodeError, EncodeError
13
+
14
+ __all__ = [
15
+ "WireType",
16
+ "MAX_FIELD_NUMBER",
17
+ "encode_varint",
18
+ "decode_varint",
19
+ "zigzag_encode",
20
+ "zigzag_decode",
21
+ "encode_tag",
22
+ "decode_tag",
23
+ "skip_field",
24
+ ]
25
+
26
+ #: Largest legal field number (2**29 - 1).
27
+ MAX_FIELD_NUMBER = (1 << 29) - 1
28
+
29
+ _UINT64_LIMIT = 1 << 64
30
+ _MAX_VARINT_BYTES = 10
31
+
32
+
33
+ class WireType(IntEnum):
34
+ """The six wire types defined by the protobuf encoding."""
35
+
36
+ VARINT = 0
37
+ I64 = 1
38
+ LEN = 2
39
+ SGROUP = 3
40
+ EGROUP = 4
41
+ I32 = 5
42
+
43
+
44
+ def encode_varint(value: int) -> bytes:
45
+ """Encode ``value`` as a base-128 varint.
46
+
47
+ Negative values are encoded as their 64-bit two's complement, which is
48
+ how protobuf serializes negative ``int32``/``int64`` (always 10 bytes).
49
+
50
+ >>> encode_varint(150).hex()
51
+ '9601'
52
+ """
53
+ if value < 0:
54
+ if value < -(1 << 63):
55
+ raise EncodeError(f"varint out of int64 range: {value}")
56
+ value += _UINT64_LIMIT
57
+ elif value >= _UINT64_LIMIT:
58
+ raise EncodeError(f"varint out of uint64 range: {value}")
59
+ out = bytearray()
60
+ while True:
61
+ byte = value & 0x7F
62
+ value >>= 7
63
+ if value:
64
+ out.append(byte | 0x80)
65
+ else:
66
+ out.append(byte)
67
+ return bytes(out)
68
+
69
+
70
+ def decode_varint(data: bytes | bytearray | memoryview, pos: int = 0) -> tuple[int, int]:
71
+ """Decode a varint from ``data`` starting at ``pos``.
72
+
73
+ Returns ``(value, new_pos)``. The value is always an unsigned integer
74
+ below 2**64; callers reinterpret it according to the field type.
75
+ """
76
+ result = 0
77
+ shift = 0
78
+ end = len(data)
79
+ for _ in range(_MAX_VARINT_BYTES):
80
+ if pos >= end:
81
+ raise DecodeError("truncated varint")
82
+ byte = data[pos]
83
+ pos += 1
84
+ result |= (byte & 0x7F) << shift
85
+ if not byte & 0x80:
86
+ if result >= _UINT64_LIMIT:
87
+ raise DecodeError("varint exceeds 64 bits")
88
+ return result, pos
89
+ shift += 7
90
+ raise DecodeError("varint longer than 10 bytes")
91
+
92
+
93
+ def zigzag_encode(value: int, bits: int = 64) -> int:
94
+ """Map a signed integer to an unsigned one so small magnitudes stay small.
95
+
96
+ Used by ``sint32``/``sint64``: 0 -> 0, -1 -> 1, 1 -> 2, -2 -> 3, ...
97
+ """
98
+ return ((value << 1) ^ (value >> (bits - 1))) & ((1 << bits) - 1)
99
+
100
+
101
+ def zigzag_decode(value: int) -> int:
102
+ """Inverse of :func:`zigzag_encode`."""
103
+ return (value >> 1) ^ -(value & 1)
104
+
105
+
106
+ def encode_tag(number: int, wire_type: WireType) -> bytes:
107
+ """Encode a field key: ``(number << 3) | wire_type`` as a varint."""
108
+ if not 1 <= number <= MAX_FIELD_NUMBER:
109
+ raise EncodeError(f"invalid field number: {number}")
110
+ return encode_varint((number << 3) | int(wire_type))
111
+
112
+
113
+ def decode_tag(data: bytes | bytearray | memoryview, pos: int = 0) -> tuple[int, WireType, int]:
114
+ """Decode a field key. Returns ``(field_number, wire_type, new_pos)``."""
115
+ key, pos = decode_varint(data, pos)
116
+ number, raw_type = key >> 3, key & 0x7
117
+ if number < 1 or number > MAX_FIELD_NUMBER:
118
+ raise DecodeError(f"invalid field number: {number}")
119
+ try:
120
+ wire_type = WireType(raw_type)
121
+ except ValueError:
122
+ raise DecodeError(f"invalid wire type: {raw_type}") from None
123
+ return number, wire_type, pos
124
+
125
+
126
+ def skip_field(
127
+ data: bytes | bytearray | memoryview,
128
+ pos: int,
129
+ wire_type: WireType,
130
+ number: int | None = None,
131
+ ) -> int:
132
+ """Skip over the payload of a field whose key was already consumed.
133
+
134
+ Returns the position just past the payload. ``number`` is only needed
135
+ for (deprecated) groups, to match the closing ``EGROUP`` tag.
136
+ """
137
+ end = len(data)
138
+ if wire_type is WireType.VARINT:
139
+ _, pos = decode_varint(data, pos)
140
+ return pos
141
+ if wire_type is WireType.I64:
142
+ new = pos + 8
143
+ elif wire_type is WireType.I32:
144
+ new = pos + 4
145
+ elif wire_type is WireType.LEN:
146
+ length, pos = decode_varint(data, pos)
147
+ new = pos + length
148
+ elif wire_type is WireType.SGROUP:
149
+ while True:
150
+ inner, inner_type, pos = decode_tag(data, pos)
151
+ if inner_type is WireType.EGROUP:
152
+ if number is not None and inner != number:
153
+ raise DecodeError("mismatched end-group tag")
154
+ return pos
155
+ pos = skip_field(data, pos, inner_type, inner)
156
+ else:
157
+ raise DecodeError("unexpected end-group tag")
158
+ if new > end:
159
+ raise DecodeError("truncated field payload")
160
+ return new
@@ -0,0 +1,238 @@
1
+ Metadata-Version: 2.4
2
+ Name: proto
3
+ Version: 0.1.0
4
+ Summary: Schema-first, protobuf wire-compatible binary messages for pure Python, declared with dataclasses.
5
+ Author: nehz
6
+ License-Expression: MIT
7
+ Keywords: protobuf,protocol-buffers,serialization,binary,varint,dataclasses,schema
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: Operating System :: OS Independent
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3 :: Only
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
18
+ Classifier: Topic :: System :: Networking
19
+ Classifier: Typing :: Typed
20
+ Requires-Python: >=3.10
21
+ Description-Content-Type: text/markdown
22
+ License-File: LICENSE
23
+ Provides-Extra: test
24
+ Requires-Dist: pytest>=7; extra == "test"
25
+ Dynamic: license-file
26
+
27
+ # proto
28
+
29
+ **Protocol Buffers without the toolchain.** `proto` lets you declare binary
30
+ messages as ordinary Python dataclasses and serialize them to bytes that are
31
+ wire-compatible with Google's Protocol Buffers (proto3). No `protoc`, no
32
+ generated code, no C extension, no dependencies: just the standard library.
33
+
34
+ Use it when you need to talk to a protobuf service from a script, persist
35
+ compact records, or prototype a schema in Python first, and still have every
36
+ other protobuf implementation read your bytes.
37
+
38
+ ```python
39
+ import proto
40
+
41
+ @proto.message
42
+ class Point:
43
+ x: int = proto.field(1, "sint32")
44
+ y: int = proto.field(2, "sint32")
45
+
46
+ data = proto.encode(Point(3, -4)) # b'\x08\x06\x10\x07'
47
+ assert proto.decode(Point, data) == Point(3, -4)
48
+ ```
49
+
50
+ ## Features
51
+
52
+ - **Wire-compatible**: varints, zigzag, fixed-width, length-delimited and packed
53
+ encodings match the official protobuf encoding byte for byte. The tests check
54
+ the exact byte strings from the protobuf encoding guide.
55
+ - **Schema-first dataclasses**: field numbers live next to the type annotations.
56
+ Messages are real `dataclasses`, so you keep `==`, `repr`, `replace()` and
57
+ your type checker.
58
+ - **All 15 scalar types**: `int32 int64 uint32 uint64 sint32 sint64 bool
59
+ fixed32 fixed64 sfixed32 sfixed64 float double string bytes`, plus `IntEnum`
60
+ enums, nested and recursive messages, `repeated` fields, and proto3 `optional`.
61
+ - **proto3 semantics**: default values are omitted on the wire, unknown fields are
62
+ skipped, packed and unpacked repeated fields are both accepted, unknown enum
63
+ values are kept as `int` (open enums).
64
+ - **Strict validation**: out-of-range integers, wrong Python types, malformed or
65
+ truncated input raise clear `EncodeError` / `DecodeError` / `SchemaError`.
66
+ - **`.proto` export**: `to_proto()` renders your classes as a `.proto` file, so
67
+ other languages can generate code from the same schema.
68
+ - **Streaming**: length-delimited framing (`writeDelimitedTo` format) for many
69
+ messages in one file or socket.
70
+ - Pure Python 3.10+, fully type-hinted (`py.typed`), zero dependencies.
71
+
72
+ ## Install
73
+
74
+ ```bash
75
+ pip install proto # once published
76
+ pip install -e . # from a checkout
77
+ ```
78
+
79
+ > Note: Google's `proto-plus` package also installs a top-level `proto` module.
80
+ > Don't install both in the same environment.
81
+
82
+ ## Quickstart
83
+
84
+ ```python
85
+ from __future__ import annotations
86
+
87
+ import enum
88
+ import io
89
+
90
+ import proto
91
+
92
+
93
+ class Role(enum.IntEnum):
94
+ ROLE_UNSPECIFIED = 0 # proto3 enums need a zero value
95
+ ADMIN = 1
96
+ MEMBER = 2
97
+
98
+
99
+ @proto.message
100
+ class Address:
101
+ city: str = proto.field(1)
102
+ zip_code: str = proto.field(2)
103
+
104
+
105
+ @proto.message
106
+ class User:
107
+ name: str = proto.field(1) # inferred: string
108
+ id: int = proto.field(2, "uint32") # explicit scalar type
109
+ age: int | None = proto.field(3, "int32") # proto3 `optional`: None = unset
110
+ role: Role = proto.field(4) # enum
111
+ emails: list[str] = proto.field(5) # repeated
112
+ scores: list[int] = proto.field(6, "sint32") # repeated, packed by default
113
+ address: Address | None = proto.field(7) # nested message
114
+ friends: list[User] = proto.field(8) # recursive
115
+
116
+
117
+ ada = User("ada", id=1, role=Role.ADMIN, emails=["ada@example.com"],
118
+ address=Address("London", "N1"))
119
+
120
+ data = ada.to_bytes() # same as proto.encode(ada)
121
+ again = User.from_bytes(data) # same as proto.decode(User, data)
122
+ assert again == ada
123
+
124
+ proto.to_dict(ada)
125
+ # {'name': 'ada', 'id': 1, 'role': 'ADMIN', 'emails': ['ada@example.com'],
126
+ # 'scores': [], 'address': {'city': 'London', 'zip_code': 'N1'}, 'friends': []}
127
+
128
+ # Many messages in one stream
129
+ buf = io.BytesIO()
130
+ for user in (ada, User("bob", id=2)):
131
+ proto.write_delimited(buf, user)
132
+ buf.seek(0)
133
+ names = [u.name for u in proto.iter_delimited(User, buf)] # ['ada', 'bob']
134
+
135
+ print(proto.to_proto(User, package="example.v1"))
136
+ ```
137
+
138
+ The last line prints:
139
+
140
+ ```proto
141
+ syntax = "proto3";
142
+
143
+ package example.v1;
144
+
145
+ enum Role {
146
+ ROLE_UNSPECIFIED = 0;
147
+ ADMIN = 1;
148
+ MEMBER = 2;
149
+ }
150
+
151
+ message Address {
152
+ string city = 1;
153
+ string zip_code = 2;
154
+ }
155
+
156
+ message User {
157
+ string name = 1;
158
+ uint32 id = 2;
159
+ optional int32 age = 3;
160
+ Role role = 4;
161
+ repeated string emails = 5;
162
+ repeated sint32 scores = 6;
163
+ Address address = 7;
164
+ repeated User friends = 8;
165
+ }
166
+ ```
167
+
168
+ ## Declaring fields
169
+
170
+ | Annotation | Inferred `.proto` type | Default when omitted |
171
+ |-------------------------|------------------------|----------------------|
172
+ | `int` | `int64` | `0` |
173
+ | `float` | `double` | `0.0` |
174
+ | `bool` | `bool` | `False` |
175
+ | `str` | `string` | `""` |
176
+ | `bytes` | `bytes` | `b""` |
177
+ | `SomeIntEnum` | `SomeIntEnum` | the member with value `0` |
178
+ | `SomeMessage \| None` | `SomeMessage` | `None` (unset) |
179
+ | `T \| None` (scalar) | `optional T` | `None` (unset) |
180
+ | `list[T]` | `repeated T` | `[]` |
181
+
182
+ Pass `type=` (the second argument of `field`) to choose another scalar
183
+ encoding such as `"sint32"` or `"fixed64"`, or to name an enum/message class
184
+ explicitly.
185
+
186
+ ## API overview
187
+
188
+ Everything is importable from the top-level `proto` package.
189
+
190
+ | Name | Description |
191
+ |------|-------------|
192
+ | `@message` / `@message(name="Wire")` | Class decorator. Turns an annotated class into a dataclass-based message and adds `to_bytes()` and classmethod `from_bytes(data)`. `name` sets the name used by `to_proto` (default: class name). |
193
+ | `field(number, type=None, *, default=..., default_factory=..., packed=None)` | Declares a field. `type` is a scalar name, an `IntEnum` subclass or a message class; it is inferred from the annotation when omitted. `packed=False` disables packed encoding for repeated numeric/enum fields. Every annotated attribute must use `field()`. |
194
+ | `encode(msg) -> bytes` | Serialize a message instance. |
195
+ | `decode(cls, data) -> cls` | Parse `bytes`/`bytearray`/`memoryview` into a new `cls` instance. Absent fields get their proto3 zero value; the last occurrence of a singular field wins. |
196
+ | `fields(cls) -> tuple[FieldInfo, ...]` | Resolved schema of a message class, ordered by field number. |
197
+ | `FieldInfo` | Frozen dataclass: `name`, `number`, `kind` (`"scalar"`/`"enum"`/`"message"`), `type_name`, `repeated`, `optional`, `packed`, `scalar`, `target`, and property `wire_type`. |
198
+ | `is_message(obj) -> bool` | True for `@message` classes and their instances. |
199
+ | `to_dict(msg) -> dict` | Plain-dict view: nested messages become dicts, enums become names, unset optionals are omitted. |
200
+ | `from_dict(cls, data) -> cls` | Inverse of `to_dict`; enums may be given by name or number. |
201
+ | `to_proto(*classes, package=None) -> str` | Render the classes and every enum/message they reference as proto3 source. |
202
+ | `write_delimited(stream, msg) -> int` | Write a varint length prefix plus the message; returns bytes written. |
203
+ | `read_delimited(cls, stream) -> cls \| None` | Read one framed message; `None` at a clean end of stream. |
204
+ | `iter_delimited(cls, stream)` | Iterate framed messages until the stream is exhausted. |
205
+ | `ProtoError` | Base exception. Subclasses: `SchemaError` (also a `TypeError`), `EncodeError` and `DecodeError` (also `ValueError`). |
206
+ | `__version__` | `"0.1.0"` |
207
+
208
+ Low-level primitives live in `proto.wire`:
209
+
210
+ | Name | Description |
211
+ |------|-------------|
212
+ | `WireType` | `IntEnum`: `VARINT`, `I64`, `LEN`, `SGROUP`, `EGROUP`, `I32`. |
213
+ | `MAX_FIELD_NUMBER` | `2**29 - 1`. |
214
+ | `encode_varint(value) -> bytes` | Base-128 varint; negatives use 64-bit two's complement. |
215
+ | `decode_varint(data, pos=0) -> (value, new_pos)` | Decode one varint. |
216
+ | `zigzag_encode(value, bits=64) -> int` / `zigzag_decode(value) -> int` | ZigZag mapping used by `sint32`/`sint64`. |
217
+ | `encode_tag(number, wire_type) -> bytes` | Field key. |
218
+ | `decode_tag(data, pos=0) -> (number, wire_type, new_pos)` | Parse a field key. |
219
+ | `skip_field(data, pos, wire_type, number=None) -> int` | Skip an unknown field's payload, groups included. |
220
+
221
+ ## Limitations
222
+
223
+ `proto` 0.1 covers the core of proto3. Not supported yet: `oneof`, `map<K, V>`
224
+ fields, well-known types (`Timestamp`, `Any`, ...), proto2 groups (they are
225
+ skipped when decoding), services/gRPC, and merging of repeated occurrences of
226
+ a singular message field (the last occurrence wins). Annotations that name
227
+ other classes must be resolvable from the scope where the class is defined.
228
+
229
+ ## Development
230
+
231
+ ```bash
232
+ PYTHONPATH=src python3 -m unittest discover -s tests -v # standard library only
233
+ python3 -m pytest # if pytest is installed
234
+ ```
235
+
236
+ ## License
237
+
238
+ MIT
@@ -0,0 +1,13 @@
1
+ proto/__init__.py,sha256=aWXU9s-SZV1-6LitTm5Qq7_aix04X8DyPxJmBxGMDRw,1134
2
+ proto/errors.py,sha256=-Rs_tvrRgMVfFt0-oh2DisIWU0oyliqqVPsukoH1A3M,607
3
+ proto/message.py,sha256=YmFjTYCwN6m2Cs8e8FsYmATDAGRteR1uCO1BUH0MRzQ,20433
4
+ proto/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ proto/scalars.py,sha256=wv314e3jyFsgUjOYWSPNFwn1kk3t_0fHlk-xSelclkM,4722
6
+ proto/schema.py,sha256=TvyNmfwhiFhjhGuE98zlJZi62-powFVT1iN-0iA872I,2183
7
+ proto/stream.py,sha256=HEwYxzl03UbiztQbbNqRU7tIUPw2dW7B3ZrjHmZpq30,1944
8
+ proto/wire.py,sha256=RykpAzEFeL_aEdZzY9DaUzuy2dTGx5PU6scYZRAzUKw,4861
9
+ proto-0.1.0.dist-info/licenses/LICENSE,sha256=pAfYREEW9GAy7cnK20OXjDn7ofJahYny9GCuIZQTDAA,1061
10
+ proto-0.1.0.dist-info/METADATA,sha256=xII6_7Q1Ttq5xMoRGvdG0sF8BiQa0uknFOeKDQ3-MtI,9941
11
+ proto-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
12
+ proto-0.1.0.dist-info/top_level.txt,sha256=HFg_NW9VxhDySzqGDmUxqUh6w8QZlVLh4ZDIxYksTCM,6
13
+ proto-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 nehz
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ proto