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 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)