macula-py 0.2.0__py3-none-win_amd64.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,88 @@
1
+ """macula-py: a Python node on the macula 12 mesh, over macula-go's C ABI.
2
+
3
+ key = await NodeKey.generate("pq_hybrid")
4
+ async with await Pool.connect(key, [Seed(host, 4433, station_id)],
5
+ realm_trust={realm: realm_key}) as pool:
6
+ print(await pool.call(realm, "mcl-echo/echo", "hello"))
7
+ """
8
+
9
+ from macula_py._wire import (
10
+ DEFAULT_CALL_TIMEOUT_MS,
11
+ DEFAULT_CONTENT_TIMEOUT_MS,
12
+ AlreadyAnsweredError,
13
+ ClosedError,
14
+ ContentUnavailableError,
15
+ InvalidArgumentError,
16
+ InvalidHandleError,
17
+ MaculaError,
18
+ MaculaTimeoutError,
19
+ NotFoundError,
20
+ NotSharedError,
21
+ ProviderError,
22
+ RefusedError,
23
+ RelayError,
24
+ StreamError,
25
+ )
26
+ from macula_py.key import NodeKey, Profile
27
+ from macula_py.pool import (
28
+ DhtRecord,
29
+ Event,
30
+ FoundRecords,
31
+ LinkStatus,
32
+ Pool,
33
+ PoolEvent,
34
+ Provider,
35
+ RecordType,
36
+ Seed,
37
+ Served,
38
+ Subscription,
39
+ )
40
+ from macula_py.stream import (
41
+ Request,
42
+ Stream,
43
+ StreamData,
44
+ StreamEnd,
45
+ StreamEof,
46
+ StreamFrame,
47
+ StreamMode,
48
+ StreamReply,
49
+ )
50
+
51
+ __all__ = [
52
+ "DEFAULT_CALL_TIMEOUT_MS",
53
+ "DEFAULT_CONTENT_TIMEOUT_MS",
54
+ "AlreadyAnsweredError",
55
+ "ClosedError",
56
+ "ContentUnavailableError",
57
+ "DhtRecord",
58
+ "Event",
59
+ "FoundRecords",
60
+ "InvalidArgumentError",
61
+ "InvalidHandleError",
62
+ "LinkStatus",
63
+ "MaculaError",
64
+ "MaculaTimeoutError",
65
+ "NodeKey",
66
+ "NotFoundError",
67
+ "NotSharedError",
68
+ "Pool",
69
+ "PoolEvent",
70
+ "Profile",
71
+ "Provider",
72
+ "ProviderError",
73
+ "RecordType",
74
+ "RefusedError",
75
+ "RelayError",
76
+ "Request",
77
+ "Seed",
78
+ "Served",
79
+ "Stream",
80
+ "StreamData",
81
+ "StreamEnd",
82
+ "StreamEof",
83
+ "StreamError",
84
+ "StreamFrame",
85
+ "StreamMode",
86
+ "StreamReply",
87
+ "Subscription",
88
+ ]
macula_py/_abi.py ADDED
@@ -0,0 +1,91 @@
1
+ """macula-go's C ABI (abi/macula.h), declared for ctypes.
2
+
3
+ Types are written as the header writes them, so tests/test_abi_declarations.py
4
+ can hold this table to the header function for function; _native maps them to
5
+ ctypes. Returned strings and byte buffers are declared as pointers (never
6
+ c_char_p) so they can be freed with macula_free_string / macula_free_bytes.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ ABI_VERSION = 1
12
+
13
+ H = "macula_handle"
14
+ ERR = "char**"
15
+ REALM = "const uint8_t*"
16
+
17
+ FUNCTIONS: dict[str, tuple[str, list[str]]] = {
18
+ # The library
19
+ "macula_abi_version": ("int32_t", []),
20
+ "macula_free_string": ("void", ["char*"]),
21
+ "macula_free_bytes": ("void", ["uint8_t*"]),
22
+ # Cancellation
23
+ "macula_cancel_new": (H, []),
24
+ "macula_cancel": ("void", [H]),
25
+ "macula_cancel_free": ("void", [H]),
26
+ # Node keys
27
+ "macula_key_generate": (H, ["const char*", H, ERR]),
28
+ "macula_key_load": (H, ["const char*", "const char*", ERR]),
29
+ "macula_key_load_or_create": (H, ["const char*", "const char*", H, ERR]),
30
+ "macula_key_save": ("void", [H, "const char*", ERR]),
31
+ "macula_key_node_id": ("void", [H, "uint8_t*", ERR]),
32
+ "macula_key_public_key": ("uint8_t*", [H, "size_t*", ERR]),
33
+ "macula_key_profile": ("char*", [H, ERR]),
34
+ "macula_key_sign": ("uint8_t*", [H, "const uint8_t*", "size_t", "size_t*", ERR]),
35
+ "macula_verify": (
36
+ "int32_t",
37
+ ["const uint8_t*", "size_t", "const uint8_t*", "size_t", "const uint8_t*", "size_t", "const char*", ERR],
38
+ ),
39
+ "macula_key_free": ("void", [H]),
40
+ # Pool
41
+ "macula_pool_connect": (H, [H, "const char*", "const char*", H, ERR]),
42
+ "macula_pool_close": ("void", [H]),
43
+ "macula_pool_node_id": ("void", [H, "uint8_t*", ERR]),
44
+ "macula_pool_status": ("char*", [H, ERR]),
45
+ "macula_pool_events_next": ("char*", [H, "int64_t", H, "int32_t*", ERR]),
46
+ # Calls
47
+ "macula_pool_call": ("char*", [H, REALM, "const char*", "const char*", "const uint8_t*", "int64_t", H, ERR]),
48
+ "macula_pool_providers": ("char*", [H, REALM, "const char*", "int64_t", H, ERR]),
49
+ # Publish / subscribe
50
+ "macula_pool_publish": ("void", [H, REALM, "const char*", "const char*", "int64_t", ERR]),
51
+ "macula_pool_subscribe": (H, [H, REALM, "const char*", ERR]),
52
+ "macula_subscription_next": ("char*", [H, "int64_t", H, "int32_t*", ERR]),
53
+ "macula_subscription_dropped": ("uint64_t", [H, ERR]),
54
+ "macula_subscription_stop": ("void", [H]),
55
+ # Serving
56
+ "macula_pool_serve": (H, [H, REALM, "const char*", ERR]),
57
+ "macula_pool_serve_stream": (H, [H, REALM, "const char*", "int32_t", ERR]),
58
+ "macula_served_next": ("char*", [H, "int64_t", H, "macula_handle*", "int32_t*", ERR]),
59
+ "macula_pending_reply": ("void", [H, "const char*", ERR]),
60
+ "macula_pending_error": ("void", [H, "const char*", ERR]),
61
+ "macula_served_stop": ("void", [H, ERR]),
62
+ # Streams
63
+ "macula_pool_open_stream": (
64
+ H,
65
+ [H, REALM, "const char*", "int32_t", "const char*", "const uint8_t*", "int64_t", "int64_t", H, ERR],
66
+ ),
67
+ "macula_stream_request": ("char*", [H, ERR]),
68
+ "macula_stream_send_bytes": ("void", [H, "const uint8_t*", "size_t", ERR]),
69
+ "macula_stream_send_json": ("void", [H, "const char*", ERR]),
70
+ "macula_stream_close_send": ("void", [H, ERR]),
71
+ "macula_stream_reply": ("void", [H, "const char*", ERR]),
72
+ "macula_stream_abort": ("void", [H, "const char*", "const char*", ERR]),
73
+ "macula_stream_close": ("void", [H, ERR]),
74
+ "macula_stream_recv": ("char*", [H, "int64_t", H, ERR]),
75
+ "macula_stream_free": ("void", [H]),
76
+ # Content
77
+ "macula_pool_share_content": (
78
+ "void",
79
+ [H, REALM, "const uint8_t*", "size_t", "const char*", "int64_t", H, "uint8_t*", ERR],
80
+ ),
81
+ "macula_pool_unshare_content": ("void", [H, REALM, "const uint8_t*", "int64_t", H, ERR]),
82
+ "macula_pool_get_content": (
83
+ "uint8_t*",
84
+ [H, REALM, "const uint8_t*", "const char*", "int64_t", H, "size_t*", ERR],
85
+ ),
86
+ # DHT records
87
+ "macula_pool_find_record": ("char*", [H, "const uint8_t*", "int64_t", H, ERR]),
88
+ "macula_pool_find_records": ("char*", [H, "const uint8_t*", "int64_t", H, ERR]),
89
+ "macula_pool_find_records_by_type": ("char*", [H, "int32_t", "int64_t", H, ERR]),
90
+ "macula_pool_put_record": ("void", [H, "const uint8_t*", "size_t", "int64_t", H, ERR]),
91
+ }
macula_py/_blocking.py ADDED
@@ -0,0 +1,123 @@
1
+ """Running blocking native calls from asyncio.
2
+
3
+ Every native call runs on a daemon thread of its own (ctypes releases the
4
+ GIL for its duration), never on asyncio's default executor: inbox waits
5
+ (served calls, subscriptions, stream receives) block for as long as their
6
+ source lives, and a bounded shared pool full of them would starve every
7
+ other call, including the cancellations meant to end them.
8
+
9
+ run_blocking gives the call a cancel token of its own. Cancelling the
10
+ awaiting task cancels the token, which ends the native call early instead of
11
+ waiting out its timeout, and returns at once; the call's thread frees the
12
+ token once the native call has returned. A result nobody awaits any more (it
13
+ raced the cancellation, or landed in the same loop iteration) is handed to
14
+ discard, so a handle it carries is released rather than leaked; an error is
15
+ taken and dropped, however many times the task was cancelled.
16
+
17
+ run_native runs a short native call that takes no token.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import asyncio
23
+ import threading
24
+ from typing import Callable, Protocol, TypeVar
25
+
26
+ T = TypeVar("T")
27
+
28
+
29
+ class Cancels(Protocol):
30
+ def new(self) -> int: ...
31
+ def cancel(self, h: int) -> None: ...
32
+ def free(self, h: int) -> None: ...
33
+
34
+
35
+ class NativeCancelled(Exception):
36
+ """The native call ended with the error kind "cancelled"."""
37
+
38
+
39
+ def _settle(future: asyncio.Future, result: object, error: BaseException | None) -> None:
40
+ if future.done():
41
+ return
42
+ if error is not None:
43
+ future.set_exception(error)
44
+ else:
45
+ future.set_result(result)
46
+
47
+
48
+ def _start(work: Callable[[], T], name: str) -> asyncio.Future:
49
+ """work() on a thread of its own; its outcome settles the returned future."""
50
+ loop = asyncio.get_running_loop()
51
+ future: asyncio.Future = loop.create_future()
52
+ # Consume the outcome up front, so an outcome nobody awaits any more (the
53
+ # task was cancelled) is never reported as unretrieved.
54
+ future.add_done_callback(lambda f: f.cancelled() or f.exception())
55
+
56
+ def run() -> None:
57
+ try:
58
+ outcome: tuple[object, BaseException | None] = (work(), None)
59
+ except BaseException as e: # handed to the awaiting task
60
+ outcome = (None, e)
61
+ try:
62
+ loop.call_soon_threadsafe(_settle, future, *outcome)
63
+ except RuntimeError:
64
+ pass # the loop closed; nobody is waiting
65
+
66
+ threading.Thread(target=run, name=name, daemon=True).start()
67
+ return future
68
+
69
+
70
+ async def run_native(call: Callable[[], T]) -> T:
71
+ """call() on a thread of its own. Not cancellable: cancelling the task
72
+ stops the waiting, and the native call runs to its end."""
73
+ return await asyncio.shield(_start(call, "macula-py native"))
74
+
75
+
76
+ def _discard_on_result(future: asyncio.Future, discard: Callable[[T], None]) -> None:
77
+ def hand_over(f: asyncio.Future) -> None:
78
+ if f.cancelled() or f.exception() is not None:
79
+ return
80
+ threading.Thread(target=discard, args=(f.result(),), name="macula-py discard", daemon=True).start()
81
+
82
+ future.add_done_callback(hand_over)
83
+
84
+
85
+ async def run_blocking(
86
+ call: Callable[[int], T], cancels: Cancels, discard: Callable[[T], None] | None = None
87
+ ) -> T:
88
+ """call(cancel_token) on a thread of its own, cancelled with the task.
89
+ discard(result) releases a result that arrives when nobody awaits it."""
90
+ cancelled = threading.Event()
91
+ token: list[int] = []
92
+
93
+ def work() -> T:
94
+ h = cancels.new()
95
+ token.append(h)
96
+ try:
97
+ if cancelled.is_set():
98
+ cancels.cancel(h)
99
+ return call(h)
100
+ except NativeCancelled as e:
101
+ if cancelled.is_set():
102
+ raise
103
+ # "cancelled" without our token cancelled is the native side's
104
+ # own failure, not a cancellation this task asked for.
105
+ from macula_py._wire import MaculaError
106
+
107
+ raise MaculaError(f"macula-py: {e}") from None
108
+ finally:
109
+ cancels.free(h)
110
+
111
+ future = _start(work, "macula-py blocking")
112
+ try:
113
+ return await asyncio.shield(future)
114
+ except NativeCancelled:
115
+ raise asyncio.CancelledError() from None
116
+ except asyncio.CancelledError:
117
+ if not future.done():
118
+ cancelled.set()
119
+ if token:
120
+ cancels.cancel(token[0])
121
+ if discard is not None:
122
+ _discard_on_result(future, discard)
123
+ raise
macula_py/_library.py ADDED
@@ -0,0 +1,38 @@
1
+ """Where macula-go's shared C ABI library is: the copy the platform wheel
2
+ carries in macula_py/_native/, or the file MACULA_LIBRARY_PATH names."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import os
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ _PACKAGE_NATIVE_DIR = Path(__file__).parent / "_native"
11
+
12
+
13
+ def library_file_name(platform: str = sys.platform) -> str:
14
+ """The library's file name on platform (a sys.platform value)."""
15
+ if platform.startswith("linux"):
16
+ return "libmacula.so"
17
+ if platform == "darwin":
18
+ return "libmacula.dylib"
19
+ if platform == "win32":
20
+ return "macula.dll"
21
+ raise OSError(f"macula-py: no macula-go library build for platform {platform!r}")
22
+
23
+
24
+ def library_path(package_dir: Path = _PACKAGE_NATIVE_DIR) -> Path:
25
+ """The library file to load."""
26
+ override = os.environ.get("MACULA_LIBRARY_PATH")
27
+ if override:
28
+ path = Path(override)
29
+ if not path.is_file():
30
+ raise OSError(f"macula-py: MACULA_LIBRARY_PATH names {path}, which is not a file")
31
+ return path
32
+ path = package_dir / library_file_name()
33
+ if not path.is_file():
34
+ raise OSError(
35
+ f"macula-py: {path} is missing: there is no macula-py wheel for this platform "
36
+ "installed. Build macula-go's cabi as a shared library and set MACULA_LIBRARY_PATH to it."
37
+ )
38
+ return path
Binary file
macula_py/_native.py ADDED
@@ -0,0 +1,115 @@
1
+ """macula-go's shared library, loaded once with ctypes and declared from
2
+ _abi. A library built for another ABI version is refused on load.
3
+
4
+ invoke() calls one function with the trailing err_out the contract gives
5
+ every fallible call, and raises the error its JSON names. Strings and byte
6
+ buffers the library returns are copied and freed here, so nothing above
7
+ this module touches library memory.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import ctypes
13
+ import threading
14
+ from typing import Any
15
+
16
+ from macula_py._abi import ABI_VERSION, FUNCTIONS
17
+ from macula_py._library import library_path
18
+ from macula_py._wire import MaculaError, native_error
19
+
20
+ _C_TYPES: dict[str, Any] = {
21
+ "void": None,
22
+ "int32_t": ctypes.c_int32,
23
+ "int64_t": ctypes.c_int64,
24
+ "uint64_t": ctypes.c_uint64,
25
+ "size_t": ctypes.c_size_t,
26
+ "macula_handle": ctypes.c_size_t,
27
+ # Returned memory stays a raw address, to be copied and freed.
28
+ "char*": ctypes.c_void_p,
29
+ "uint8_t*": ctypes.c_void_p,
30
+ "const char*": ctypes.c_char_p,
31
+ "const uint8_t*": ctypes.c_char_p,
32
+ "size_t*": ctypes.POINTER(ctypes.c_size_t),
33
+ "int32_t*": ctypes.POINTER(ctypes.c_int32),
34
+ "macula_handle*": ctypes.POINTER(ctypes.c_size_t),
35
+ "char**": ctypes.POINTER(ctypes.c_void_p),
36
+ }
37
+
38
+
39
+ class Native:
40
+ """The loaded library."""
41
+
42
+ def __init__(self, path: str) -> None:
43
+ self.path = path
44
+ self.lib = ctypes.CDLL(path)
45
+ for name, (returns, parameters) in FUNCTIONS.items():
46
+ function = getattr(self.lib, name)
47
+ function.restype = _C_TYPES[returns]
48
+ function.argtypes = [_C_TYPES[p] for p in parameters]
49
+ version = self.lib.macula_abi_version()
50
+ if version != ABI_VERSION:
51
+ raise MaculaError(
52
+ f"macula-py: {path} is macula C ABI {version}, and this macula-py binds ABI {ABI_VERSION}"
53
+ )
54
+ self.cancels = _Cancels(self.lib)
55
+
56
+ def invoke(self, name: str, *args: Any) -> Any:
57
+ """name(*args, &err_out), raising the error err_out names."""
58
+ err = ctypes.c_void_p(None)
59
+ result = getattr(self.lib, name)(*args, ctypes.byref(err))
60
+ if err.value:
61
+ text = ctypes.string_at(err.value).decode("utf-8", "replace")
62
+ self.lib.macula_free_string(err.value)
63
+ raise native_error(text)
64
+ return result
65
+
66
+ def call(self, name: str, *args: Any) -> Any:
67
+ """name(*args) for a function with no err_out."""
68
+ return getattr(self.lib, name)(*args)
69
+
70
+ def take_string(self, address: int | None) -> str | None:
71
+ """The string at address, freed; None for NULL."""
72
+ if not address:
73
+ return None
74
+ try:
75
+ return ctypes.string_at(address).decode("utf-8")
76
+ finally:
77
+ self.lib.macula_free_string(address)
78
+
79
+ def take_bytes(self, address: int | None, length: int) -> bytes:
80
+ """length bytes at address, freed; b"" for NULL."""
81
+ if not address:
82
+ return b""
83
+ try:
84
+ return ctypes.string_at(address, length)
85
+ finally:
86
+ self.lib.macula_free_bytes(address)
87
+
88
+
89
+ class _Cancels:
90
+ """Cancel tokens, as _blocking.run_blocking takes them."""
91
+
92
+ def __init__(self, lib: ctypes.CDLL) -> None:
93
+ self._lib = lib
94
+
95
+ def new(self) -> int:
96
+ return self._lib.macula_cancel_new()
97
+
98
+ def cancel(self, h: int) -> None:
99
+ self._lib.macula_cancel(h)
100
+
101
+ def free(self, h: int) -> None:
102
+ self._lib.macula_cancel_free(h)
103
+
104
+
105
+ _native: Native | None = None
106
+ _lock = threading.Lock()
107
+
108
+
109
+ def native() -> Native:
110
+ """The library, loaded on first use."""
111
+ global _native
112
+ with _lock:
113
+ if _native is None:
114
+ _native = Native(str(library_path()))
115
+ return _native
macula_py/_wire.py ADDED
@@ -0,0 +1,213 @@
1
+ """The shapes every part of the API shares: payloads, ids, and the errors a
2
+ call, a stream or a fetch ends with.
3
+
4
+ A payload is what macula's wire CBOR carries: str, int (within int64), float,
5
+ None, bytes, lists (or tuples) and dicts with str keys. There is no boolean on the wire;
6
+ encode true/false as 1/0 yourself. Python's bool is an int, so a bool
7
+ reaching the wire is refused here rather than silently sent as JSON true.
8
+
9
+ Bytes go in as ``bytes`` and come out as ``bytes``, so a value received can
10
+ be sent back unchanged.
11
+
12
+ Errors: the native layer reports each failure as JSON with a fixed ``kind``
13
+ (macula-go's cabi/CONTRACT.md "Errors"); native_error maps each kind to its
14
+ class here.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import base64
20
+ import json
21
+ import re
22
+ from typing import Union
23
+
24
+ from macula_py._blocking import NativeCancelled
25
+
26
+ JsonValue = Union[str, int, float, None, bytes, list["JsonValue"], tuple, dict[str, "JsonValue"]]
27
+ Id = Union[str, bytes]
28
+ Mcid = Union[str, bytes]
29
+
30
+ DEFAULT_CALL_TIMEOUT_MS = 5_000
31
+ DEFAULT_CONTENT_TIMEOUT_MS = 300_000
32
+
33
+
34
+ class MaculaError(Exception):
35
+ """A failure the native layer reported (kind ``failed``), and the base of
36
+ every macula-py error."""
37
+
38
+
39
+ class MaculaTimeoutError(MaculaError, TimeoutError):
40
+ """The call's timeout ran out."""
41
+
42
+
43
+ class InvalidArgumentError(MaculaError, ValueError):
44
+ """A malformed id, JSON, profile, mode, size or payload."""
45
+
46
+
47
+ class InvalidHandleError(MaculaError):
48
+ """A handle this process does not hold (freed, closed, or of another kind)."""
49
+
50
+
51
+ class NotFoundError(MaculaError):
52
+ """No DHT record under that key."""
53
+
54
+
55
+ class AlreadyAnsweredError(MaculaError):
56
+ """A served call was answered already."""
57
+
58
+
59
+ class ClosedError(MaculaError):
60
+ """The pool, subscription, served procedure or stream has ended."""
61
+
62
+
63
+ class RefusedError(MaculaError):
64
+ """The network refused it: an advertisement, an admission, a key."""
65
+
66
+
67
+ class ProviderError(MaculaError):
68
+ """A provider's own ERROR for a call: ``handler_error`` with the handler's
69
+ text, ``temporary_relay_failure`` for a handler that crashed,
70
+ ``unknown_next_peer`` for a procedure it does not serve, or an admission
71
+ refusal (``expired``, ``request_copy``, ``caller_quota``, ...)."""
72
+
73
+ def __init__(self, code: str, detail: str) -> None:
74
+ super().__init__(f"macula-py: the provider answered {code}" + (f": {detail}" if detail else ""))
75
+ self.code = code
76
+ self.detail = detail
77
+
78
+
79
+ class RelayError(MaculaError):
80
+ """A station's signed relay error for a call: it could not relay it."""
81
+
82
+ def __init__(self, code: str) -> None:
83
+ super().__init__(f"macula-py: the station could not relay the call: {code}")
84
+ self.code = code
85
+
86
+
87
+ class StreamError(MaculaError):
88
+ """A stream ended by a STREAM_ERROR: the peer's, a relay error from the
89
+ station (``relay``), or this side's own (e.g. ``resource_exhausted``)."""
90
+
91
+ def __init__(self, code: str, detail: str, relay: bool) -> None:
92
+ super().__init__(f"macula-py: stream error {code}" + (f": {detail}" if detail else ""))
93
+ self.code = code
94
+ self.detail = detail
95
+ self.relay = relay
96
+
97
+
98
+ class NotSharedError(MaculaError):
99
+ """Content no node announces in the realm."""
100
+
101
+
102
+ class ContentUnavailableError(MaculaError):
103
+ """Content every announcing node failed to give; ``failures`` names each
104
+ failure (unreachable, not the content asked for, over the bounds, ...)."""
105
+
106
+ def __init__(self, failures: list[str]) -> None:
107
+ super().__init__("macula-py: no sharer gave the content: " + "; ".join(failures))
108
+ self.failures = failures
109
+
110
+
111
+ _INT64_MIN, _INT64_MAX = -(2**63), 2**63 - 1
112
+
113
+
114
+ def _to_wire(value: object) -> object:
115
+ if isinstance(value, bool):
116
+ raise TypeError("macula-py: no boolean on the macula wire; encode true/false as 1/0")
117
+ if isinstance(value, int):
118
+ if not _INT64_MIN <= value <= _INT64_MAX:
119
+ raise ValueError(f"macula-py: {value} is outside int64, the integers macula's wire carries")
120
+ return value
121
+ if value is None or isinstance(value, (str, float)):
122
+ return value
123
+ if isinstance(value, (bytes, bytearray, memoryview)):
124
+ return {"$bytes": base64.b64encode(bytes(value)).decode("ascii")}
125
+ if isinstance(value, (list, tuple)):
126
+ return [_to_wire(v) for v in value]
127
+ if isinstance(value, dict):
128
+ out = {}
129
+ for k, v in value.items():
130
+ if not isinstance(k, str):
131
+ raise TypeError(f"macula-py: map keys must be str, got {type(k).__name__}")
132
+ out[k] = _to_wire(v)
133
+ return out
134
+ raise TypeError(f"macula-py: a {type(value).__name__} has no macula wire form")
135
+
136
+
137
+ def encode_payload(value: object) -> str:
138
+ """value as the JSON text the native layer converts to wire CBOR."""
139
+ return json.dumps(_to_wire(value), separators=(",", ":"), allow_nan=False)
140
+
141
+
142
+ def _from_wire(obj: dict) -> object:
143
+ if len(obj) == 1 and isinstance(obj.get("$bytes"), str):
144
+ return base64.b64decode(obj["$bytes"], validate=True)
145
+ return obj
146
+
147
+
148
+ def decode_payload(text: str) -> object:
149
+ """JSON text from the native layer as Python values, tagged bytes as bytes."""
150
+ return json.loads(text, object_hook=_from_wire)
151
+
152
+
153
+ _HEX64 = re.compile(r"[0-9a-fA-F]{64}")
154
+ _HEX100 = re.compile(r"[0-9a-fA-F]{100}")
155
+
156
+
157
+ def id32(value: Id, what: str = "id") -> bytes:
158
+ """A 32-byte id (node_id, realm id, record key) from 64 hex chars or 32 bytes."""
159
+ if isinstance(value, str):
160
+ if not _HEX64.fullmatch(value):
161
+ raise ValueError(f"macula-py: {what} must be 64 hex characters")
162
+ return bytes.fromhex(value)
163
+ if len(value) != 32:
164
+ raise ValueError(f"macula-py: {what} must be 32 bytes, got {len(value)}")
165
+ return bytes(value)
166
+
167
+
168
+ def mcid50(value: Mcid) -> bytes:
169
+ """A content id (MCID) from 100 hex chars or 50 bytes."""
170
+ if isinstance(value, str):
171
+ if not _HEX100.fullmatch(value):
172
+ raise ValueError("macula-py: a content id is 100 hex characters (50 bytes)")
173
+ return bytes.fromhex(value)
174
+ if len(value) != 50:
175
+ raise ValueError(f"macula-py: a content id is 50 bytes, got {len(value)}")
176
+ return bytes(value)
177
+
178
+
179
+ _KINDS: dict[str, type[MaculaError]] = {
180
+ "timeout": MaculaTimeoutError,
181
+ "invalid_argument": InvalidArgumentError,
182
+ "invalid_handle": InvalidHandleError,
183
+ "not_found": NotFoundError,
184
+ "not_shared": NotSharedError,
185
+ "answered": AlreadyAnsweredError,
186
+ "closed": ClosedError,
187
+ "refused": RefusedError,
188
+ "failed": MaculaError,
189
+ }
190
+
191
+
192
+ def native_error(text: str) -> Exception:
193
+ """The native layer's error JSON as the class its kind names. A kind this
194
+ binding does not know, or text that is not the contract's JSON, comes out
195
+ as a MaculaError naming it, never silently as something else."""
196
+ try:
197
+ error = json.loads(text)
198
+ kind = error["kind"]
199
+ message = str(error.get("message") or kind)
200
+ except (ValueError, KeyError, TypeError):
201
+ return MaculaError(f"macula-py: the native layer failed with {text!r}")
202
+ if kind == "cancelled":
203
+ return NativeCancelled(message)
204
+ if kind == "provider_error":
205
+ return ProviderError(str(error.get("code") or ""), str(error.get("detail") or ""))
206
+ if kind == "relay_error":
207
+ return RelayError(str(error.get("code") or ""))
208
+ if kind == "unavailable":
209
+ return ContentUnavailableError([str(f) for f in error.get("failures") or []])
210
+ cls = _KINDS.get(kind)
211
+ if cls is None:
212
+ return MaculaError(f"macula-py: the native layer failed with unknown kind {kind!r}: {message}")
213
+ return cls(f"macula-py: {message}")