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 +88 -0
- macula_py/_abi.py +91 -0
- macula_py/_blocking.py +123 -0
- macula_py/_library.py +38 -0
- macula_py/_native/macula.dll +0 -0
- macula_py/_native.py +115 -0
- macula_py/_wire.py +213 -0
- macula_py/key.py +120 -0
- macula_py/pool.py +666 -0
- macula_py/py.typed +0 -0
- macula_py/stream.py +171 -0
- macula_py-0.2.0.dist-info/METADATA +172 -0
- macula_py-0.2.0.dist-info/RECORD +16 -0
- macula_py-0.2.0.dist-info/WHEEL +5 -0
- macula_py-0.2.0.dist-info/licenses/LICENSE +190 -0
- macula_py-0.2.0.dist-info/top_level.txt +1 -0
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}")
|