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 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
+ [![CI](https://github.com/Quad4-Software/cborx/actions/workflows/ci.yml/badge.svg)](https://github.com/Quad4-Software/cborx/actions/workflows/ci.yml)
25
+ [![CodeQL](https://github.com/Quad4-Software/cborx/actions/workflows/codeql.yml/badge.svg)](https://github.com/Quad4-Software/cborx/actions/workflows/codeql.yml)
26
+ [![OpenSSF Scorecard](https://api.securityscorecards.dev/projects/github.com/Quad4-Software/cborx/badge)](https://securityscorecards.dev/viewer/?uri=github.com/Quad4-Software/cborx)
27
+ [![PyPI](https://img.shields.io/pypi/v/cborx)](https://pypi.org/project/cborx/)
28
+ [![License: 0BSD](https://img.shields.io/badge/license-0BSD-blue)](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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.