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 +496 -0
- wasmkeep/__main__.py +98 -0
- wasmkeep/_call.py +33 -0
- wasmkeep/kv.py +277 -0
- wasmkeep/queue.py +214 -0
- wasmkeep/wire.py +559 -0
- wasmkeep/wit/actor.wit +273 -0
- wasmkeep/wit/deps/cli.wit +233 -0
- wasmkeep/wit/deps/clocks.wit +162 -0
- wasmkeep/wit/deps/filesystem.wit +589 -0
- wasmkeep/wit/deps/http.wit +733 -0
- wasmkeep/wit/deps/io.wit +299 -0
- wasmkeep/wit/deps/random.wit +92 -0
- wasmkeep/wit/deps/sockets.wit +949 -0
- wasmkeep/workflow.py +110 -0
- wasmkeep-0.2.0.dist-info/METADATA +254 -0
- wasmkeep-0.2.0.dist-info/RECORD +20 -0
- wasmkeep-0.2.0.dist-info/WHEEL +4 -0
- wasmkeep-0.2.0.dist-info/licenses/LICENSE +202 -0
- wasmkeep-0.2.0.dist-info/licenses/NOTICE +14 -0
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)
|