macula-py 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.
macula_py/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Native Python client for the Macula mesh wire protocol."""
2
+
3
+ __all__ = ["blake3_hash", "bolt4", "cbor", "connection", "content", "frame", "identity", "manifest"]
@@ -0,0 +1,34 @@
1
+ """BLAKE3 content-addressing.
2
+
3
+ Ports the real algorithm ``macula_blake3_nif`` uses (a Rust NIF over the
4
+ ``blake3`` crate), NOT its pure-Erlang fallback path -- that fallback is
5
+ explicitly documented in its own source as "NOT cryptographically
6
+ equivalent to BLAKE3" (a SHA-256-based stand-in used only when the NIF
7
+ fails to load) and produces output that matches neither real BLAKE3 nor
8
+ plain SHA-256. Verified directly against the real NIF, not assumed
9
+ correct because the package name matches: ``blake3.blake3(b"macula")``
10
+ produces the identical 32-byte digest ``macula_blake3_nif:hash(<<"macula">>)``
11
+ does when run against the actual compiled NIF (see
12
+ ``tests/test_blake3_hash.py``'s golden vectors).
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import blake3 as _blake3
18
+
19
+ DIGEST_SIZE = 32
20
+
21
+
22
+ def hash_bytes(data: bytes) -> bytes:
23
+ """BLAKE3(`data`) -- a 32-byte digest."""
24
+ return _blake3.blake3(data).digest()
25
+
26
+
27
+ def hash_hex(data: bytes) -> str:
28
+ """BLAKE3(`data`), lowercase hex-encoded."""
29
+ return _blake3.blake3(data).hexdigest()
30
+
31
+
32
+ def verify(data: bytes, expected_hash: bytes) -> bool:
33
+ """Whether `data` hashes to `expected_hash` under BLAKE3."""
34
+ return hash_bytes(data) == expected_hash
macula_py/bolt4.py ADDED
@@ -0,0 +1,80 @@
1
+ """BOLT#4-style error taxonomy for CALL failures.
2
+
3
+ Ports the complete 17-entry table from ``macula_bolt4.erl`` (macula-io/macula,
4
+ src/peering/) -- adapted from Lightning Network's BOLT#4 onion-failure
5
+ codes. Every CALL ERROR frame carries one of these codes; `name` is
6
+ derived from `code` on the wire (see :mod:`macula_py.frame`'s ERROR builder),
7
+ never sent independently.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass
13
+
14
+ RetryPolicy = str # one of the RETRY_POLICIES values below, kept as plain str (no enum) to match this module's simple lookup-table shape
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class Bolt4Info:
19
+ code: int
20
+ name: str
21
+ retry: RetryPolicy
22
+
23
+
24
+ _TABLE: list[Bolt4Info] = [
25
+ Bolt4Info(0x00, "ok", "none"),
26
+ Bolt4Info(0x01, "unknown_next_peer", "different_path"),
27
+ Bolt4Info(0x02, "temporary_relay_failure", "same_path_after_backoff"),
28
+ Bolt4Info(0x03, "relay_disabled", "different_path"),
29
+ Bolt4Info(0x04, "node_not_found_at_target_relay", "caller_recompute_with_lookup"),
30
+ Bolt4Info(0x05, "target_realm_refused", "application"),
31
+ Bolt4Info(0x06, "loop_detected", "caller_recompute"),
32
+ Bolt4Info(0x07, "expiry_too_soon", "caller_extends_deadline"),
33
+ Bolt4Info(0x08, "upstream_congestion", "exponential_backoff"),
34
+ Bolt4Info(0x09, "invalid_path_header", "caller_recompute"),
35
+ Bolt4Info(0x0A, "crypto_puzzle_invalid", "crypto_drop"),
36
+ Bolt4Info(0x0B, "realm_not_authoritative_here", "caller_recompute_with_lookup"),
37
+ Bolt4Info(0x0C, "tombstoned", "application"),
38
+ Bolt4Info(0x0D, "payload_too_large", "application"),
39
+ Bolt4Info(0x0E, "signature_invalid", "crypto_drop"),
40
+ Bolt4Info(0x0F, "unknown_error", "log_and_caution"),
41
+ # A gated provider refused: the caller lacked a valid capability
42
+ # (UCAN) for this procedure. Not retryable as-is -- the caller must
43
+ # present valid authorization, so this is an application concern.
44
+ Bolt4Info(0x10, "unauthorized", "application"),
45
+ ]
46
+
47
+ BY_CODE: dict[int, Bolt4Info] = {entry.code: entry for entry in _TABLE}
48
+ BY_NAME: dict[str, Bolt4Info] = {entry.name: entry for entry in _TABLE}
49
+
50
+ # Convenience constants for the codes this SDK's own code actually raises/checks.
51
+ UNKNOWN_NEXT_PEER = BY_NAME["unknown_next_peer"].code
52
+ UNAUTHORIZED = BY_NAME["unauthorized"].code
53
+ UNKNOWN_ERROR = BY_NAME["unknown_error"].code
54
+ TEMPORARY_RELAY_FAILURE = BY_NAME["temporary_relay_failure"].code
55
+
56
+
57
+ class UnknownBolt4CodeError(Exception):
58
+ pass
59
+
60
+
61
+ def name_for_code(code: int) -> str:
62
+ entry = BY_CODE.get(code)
63
+ if entry is None:
64
+ raise UnknownBolt4CodeError(f"no BOLT#4 entry for code {code}")
65
+ return entry.name
66
+
67
+
68
+ def code_for_name(name: str) -> int:
69
+ entry = BY_NAME.get(name)
70
+ if entry is None:
71
+ raise UnknownBolt4CodeError(f"no BOLT#4 entry named {name!r}")
72
+ return entry.code
73
+
74
+
75
+ _NON_RETRYABLE = {"none", "application", "crypto_drop"}
76
+
77
+
78
+ def is_retryable(name_or_code: str | int) -> bool:
79
+ entry = BY_CODE[name_or_code] if isinstance(name_or_code, int) else BY_NAME[name_or_code]
80
+ return entry.retry not in _NON_RETRYABLE
macula_py/cbor.py ADDED
@@ -0,0 +1,304 @@
1
+ """Deterministic CBOR encoder/decoder.
2
+
3
+ Ports ``macula_record_cbor.erl`` (macula-io/macula, src/record/) exactly --
4
+ NOT a generic CBOR library (Python's own ``cbor2``/stdlib would diverge:
5
+ shortest-float-width canonicalization per RFC 8949 Appendix, boolean simple
6
+ values, indefinite-length items). Macula's wire format enforces its own,
7
+ narrower deterministic subset:
8
+
9
+ - Definite lengths only, smallest length-prefix encoding (RFC 8949 4.2.1).
10
+ - Map keys sorted by BYTEWISE order of their own encoded bytes, not by the
11
+ key's own natural ordering.
12
+ - Floats ALWAYS emitted as IEEE 754 binary64 (major 7, additional info 27)
13
+ -- never the shorter half/single forms, even when a value would round-trip
14
+ through them. Determinism requires one canonical width per value, not the
15
+ shortest one that happens to work.
16
+ - NO boolean simple values (CBOR major 7, additional info 20/21) exist on
17
+ this wire at all -- the station's own decoder has no clause for them.
18
+ Every sibling Macula SDK's convention is the same: encode true/false as
19
+ the integers 1/0. This module doesn't special-case Python `bool` at all;
20
+ it falls through the plain integer branch naturally (`bool` is an `int`
21
+ subclass in Python), which already produces exactly that encoding.
22
+
23
+ Python's own type system maps onto Macula's wire types with no wrapper
24
+ class needed, unlike Erlang (which needs a `{text, binary()}` tag to tell a
25
+ text string apart from a byte string -- both are just `binary()`
26
+ otherwise): `bytes`/`bytearray` -> byte string (major 2), `str` -> UTF-8
27
+ text string (major 3), each already a distinct native Python type.
28
+
29
+ Two deliberate, documented divergences from the Erlang reference, both
30
+ narrower (stricter) than it, never looser:
31
+
32
+ - Decoding an invalid-UTF-8 text string raises `DecodeError`. Erlang's own
33
+ decoder has no such check -- `{text, <<255>>}` decodes "successfully"
34
+ there with un-decodable bytes inside. Python has no equivalent of a
35
+ "text string that isn't really text," so this codec refuses it instead
36
+ of silently handing a caller something that looks like `str` but isn't.
37
+ - A CBOR map key that isn't hashable in Python (e.g. a decoded array or
38
+ map used as a key -- legal but rare in Erlang, where any term can be a
39
+ map key) raises `DecodeError` rather than crashing with `TypeError`.
40
+ Erlang maps have no such restriction; Python `dict` does. Relatedly,
41
+ Python's own `1 == 1.0` and `hash(1) == hash(1.0)` mean a map with both
42
+ integer key `1` and float key `1.0` collapses to one entry after
43
+ decoding, where Erlang would keep both distinct -- there is no way to
44
+ represent that map faithfully as a Python `dict`. No real Macula frame
45
+ is expected to construct either case (every real field name is an atom,
46
+ encoding as a scalar text-string key), so this is a documented
47
+ representational gap versus a Python `dict`'s own limits, not something
48
+ this codec works around.
49
+ """
50
+
51
+ from __future__ import annotations
52
+
53
+ import math
54
+ import struct
55
+
56
+ MAX_UINT64 = 0xFFFFFFFFFFFFFFFF
57
+ # Deliberately -2**64, not -2**63: mirrors macula_record_cbor.erl's own
58
+ # asymmetric bound exactly (`-(?MAX_UINT64 + 1)`), not a signed-64-bit
59
+ # integer's natural range. The name pairs with MAX_UINT64 (this wire's
60
+ # actual admissibility bound), not with a two's-complement width.
61
+ MIN_INT64 = -(MAX_UINT64 + 1)
62
+
63
+ # Bounds recursion for decode_one's own descent into nested arrays/maps --
64
+ # adversarial or corrupt input (e.g. ~1500 levels of single-element nested
65
+ # arrays) would otherwise blow Python's call stack with a bare
66
+ # RecursionError instead of the DecodeError this module's contract promises.
67
+ # Far beyond anything a real macula frame nests.
68
+ _MAX_DECODE_DEPTH = 64
69
+
70
+ Value = None | bool | int | float | bytes | bytearray | str | list | tuple | dict
71
+
72
+
73
+ class DecodeError(Exception):
74
+ """Raised when a buffer isn't a valid deterministic-CBOR encoding of a supported value."""
75
+
76
+
77
+ def is_encodable_int(n: int) -> bool:
78
+ """Can this integer be rendered as major 0 / major 1 on this wire?
79
+
80
+ Exported so a caller that must decide admissibility BEFORE encoding
81
+ (payload validation) can ask rather than restate the bound.
82
+ """
83
+ # isinstance, not a bare comparison: a bool passes (it legitimately
84
+ # encodes via the plain-integer path, see _encode_into), but a float
85
+ # like 1.5 must NOT report itself encodable here even though it
86
+ # numerically satisfies the bound -- floats take a completely
87
+ # different wire encoding (always binary64), never major 0/1.
88
+ return isinstance(n, int) and MIN_INT64 <= n <= MAX_UINT64
89
+
90
+
91
+ def encode(value: Value) -> bytes:
92
+ """Encode `value` as deterministic CBOR. Raises TypeError/ValueError for anything this wire can't carry."""
93
+ out = bytearray()
94
+ _encode_into(value, out)
95
+ return bytes(out)
96
+
97
+
98
+ def _encode_into(value: Value, out: bytearray) -> None:
99
+ if value is None:
100
+ out.append(0xF6) # major 7, simple value 22 (null)
101
+ elif isinstance(value, int):
102
+ # `bool` is an `int` subclass in Python, so True/False fall
103
+ # through here too -- isinstance(True, int) is True, and
104
+ # True >= 0 / int(True) == 1 both hold. That is deliberate: this
105
+ # wire has no CBOR-boolean simple value at all (see module doc),
106
+ # and encoding True/False as the plain integers 1/0 is exactly
107
+ # the convention every sibling Macula SDK follows. Do not add a
108
+ # dedicated `isinstance(value, bool)` branch above this one.
109
+ if not is_encodable_int(value):
110
+ raise ValueError(f"integer {value} does not fit this wire's 64-bit signed/unsigned range")
111
+ if value >= 0:
112
+ _encode_head_into(0, value, out)
113
+ else:
114
+ _encode_head_into(1, -1 - value, out)
115
+ elif isinstance(value, float):
116
+ # Erlang arithmetic structurally cannot produce NaN or an
117
+ # infinity (it raises badarith instead), so macula_record_cbor.erl
118
+ # never had to reject them -- but Python can construct both
119
+ # directly (float('nan')/float('inf')), and the station's decoder
120
+ # has no clause that accepts them (confirmed live: sending either
121
+ # gets a bad_frame, not a value back). Reject here rather than
122
+ # silently emitting bytes the peer will only ever drop.
123
+ if not math.isfinite(value):
124
+ raise ValueError(f"{value!r} has no representation on this wire (Erlang floats are always finite)")
125
+ # ALWAYS binary64 -- see module doc. struct.pack('>d', ...) gives
126
+ # the big-endian IEEE 754 binary64 bytes RFC 8949 major 7 wants.
127
+ out.append((7 << 5) | 27)
128
+ out.extend(struct.pack(">d", value))
129
+ elif isinstance(value, (bytes, bytearray)):
130
+ _encode_head_into(2, len(value), out)
131
+ out.extend(value)
132
+ elif isinstance(value, str):
133
+ encoded = value.encode("utf-8")
134
+ _encode_head_into(3, len(encoded), out)
135
+ out.extend(encoded)
136
+ elif isinstance(value, (list, tuple)):
137
+ _encode_head_into(4, len(value), out)
138
+ for item in value:
139
+ _encode_into(item, out)
140
+ elif isinstance(value, dict):
141
+ _encode_map_into(value, out)
142
+ else:
143
+ raise TypeError(f"value of type {type(value).__name__} cannot be encoded on this wire")
144
+
145
+
146
+ def _encode_map_into(value: dict, out: bytearray) -> None:
147
+ # Encode each key/value independently, then sort pairs by the KEY'S
148
+ # OWN ENCODED BYTES (bytewise comparison) -- exactly what the
149
+ # deterministic-CBOR spec requires, matching macula_record_cbor.erl's
150
+ # own `lists:sort/1` over `{encode(K), encode(V)}` pairs.
151
+ pairs = [(encode(k), encode(v)) for k, v in value.items()]
152
+ pairs.sort(key=lambda pair: pair[0])
153
+ _encode_head_into(5, len(value), out)
154
+ for k_bytes, v_bytes in pairs:
155
+ out.extend(k_bytes)
156
+ out.extend(v_bytes)
157
+
158
+
159
+ def _encode_head_into(major_type: int, n: int, out: bytearray) -> None:
160
+ mt = major_type << 5
161
+ if n <= 23:
162
+ out.append(mt | n)
163
+ elif n <= 0xFF:
164
+ out.append(mt | 24)
165
+ out.append(n)
166
+ elif n <= 0xFFFF:
167
+ out.append(mt | 25)
168
+ out.extend(struct.pack(">H", n))
169
+ elif n <= 0xFFFFFFFF:
170
+ out.append(mt | 26)
171
+ out.extend(struct.pack(">I", n))
172
+ elif n <= MAX_UINT64:
173
+ out.append(mt | 27)
174
+ out.extend(struct.pack(">Q", n))
175
+ else:
176
+ raise ValueError(f"length/count {n} exceeds 64 bits")
177
+
178
+
179
+ def decode(data: bytes) -> Value:
180
+ """Decode a single deterministic-CBOR value. The ENTIRE buffer must be one value -- trailing bytes raise DecodeError."""
181
+ value, consumed = decode_one(data)
182
+ if consumed != len(data):
183
+ raise DecodeError(f"{len(data) - consumed} trailing byte(s) after a complete value")
184
+ return value
185
+
186
+
187
+ def decode_one(data: bytes, offset: int = 0, _depth: int = 0) -> tuple[Value, int]:
188
+ """Decode exactly one value starting at `offset`. Returns (value, new_offset) -- new_offset is where the NEXT value would start, not a count.
189
+
190
+ Exposed (not just `decode`) so a frame-stream reader can parse one
191
+ length-prefixed frame's body without first knowing exactly where it
192
+ ends. `_depth` is an internal recursion guard, not part of the public
193
+ signature -- callers should never pass it.
194
+ """
195
+ if _depth > _MAX_DECODE_DEPTH:
196
+ raise DecodeError(f"nesting exceeds {_MAX_DECODE_DEPTH} levels")
197
+ if offset >= len(data):
198
+ raise DecodeError("unexpected end of buffer")
199
+ first = data[offset]
200
+ major = first >> 5
201
+ ai = first & 0x1F
202
+ offset += 1
203
+
204
+ if major == 7:
205
+ if ai == 22:
206
+ return None, offset
207
+ if ai == 25:
208
+ return _decode_half_float(data, offset)
209
+ if ai == 26:
210
+ return _decode_struct(data, offset, ">f", 4)
211
+ if ai == 27:
212
+ return _decode_struct(data, offset, ">d", 8)
213
+ raise DecodeError(f"unsupported major-7 additional info {ai} (no boolean/undefined/indefinite on this wire)")
214
+
215
+ count, offset = _decode_count(ai, data, offset)
216
+
217
+ if major == 0:
218
+ return count, offset
219
+ if major == 1:
220
+ return -1 - count, offset
221
+ if major == 2:
222
+ _require(data, offset, count)
223
+ return bytes(data[offset : offset + count]), offset + count
224
+ if major == 3:
225
+ _require(data, offset, count)
226
+ raw = data[offset : offset + count]
227
+ try:
228
+ text = raw.decode("utf-8")
229
+ except UnicodeDecodeError as e:
230
+ raise DecodeError(f"text string is not valid UTF-8: {e}") from e
231
+ return text, offset + count
232
+ if major == 4:
233
+ items = []
234
+ for _ in range(count):
235
+ item, offset = decode_one(data, offset, _depth + 1)
236
+ items.append(item)
237
+ return items, offset
238
+ if major == 5:
239
+ result: dict = {}
240
+ for _ in range(count):
241
+ key, offset = decode_one(data, offset, _depth + 1)
242
+ val, offset = decode_one(data, offset, _depth + 1)
243
+ try:
244
+ result[key] = val
245
+ except TypeError as e:
246
+ # A decoded array or map used as a map key -- legal in
247
+ # Erlang (any term can be a map key), unrepresentable as a
248
+ # Python dict key. See module doc's divergence note.
249
+ raise DecodeError(f"map key of type {type(key).__name__} is not usable as a Python dict key") from e
250
+ return result, offset
251
+
252
+ raise DecodeError(f"unsupported major type {major}")
253
+
254
+
255
+ def _decode_count(ai: int, data: bytes, offset: int) -> tuple[int, int]:
256
+ if ai <= 23:
257
+ return ai, offset
258
+ if ai == 24:
259
+ _require(data, offset, 1)
260
+ return data[offset], offset + 1
261
+ if ai == 25:
262
+ _require(data, offset, 2)
263
+ return struct.unpack_from(">H", data, offset)[0], offset + 2
264
+ if ai == 26:
265
+ _require(data, offset, 4)
266
+ return struct.unpack_from(">I", data, offset)[0], offset + 4
267
+ if ai == 27:
268
+ _require(data, offset, 8)
269
+ return struct.unpack_from(">Q", data, offset)[0], offset + 8
270
+ raise DecodeError(f"unsupported additional info {ai} (indefinite-length items are not on this wire)")
271
+
272
+
273
+ def _decode_struct(data: bytes, offset: int, fmt: str, size: int) -> tuple[float, int]:
274
+ _require(data, offset, size)
275
+ value = struct.unpack_from(fmt, data, offset)[0]
276
+ if not math.isfinite(value):
277
+ # Symmetric with the encode-side rejection: Erlang can never
278
+ # produce or accept a NaN/infinity float, so a peer claiming to
279
+ # send one is sending something no real macula peer ever would.
280
+ raise DecodeError(f"{value!r} has no representation this wire's own encoder could have produced")
281
+ return value, offset + size
282
+
283
+
284
+ def _decode_half_float(data: bytes, offset: int) -> tuple[float, int]:
285
+ # We only ever EMIT binary64, but a conforming peer may send the
286
+ # shorter forms, so this codec accepts them on decode. Python's
287
+ # struct module has no native IEEE 754 binary16, so unpack by hand.
288
+ _require(data, offset, 2)
289
+ bits = struct.unpack_from(">H", data, offset)[0]
290
+ sign = -1.0 if (bits & 0x8000) else 1.0
291
+ exponent = (bits >> 10) & 0x1F
292
+ fraction = bits & 0x3FF
293
+ if exponent == 0:
294
+ value = sign * (2.0**-14) * (fraction / 1024.0)
295
+ elif exponent < 31:
296
+ value = sign * (2.0 ** (exponent - 15)) * (1 + fraction / 1024.0)
297
+ else:
298
+ raise DecodeError("half-float NaN/infinity has no representation this codec accepts")
299
+ return value, offset + 2
300
+
301
+
302
+ def _require(data: bytes, offset: int, n: int) -> None:
303
+ if offset + n > len(data):
304
+ raise DecodeError("unexpected end of buffer")