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 +47 -0
- proto/errors.py +21 -0
- proto/message.py +535 -0
- proto/py.typed +0 -0
- proto/scalars.py +140 -0
- proto/schema.py +57 -0
- proto/stream.py +62 -0
- proto/wire.py +160 -0
- proto-0.1.0.dist-info/METADATA +238 -0
- proto-0.1.0.dist-info/RECORD +13 -0
- proto-0.1.0.dist-info/WHEEL +5 -0
- proto-0.1.0.dist-info/licenses/LICENSE +21 -0
- proto-0.1.0.dist-info/top_level.txt +1 -0
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,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
|