wasmkeep 0.2.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.
wasmkeep/__init__.py ADDED
@@ -0,0 +1,496 @@
1
+ """The WasmKeep Python SDK: write actors as plain Python classes.
2
+
3
+ ```python
4
+ from wasmkeep import Actor, storage
5
+
6
+ class Hits(Actor):
7
+ def init(self):
8
+ storage.exec("CREATE TABLE IF NOT EXISTS hits (at INTEGER NOT NULL)")
9
+
10
+ def fetch(self, req):
11
+ storage.exec("INSERT INTO hits (at) VALUES (unixepoch())")
12
+ [row] = storage.query("SELECT count(*) AS n FROM hits")
13
+ return {"hits": row["n"], "path": req.path}
14
+
15
+ WitWorld, IncomingHandler = Hits.exports()
16
+ ```
17
+
18
+ Build `app.py` into a component (componentize-py, with the WIT world this
19
+ package carries):
20
+
21
+ python -m wasmkeep build app -o actor.wasm
22
+
23
+ Every hook runs one event at a time per actor: no locks needed. One
24
+ instance of the class handles events until the actor hibernates; keep state
25
+ that must survive in `storage`, not on `self`.
26
+ """
27
+
28
+ import json as _json
29
+ from typing import Any, Dict, List, Optional, Tuple, Union
30
+
31
+ # componentize-py generates these two modules into the component: they exist
32
+ # there and nowhere else. Under plain CPython (`python -m wasmkeep build`, an
33
+ # editor, a test runner) the package still imports, and whatever needs the
34
+ # host says so when it is used.
35
+ try:
36
+ from componentize_py_types import Err, Ok
37
+ from wit_world import exports as _exports
38
+ from wit_world.imports import actors as _actors
39
+ from wit_world.imports import alarms as _alarms
40
+ from wit_world.imports import env as _env
41
+ from wit_world.imports import storage as _storage
42
+ from wit_world.imports import websockets as _ws
43
+ from wit_world.imports.streams import StreamError_Closed
44
+ from wit_world.imports.wasi_http_types import (
45
+ Fields,
46
+ Method_Other,
47
+ OutgoingBody,
48
+ OutgoingResponse,
49
+ ResponseOutparam,
50
+ )
51
+ from wit_world.imports.wasmkeep_actor_types import (
52
+ Value_Blob,
53
+ Value_Integer,
54
+ Value_Null,
55
+ Value_Real,
56
+ Value_Text,
57
+ )
58
+ except ModuleNotFoundError as _missing:
59
+ if _missing.name not in ("componentize_py_types", "wit_world"):
60
+ raise
61
+
62
+ class _Outside:
63
+ def __call__(self, *args, **kwargs):
64
+ raise RuntimeError(
65
+ "wasmkeep: the host's functions exist only inside a component; "
66
+ "build one with `python -m wasmkeep build` and serve it with wasmkeep"
67
+ )
68
+
69
+ def __getattr__(self, name):
70
+ if name.startswith("__"):
71
+ raise AttributeError(name)
72
+ self()
73
+
74
+ class Err(Exception): # type: ignore[no-redef]
75
+ """Never raised out here: only the host fails a call."""
76
+
77
+ Ok = _exports = _actors = _alarms = _env = _storage = _ws = _Outside()
78
+ StreamError_Closed = Fields = Method_Other = OutgoingBody = _Outside()
79
+ OutgoingResponse = ResponseOutparam = _Outside()
80
+ Value_Blob = Value_Integer = Value_Null = Value_Real = Value_Text = _Outside()
81
+
82
+ __all__ = [
83
+ "Actor",
84
+ "ActorAddress",
85
+ "CallError",
86
+ "CallResponse",
87
+ "Request",
88
+ "Response",
89
+ "SqlError",
90
+ "actors",
91
+ "alarms",
92
+ "env",
93
+ "json",
94
+ "kv",
95
+ "queue",
96
+ "sockets",
97
+ "storage",
98
+ "workflow",
99
+ ]
100
+
101
+ # The package's version, as in pyproject.toml (test/test_package.py checks it).
102
+ __version__ = "0.2.0"
103
+ # The version of the `wasmkeep:actor` WIT package in wasmkeep/wit: the
104
+ # interface the SDK is written against, which changes less often.
105
+ WIT_VERSION = "0.2.0"
106
+
107
+ SqlValue = Union[None, int, float, str, bytes, bool]
108
+
109
+ # ---------------------------------------------------------------- storage
110
+
111
+
112
+ class SqlError(Exception):
113
+ """A failed SQL statement. `kind` is one of: syntax, constraint, bind,
114
+ multiple-statements, quota-exceeded, not-allowed, internal."""
115
+
116
+ def __init__(self, kind: str, message: str):
117
+ super().__init__(message)
118
+ self.kind = kind
119
+ self.message = message
120
+
121
+
122
+ def _to_value(v: SqlValue):
123
+ if v is None:
124
+ return Value_Null()
125
+ if isinstance(v, bool):
126
+ return Value_Integer(1 if v else 0)
127
+ if isinstance(v, int):
128
+ return Value_Integer(v)
129
+ if isinstance(v, float):
130
+ return Value_Real(v)
131
+ if isinstance(v, str):
132
+ return Value_Text(v)
133
+ if isinstance(v, (bytes, bytearray, memoryview)):
134
+ return Value_Blob(bytes(v))
135
+ raise TypeError(f"cannot bind {type(v).__name__} to SQL")
136
+
137
+
138
+ def _from_value(v) -> SqlValue:
139
+ return None if isinstance(v, Value_Null) else v.value
140
+
141
+
142
+ def _sql(f, *args):
143
+ try:
144
+ return f(*args)
145
+ except Err as e:
146
+ err = e.value
147
+ raise SqlError(err.kind.name.lower().replace("_", "-"), err.message) from None
148
+
149
+
150
+ class _Storage:
151
+ """The actor's private SQLite database."""
152
+
153
+ def exec(self, sql: str, *params: SqlValue) -> int:
154
+ """Runs a statement that returns no rows; returns the rows changed."""
155
+ return _sql(_storage.exec, sql, [_to_value(p) for p in params])
156
+
157
+ def exec_durable(self, sql: str, *params: SqlValue) -> int:
158
+ """Like `exec`, but returns only once the write is durable
159
+ (replicated to the cluster's bucket, or fsynced on a single node).
160
+ Inside a transaction it takes only `COMMIT` (or `END`), which makes
161
+ the whole transaction durable."""
162
+ return _sql(_storage.exec_durable, sql, [_to_value(p) for p in params])
163
+
164
+ def query(self, sql: str, *params: SqlValue) -> List[Dict[str, SqlValue]]:
165
+ """Runs a statement and returns its rows as dicts."""
166
+ rows = _sql(_storage.query, sql, [_to_value(p) for p in params])
167
+ return [
168
+ {c: _from_value(v) for c, v in zip(rows.columns, values)}
169
+ for values in rows.values
170
+ ]
171
+
172
+ def delete_self(self) -> None:
173
+ """Deletes this actor once the current event ends."""
174
+ _storage.delete_self()
175
+
176
+
177
+ storage = _Storage()
178
+
179
+ # ----------------------------------------------------------------- alarms
180
+
181
+
182
+ class _Alarms:
183
+ """The actor's single alarm. It fires `alarm()` at least once, also after
184
+ a crash or a move to another node."""
185
+
186
+ def set(self, at_ms: int) -> None:
187
+ """Sets (replaces) the alarm: a time in ms since the Unix epoch."""
188
+ _alarms.set_alarm(int(at_ms))
189
+
190
+ def get(self) -> Optional[int]:
191
+ """The pending alarm (ms since the Unix epoch), if any."""
192
+ return _alarms.get_alarm()
193
+
194
+ def clear(self) -> None:
195
+ """Cancels the alarm."""
196
+ _alarms.clear_alarm()
197
+
198
+
199
+ alarms = _Alarms()
200
+
201
+ # ----------------------------------------------------------- actor calls
202
+
203
+
204
+ class CallError(Exception):
205
+ """A call that did not get an answer. `kind`: invalid-address,
206
+ reentrant, too-large, timeout, failed."""
207
+
208
+ def __init__(self, kind: str, message: str, detail: str = ""):
209
+ super().__init__(message)
210
+ self.kind = kind
211
+ # The host's text for invalid-address and failed ("" otherwise).
212
+ self.detail = detail
213
+
214
+
215
+ class CallResponse:
216
+ """Another actor's answer."""
217
+
218
+ def __init__(self, status: int, headers: Dict[str, str], body: bytes):
219
+ self.status = status
220
+ self.headers = headers
221
+ self.body = body
222
+
223
+ def text(self) -> str:
224
+ return self.body.decode()
225
+
226
+ def json(self) -> Any:
227
+ return _json.loads(self.body)
228
+
229
+
230
+ def _kebab(name: str) -> str:
231
+ # "CallError_TooLarge" -> "too-large"
232
+ tag = name.split("_", 1)[1]
233
+ return "".join("-" + c.lower() if c.isupper() else c for c in tag).lstrip("-")
234
+
235
+
236
+ class ActorAddress:
237
+ """An actor's address. Within a tenant, `f"{class_name}/{name}"` is what
238
+ `actors.call` takes."""
239
+
240
+ def __init__(self, tenant: str, class_name: str, name: str):
241
+ self.tenant = tenant
242
+ self.class_name = class_name
243
+ self.name = name
244
+
245
+ def __repr__(self) -> str:
246
+ return f"ActorAddress({self.tenant!r}, {self.class_name!r}, {self.name!r})"
247
+
248
+
249
+ class _Actors:
250
+ def self_address(self) -> ActorAddress:
251
+ """This actor's own address (requests arrive with
252
+ `/actor/<class>/<name>` removed, so this is how an actor learns who
253
+ it is)."""
254
+ a = _actors.self_address()
255
+ return ActorAddress(a.tenant, a.class_, a.name)
256
+
257
+ def call(
258
+ self,
259
+ address: str,
260
+ method: str = "GET",
261
+ path: str = "/",
262
+ headers: Optional[Dict[str, str]] = None,
263
+ body: Union[str, bytes] = b"",
264
+ ) -> CallResponse:
265
+ """Calls actor `class/name` (same tenant) and waits for its
266
+ response. Other events to this actor wait meanwhile."""
267
+ if isinstance(body, str):
268
+ body = body.encode()
269
+ request = _actors.Request(method, path, list((headers or {}).items()), body)
270
+ try:
271
+ r = _actors.call(address, request)
272
+ except Err as e:
273
+ kind = _kebab(type(e.value).__name__)
274
+ detail = getattr(e.value, "value", None) or ""
275
+ raise CallError(kind, detail or kind, detail) from None
276
+ return CallResponse(r.status, dict(r.headers), bytes(r.body))
277
+
278
+
279
+ actors = _Actors()
280
+
281
+ # ---------------------------------------------------------------- env
282
+
283
+
284
+ class _Env:
285
+ """Configuration and secrets the operator gives this class
286
+ (``wasmkeep serve --env CLASS=FILE``)."""
287
+
288
+ def get(self, name: str, default: Optional[str] = None) -> Optional[str]:
289
+ """The value of `name`, or `default` if it is not set."""
290
+ value = _env.get(name)
291
+ return default if value is None else value
292
+
293
+
294
+ env = _Env()
295
+
296
+ # ------------------------------------------------------------- websockets
297
+
298
+
299
+ class _Sockets:
300
+ def send(self, conn: int, data: Union[str, bytes]) -> bool:
301
+ """Sends a text or binary message. Returns False if the connection
302
+ is gone or its buffer is full."""
303
+ msg = _ws.Message_Text(data) if isinstance(data, str) else _ws.Message_Binary(bytes(data))
304
+ try:
305
+ _ws.send(conn, msg)
306
+ return True
307
+ except Err:
308
+ return False
309
+
310
+ def broadcast(self, data: Union[str, bytes], except_conn: Optional[int] = None) -> None:
311
+ """Sends to every open connection of this actor, except `except_conn`."""
312
+ for conn in _ws.connections():
313
+ if conn != except_conn:
314
+ self.send(conn, data)
315
+
316
+ def close(self, conn: int, code: int = 1000, reason: str = "") -> None:
317
+ """Closes a connection."""
318
+ _ws.close(conn, code, reason)
319
+
320
+ def connections(self) -> List[int]:
321
+ """This actor's open connections."""
322
+ return list(_ws.connections())
323
+
324
+
325
+ sockets = _Sockets()
326
+
327
+ # ------------------------------------------------------------------- HTTP
328
+
329
+
330
+ class Request:
331
+ """An incoming request. `path` includes the query, relative to the
332
+ actor (`/actor/<class>/<name>` removed)."""
333
+
334
+ def __init__(self, incoming):
335
+ self._incoming = incoming
336
+ self._body: Optional[bytes] = None
337
+ method = incoming.method()
338
+ self.method: str = (
339
+ method.value if isinstance(method, Method_Other) else type(method).__name__.split("_", 1)[1].upper()
340
+ )
341
+ self.path: str = incoming.path_with_query() or "/"
342
+ self.headers: Dict[str, str] = {
343
+ k: v.decode(errors="replace") for k, v in incoming.headers().entries()
344
+ }
345
+
346
+ def bytes(self) -> bytes:
347
+ """The body, read on first use."""
348
+ if self._body is None:
349
+ self._body = _read_all(self._incoming)
350
+ return self._body
351
+
352
+ def text(self) -> str:
353
+ return self.bytes().decode()
354
+
355
+ def json(self) -> Any:
356
+ return _json.loads(self.bytes())
357
+
358
+
359
+ class Response:
360
+ """A full response; a handler may also return just a body (str, bytes),
361
+ or a dict or list (sent as JSON)."""
362
+
363
+ def __init__(
364
+ self,
365
+ body: Union[str, bytes] = b"",
366
+ status: int = 200,
367
+ headers: Optional[Dict[str, str]] = None,
368
+ ):
369
+ self.body = body
370
+ self.status = status
371
+ self.headers = headers or {}
372
+
373
+
374
+ def json(value: Any, status: int = 200) -> Response:
375
+ """A JSON response."""
376
+ return Response(_json.dumps(value), status, {"content-type": "application/json"})
377
+
378
+
379
+ def _read_all(incoming) -> bytes:
380
+ body = incoming.consume()
381
+ stream = body.stream()
382
+ chunks = []
383
+ while True:
384
+ try:
385
+ chunks.append(stream.blocking_read(65536))
386
+ except Err as e:
387
+ if isinstance(e.value, StreamError_Closed):
388
+ break
389
+ raise
390
+ stream.__exit__(None, None, None)
391
+ return b"".join(chunks)
392
+
393
+
394
+ def _respond(out, response: Any) -> None:
395
+ if isinstance(response, Response):
396
+ r = response
397
+ elif isinstance(response, (dict, list)):
398
+ r = json(response)
399
+ elif response is None:
400
+ r = Response(b"", 204)
401
+ else:
402
+ r = Response(response)
403
+ body = r.body.encode() if isinstance(r.body, str) else bytes(r.body)
404
+ headers = {"content-type": "text/plain; charset=utf-8"}
405
+ headers.update({k.lower(): v for k, v in r.headers.items()})
406
+ outgoing = OutgoingResponse(Fields.from_list([(k, v.encode()) for k, v in headers.items()]))
407
+ outgoing.set_status_code(r.status)
408
+ out_body = outgoing.body()
409
+ ResponseOutparam.set(out, Ok(outgoing))
410
+ stream = out_body.write()
411
+ for at in range(0, len(body), 4096):
412
+ stream.blocking_write_and_flush(body[at : at + 4096])
413
+ stream.__exit__(None, None, None)
414
+ OutgoingBody.finish(out_body, None)
415
+
416
+
417
+ # ------------------------------------------------------------------ actor
418
+
419
+
420
+ class Actor:
421
+ """Subclass and override any hook; all are optional."""
422
+
423
+ def init(self) -> None:
424
+ """Runs once per component version, before any other event
425
+ (create or migrate tables here)."""
426
+
427
+ def fetch(self, req: Request) -> Any:
428
+ """Handles an HTTP request. Return a str or bytes (status 200), a
429
+ dict or list (JSON), or a `Response`."""
430
+ return Response("no fetch handler\n", 404)
431
+
432
+ def alarm(self) -> None:
433
+ """The alarm fired. Raising retries it with backoff."""
434
+
435
+ def websocket_open(self, conn: int, path: str) -> Optional[bool]:
436
+ """A client connected. Return False (or raise) to refuse it."""
437
+ return True
438
+
439
+ def websocket_message(self, conn: int, data: Union[str, bytes]) -> None:
440
+ """A message arrived on one of this actor's connections."""
441
+
442
+ def websocket_close(self, conn: int, code: int, reason: str) -> None:
443
+ """A connection closed."""
444
+
445
+ @classmethod
446
+ def exports(cls) -> Tuple[type, type]:
447
+ """The component exports for this actor:
448
+ `WitWorld, IncomingHandler = MyActor.exports()` at module level."""
449
+ holder: Dict[str, Actor] = {}
450
+
451
+ def instance() -> Actor:
452
+ if "actor" not in holder:
453
+ holder["actor"] = cls()
454
+ return holder["actor"]
455
+
456
+ def guarded(f, *args):
457
+ # An exception becomes the WIT `result` error (shown in the
458
+ # host's logs).
459
+ try:
460
+ return f(*args)
461
+ except Err:
462
+ raise
463
+ except Exception as e: # noqa: BLE001 - reported to the host
464
+ raise Err(f"{type(e).__name__}: {e}") from None
465
+
466
+ class WitWorld:
467
+ def on_init(self) -> None:
468
+ guarded(instance().init)
469
+
470
+ def on_alarm(self) -> None:
471
+ guarded(instance().alarm)
472
+
473
+ def on_websocket_open(self, conn: int, path: str) -> None:
474
+ if guarded(instance().websocket_open, conn, path) is False:
475
+ raise Err("refused")
476
+
477
+ def on_websocket_message(self, conn: int, msg) -> None:
478
+ instance().websocket_message(conn, msg.value)
479
+
480
+ def on_websocket_close(self, conn: int, code: int, reason: str) -> None:
481
+ instance().websocket_close(conn, code, reason)
482
+
483
+ class IncomingHandler(_exports.IncomingHandler):
484
+ def handle(self, request, response_out) -> None:
485
+ try:
486
+ response = instance().fetch(Request(request))
487
+ except Exception as e: # noqa: BLE001 - becomes a 500
488
+ response = Response(f"{type(e).__name__}: {e}\n", 500)
489
+ _respond(response_out, response)
490
+
491
+ return WitWorld, IncomingHandler
492
+
493
+
494
+ # The kv, queue and workflow clients (ADR 0015, ADR 0019) use `actors`,
495
+ # `CallError` and `Response` above, so they are imported last.
496
+ from . import kv, queue, workflow # noqa: E402
wasmkeep/__main__.py ADDED
@@ -0,0 +1,98 @@
1
+ """`python -m wasmkeep build APP -o actor.wasm`: builds a module into a
2
+ WasmKeep actor, a WebAssembly component with CPython embedded.
3
+
4
+ It runs componentize-py on the `actor` world of the WIT files this package
5
+ carries (wasmkeep/wit), with this package on the module path, so an
6
+ installed `wasmkeep` is all a build needs: no clone, no WIT directory to
7
+ name. componentize-py is the one installed with this package (its pinned
8
+ requirement); from a source checkout without it, the `componentize-py`
9
+ command on PATH.
10
+ """
11
+
12
+ import argparse
13
+ import importlib.util
14
+ import os
15
+ import shutil
16
+ import subprocess
17
+ import sys
18
+
19
+ # Not `from . import ...`: the versions are read without touching the API.
20
+ from wasmkeep import WIT_VERSION, __version__
21
+
22
+ _PACKAGE = os.path.dirname(os.path.abspath(__file__))
23
+ # The version pyproject.toml requires (test_package.py compares the two).
24
+ COMPONENTIZE_PY = "0.25.1"
25
+
26
+
27
+ def _componentize_py():
28
+ """The command that runs componentize-py: this interpreter's, else PATH's."""
29
+ if importlib.util.find_spec("componentize_py") is not None:
30
+ return [sys.executable, "-c", "import sys, componentize_py; sys.exit(componentize_py.script())"]
31
+ found = shutil.which("componentize-py")
32
+ if found is None:
33
+ sys.exit(f"wasmkeep: componentize-py is not installed: pip install componentize-py=={COMPONENTIZE_PY}")
34
+ # Another version generates other bindings than the ones this SDK imports.
35
+ version = subprocess.run([found, "--version"], capture_output=True, text=True).stdout.split()
36
+ if COMPONENTIZE_PY not in version:
37
+ sys.exit(
38
+ f"wasmkeep: {found} is {' '.join(version) or 'an unknown version'}; "
39
+ f"this SDK needs componentize-py {COMPONENTIZE_PY}"
40
+ )
41
+ return [found]
42
+
43
+
44
+ def _build(args):
45
+ wit = os.path.join(_PACKAGE, "wit")
46
+ if not os.path.isfile(os.path.join(wit, "actor.wit")):
47
+ sys.exit(f"wasmkeep: no WIT files in {wit}: the package is incomplete")
48
+ if args.app.endswith(".py") or os.sep in args.app or "/" in args.app:
49
+ sys.exit(f"wasmkeep: APP is a module name, not a file: `app` for app.py (got {args.app})")
50
+ # The current directory first, then the other directories named, then
51
+ # the one this package is in.
52
+ paths = ["."] + (args.python_path or [])
53
+ top = args.app.split(".")[0]
54
+ if not any(
55
+ os.path.isfile(os.path.join(path, top + ".py")) or os.path.isdir(os.path.join(path, top)) for path in paths
56
+ ):
57
+ sys.exit(f"wasmkeep: no module {top} in {', '.join(os.path.abspath(path) for path in paths)}")
58
+ command = _componentize_py() + ["-d", wit, "-w", "actor", "componentize", args.app]
59
+ for path in paths:
60
+ command += ["-p", path]
61
+ command += ["-p", os.path.dirname(_PACKAGE), "-o", args.output]
62
+ code = subprocess.call(command)
63
+ if code == 0:
64
+ print(f"wrote {args.output} ({os.path.getsize(args.output)} bytes)")
65
+ return code
66
+
67
+
68
+ def main(argv=None):
69
+ parser = argparse.ArgumentParser(
70
+ prog="python -m wasmkeep",
71
+ description="Build a Python module into a WasmKeep actor (a WebAssembly component).",
72
+ )
73
+ parser.add_argument(
74
+ "--version", action="version", version=f"wasmkeep {__version__} (wasmkeep:actor@{WIT_VERSION})"
75
+ )
76
+ commands = parser.add_subparsers(dest="command", required=True, metavar="build")
77
+ build = commands.add_parser(
78
+ "build",
79
+ help="build APP into a component",
80
+ description="Build the module APP into a component with componentize-py, "
81
+ "against the WIT world this package carries.",
82
+ )
83
+ build.add_argument("app", metavar="APP", help="the module that exports the actor: `app` for app.py")
84
+ build.add_argument("-o", "--output", default="actor.wasm", help="the component to write (default: actor.wasm)")
85
+ build.add_argument(
86
+ "-p",
87
+ "--python-path",
88
+ action="append",
89
+ metavar="DIR",
90
+ help="another directory to find APP and what it imports in, besides the current one; repeatable",
91
+ )
92
+ build.set_defaults(run=_build)
93
+ args = parser.parse_args(argv)
94
+ return args.run(args)
95
+
96
+
97
+ if __name__ == "__main__":
98
+ sys.exit(main())
wasmkeep/_call.py ADDED
@@ -0,0 +1,33 @@
1
+ """One request to another actor for the `kv` and `queue` clients (the Rust
2
+ SDK's `wire::call`). A failed call becomes a message, worded as in Rust: the
3
+ call failing, not the callee's answer (that is in the status)."""
4
+
5
+ from typing import Dict, Tuple
6
+
7
+ from . import CallError, actors
8
+
9
+
10
+ class CallFailed(Exception):
11
+ """The call got no answer; the text says why, as in Rust."""
12
+
13
+
14
+ def call(address: str, method: str, path: str, headers: Dict[str, str], body: bytes) -> Tuple[int, bytes]:
15
+ """`(status, body)` once the callee answered; `CallFailed` if the call
16
+ failed."""
17
+ try:
18
+ r = actors.call(address, method=method, path=path, headers=headers, body=body)
19
+ except CallError as e:
20
+ if e.kind == "invalid-address":
21
+ text = f"invalid address {address}: {e.detail}"
22
+ elif e.kind == "reentrant":
23
+ text = f"call to {address} would re-enter an actor already in this call chain"
24
+ elif e.kind == "too-large":
25
+ text = f"call to {address}: body too large"
26
+ elif e.kind == "timeout":
27
+ text = f"call to {address} timed out"
28
+ elif e.kind == "failed":
29
+ text = f"call to {address} failed: {e.detail}"
30
+ else:
31
+ raise
32
+ raise CallFailed(text) from None
33
+ return r.status, bytes(r.body)