cborx 0.1.1__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.
- cborx/__init__.py +28 -0
- cborx/_util.py +29 -0
- cborx/decoder.py +456 -0
- cborx/encoder.py +335 -0
- cborx/exceptions.py +14 -0
- cborx/py.typed +0 -0
- cborx/types.py +58 -0
- cborx-0.1.1.dist-info/METADATA +86 -0
- cborx-0.1.1.dist-info/RECORD +11 -0
- cborx-0.1.1.dist-info/WHEEL +4 -0
- cborx-0.1.1.dist-info/licenses/LICENSE +12 -0
cborx/__init__.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# SPDX-License-Identifier: 0BSD
|
|
2
|
+
"""Pure-Python CBOR (RFC 8949) encoder/decoder. No dependencies, no Rust."""
|
|
3
|
+
|
|
4
|
+
from .decoder import CBORDecoder, DuplicateMode, TagHook, load, loads
|
|
5
|
+
from .encoder import CBOREncoder, DefaultCallback, dump, dumps
|
|
6
|
+
from .exceptions import CBORDecodeError, CBOREncodeError, CBORError
|
|
7
|
+
from .types import CBORSimpleValue, CBORTag, UndefinedType, undefined
|
|
8
|
+
|
|
9
|
+
__version__ = "0.1.1"
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"CBORDecodeError",
|
|
13
|
+
"CBORDecoder",
|
|
14
|
+
"CBOREncodeError",
|
|
15
|
+
"CBOREncoder",
|
|
16
|
+
"CBORError",
|
|
17
|
+
"CBORSimpleValue",
|
|
18
|
+
"CBORTag",
|
|
19
|
+
"DefaultCallback",
|
|
20
|
+
"DuplicateMode",
|
|
21
|
+
"TagHook",
|
|
22
|
+
"UndefinedType",
|
|
23
|
+
"dump",
|
|
24
|
+
"dumps",
|
|
25
|
+
"load",
|
|
26
|
+
"loads",
|
|
27
|
+
"undefined",
|
|
28
|
+
]
|
cborx/_util.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# SPDX-License-Identifier: 0BSD
|
|
2
|
+
"""Shared internal helpers."""
|
|
3
|
+
|
|
4
|
+
import math
|
|
5
|
+
import struct
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def float_min_ai(value: float) -> int:
|
|
9
|
+
"""Return the smallest float additional-info width preserving a value.
|
|
10
|
+
|
|
11
|
+
The result is one of the CBOR additional information values 25
|
|
12
|
+
(half), 26 (single) or 27 (double). NaN maps to 25, the preferred
|
|
13
|
+
encoding 0xf97e00.
|
|
14
|
+
"""
|
|
15
|
+
if math.isnan(value):
|
|
16
|
+
return 25
|
|
17
|
+
if _fits(">e", value):
|
|
18
|
+
return 25
|
|
19
|
+
if _fits(">f", value):
|
|
20
|
+
return 26
|
|
21
|
+
return 27
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _fits(fmt: str, value: float) -> bool:
|
|
25
|
+
"""True if value survives a round trip through format fmt."""
|
|
26
|
+
try:
|
|
27
|
+
return bool(struct.unpack(fmt, struct.pack(fmt, value))[0] == value)
|
|
28
|
+
except OverflowError:
|
|
29
|
+
return False
|
cborx/decoder.py
ADDED
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
# SPDX-License-Identifier: 0BSD
|
|
2
|
+
"""Hardened iterative CBOR decoder (RFC 8949)."""
|
|
3
|
+
|
|
4
|
+
import struct
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from datetime import date, datetime, timedelta, timezone
|
|
7
|
+
from typing import Any, BinaryIO, Literal
|
|
8
|
+
|
|
9
|
+
from ._util import float_min_ai
|
|
10
|
+
from .exceptions import CBORDecodeError
|
|
11
|
+
from .types import CBORSimpleValue, CBORTag, undefined
|
|
12
|
+
|
|
13
|
+
TagHook = Callable[["CBORDecoder", CBORTag], Any]
|
|
14
|
+
"""Called for every decoded tag when provided.
|
|
15
|
+
|
|
16
|
+
Receives the decoder and a CBORTag. The return value replaces the item.
|
|
17
|
+
When a tag_hook is set it overrides all built-in semantic tag handling.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
DuplicateMode = Literal["last", "first", "error"]
|
|
21
|
+
|
|
22
|
+
_MISSING = object()
|
|
23
|
+
_ARR, _MAP, _TAG = 0, 1, 2
|
|
24
|
+
|
|
25
|
+
_FLOAT_FORMATS: dict[int, tuple[int, str]] = {
|
|
26
|
+
25: (2, ">e"),
|
|
27
|
+
26: (4, ">f"),
|
|
28
|
+
27: (8, ">d"),
|
|
29
|
+
}
|
|
30
|
+
_MINIMAL_ARG = {1: 24, 2: 0x100, 4: 0x10000, 8: 0x100000000}
|
|
31
|
+
_EPOCH_DATE = date(1970, 1, 1)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class _Frame:
|
|
35
|
+
"""One open container or tag on the decode work stack."""
|
|
36
|
+
|
|
37
|
+
__slots__ = ("items", "kind", "prev_key", "remaining", "start", "tag")
|
|
38
|
+
|
|
39
|
+
def __init__(self, kind: int, remaining: int, start: int, tag: int = 0) -> None:
|
|
40
|
+
self.kind = kind
|
|
41
|
+
self.remaining = remaining # -1 means indefinite length
|
|
42
|
+
self.items: list[Any] = []
|
|
43
|
+
self.tag = tag
|
|
44
|
+
self.start = start
|
|
45
|
+
self.prev_key: bytes | None = None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class CBORDecoder:
|
|
49
|
+
"""Decode CBOR data items.
|
|
50
|
+
|
|
51
|
+
Decoding is iterative with an explicit work stack: nesting depth is
|
|
52
|
+
bounded by max_depth and never by the Python call stack.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
def __init__(
|
|
56
|
+
self,
|
|
57
|
+
*,
|
|
58
|
+
tag_hook: TagHook | None = None,
|
|
59
|
+
strict_utf8: bool = True,
|
|
60
|
+
max_depth: int = 200,
|
|
61
|
+
allow_indefinite: bool = True,
|
|
62
|
+
canonical: bool = False,
|
|
63
|
+
duplicate_keys: DuplicateMode = "last",
|
|
64
|
+
) -> None:
|
|
65
|
+
if max_depth < 1:
|
|
66
|
+
raise ValueError("max_depth must be at least 1")
|
|
67
|
+
if duplicate_keys not in ("last", "first", "error"):
|
|
68
|
+
raise ValueError(f"invalid duplicate_keys mode {duplicate_keys!r}")
|
|
69
|
+
self._tag_hook = tag_hook
|
|
70
|
+
self._utf8_errors = "strict" if strict_utf8 else "replace"
|
|
71
|
+
self._max_depth = max_depth
|
|
72
|
+
self._allow_indefinite = allow_indefinite
|
|
73
|
+
self._canonical = canonical
|
|
74
|
+
self._duplicate_keys = duplicate_keys
|
|
75
|
+
|
|
76
|
+
def decode(
|
|
77
|
+
self, data: bytes | bytearray | memoryview, *, allow_trailing: bool = False
|
|
78
|
+
) -> Any:
|
|
79
|
+
"""Decode a single data item from data.
|
|
80
|
+
|
|
81
|
+
Raises CBORDecodeError on trailing bytes unless allow_trailing is
|
|
82
|
+
set.
|
|
83
|
+
"""
|
|
84
|
+
value, pos = self.decode_item(data, 0)
|
|
85
|
+
if not allow_trailing and pos != len(data):
|
|
86
|
+
raise CBORDecodeError(f"trailing data at offset {pos}")
|
|
87
|
+
return value
|
|
88
|
+
|
|
89
|
+
def decode_item(
|
|
90
|
+
self, data: bytes | bytearray | memoryview, pos: int = 0
|
|
91
|
+
) -> tuple[Any, int]:
|
|
92
|
+
"""Decode one data item starting at pos and return (value, new pos)."""
|
|
93
|
+
mv = data if isinstance(data, memoryview) else memoryview(data)
|
|
94
|
+
frames: list[_Frame] = []
|
|
95
|
+
value: Any = _MISSING
|
|
96
|
+
start = end = pos
|
|
97
|
+
while True:
|
|
98
|
+
if value is _MISSING:
|
|
99
|
+
value, start, end = self._read_one(mv, pos, frames)
|
|
100
|
+
pos = end
|
|
101
|
+
if value is _MISSING:
|
|
102
|
+
continue # a container or tag frame was pushed
|
|
103
|
+
while value is not _MISSING:
|
|
104
|
+
if not frames:
|
|
105
|
+
return value, end
|
|
106
|
+
frame = frames[-1]
|
|
107
|
+
if frame.kind == _TAG:
|
|
108
|
+
frames.pop()
|
|
109
|
+
value = self._apply_tag(frame.tag, value)
|
|
110
|
+
start = frame.start
|
|
111
|
+
continue
|
|
112
|
+
if frame.kind == _MAP and len(frame.items) % 2 == 0 and self._canonical:
|
|
113
|
+
self._check_key_order(mv, frame, start, end)
|
|
114
|
+
frame.items.append(value)
|
|
115
|
+
if frame.remaining > 0:
|
|
116
|
+
frame.remaining -= 1
|
|
117
|
+
if frame.remaining == 0:
|
|
118
|
+
frames.pop()
|
|
119
|
+
value = self._finish(frame)
|
|
120
|
+
start = frame.start
|
|
121
|
+
continue
|
|
122
|
+
value = _MISSING
|
|
123
|
+
|
|
124
|
+
@staticmethod
|
|
125
|
+
def _check_key_order(mv: memoryview, frame: _Frame, start: int, end: int) -> None:
|
|
126
|
+
key = bytes(mv[start:end])
|
|
127
|
+
prev = frame.prev_key
|
|
128
|
+
if prev is not None and (len(key), key) <= (len(prev), prev):
|
|
129
|
+
raise CBORDecodeError("map keys are not in canonical order")
|
|
130
|
+
frame.prev_key = key
|
|
131
|
+
|
|
132
|
+
def _read_one(
|
|
133
|
+
self, mv: memoryview, pos: int, frames: list[_Frame]
|
|
134
|
+
) -> tuple[Any, int, int]:
|
|
135
|
+
"""Read one item header and return (value or _MISSING, start, end)."""
|
|
136
|
+
start = pos
|
|
137
|
+
if pos >= len(mv):
|
|
138
|
+
raise CBORDecodeError("unexpected end of input")
|
|
139
|
+
head = mv[pos]
|
|
140
|
+
pos += 1
|
|
141
|
+
major = head >> 5
|
|
142
|
+
ai = head & 0x1F
|
|
143
|
+
if ai == 0x1F:
|
|
144
|
+
return self._read_indefinite(mv, pos, start, major, frames)
|
|
145
|
+
if ai > 0x1B:
|
|
146
|
+
raise CBORDecodeError(
|
|
147
|
+
f"reserved additional information {ai} at offset {start}"
|
|
148
|
+
)
|
|
149
|
+
if major == 7:
|
|
150
|
+
return self._read_simple_or_float(mv, pos, start, ai)
|
|
151
|
+
arg, pos = self._read_arg(mv, pos, ai)
|
|
152
|
+
if major == 0:
|
|
153
|
+
return arg, start, pos
|
|
154
|
+
if major == 1:
|
|
155
|
+
return -1 - arg, start, pos
|
|
156
|
+
if major == 2 or major == 3:
|
|
157
|
+
return self._read_string(mv, pos, start, major, arg)
|
|
158
|
+
return self._open_container(pos, start, major, arg, frames)
|
|
159
|
+
|
|
160
|
+
def _read_indefinite(
|
|
161
|
+
self, mv: memoryview, pos: int, start: int, major: int, frames: list[_Frame]
|
|
162
|
+
) -> tuple[Any, int, int]:
|
|
163
|
+
if major == 7:
|
|
164
|
+
# Break byte: only valid closing an indefinite container.
|
|
165
|
+
if frames and frames[-1].remaining == -1:
|
|
166
|
+
frame = frames.pop()
|
|
167
|
+
if frame.kind == _MAP and len(frame.items) % 2 != 0:
|
|
168
|
+
raise CBORDecodeError("indefinite-length map has an odd item count")
|
|
169
|
+
return self._finish(frame), frame.start, pos
|
|
170
|
+
raise CBORDecodeError(
|
|
171
|
+
f"break byte outside indefinite-length item at offset {start}"
|
|
172
|
+
)
|
|
173
|
+
if major in (0, 1, 6):
|
|
174
|
+
raise CBORDecodeError(
|
|
175
|
+
f"indefinite length not allowed for major type {major} "
|
|
176
|
+
f"at offset {start}"
|
|
177
|
+
)
|
|
178
|
+
if not self._allow_indefinite:
|
|
179
|
+
raise CBORDecodeError(
|
|
180
|
+
f"indefinite-length item not permitted at offset {start}"
|
|
181
|
+
)
|
|
182
|
+
if self._canonical:
|
|
183
|
+
raise CBORDecodeError(
|
|
184
|
+
f"indefinite-length item is not canonical at offset {start}"
|
|
185
|
+
)
|
|
186
|
+
if major in (2, 3):
|
|
187
|
+
return self._read_indef_string(mv, pos, start, major)
|
|
188
|
+
if len(frames) >= self._max_depth:
|
|
189
|
+
raise CBORDecodeError(
|
|
190
|
+
f"maximum depth {self._max_depth} exceeded at offset {start}"
|
|
191
|
+
)
|
|
192
|
+
frames.append(_Frame(_ARR if major == 4 else _MAP, -1, start))
|
|
193
|
+
return _MISSING, start, pos
|
|
194
|
+
|
|
195
|
+
def _read_arg(self, mv: memoryview, pos: int, ai: int) -> tuple[int, int]:
|
|
196
|
+
if ai < 24:
|
|
197
|
+
return ai, pos
|
|
198
|
+
nbytes = 1 << (ai - 24) # ai 24..27 gives 1, 2, 4, 8
|
|
199
|
+
if pos + nbytes > len(mv):
|
|
200
|
+
raise CBORDecodeError("truncated integer argument")
|
|
201
|
+
arg = int.from_bytes(mv[pos : pos + nbytes], "big")
|
|
202
|
+
pos += nbytes
|
|
203
|
+
if self._canonical and arg < _MINIMAL_ARG[nbytes]:
|
|
204
|
+
raise CBORDecodeError("non-minimal integer encoding")
|
|
205
|
+
return arg, pos
|
|
206
|
+
|
|
207
|
+
def _read_string(
|
|
208
|
+
self, mv: memoryview, pos: int, start: int, major: int, arg: int
|
|
209
|
+
) -> tuple[Any, int, int]:
|
|
210
|
+
# Check the declared length against remaining input before
|
|
211
|
+
# touching it, so a forged huge length fails cheaply.
|
|
212
|
+
if arg > len(mv) - pos:
|
|
213
|
+
raise CBORDecodeError(
|
|
214
|
+
f"declared length {arg} exceeds {len(mv) - pos} "
|
|
215
|
+
f"remaining bytes at offset {start}"
|
|
216
|
+
)
|
|
217
|
+
raw = bytes(mv[pos : pos + arg])
|
|
218
|
+
pos += arg
|
|
219
|
+
if major == 2:
|
|
220
|
+
return raw, start, pos
|
|
221
|
+
return self._decode_text(raw), start, pos
|
|
222
|
+
|
|
223
|
+
def _read_indef_string(
|
|
224
|
+
self, mv: memoryview, pos: int, start: int, major: int
|
|
225
|
+
) -> tuple[Any, int, int]:
|
|
226
|
+
parts: list[bytes] = []
|
|
227
|
+
while True:
|
|
228
|
+
if pos >= len(mv):
|
|
229
|
+
raise CBORDecodeError("unterminated indefinite-length string")
|
|
230
|
+
head = mv[pos]
|
|
231
|
+
pos += 1
|
|
232
|
+
if head == 0xFF:
|
|
233
|
+
break
|
|
234
|
+
if head >> 5 != major:
|
|
235
|
+
raise CBORDecodeError("wrong chunk type in indefinite-length string")
|
|
236
|
+
ai = head & 0x1F
|
|
237
|
+
if ai >= 28:
|
|
238
|
+
raise CBORDecodeError(
|
|
239
|
+
"invalid chunk header in indefinite-length string"
|
|
240
|
+
)
|
|
241
|
+
arg, pos = self._read_arg(mv, pos, ai)
|
|
242
|
+
if arg > len(mv) - pos:
|
|
243
|
+
raise CBORDecodeError("declared chunk length exceeds remaining input")
|
|
244
|
+
parts.append(bytes(mv[pos : pos + arg]))
|
|
245
|
+
pos += arg
|
|
246
|
+
if major == 2:
|
|
247
|
+
return b"".join(parts), start, pos
|
|
248
|
+
return "".join(self._decode_text(chunk) for chunk in parts), start, pos
|
|
249
|
+
|
|
250
|
+
def _read_simple_or_float( # noqa: C901
|
|
251
|
+
self, mv: memoryview, pos: int, start: int, ai: int
|
|
252
|
+
) -> tuple[Any, int, int]:
|
|
253
|
+
if ai < 20:
|
|
254
|
+
return CBORSimpleValue(ai), start, pos
|
|
255
|
+
if ai == 20:
|
|
256
|
+
return False, start, pos
|
|
257
|
+
if ai == 21:
|
|
258
|
+
return True, start, pos
|
|
259
|
+
if ai == 22:
|
|
260
|
+
return None, start, pos
|
|
261
|
+
if ai == 23:
|
|
262
|
+
return undefined, start, pos
|
|
263
|
+
if ai == 24:
|
|
264
|
+
if pos >= len(mv):
|
|
265
|
+
raise CBORDecodeError("truncated simple value")
|
|
266
|
+
value = mv[pos]
|
|
267
|
+
pos += 1
|
|
268
|
+
if self._canonical and value < 24:
|
|
269
|
+
raise CBORDecodeError("non-minimal simple value encoding")
|
|
270
|
+
if value == 20:
|
|
271
|
+
return False, start, pos
|
|
272
|
+
if value == 21:
|
|
273
|
+
return True, start, pos
|
|
274
|
+
if value == 22:
|
|
275
|
+
return None, start, pos
|
|
276
|
+
if value == 23:
|
|
277
|
+
return undefined, start, pos
|
|
278
|
+
return CBORSimpleValue(value), start, pos
|
|
279
|
+
nbytes, fmt = _FLOAT_FORMATS[ai]
|
|
280
|
+
if pos + nbytes > len(mv):
|
|
281
|
+
raise CBORDecodeError("truncated float")
|
|
282
|
+
fval = float(struct.unpack(fmt, mv[pos : pos + nbytes])[0])
|
|
283
|
+
pos += nbytes
|
|
284
|
+
if self._canonical and float_min_ai(fval) != ai:
|
|
285
|
+
raise CBORDecodeError("float is not in shortest form")
|
|
286
|
+
return fval, start, pos
|
|
287
|
+
|
|
288
|
+
def _open_container(
|
|
289
|
+
self, pos: int, start: int, major: int, arg: int, frames: list[_Frame]
|
|
290
|
+
) -> tuple[Any, int, int]:
|
|
291
|
+
# The claimed length only sizes the item budget, so nothing is
|
|
292
|
+
# allocated up front, so forged counts fail at end of input.
|
|
293
|
+
if major == 4:
|
|
294
|
+
if arg == 0:
|
|
295
|
+
return [], start, pos
|
|
296
|
+
if len(frames) >= self._max_depth:
|
|
297
|
+
raise CBORDecodeError(
|
|
298
|
+
f"maximum depth {self._max_depth} exceeded at offset {start}"
|
|
299
|
+
)
|
|
300
|
+
frames.append(_Frame(_ARR, arg, start))
|
|
301
|
+
elif major == 5:
|
|
302
|
+
if arg == 0:
|
|
303
|
+
return {}, start, pos
|
|
304
|
+
if len(frames) >= self._max_depth:
|
|
305
|
+
raise CBORDecodeError(
|
|
306
|
+
f"maximum depth {self._max_depth} exceeded at offset {start}"
|
|
307
|
+
)
|
|
308
|
+
frames.append(_Frame(_MAP, arg * 2, start))
|
|
309
|
+
else: # major == 6, tag: arg is the tag number, needs one item
|
|
310
|
+
if len(frames) >= self._max_depth:
|
|
311
|
+
raise CBORDecodeError(
|
|
312
|
+
f"maximum depth {self._max_depth} exceeded at offset {start}"
|
|
313
|
+
)
|
|
314
|
+
frames.append(_Frame(_TAG, 1, start, arg))
|
|
315
|
+
return _MISSING, start, pos
|
|
316
|
+
|
|
317
|
+
def _decode_text(self, raw: bytes) -> str:
|
|
318
|
+
try:
|
|
319
|
+
return raw.decode("utf-8", self._utf8_errors)
|
|
320
|
+
except UnicodeDecodeError as e:
|
|
321
|
+
raise CBORDecodeError("invalid UTF-8 in text string") from e
|
|
322
|
+
|
|
323
|
+
def _finish(self, frame: _Frame) -> Any:
|
|
324
|
+
if frame.kind == _ARR:
|
|
325
|
+
return frame.items
|
|
326
|
+
items = frame.items
|
|
327
|
+
result: dict[Any, Any] = {}
|
|
328
|
+
try:
|
|
329
|
+
if self._duplicate_keys == "error":
|
|
330
|
+
for i in range(0, len(items), 2):
|
|
331
|
+
key = items[i]
|
|
332
|
+
if key in result:
|
|
333
|
+
raise CBORDecodeError(f"duplicate map key {key!r}")
|
|
334
|
+
result[key] = items[i + 1]
|
|
335
|
+
elif self._duplicate_keys == "first":
|
|
336
|
+
for i in range(0, len(items), 2):
|
|
337
|
+
result.setdefault(items[i], items[i + 1])
|
|
338
|
+
else:
|
|
339
|
+
for i in range(0, len(items), 2):
|
|
340
|
+
result[items[i]] = items[i + 1]
|
|
341
|
+
except TypeError as e:
|
|
342
|
+
raise CBORDecodeError("unhashable map key") from e
|
|
343
|
+
return result
|
|
344
|
+
|
|
345
|
+
def _apply_tag(self, tag: int, value: Any) -> Any:
|
|
346
|
+
if self._tag_hook is not None:
|
|
347
|
+
return self._tag_hook(self, CBORTag(tag, value))
|
|
348
|
+
if tag == 0:
|
|
349
|
+
return _decode_datetime(value)
|
|
350
|
+
if tag == 1:
|
|
351
|
+
return _decode_epoch(value)
|
|
352
|
+
if tag == 2:
|
|
353
|
+
return _decode_bignum(value, negative=False)
|
|
354
|
+
if tag == 3:
|
|
355
|
+
return _decode_bignum(value, negative=True)
|
|
356
|
+
if tag == 32:
|
|
357
|
+
# URI: returned as a plain str, since Python has no URI scalar type.
|
|
358
|
+
if not isinstance(value, str):
|
|
359
|
+
raise CBORDecodeError("tag 32 (URI) must wrap a text string")
|
|
360
|
+
return value
|
|
361
|
+
if tag == 55799:
|
|
362
|
+
# Self-described CBOR: pass the enclosed item through.
|
|
363
|
+
return value
|
|
364
|
+
if tag == 1004:
|
|
365
|
+
return _decode_tag_date(value)
|
|
366
|
+
return CBORTag(tag, value)
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _decode_datetime(value: Any) -> datetime:
|
|
370
|
+
if not isinstance(value, str):
|
|
371
|
+
raise CBORDecodeError("tag 0 must wrap a text string")
|
|
372
|
+
text = value[:-1] + "+00:00" if value.endswith("Z") else value
|
|
373
|
+
try:
|
|
374
|
+
return datetime.fromisoformat(text)
|
|
375
|
+
except ValueError as e:
|
|
376
|
+
raise CBORDecodeError(f"malformed tag 0 datetime string {value!r}") from e
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _decode_epoch(value: Any) -> datetime:
|
|
380
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
381
|
+
raise CBORDecodeError("tag 1 must wrap a number")
|
|
382
|
+
try:
|
|
383
|
+
return datetime.fromtimestamp(value, tz=timezone.utc)
|
|
384
|
+
except (OverflowError, OSError, ValueError) as e:
|
|
385
|
+
raise CBORDecodeError(f"tag 1 timestamp {value!r} out of range") from e
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def _decode_bignum(value: Any, *, negative: bool) -> int:
|
|
389
|
+
if not isinstance(value, bytes):
|
|
390
|
+
raise CBORDecodeError("bignum tag must wrap a byte string")
|
|
391
|
+
magnitude = int.from_bytes(value, "big")
|
|
392
|
+
return -1 - magnitude if negative else magnitude
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def _decode_tag_date(value: Any) -> date:
|
|
396
|
+
if isinstance(value, bool) or not isinstance(value, int):
|
|
397
|
+
raise CBORDecodeError("tag 1004 must wrap an integer day count")
|
|
398
|
+
try:
|
|
399
|
+
return _EPOCH_DATE + timedelta(days=value)
|
|
400
|
+
except OverflowError as e:
|
|
401
|
+
raise CBORDecodeError(f"tag 1004 day count {value} out of range") from e
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def loads(
|
|
405
|
+
data: bytes | bytearray | memoryview,
|
|
406
|
+
*,
|
|
407
|
+
tag_hook: TagHook | None = None,
|
|
408
|
+
strict_utf8: bool = True,
|
|
409
|
+
max_depth: int = 200,
|
|
410
|
+
allow_indefinite: bool = True,
|
|
411
|
+
canonical: bool = False,
|
|
412
|
+
duplicate_keys: DuplicateMode = "last",
|
|
413
|
+
allow_trailing: bool = False,
|
|
414
|
+
) -> Any:
|
|
415
|
+
"""Decode a single CBOR data item from data.
|
|
416
|
+
|
|
417
|
+
canonical=True enforces RFC 8949 preferred serialization: non-minimal
|
|
418
|
+
integers, non-shortest floats, indefinite lengths and unsorted map
|
|
419
|
+
keys are rejected. duplicate_keys selects "last" (keep the last
|
|
420
|
+
value), "first" or "error" handling for repeated map keys.
|
|
421
|
+
"""
|
|
422
|
+
return CBORDecoder(
|
|
423
|
+
tag_hook=tag_hook,
|
|
424
|
+
strict_utf8=strict_utf8,
|
|
425
|
+
max_depth=max_depth,
|
|
426
|
+
allow_indefinite=allow_indefinite,
|
|
427
|
+
canonical=canonical,
|
|
428
|
+
duplicate_keys=duplicate_keys,
|
|
429
|
+
).decode(data, allow_trailing=allow_trailing)
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def load(
|
|
433
|
+
fp: BinaryIO,
|
|
434
|
+
*,
|
|
435
|
+
tag_hook: TagHook | None = None,
|
|
436
|
+
strict_utf8: bool = True,
|
|
437
|
+
max_depth: int = 200,
|
|
438
|
+
allow_indefinite: bool = True,
|
|
439
|
+
canonical: bool = False,
|
|
440
|
+
duplicate_keys: DuplicateMode = "last",
|
|
441
|
+
) -> Any:
|
|
442
|
+
"""Decode one CBOR data item from a binary file-like object.
|
|
443
|
+
|
|
444
|
+
The stream is read to end. The first data item is decoded and any
|
|
445
|
+
remaining bytes are ignored, matching single-item stream semantics.
|
|
446
|
+
"""
|
|
447
|
+
return loads(
|
|
448
|
+
fp.read(),
|
|
449
|
+
tag_hook=tag_hook,
|
|
450
|
+
strict_utf8=strict_utf8,
|
|
451
|
+
max_depth=max_depth,
|
|
452
|
+
allow_indefinite=allow_indefinite,
|
|
453
|
+
canonical=canonical,
|
|
454
|
+
duplicate_keys=duplicate_keys,
|
|
455
|
+
allow_trailing=True,
|
|
456
|
+
)
|
cborx/encoder.py
ADDED
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
# SPDX-License-Identifier: 0BSD
|
|
2
|
+
"""CBOR encoder (RFC 8949)."""
|
|
3
|
+
|
|
4
|
+
import io
|
|
5
|
+
import struct
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from datetime import date, datetime, timezone
|
|
8
|
+
from typing import Any, BinaryIO
|
|
9
|
+
|
|
10
|
+
from ._util import float_min_ai
|
|
11
|
+
from .exceptions import CBOREncodeError
|
|
12
|
+
from .types import CBORSimpleValue, CBORTag, UndefinedType, undefined
|
|
13
|
+
|
|
14
|
+
_UINT64_MAX = (1 << 64) - 1
|
|
15
|
+
_EPOCH_DATE = date(1970, 1, 1)
|
|
16
|
+
|
|
17
|
+
DefaultCallback = Callable[["CBOREncoder", Any], Any]
|
|
18
|
+
"""Called for objects with no built-in encoding.
|
|
19
|
+
|
|
20
|
+
The callback may either call encoder.encode(substitute) or return a
|
|
21
|
+
substitute object, which is then encoded in place of the original.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _to_bignum(value: int) -> bytes:
|
|
26
|
+
return value.to_bytes((value.bit_length() + 7) // 8, "big")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class CBOREncoder:
|
|
30
|
+
"""Encode Python objects to a binary stream as CBOR."""
|
|
31
|
+
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
fp: BinaryIO,
|
|
35
|
+
*,
|
|
36
|
+
canonical: bool = False,
|
|
37
|
+
indefinite: bool = False,
|
|
38
|
+
datetime_as_timestamp: bool = False,
|
|
39
|
+
default: DefaultCallback | None = None,
|
|
40
|
+
max_depth: int = 200,
|
|
41
|
+
) -> None:
|
|
42
|
+
if canonical and indefinite:
|
|
43
|
+
raise ValueError("canonical and indefinite encoding are mutually exclusive")
|
|
44
|
+
self._fp = fp
|
|
45
|
+
self._canonical = canonical
|
|
46
|
+
self._indefinite = indefinite
|
|
47
|
+
self._datetime_as_timestamp = datetime_as_timestamp
|
|
48
|
+
self._default = default
|
|
49
|
+
self._max_depth = max_depth
|
|
50
|
+
self._active: bytearray | None = None
|
|
51
|
+
|
|
52
|
+
def encode(self, obj: Any) -> None:
|
|
53
|
+
"""Write obj to the stream as a single CBOR data item."""
|
|
54
|
+
active = self._active
|
|
55
|
+
if active is not None:
|
|
56
|
+
# Nested call from a default callback: append in place so the
|
|
57
|
+
# substituted bytes land at the correct position.
|
|
58
|
+
self._encode(obj, active, 0)
|
|
59
|
+
return
|
|
60
|
+
buf = bytearray()
|
|
61
|
+
try:
|
|
62
|
+
self._encode(obj, buf, 0)
|
|
63
|
+
except RecursionError as e:
|
|
64
|
+
raise CBOREncodeError("object graph too deep to encode") from e
|
|
65
|
+
self._fp.write(bytes(buf))
|
|
66
|
+
|
|
67
|
+
def _encode(self, obj: Any, buf: bytearray, depth: int) -> None:
|
|
68
|
+
if depth > self._max_depth:
|
|
69
|
+
raise CBOREncodeError("maximum container depth exceeded")
|
|
70
|
+
prev = self._active
|
|
71
|
+
self._active = buf
|
|
72
|
+
try:
|
|
73
|
+
self._write_item(obj, buf, depth)
|
|
74
|
+
finally:
|
|
75
|
+
self._active = prev
|
|
76
|
+
|
|
77
|
+
def _write_item(self, obj: Any, buf: bytearray, depth: int) -> None: # noqa: C901
|
|
78
|
+
if obj is None:
|
|
79
|
+
buf.append(0xF6)
|
|
80
|
+
elif obj is True:
|
|
81
|
+
buf.append(0xF5)
|
|
82
|
+
elif obj is False:
|
|
83
|
+
buf.append(0xF4)
|
|
84
|
+
elif obj is undefined:
|
|
85
|
+
buf.append(0xF7)
|
|
86
|
+
elif isinstance(obj, int):
|
|
87
|
+
self._write_int(obj, buf)
|
|
88
|
+
elif isinstance(obj, float):
|
|
89
|
+
self._write_float(obj, buf)
|
|
90
|
+
elif isinstance(obj, str):
|
|
91
|
+
self._write_text(obj, buf)
|
|
92
|
+
elif isinstance(obj, (bytes, bytearray, memoryview)):
|
|
93
|
+
self._write_bytes(bytes(obj), buf)
|
|
94
|
+
elif isinstance(obj, CBORTag):
|
|
95
|
+
self._write_tag(obj, buf, depth)
|
|
96
|
+
elif isinstance(obj, CBORSimpleValue):
|
|
97
|
+
self._write_simple(obj, buf)
|
|
98
|
+
elif isinstance(obj, UndefinedType):
|
|
99
|
+
buf.append(0xF7)
|
|
100
|
+
elif isinstance(obj, datetime):
|
|
101
|
+
self._write_datetime(obj, buf, depth)
|
|
102
|
+
elif isinstance(obj, date):
|
|
103
|
+
self._write_date(obj, buf, depth)
|
|
104
|
+
elif isinstance(obj, (list, tuple)):
|
|
105
|
+
self._write_array(obj, buf, depth)
|
|
106
|
+
elif isinstance(obj, dict):
|
|
107
|
+
self._write_map(obj, buf, depth)
|
|
108
|
+
else:
|
|
109
|
+
self._write_default(obj, buf, depth)
|
|
110
|
+
|
|
111
|
+
@staticmethod
|
|
112
|
+
def _write_head(buf: bytearray, major: int, arg: int) -> None:
|
|
113
|
+
if arg < 0 or arg > _UINT64_MAX:
|
|
114
|
+
raise CBOREncodeError(f"argument {arg} out of range for CBOR header")
|
|
115
|
+
base = major << 5
|
|
116
|
+
if arg < 24:
|
|
117
|
+
buf.append(base | arg)
|
|
118
|
+
elif arg <= 0xFF:
|
|
119
|
+
buf += bytes((base | 24, arg))
|
|
120
|
+
elif arg <= 0xFFFF:
|
|
121
|
+
buf.append(base | 25)
|
|
122
|
+
buf += arg.to_bytes(2, "big")
|
|
123
|
+
elif arg <= 0xFFFFFFFF:
|
|
124
|
+
buf.append(base | 26)
|
|
125
|
+
buf += arg.to_bytes(4, "big")
|
|
126
|
+
else:
|
|
127
|
+
buf.append(base | 27)
|
|
128
|
+
buf += arg.to_bytes(8, "big")
|
|
129
|
+
|
|
130
|
+
def _write_int(self, n: int, buf: bytearray) -> None:
|
|
131
|
+
if n >= 0:
|
|
132
|
+
if n <= _UINT64_MAX:
|
|
133
|
+
self._write_head(buf, 0, n)
|
|
134
|
+
else:
|
|
135
|
+
self._write_head(buf, 6, 2)
|
|
136
|
+
self._write_bytes(_to_bignum(n), buf)
|
|
137
|
+
else:
|
|
138
|
+
magnitude = -1 - n
|
|
139
|
+
if magnitude <= _UINT64_MAX:
|
|
140
|
+
self._write_head(buf, 1, magnitude)
|
|
141
|
+
else:
|
|
142
|
+
self._write_head(buf, 6, 3)
|
|
143
|
+
self._write_bytes(_to_bignum(magnitude), buf)
|
|
144
|
+
|
|
145
|
+
def _write_float(self, value: float, buf: bytearray) -> None:
|
|
146
|
+
ai = float_min_ai(value) if self._canonical else 27
|
|
147
|
+
if ai == 25:
|
|
148
|
+
buf.append(0xF9)
|
|
149
|
+
buf += struct.pack(">e", value)
|
|
150
|
+
elif ai == 26:
|
|
151
|
+
buf.append(0xFA)
|
|
152
|
+
buf += struct.pack(">f", value)
|
|
153
|
+
else:
|
|
154
|
+
buf.append(0xFB)
|
|
155
|
+
buf += struct.pack(">d", value)
|
|
156
|
+
|
|
157
|
+
def _write_text(self, s: str, buf: bytearray) -> None:
|
|
158
|
+
try:
|
|
159
|
+
raw = s.encode("utf-8")
|
|
160
|
+
except UnicodeEncodeError as e:
|
|
161
|
+
raise CBOREncodeError("text string contains lone surrogates") from e
|
|
162
|
+
if self._indefinite:
|
|
163
|
+
buf.append(0x7F)
|
|
164
|
+
self._write_head(buf, 3, len(raw))
|
|
165
|
+
buf += raw
|
|
166
|
+
buf.append(0xFF)
|
|
167
|
+
return
|
|
168
|
+
self._write_head(buf, 3, len(raw))
|
|
169
|
+
buf += raw
|
|
170
|
+
|
|
171
|
+
def _write_bytes(self, raw: bytes, buf: bytearray) -> None:
|
|
172
|
+
if self._indefinite:
|
|
173
|
+
buf.append(0x5F)
|
|
174
|
+
self._write_head(buf, 2, len(raw))
|
|
175
|
+
buf += raw
|
|
176
|
+
buf.append(0xFF)
|
|
177
|
+
return
|
|
178
|
+
self._write_head(buf, 2, len(raw))
|
|
179
|
+
buf += raw
|
|
180
|
+
|
|
181
|
+
def _write_tag(self, obj: CBORTag, buf: bytearray, depth: int) -> None:
|
|
182
|
+
if isinstance(obj.tag, bool) or not isinstance(obj.tag, int):
|
|
183
|
+
raise CBOREncodeError("CBORTag tag must be an integer")
|
|
184
|
+
self._write_head(buf, 6, obj.tag)
|
|
185
|
+
self._encode(obj.value, buf, depth + 1)
|
|
186
|
+
|
|
187
|
+
@staticmethod
|
|
188
|
+
def _write_simple(obj: CBORSimpleValue, buf: bytearray) -> None:
|
|
189
|
+
value = obj.value
|
|
190
|
+
if (
|
|
191
|
+
isinstance(value, bool)
|
|
192
|
+
or not isinstance(value, int)
|
|
193
|
+
or not 0 <= value <= 0xFF
|
|
194
|
+
):
|
|
195
|
+
raise CBOREncodeError(f"simple value {value!r} out of range 0..255")
|
|
196
|
+
if 24 <= value <= 31:
|
|
197
|
+
raise CBOREncodeError(f"simple value {value} is reserved")
|
|
198
|
+
if value < 24:
|
|
199
|
+
buf.append(0xE0 | value)
|
|
200
|
+
else:
|
|
201
|
+
buf += bytes((0xF8, value))
|
|
202
|
+
|
|
203
|
+
def _write_datetime(self, obj: datetime, buf: bytearray, depth: int) -> None:
|
|
204
|
+
if self._datetime_as_timestamp:
|
|
205
|
+
aware = obj if obj.tzinfo is not None else obj.replace(tzinfo=timezone.utc)
|
|
206
|
+
timestamp = float(aware.timestamp())
|
|
207
|
+
self._write_head(buf, 6, 1)
|
|
208
|
+
self._encode(
|
|
209
|
+
int(timestamp) if timestamp.is_integer() else timestamp, buf, depth + 1
|
|
210
|
+
)
|
|
211
|
+
return
|
|
212
|
+
text = obj.isoformat()
|
|
213
|
+
if text.endswith("+00:00"):
|
|
214
|
+
text = text[:-6] + "Z"
|
|
215
|
+
self._write_head(buf, 6, 0)
|
|
216
|
+
self._encode(text, buf, depth + 1)
|
|
217
|
+
|
|
218
|
+
def _write_date(self, obj: date, buf: bytearray, depth: int) -> None:
|
|
219
|
+
# RFC 8949 calendar date: tag 1004, days since 1970-01-01.
|
|
220
|
+
self._write_head(buf, 6, 1004)
|
|
221
|
+
self._encode((obj - _EPOCH_DATE).days, buf, depth + 1)
|
|
222
|
+
|
|
223
|
+
def _write_array(
|
|
224
|
+
self, obj: list[Any] | tuple[Any, ...], buf: bytearray, depth: int
|
|
225
|
+
) -> None:
|
|
226
|
+
if self._indefinite:
|
|
227
|
+
buf.append(0x9F)
|
|
228
|
+
for item in obj:
|
|
229
|
+
self._encode(item, buf, depth + 1)
|
|
230
|
+
buf.append(0xFF)
|
|
231
|
+
return
|
|
232
|
+
self._write_head(buf, 4, len(obj))
|
|
233
|
+
for item in obj:
|
|
234
|
+
self._encode(item, buf, depth + 1)
|
|
235
|
+
|
|
236
|
+
def _write_map(self, obj: dict[Any, Any], buf: bytearray, depth: int) -> None:
|
|
237
|
+
if self._indefinite:
|
|
238
|
+
buf.append(0xBF)
|
|
239
|
+
for key, value in obj.items():
|
|
240
|
+
self._encode(key, buf, depth + 1)
|
|
241
|
+
self._encode(value, buf, depth + 1)
|
|
242
|
+
buf.append(0xFF)
|
|
243
|
+
return
|
|
244
|
+
self._write_head(buf, 5, len(obj))
|
|
245
|
+
if self._canonical:
|
|
246
|
+
pairs = [(self._key_bytes(key, depth), value) for key, value in obj.items()]
|
|
247
|
+
pairs.sort(key=lambda pair: (len(pair[0]), pair[0]))
|
|
248
|
+
for key_bytes, value in pairs:
|
|
249
|
+
buf += key_bytes
|
|
250
|
+
self._encode(value, buf, depth + 1)
|
|
251
|
+
else:
|
|
252
|
+
for key, value in obj.items():
|
|
253
|
+
self._encode(key, buf, depth + 1)
|
|
254
|
+
self._encode(value, buf, depth + 1)
|
|
255
|
+
|
|
256
|
+
def _key_bytes(self, key: Any, depth: int) -> bytes:
|
|
257
|
+
buf = bytearray()
|
|
258
|
+
self._encode(key, buf, depth + 1)
|
|
259
|
+
return bytes(buf)
|
|
260
|
+
|
|
261
|
+
def _write_default(self, obj: Any, buf: bytearray, depth: int) -> None:
|
|
262
|
+
if self._default is None:
|
|
263
|
+
raise CBOREncodeError(f"cannot encode object of type {type(obj).__name__}")
|
|
264
|
+
before = len(buf)
|
|
265
|
+
substitute = self._default(self, obj)
|
|
266
|
+
wrote = len(buf) != before
|
|
267
|
+
if substitute is None:
|
|
268
|
+
if not wrote:
|
|
269
|
+
# Emitting nothing would silently corrupt the stream (a
|
|
270
|
+
# map would lose its value).
|
|
271
|
+
raise CBOREncodeError(
|
|
272
|
+
f"default callback emitted nothing for {type(obj).__name__}"
|
|
273
|
+
)
|
|
274
|
+
return
|
|
275
|
+
if wrote:
|
|
276
|
+
raise CBOREncodeError(
|
|
277
|
+
"default callback both wrote via encode() and returned a value"
|
|
278
|
+
)
|
|
279
|
+
if substitute is obj:
|
|
280
|
+
raise CBOREncodeError("default callback returned the object it was given")
|
|
281
|
+
self._encode(substitute, buf, depth + 1)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def dumps(
|
|
285
|
+
obj: Any,
|
|
286
|
+
*,
|
|
287
|
+
canonical: bool = False,
|
|
288
|
+
indefinite: bool = False,
|
|
289
|
+
datetime_as_timestamp: bool = False,
|
|
290
|
+
default: DefaultCallback | None = None,
|
|
291
|
+
max_depth: int = 200,
|
|
292
|
+
) -> bytes:
|
|
293
|
+
"""Encode obj to CBOR and return the bytes.
|
|
294
|
+
|
|
295
|
+
canonical selects deterministic encoding per RFC 8949 section 4.2:
|
|
296
|
+
shortest-form integers, shortest preserving floats, definite lengths
|
|
297
|
+
and map keys sorted by encoded key bytes (shorter first, then
|
|
298
|
+
lexicographic). indefinite emits indefinite-length byte strings,
|
|
299
|
+
text strings, arrays and maps instead of definite ones. It cannot
|
|
300
|
+
be combined with canonical. datetime_as_timestamp encodes datetime
|
|
301
|
+
as tag 1 (seconds since epoch, naive treated as UTC) instead of
|
|
302
|
+
tag 0 (ISO 8601 text). default handles otherwise unencodable
|
|
303
|
+
objects.
|
|
304
|
+
"""
|
|
305
|
+
fp = io.BytesIO()
|
|
306
|
+
CBOREncoder(
|
|
307
|
+
fp,
|
|
308
|
+
canonical=canonical,
|
|
309
|
+
indefinite=indefinite,
|
|
310
|
+
datetime_as_timestamp=datetime_as_timestamp,
|
|
311
|
+
default=default,
|
|
312
|
+
max_depth=max_depth,
|
|
313
|
+
).encode(obj)
|
|
314
|
+
return fp.getvalue()
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def dump(
|
|
318
|
+
obj: Any,
|
|
319
|
+
fp: BinaryIO,
|
|
320
|
+
*,
|
|
321
|
+
canonical: bool = False,
|
|
322
|
+
indefinite: bool = False,
|
|
323
|
+
datetime_as_timestamp: bool = False,
|
|
324
|
+
default: DefaultCallback | None = None,
|
|
325
|
+
max_depth: int = 200,
|
|
326
|
+
) -> None:
|
|
327
|
+
"""Encode obj to CBOR and write it to a binary file-like object."""
|
|
328
|
+
CBOREncoder(
|
|
329
|
+
fp,
|
|
330
|
+
canonical=canonical,
|
|
331
|
+
indefinite=indefinite,
|
|
332
|
+
datetime_as_timestamp=datetime_as_timestamp,
|
|
333
|
+
default=default,
|
|
334
|
+
max_depth=max_depth,
|
|
335
|
+
).encode(obj)
|
cborx/exceptions.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# SPDX-License-Identifier: 0BSD
|
|
2
|
+
"""Exception hierarchy for cborx."""
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class CBORError(Exception):
|
|
6
|
+
"""Base class for all cborx errors."""
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class CBOREncodeError(CBORError):
|
|
10
|
+
"""Raised when an object cannot be encoded as CBOR."""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class CBORDecodeError(CBORError):
|
|
14
|
+
"""Raised when input bytes cannot be decoded as CBOR."""
|
cborx/py.typed
ADDED
|
File without changes
|
cborx/types.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# SPDX-License-Identifier: 0BSD
|
|
2
|
+
"""Public value types for the CBOR codec."""
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True, slots=True)
|
|
9
|
+
class CBORTag:
|
|
10
|
+
"""A CBOR semantic tag (major type 6) wrapping a value.
|
|
11
|
+
|
|
12
|
+
Tags without built-in handling decode to CBORTag. Encoding a CBORTag
|
|
13
|
+
writes the tag header followed by the encoded value.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
tag: int
|
|
17
|
+
value: Any
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True, slots=True)
|
|
21
|
+
class CBORSimpleValue:
|
|
22
|
+
"""An unassigned CBOR simple value (major type 7).
|
|
23
|
+
|
|
24
|
+
Values 20, 21, 22 and 23 are the named simple values false, true, null
|
|
25
|
+
and undefined and are never wrapped: decoding yields False, True, None
|
|
26
|
+
and undefined instead. Values 24 through 31 are reserved by RFC 8949
|
|
27
|
+
and are rejected by the encoder.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
value: int
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class UndefinedType:
|
|
34
|
+
"""Type of the undefined sentinel."""
|
|
35
|
+
|
|
36
|
+
__slots__ = ()
|
|
37
|
+
|
|
38
|
+
def __repr__(self) -> str:
|
|
39
|
+
return "undefined"
|
|
40
|
+
|
|
41
|
+
def __bool__(self) -> bool:
|
|
42
|
+
return False
|
|
43
|
+
|
|
44
|
+
def __eq__(self, other: object) -> bool:
|
|
45
|
+
return isinstance(other, UndefinedType)
|
|
46
|
+
|
|
47
|
+
def __hash__(self) -> int:
|
|
48
|
+
return hash(UndefinedType)
|
|
49
|
+
|
|
50
|
+
def __copy__(self) -> "UndefinedType":
|
|
51
|
+
return self
|
|
52
|
+
|
|
53
|
+
def __deepcopy__(self, memo: dict[int, Any]) -> "UndefinedType":
|
|
54
|
+
return self
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
undefined = UndefinedType()
|
|
58
|
+
"""Sentinel for the CBOR simple value undefined (0xf7)."""
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: cborx
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Pure-Python CBOR (RFC 8949) encoder/decoder. No dependencies, no Rust.
|
|
5
|
+
Project-URL: Homepage, https://quad4.io
|
|
6
|
+
Project-URL: Repository, https://github.com/Quad4-Software/cborx
|
|
7
|
+
Project-URL: Issues, https://github.com/Quad4-Software/cborx/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/Quad4-Software/cborx/blob/master/CHANGELOG.md
|
|
9
|
+
Author: Quad4
|
|
10
|
+
License-Expression: 0BSD
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: binary,cbor,codec,rfc8949,serialization
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: BSD License
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Typing :: Typed
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
# cborx
|
|
23
|
+
|
|
24
|
+
[](https://github.com/Quad4-Software/cborx/actions/workflows/ci.yml)
|
|
25
|
+
[](https://github.com/Quad4-Software/cborx/actions/workflows/codeql.yml)
|
|
26
|
+
[](https://securityscorecards.dev/viewer/?uri=github.com/Quad4-Software/cborx)
|
|
27
|
+
[](https://pypi.org/project/cborx/)
|
|
28
|
+
[](LICENSE)
|
|
29
|
+
|
|
30
|
+
Pure-Python CBOR (RFC 8949) encoder/decoder. No dependencies, no Rust.
|
|
31
|
+
|
|
32
|
+
A hardened, fully typed replacement for cbor2 without a native
|
|
33
|
+
extension. The decoder is iterative, bounds every claimed length
|
|
34
|
+
against the remaining input, and enforces a configurable nesting
|
|
35
|
+
limit, so hostile input fails fast instead of exhausting memory or
|
|
36
|
+
the call stack.
|
|
37
|
+
|
|
38
|
+
## Install
|
|
39
|
+
|
|
40
|
+
```sh
|
|
41
|
+
pip install cborx
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Usage
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
import cborx
|
|
48
|
+
|
|
49
|
+
data = cborx.dumps({"a": [1, 2, 3], "b": None})
|
|
50
|
+
assert cborx.loads(data) == {"a": [1, 2, 3], "b": None}
|
|
51
|
+
|
|
52
|
+
# Deterministic encoding: shortest-form integers and floats, map keys
|
|
53
|
+
# sorted by encoded key bytes (RFC 8949 section 4.2).
|
|
54
|
+
canonical = cborx.dumps({"b": 1, "a": 2}, canonical=True)
|
|
55
|
+
|
|
56
|
+
# Strict canonical validation on decode, duplicate-key policy, depth
|
|
57
|
+
# and indefinite-length controls.
|
|
58
|
+
cborx.loads(data, canonical=True, duplicate_keys="error", max_depth=100)
|
|
59
|
+
|
|
60
|
+
# Unhandled semantic tags round-trip as CBORTag. A tag_hook overrides
|
|
61
|
+
# all built-in tag handling.
|
|
62
|
+
cborx.loads(b"\xd8\x2a\x01", tag_hook=lambda decoder, tag: (tag.tag, tag.value))
|
|
63
|
+
assert cborx.loads(cborx.dumps(cborx.CBORTag(42, "x"))) == cborx.CBORTag(42, "x")
|
|
64
|
+
|
|
65
|
+
# A default callback handles otherwise unencodable objects, like cbor2.
|
|
66
|
+
cborx.dumps(object(), default=lambda encoder, obj: {"type": type(obj).__name__})
|
|
67
|
+
|
|
68
|
+
# Simple values and the undefined sentinel.
|
|
69
|
+
assert cborx.loads(b"\xf7") is cborx.undefined
|
|
70
|
+
assert cborx.loads(b"\xf0") == cborx.CBORSimpleValue(16)
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Built-in semantic tags: 0 (ISO 8601 datetime), 1 (epoch datetime),
|
|
74
|
+
2/3 (bignum integers beyond the 64-bit range), 32 (URI, decoded to a
|
|
75
|
+
plain str since Python has no URI scalar), 1004 (calendar date) and
|
|
76
|
+
55799 (self-described CBOR, passed through). All other tags decode to
|
|
77
|
+
CBORTag. See RFC 8949: https://www.rfc-editor.org/rfc/rfc8949
|
|
78
|
+
|
|
79
|
+
## Development
|
|
80
|
+
|
|
81
|
+
```sh
|
|
82
|
+
uv sync --group dev
|
|
83
|
+
make check
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
License: 0BSD. Quad4 Software, https://quad4.io
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
cborx/__init__.py,sha256=tJr2AeOht5RywKWAkdyP25I_ImwmvnVmWXwsX4oB6Fg,705
|
|
2
|
+
cborx/_util.py,sha256=rsBuDidZNfyrdNO_eGoBwOutIWzigQN_lOooBKAy2hM,767
|
|
3
|
+
cborx/decoder.py,sha256=ZcLyWoR5dopbb1ybLhPI-dbufww5xU7W8RCg45EOBKs,17178
|
|
4
|
+
cborx/encoder.py,sha256=LlwZXM7RI0m9CbXxgK1pjubuj__OpgK0Ue-Ij1DY_hk,11806
|
|
5
|
+
cborx/exceptions.py,sha256=DogJ_-DUPAiMuaduFAHiCw7VMr6MOaODfnLAYn6c_Qw,334
|
|
6
|
+
cborx/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
cborx/types.py,sha256=yeX-WPZpZcU1RPpbGOj-ws-fcYLVIz80u0IhqzmX5pE,1419
|
|
8
|
+
cborx-0.1.1.dist-info/METADATA,sha256=GStVn2V_xuDfLMEgVlPw9Y-4NKvPY3vXcerw_PVK18Y,3390
|
|
9
|
+
cborx-0.1.1.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
10
|
+
cborx-0.1.1.dist-info/licenses/LICENSE,sha256=3Hnwsz5EXuTC9iTlGjzA171dKQtIVsgjFoF5DEMRl48,633
|
|
11
|
+
cborx-0.1.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
Copyright (c) 2026 Quad4
|
|
2
|
+
|
|
3
|
+
Permission to use, copy, modify, and/or distribute this software for any purpose
|
|
4
|
+
with or without fee is hereby granted.
|
|
5
|
+
|
|
6
|
+
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
|
7
|
+
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND
|
|
8
|
+
FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
|
9
|
+
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
|
10
|
+
LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
|
11
|
+
OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
|
12
|
+
PERFORMANCE OF THIS SOFTWARE.
|