supie 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- supie/__init__.py +30 -0
- supie/app.py +640 -0
- supie/build.py +510 -0
- supie/cli.py +354 -0
- supie/flow.py +986 -0
- supie/models.py +117 -0
- supie/onnx.py +80 -0
- supie/queue.py +644 -0
- supie/runtimes.py +111 -0
- supie/sql/flows.sql +86 -0
- supie/sql/pgflow.sql +177 -0
- supie/sql/queue.sql +378 -0
- supie/stubs/__future__.py +3 -0
- supie/stubs/typing.py +78 -0
- supie/supabase.py +455 -0
- supie/templates/compat.ts +46 -0
- supie/templates/index.ts +210 -0
- supie/templates/install_packages.ts +39 -0
- supie/templates/main.py +20 -0
- supie/templates/make_snapshot.ts +64 -0
- supie/templates/python_micropython.ts +29 -0
- supie/templates/python_pyodide.ts +89 -0
- supie/templates/test_main.py +17 -0
- supie/testing.py +317 -0
- supie-0.1.0.dist-info/METADATA +520 -0
- supie-0.1.0.dist-info/RECORD +29 -0
- supie-0.1.0.dist-info/WHEEL +4 -0
- supie-0.1.0.dist-info/entry_points.txt +2 -0
- supie-0.1.0.dist-info/licenses/LICENSE +202 -0
supie/__init__.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Python edge functions and workflows for Supabase."""
|
|
2
|
+
|
|
3
|
+
from supie.app import (
|
|
4
|
+
App,
|
|
5
|
+
HTTPError,
|
|
6
|
+
Request,
|
|
7
|
+
Response,
|
|
8
|
+
Timeout,
|
|
9
|
+
compare_digest,
|
|
10
|
+
env,
|
|
11
|
+
gather,
|
|
12
|
+
sleep,
|
|
13
|
+
wait_for,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
__version__ = "0.1.0"
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"App",
|
|
20
|
+
"HTTPError",
|
|
21
|
+
"Request",
|
|
22
|
+
"Response",
|
|
23
|
+
"Timeout",
|
|
24
|
+
"compare_digest",
|
|
25
|
+
"env",
|
|
26
|
+
"gather",
|
|
27
|
+
"sleep",
|
|
28
|
+
"wait_for",
|
|
29
|
+
"__version__",
|
|
30
|
+
]
|
supie/app.py
ADDED
|
@@ -0,0 +1,640 @@
|
|
|
1
|
+
"""Handlers, routing, requests and responses: the part of supie that runs in the function.
|
|
2
|
+
|
|
3
|
+
This module runs under CPython (in tests), MicroPython and Pyodide, so it keeps to
|
|
4
|
+
what all three share: no dataclasses, re or inspect. Annotations are never
|
|
5
|
+
evaluated (supie ships typing and __future__ stand-ins for MicroPython), and
|
|
6
|
+
type-only names live under TYPE_CHECKING. The shim and Python exchange requests
|
|
7
|
+
and responses as JSON strings.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
from typing import TYPE_CHECKING, Any
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from collections.abc import Awaitable, Callable
|
|
17
|
+
from typing import Protocol, TypeVar
|
|
18
|
+
|
|
19
|
+
from supie.supabase import Supabase
|
|
20
|
+
|
|
21
|
+
Handler = Callable[..., Any]
|
|
22
|
+
F = TypeVar("F", bound=Callable[..., Any])
|
|
23
|
+
|
|
24
|
+
class Host(Protocol):
|
|
25
|
+
"""What the shim (or supie.testing's fake) provides to Python."""
|
|
26
|
+
|
|
27
|
+
def env(self, name: str) -> object:
|
|
28
|
+
"""An environment variable: a str, or something else when unset."""
|
|
29
|
+
...
|
|
30
|
+
|
|
31
|
+
def fetch(
|
|
32
|
+
self,
|
|
33
|
+
url: str,
|
|
34
|
+
method: str,
|
|
35
|
+
headers_json: str,
|
|
36
|
+
body: str | None,
|
|
37
|
+
body_base64: bool,
|
|
38
|
+
want_base64: bool,
|
|
39
|
+
) -> Awaitable[str]:
|
|
40
|
+
"""An HTTP request; resolves to JSON with status, headers and body."""
|
|
41
|
+
...
|
|
42
|
+
|
|
43
|
+
def gather(self, *tasks: Awaitable[Any]) -> Awaitable[None]:
|
|
44
|
+
"""Run awaitables concurrently."""
|
|
45
|
+
...
|
|
46
|
+
|
|
47
|
+
def race(self, *tasks: Awaitable[Any]) -> Awaitable[None]:
|
|
48
|
+
"""Wait for the first awaitable to finish."""
|
|
49
|
+
...
|
|
50
|
+
|
|
51
|
+
def sleep(self, ms: int) -> Awaitable[None]:
|
|
52
|
+
"""Wait ms milliseconds."""
|
|
53
|
+
...
|
|
54
|
+
|
|
55
|
+
def uuid(self) -> str:
|
|
56
|
+
"""A random hex ID."""
|
|
57
|
+
...
|
|
58
|
+
|
|
59
|
+
def onnx_load(self, url: str, headers_json: str) -> Awaitable[str]:
|
|
60
|
+
"""Load an ONNX model; resolves to JSON with its handle and names."""
|
|
61
|
+
...
|
|
62
|
+
|
|
63
|
+
def onnx_run(self, handle: int, inputs_json: str) -> Awaitable[str]:
|
|
64
|
+
"""Run a loaded model; resolves to JSON outputs."""
|
|
65
|
+
...
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
REASONS = {
|
|
69
|
+
400: "Bad Request",
|
|
70
|
+
401: "Unauthorized",
|
|
71
|
+
403: "Forbidden",
|
|
72
|
+
404: "Not Found",
|
|
73
|
+
405: "Method Not Allowed",
|
|
74
|
+
409: "Conflict",
|
|
75
|
+
422: "Unprocessable Content",
|
|
76
|
+
429: "Too Many Requests",
|
|
77
|
+
500: "Internal Server Error",
|
|
78
|
+
502: "Bad Gateway",
|
|
79
|
+
503: "Service Unavailable",
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
_host: Host | None = None # the shim's JS module in the function, a fake in tests
|
|
83
|
+
_pending: dict[int, list[Awaitable[Any]]] = {} # background tasks per request
|
|
84
|
+
_next_id = [0]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def set_host(host: Host | None) -> None:
|
|
88
|
+
"""Bind the host: fetch and environment access, provided by the shim or a fake."""
|
|
89
|
+
global _host
|
|
90
|
+
_host = host
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def get_host() -> Host:
|
|
94
|
+
"""Return the bound host, or fail with a hint when there is none."""
|
|
95
|
+
if _host is None:
|
|
96
|
+
raise RuntimeError("no host bound: run in the function, or use supie.testing")
|
|
97
|
+
return _host
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class HTTPError(Exception):
|
|
101
|
+
"""An error that becomes a response with this status and a JSON error body."""
|
|
102
|
+
|
|
103
|
+
def __init__(
|
|
104
|
+
self,
|
|
105
|
+
status: int,
|
|
106
|
+
detail: Any = None,
|
|
107
|
+
headers: dict[str, str] | None = None,
|
|
108
|
+
) -> None:
|
|
109
|
+
"""Store the status, the detail shown to the caller and extra headers."""
|
|
110
|
+
if detail is None:
|
|
111
|
+
detail = REASONS.get(status, "Error")
|
|
112
|
+
super().__init__(detail)
|
|
113
|
+
self.status = status
|
|
114
|
+
self.detail = detail
|
|
115
|
+
self.headers = headers or {}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class Env:
|
|
119
|
+
"""Read-only access to the function's environment variables and secrets."""
|
|
120
|
+
|
|
121
|
+
def _read(self, name: str) -> str | None:
|
|
122
|
+
"""The variable as a str, or None when it isn't set.
|
|
123
|
+
|
|
124
|
+
Anything but a str counts as unset: the runtimes turn JavaScript's
|
|
125
|
+
undefined and null into different Python objects (Pyodide's jsnull).
|
|
126
|
+
"""
|
|
127
|
+
value = get_host().env(name)
|
|
128
|
+
return value if isinstance(value, str) else None
|
|
129
|
+
|
|
130
|
+
def get(self, name: str, default: str | None = None) -> str | None:
|
|
131
|
+
"""Return the variable, or default when it isn't set."""
|
|
132
|
+
value = self._read(name)
|
|
133
|
+
return default if value is None else value
|
|
134
|
+
|
|
135
|
+
def __getitem__(self, name: str) -> str:
|
|
136
|
+
"""Return the variable, raising KeyError when it isn't set."""
|
|
137
|
+
value = self._read(name)
|
|
138
|
+
if value is None:
|
|
139
|
+
raise KeyError(name)
|
|
140
|
+
return value
|
|
141
|
+
|
|
142
|
+
def __contains__(self, name: object) -> bool:
|
|
143
|
+
"""Tell whether the variable is set."""
|
|
144
|
+
return isinstance(name, str) and self._read(name) is not None
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
env = Env()
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class State:
|
|
151
|
+
"""A plain namespace for whatever on_boot sets up, shared across requests."""
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _b64url_decode(segment: str) -> bytes:
|
|
155
|
+
"""Decode a base64url segment without padding, as JWTs use."""
|
|
156
|
+
import binascii
|
|
157
|
+
|
|
158
|
+
segment = segment.replace("-", "+").replace("_", "/")
|
|
159
|
+
return binascii.a2b_base64((segment + "=" * (-len(segment) % 4)).encode())
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class Request:
|
|
163
|
+
"""An incoming request, with the body already read."""
|
|
164
|
+
|
|
165
|
+
def __init__(
|
|
166
|
+
self,
|
|
167
|
+
method: str,
|
|
168
|
+
path: str,
|
|
169
|
+
query: dict[str, str] | None = None,
|
|
170
|
+
headers: dict[str, str] | None = None,
|
|
171
|
+
body: str = "",
|
|
172
|
+
params: dict[str, Any] | None = None,
|
|
173
|
+
) -> None:
|
|
174
|
+
"""Store the request parts; header names are lower case."""
|
|
175
|
+
self.method = method.upper()
|
|
176
|
+
self.path = path
|
|
177
|
+
self.query: dict[str, str] = query or {}
|
|
178
|
+
self.headers: dict[str, str] = {}
|
|
179
|
+
for name, value in (headers or {}).items():
|
|
180
|
+
self.headers[name.lower()] = value
|
|
181
|
+
self.body = body or ""
|
|
182
|
+
self.params: dict[str, Any] = params or {}
|
|
183
|
+
self.app: App | None = None
|
|
184
|
+
self._background: list[Awaitable[Any]] = []
|
|
185
|
+
|
|
186
|
+
def text(self) -> str:
|
|
187
|
+
"""Return the body as a string."""
|
|
188
|
+
return self.body
|
|
189
|
+
|
|
190
|
+
def json(self) -> Any:
|
|
191
|
+
"""Parse the body as JSON, answering 400 when it is missing or invalid."""
|
|
192
|
+
if not self.body:
|
|
193
|
+
raise HTTPError(400, "expected a JSON body")
|
|
194
|
+
try:
|
|
195
|
+
return json.loads(self.body)
|
|
196
|
+
except ValueError:
|
|
197
|
+
raise HTTPError(400, "invalid JSON body")
|
|
198
|
+
|
|
199
|
+
@property
|
|
200
|
+
def state(self) -> Any:
|
|
201
|
+
"""The app's shared state, as set up by on_boot."""
|
|
202
|
+
if self.app is None:
|
|
203
|
+
raise RuntimeError("this request isn't being handled by an App")
|
|
204
|
+
return self.app.state
|
|
205
|
+
|
|
206
|
+
@property
|
|
207
|
+
def token(self) -> str | None:
|
|
208
|
+
"""The caller's bearer token, or None."""
|
|
209
|
+
auth = self.headers.get("authorization", "")
|
|
210
|
+
if auth[:7].lower() == "bearer ":
|
|
211
|
+
return auth[7:].strip()
|
|
212
|
+
return None
|
|
213
|
+
|
|
214
|
+
@property
|
|
215
|
+
def user(self) -> dict[str, Any] | None:
|
|
216
|
+
"""The caller's JWT claims, or None.
|
|
217
|
+
|
|
218
|
+
The claims are decoded, not verified: Supabase's gateway checks the
|
|
219
|
+
signature only while the function has verify_jwt on, the default. With
|
|
220
|
+
it off, anyone can send any claims, so use is_service to authorize the
|
|
221
|
+
service role.
|
|
222
|
+
"""
|
|
223
|
+
token = self.token
|
|
224
|
+
if not token or token.count(".") != 2:
|
|
225
|
+
return None
|
|
226
|
+
try:
|
|
227
|
+
return json.loads(_b64url_decode(token.split(".")[1]))
|
|
228
|
+
except Exception:
|
|
229
|
+
return None
|
|
230
|
+
|
|
231
|
+
@property
|
|
232
|
+
def is_service(self) -> bool:
|
|
233
|
+
"""Whether the caller sent this project's service role key, compared in
|
|
234
|
+
constant time, so a token that merely claims the role doesn't pass.
|
|
235
|
+
"""
|
|
236
|
+
token, key = self.token, env.get("SUPABASE_SERVICE_ROLE_KEY")
|
|
237
|
+
return bool(token and key) and compare_digest(token, key or "")
|
|
238
|
+
|
|
239
|
+
@property
|
|
240
|
+
def supabase(self) -> Supabase:
|
|
241
|
+
"""A Supabase client acting as the caller, so row level security applies."""
|
|
242
|
+
from supie.supabase import Supabase
|
|
243
|
+
|
|
244
|
+
key = env["SUPABASE_ANON_KEY"]
|
|
245
|
+
return Supabase(env["SUPABASE_URL"], key, self.token or key)
|
|
246
|
+
|
|
247
|
+
@property
|
|
248
|
+
def supabase_admin(self) -> Supabase:
|
|
249
|
+
"""A Supabase client with the service role key, which bypasses row level security."""
|
|
250
|
+
from supie.supabase import Supabase
|
|
251
|
+
|
|
252
|
+
key = env["SUPABASE_SERVICE_ROLE_KEY"]
|
|
253
|
+
return Supabase(env["SUPABASE_URL"], key, key)
|
|
254
|
+
|
|
255
|
+
def background(self, task: Awaitable[Any]) -> None:
|
|
256
|
+
"""Run a coroutine after the response is sent."""
|
|
257
|
+
self._background.append(task)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
class Response:
|
|
261
|
+
"""A response with full control over the body, status and headers.
|
|
262
|
+
|
|
263
|
+
A dict or list body becomes JSON, a str becomes text, bytes stay binary and
|
|
264
|
+
None sends no body.
|
|
265
|
+
"""
|
|
266
|
+
|
|
267
|
+
def __init__(
|
|
268
|
+
self,
|
|
269
|
+
body: Any = None,
|
|
270
|
+
status: int = 200,
|
|
271
|
+
headers: dict[str, str] | None = None,
|
|
272
|
+
media_type: str | None = None,
|
|
273
|
+
) -> None:
|
|
274
|
+
"""Store the body, status, headers and an optional content type."""
|
|
275
|
+
self.body = body
|
|
276
|
+
self.status = status
|
|
277
|
+
self.headers: dict[str, str] = {}
|
|
278
|
+
for name, value in (headers or {}).items():
|
|
279
|
+
self.headers[name.lower()] = value
|
|
280
|
+
self.media_type = media_type
|
|
281
|
+
|
|
282
|
+
def wire(self) -> dict[str, Any]:
|
|
283
|
+
"""Encode the response for the shim: status, headers and a string body."""
|
|
284
|
+
body = self.body
|
|
285
|
+
headers = dict(self.headers)
|
|
286
|
+
encoded = False
|
|
287
|
+
if body is None:
|
|
288
|
+
text = ""
|
|
289
|
+
elif isinstance(body, (bytes, bytearray)):
|
|
290
|
+
import binascii
|
|
291
|
+
|
|
292
|
+
text = binascii.b2a_base64(bytes(body)).decode().strip()
|
|
293
|
+
encoded = True
|
|
294
|
+
headers.setdefault(
|
|
295
|
+
"content-type", self.media_type or "application/octet-stream"
|
|
296
|
+
)
|
|
297
|
+
elif isinstance(body, str):
|
|
298
|
+
text = body
|
|
299
|
+
headers.setdefault(
|
|
300
|
+
"content-type", self.media_type or "text/plain; charset=utf-8"
|
|
301
|
+
)
|
|
302
|
+
else:
|
|
303
|
+
text = json.dumps(body)
|
|
304
|
+
headers.setdefault("content-type", self.media_type or "application/json")
|
|
305
|
+
return {
|
|
306
|
+
"status": self.status,
|
|
307
|
+
"headers": headers,
|
|
308
|
+
"body": text,
|
|
309
|
+
"base64": encoded,
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _to_response(result: Any) -> Response:
|
|
314
|
+
"""Turn whatever a handler returned into a Response."""
|
|
315
|
+
if isinstance(result, Response):
|
|
316
|
+
return result
|
|
317
|
+
if result is None:
|
|
318
|
+
return Response(None, status=204)
|
|
319
|
+
return Response(result)
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _error_response(error: HTTPError) -> Response:
|
|
323
|
+
"""Turn an HTTPError into a JSON error response."""
|
|
324
|
+
return Response({"error": error.detail}, status=error.status, headers=error.headers)
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
async def _maybe_await(value: Any) -> Any:
|
|
328
|
+
"""Await a coroutine, or pass any other value through."""
|
|
329
|
+
if hasattr(value, "send"):
|
|
330
|
+
return await value
|
|
331
|
+
return value
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
class Timeout(Exception):
|
|
335
|
+
"""An awaitable passed to wait_for didn't finish in time."""
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
async def sleep(seconds: float) -> None:
|
|
339
|
+
"""Wait without blocking other requests or tasks."""
|
|
340
|
+
await get_host().sleep(int(seconds * 1000))
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
async def gather(*tasks: Awaitable[Any]) -> list[Any]:
|
|
344
|
+
"""Run awaitables concurrently and return their results in order.
|
|
345
|
+
|
|
346
|
+
Every task runs to the end; if any raised, the first error is raised after.
|
|
347
|
+
"""
|
|
348
|
+
results: list[Any] = [None] * len(tasks)
|
|
349
|
+
errors: list[Exception | None] = [None] * len(tasks)
|
|
350
|
+
|
|
351
|
+
async def one(i: int, task: Awaitable[Any]) -> None:
|
|
352
|
+
"""Run one task, keeping its result or error for after the others."""
|
|
353
|
+
try:
|
|
354
|
+
results[i] = await task
|
|
355
|
+
except Exception as error:
|
|
356
|
+
errors[i] = error
|
|
357
|
+
|
|
358
|
+
await get_host().gather(*[one(i, task) for i, task in enumerate(tasks)])
|
|
359
|
+
for error in errors:
|
|
360
|
+
if error is not None:
|
|
361
|
+
raise error
|
|
362
|
+
return results
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
async def wait_for(task: Awaitable[Any], seconds: float) -> Any:
|
|
366
|
+
"""Await task, raising Timeout if it takes longer than seconds.
|
|
367
|
+
|
|
368
|
+
In the function the task can't be cancelled: it keeps running, and its
|
|
369
|
+
result is dropped.
|
|
370
|
+
"""
|
|
371
|
+
state: dict[str, Any] = {"done": False, "value": None, "error": None}
|
|
372
|
+
|
|
373
|
+
async def body() -> None:
|
|
374
|
+
"""Run the task and record how it ended."""
|
|
375
|
+
try:
|
|
376
|
+
state["value"] = await task
|
|
377
|
+
except Exception as error:
|
|
378
|
+
state["error"] = error
|
|
379
|
+
state["done"] = True
|
|
380
|
+
|
|
381
|
+
await get_host().race(body(), get_host().sleep(int(seconds * 1000)))
|
|
382
|
+
if not state["done"]:
|
|
383
|
+
raise Timeout("timed out after " + str(seconds) + " s")
|
|
384
|
+
if state["error"] is not None:
|
|
385
|
+
raise state["error"]
|
|
386
|
+
return state["value"]
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def compare_digest(a: str | bytes, b: str | bytes) -> bool:
|
|
390
|
+
"""Compare two str or bytes values in constant time, for signatures and tokens.
|
|
391
|
+
|
|
392
|
+
hmac.compare_digest where it exists; MicroPython lacks it.
|
|
393
|
+
"""
|
|
394
|
+
if isinstance(a, str):
|
|
395
|
+
a = a.encode()
|
|
396
|
+
if isinstance(b, str):
|
|
397
|
+
b = b.encode()
|
|
398
|
+
try:
|
|
399
|
+
import hmac
|
|
400
|
+
|
|
401
|
+
return hmac.compare_digest(a, b)
|
|
402
|
+
except (ImportError, AttributeError):
|
|
403
|
+
pass
|
|
404
|
+
result = len(a) ^ len(b)
|
|
405
|
+
for x, y in zip(a, b + bytes(max(0, len(a) - len(b)))):
|
|
406
|
+
result |= x ^ y
|
|
407
|
+
return result == 0
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def new_id() -> str:
|
|
411
|
+
"""A random 128-bit hex ID from the platform's CSPRNG."""
|
|
412
|
+
return str(get_host().uuid())
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def format_exception(error: BaseException) -> str:
|
|
416
|
+
"""Format a traceback under CPython or MicroPython."""
|
|
417
|
+
try:
|
|
418
|
+
import traceback
|
|
419
|
+
|
|
420
|
+
return "".join(
|
|
421
|
+
traceback.format_exception(type(error), error, error.__traceback__)
|
|
422
|
+
)
|
|
423
|
+
except Exception:
|
|
424
|
+
import io
|
|
425
|
+
import sys
|
|
426
|
+
|
|
427
|
+
buffer = io.StringIO()
|
|
428
|
+
getattr(sys, "print_exception")(error, buffer) # MicroPython's
|
|
429
|
+
return buffer.getvalue()
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
_CONVERTERS: dict[str, Callable[[str], Any]] = {
|
|
433
|
+
"str": str,
|
|
434
|
+
"int": int,
|
|
435
|
+
"float": float,
|
|
436
|
+
"path": str,
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
class Route:
|
|
441
|
+
"""A method and a path pattern such as /loans/{loan_id:int}, bound to a handler."""
|
|
442
|
+
|
|
443
|
+
def __init__(self, methods: list[str], pattern: str, handler: Handler) -> None:
|
|
444
|
+
"""Parse the pattern into literal and parameter segments."""
|
|
445
|
+
if not pattern.startswith("/"):
|
|
446
|
+
raise ValueError("route paths start with /: " + pattern)
|
|
447
|
+
self.methods = methods
|
|
448
|
+
self.pattern = pattern
|
|
449
|
+
self.handler = handler
|
|
450
|
+
self.segments: list[tuple[str | None, str]] = []
|
|
451
|
+
for part in _split(pattern):
|
|
452
|
+
if part.startswith("{") and part.endswith("}"):
|
|
453
|
+
name, kind = part[1:-1], "str"
|
|
454
|
+
if ":" in name:
|
|
455
|
+
name, kind = name.split(":", 1)
|
|
456
|
+
if kind not in _CONVERTERS:
|
|
457
|
+
raise ValueError("unknown path parameter type: " + kind)
|
|
458
|
+
self.segments.append((name, kind))
|
|
459
|
+
else:
|
|
460
|
+
self.segments.append((None, part))
|
|
461
|
+
|
|
462
|
+
def match(self, path: str) -> dict[str, Any] | None:
|
|
463
|
+
"""Return the path parameters when the path matches, else None."""
|
|
464
|
+
parts = _split(path)
|
|
465
|
+
params: dict[str, Any] = {}
|
|
466
|
+
for i, (name, value) in enumerate(self.segments):
|
|
467
|
+
if name is not None and value == "path":
|
|
468
|
+
if i >= len(parts):
|
|
469
|
+
return None
|
|
470
|
+
params[name] = "/".join(parts[i:])
|
|
471
|
+
return params
|
|
472
|
+
if i >= len(parts):
|
|
473
|
+
return None
|
|
474
|
+
if name is None:
|
|
475
|
+
if parts[i] != value:
|
|
476
|
+
return None
|
|
477
|
+
continue
|
|
478
|
+
try:
|
|
479
|
+
params[name] = _CONVERTERS[value](_unquote(parts[i]))
|
|
480
|
+
except ValueError:
|
|
481
|
+
return None
|
|
482
|
+
if len(parts) != len(self.segments):
|
|
483
|
+
return None
|
|
484
|
+
return params
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def _split(path: str) -> list[str]:
|
|
488
|
+
"""Split a path into its non-empty segments."""
|
|
489
|
+
return [part for part in path.split("/") if part]
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def _unquote(text: str) -> str:
|
|
493
|
+
"""Decode percent-escapes in a path segment."""
|
|
494
|
+
if "%" not in text:
|
|
495
|
+
return text
|
|
496
|
+
out = bytearray()
|
|
497
|
+
i = 0
|
|
498
|
+
raw = text.encode()
|
|
499
|
+
while i < len(raw):
|
|
500
|
+
if raw[i] == 37 and i + 2 < len(raw): # %XX
|
|
501
|
+
try:
|
|
502
|
+
out.append(int(raw[i + 1 : i + 3].decode(), 16))
|
|
503
|
+
i += 3
|
|
504
|
+
continue
|
|
505
|
+
except ValueError:
|
|
506
|
+
pass
|
|
507
|
+
out.append(raw[i])
|
|
508
|
+
i += 1
|
|
509
|
+
return out.decode()
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
class App:
|
|
513
|
+
"""A set of routes, the code to run once per worker, and shared state."""
|
|
514
|
+
|
|
515
|
+
def __init__(self) -> None:
|
|
516
|
+
"""Start with no routes and empty state."""
|
|
517
|
+
self.routes: list[Route] = []
|
|
518
|
+
# Any: it holds whatever on_boot puts there
|
|
519
|
+
self.state: Any = State()
|
|
520
|
+
self._boot: list[Callable[[], Any]] = []
|
|
521
|
+
self._booted = False
|
|
522
|
+
|
|
523
|
+
def route(
|
|
524
|
+
self, path: str, methods: tuple[str, ...] | list[str] = ("GET",)
|
|
525
|
+
) -> Callable[[F], F]:
|
|
526
|
+
"""Register a handler for a path and a list of methods."""
|
|
527
|
+
|
|
528
|
+
def register(handler: F) -> F:
|
|
529
|
+
"""Add the handler and return it unchanged."""
|
|
530
|
+
upper = [m.upper() for m in methods]
|
|
531
|
+
self.routes.append(Route(upper, path, handler))
|
|
532
|
+
return handler
|
|
533
|
+
|
|
534
|
+
return register
|
|
535
|
+
|
|
536
|
+
def get(self, path: str) -> Callable[[F], F]:
|
|
537
|
+
"""Register a GET handler."""
|
|
538
|
+
return self.route(path, ("GET",))
|
|
539
|
+
|
|
540
|
+
def post(self, path: str) -> Callable[[F], F]:
|
|
541
|
+
"""Register a POST handler."""
|
|
542
|
+
return self.route(path, ("POST",))
|
|
543
|
+
|
|
544
|
+
def put(self, path: str) -> Callable[[F], F]:
|
|
545
|
+
"""Register a PUT handler."""
|
|
546
|
+
return self.route(path, ("PUT",))
|
|
547
|
+
|
|
548
|
+
def patch(self, path: str) -> Callable[[F], F]:
|
|
549
|
+
"""Register a PATCH handler."""
|
|
550
|
+
return self.route(path, ("PATCH",))
|
|
551
|
+
|
|
552
|
+
def delete(self, path: str) -> Callable[[F], F]:
|
|
553
|
+
"""Register a DELETE handler."""
|
|
554
|
+
return self.route(path, ("DELETE",))
|
|
555
|
+
|
|
556
|
+
def on_boot(self, fn: F) -> F:
|
|
557
|
+
"""Run fn once per worker before the first request: the place for secrets."""
|
|
558
|
+
self._boot.append(fn)
|
|
559
|
+
return fn
|
|
560
|
+
|
|
561
|
+
async def boot(self) -> None:
|
|
562
|
+
"""Run the on_boot hooks, once."""
|
|
563
|
+
if self._booted:
|
|
564
|
+
return
|
|
565
|
+
for fn in self._boot:
|
|
566
|
+
await _maybe_await(fn())
|
|
567
|
+
self._booted = True
|
|
568
|
+
|
|
569
|
+
async def handle(self, request: Request) -> Response:
|
|
570
|
+
"""Route a request to its handler and return a Response."""
|
|
571
|
+
request.app = self
|
|
572
|
+
allowed: list[str] = []
|
|
573
|
+
for route in self.routes:
|
|
574
|
+
params = route.match(request.path)
|
|
575
|
+
if params is None:
|
|
576
|
+
continue
|
|
577
|
+
if request.method not in route.methods:
|
|
578
|
+
allowed.extend(route.methods)
|
|
579
|
+
continue
|
|
580
|
+
request.params = params
|
|
581
|
+
try:
|
|
582
|
+
await self.boot()
|
|
583
|
+
return _to_response(
|
|
584
|
+
await _maybe_await(route.handler(request, **params))
|
|
585
|
+
)
|
|
586
|
+
except HTTPError as error:
|
|
587
|
+
return _error_response(error)
|
|
588
|
+
if allowed:
|
|
589
|
+
return _error_response(
|
|
590
|
+
HTTPError(405, headers={"allow": ", ".join(allowed)})
|
|
591
|
+
)
|
|
592
|
+
return _error_response(HTTPError(404))
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
async def dispatch(app: App, raw: str) -> str:
|
|
596
|
+
"""Handle one request from the shim.
|
|
597
|
+
|
|
598
|
+
In and out, the framing is JSON metadata, a newline, then the body as is:
|
|
599
|
+
nesting the body inside the JSON would cost MicroPython about a second per
|
|
600
|
+
megabyte to escape and unescape. JSON never contains a raw newline.
|
|
601
|
+
|
|
602
|
+
Unexpected errors become a 500 for the caller, with the traceback in "log" for
|
|
603
|
+
the function's logs. Background tasks are kept under "bg" until the shim runs
|
|
604
|
+
them with run_background.
|
|
605
|
+
"""
|
|
606
|
+
split = raw.find("\n")
|
|
607
|
+
data = json.loads(raw[:split] if split >= 0 else raw)
|
|
608
|
+
request = Request(
|
|
609
|
+
data.get("method", "GET"),
|
|
610
|
+
data.get("path", "/"),
|
|
611
|
+
data.get("query"),
|
|
612
|
+
data.get("headers"),
|
|
613
|
+
raw[split + 1 :] if split >= 0 else data.get("body", ""),
|
|
614
|
+
)
|
|
615
|
+
log: str | None = None
|
|
616
|
+
try:
|
|
617
|
+
response = await app.handle(request)
|
|
618
|
+
except Exception as error:
|
|
619
|
+
log = format_exception(error)
|
|
620
|
+
response = _error_response(HTTPError(500))
|
|
621
|
+
wire = response.wire()
|
|
622
|
+
text = wire.pop("body")
|
|
623
|
+
wire["log"] = log
|
|
624
|
+
wire["bg"] = None
|
|
625
|
+
if request._background:
|
|
626
|
+
_next_id[0] += 1
|
|
627
|
+
_pending[_next_id[0]] = request._background
|
|
628
|
+
wire["bg"] = _next_id[0]
|
|
629
|
+
return json.dumps(wire) + "\n" + text
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
async def run_background(task_id: int) -> str:
|
|
633
|
+
"""Run a request's background tasks; return their tracebacks, if any, joined."""
|
|
634
|
+
logs: list[str] = []
|
|
635
|
+
for task in _pending.pop(task_id, []):
|
|
636
|
+
try:
|
|
637
|
+
await _maybe_await(task)
|
|
638
|
+
except Exception as error:
|
|
639
|
+
logs.append(format_exception(error))
|
|
640
|
+
return "\n".join(logs)
|