supython 0.5.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.
Files changed (188) hide show
  1. supython/__init__.py +8 -0
  2. supython/admin/__init__.py +3 -0
  3. supython/admin/api/__init__.py +24 -0
  4. supython/admin/api/auth.py +118 -0
  5. supython/admin/api/auth_templates.py +67 -0
  6. supython/admin/api/auth_users.py +225 -0
  7. supython/admin/api/db.py +174 -0
  8. supython/admin/api/functions.py +92 -0
  9. supython/admin/api/jobs.py +192 -0
  10. supython/admin/api/ops.py +224 -0
  11. supython/admin/api/realtime.py +281 -0
  12. supython/admin/api/service_auth.py +49 -0
  13. supython/admin/api/service_auth_templates.py +83 -0
  14. supython/admin/api/service_auth_users.py +346 -0
  15. supython/admin/api/service_db.py +214 -0
  16. supython/admin/api/service_functions.py +287 -0
  17. supython/admin/api/service_jobs.py +282 -0
  18. supython/admin/api/service_ops.py +213 -0
  19. supython/admin/api/service_realtime.py +30 -0
  20. supython/admin/api/service_storage.py +220 -0
  21. supython/admin/api/storage.py +117 -0
  22. supython/admin/api/system.py +37 -0
  23. supython/admin/audit.py +29 -0
  24. supython/admin/deps.py +22 -0
  25. supython/admin/errors.py +16 -0
  26. supython/admin/schemas.py +310 -0
  27. supython/admin/session.py +52 -0
  28. supython/admin/spa.py +38 -0
  29. supython/admin/static/assets/Alert-dluGVkos.js +49 -0
  30. supython/admin/static/assets/Audit-Njung3HI.js +2 -0
  31. supython/admin/static/assets/Backups-DzPlFgrm.js +2 -0
  32. supython/admin/static/assets/Buckets-ByacGkU1.js +2 -0
  33. supython/admin/static/assets/Channels-BoIuTtam.js +353 -0
  34. supython/admin/static/assets/ChevronRight-CtQH1EQ1.js +2 -0
  35. supython/admin/static/assets/CodeViewer-Bqy7-wvH.js +2 -0
  36. supython/admin/static/assets/Crons-B67vc39F.js +2 -0
  37. supython/admin/static/assets/DashboardView-CUTFVL6k.js +2 -0
  38. supython/admin/static/assets/DataTable-COAAWEft.js +747 -0
  39. supython/admin/static/assets/DescriptionsItem-P8JUDaBs.js +75 -0
  40. supython/admin/static/assets/DrawerContent-TpYTFgF1.js +139 -0
  41. supython/admin/static/assets/Empty-cr2r7e2u.js +25 -0
  42. supython/admin/static/assets/EmptyState-DeDck-OL.js +2 -0
  43. supython/admin/static/assets/Grid-hFkp9F4P.js +2 -0
  44. supython/admin/static/assets/Input-DppYTq9C.js +259 -0
  45. supython/admin/static/assets/Invoke-DW3Nveeh.js +2 -0
  46. supython/admin/static/assets/JsonField-DibyJgun.js +2 -0
  47. supython/admin/static/assets/LoginView-BjLyE3Ds.css +1 -0
  48. supython/admin/static/assets/LoginView-CoOjECT_.js +111 -0
  49. supython/admin/static/assets/Logs-D9WYrnIT.js +2 -0
  50. supython/admin/static/assets/Logs-DS1XPa0h.css +1 -0
  51. supython/admin/static/assets/Migrations-DOSC2ddQ.js +2 -0
  52. supython/admin/static/assets/ObjectBrowser-_5w8vOX8.js +2 -0
  53. supython/admin/static/assets/Queue-CywZs6vI.js +2 -0
  54. supython/admin/static/assets/RefreshTokens-Ccjr53jg.js +2 -0
  55. supython/admin/static/assets/RlsEditor-BSlH9vSc.js +2 -0
  56. supython/admin/static/assets/Routes-BiLXE49D.js +2 -0
  57. supython/admin/static/assets/Routes-C-ianIGD.css +1 -0
  58. supython/admin/static/assets/SchemaBrowser-DKy2_KQi.css +1 -0
  59. supython/admin/static/assets/SchemaBrowser-XFvFbtDB.js +2 -0
  60. supython/admin/static/assets/Select-DIzZyRZb.js +434 -0
  61. supython/admin/static/assets/Space-n5-XcguU.js +400 -0
  62. supython/admin/static/assets/SqlEditor-b8pTsILY.js +3 -0
  63. supython/admin/static/assets/SqlWorkspace-BUS7IntH.js +104 -0
  64. supython/admin/static/assets/TableData-CQIagLKn.js +2 -0
  65. supython/admin/static/assets/Tag-D1fOKpTH.js +72 -0
  66. supython/admin/static/assets/Templates-BS-ugkdq.js +2 -0
  67. supython/admin/static/assets/Thing-CEAniuMg.js +107 -0
  68. supython/admin/static/assets/Users-wzwajhlh.js +2 -0
  69. supython/admin/static/assets/_plugin-vue_export-helper-DGA9ry_j.js +1 -0
  70. supython/admin/static/assets/dist-VXIJLCYq.js +13 -0
  71. supython/admin/static/assets/format-length-CGCY1rMh.js +2 -0
  72. supython/admin/static/assets/get-Ca6unauB.js +2 -0
  73. supython/admin/static/assets/index-CeE6v959.js +951 -0
  74. supython/admin/static/assets/pinia-COXwfrOX.js +2 -0
  75. supython/admin/static/assets/resources-Bt6thQCD.js +44 -0
  76. supython/admin/static/assets/use-locale-mtgM0a3a.js +2 -0
  77. supython/admin/static/assets/use-merged-state-BvhkaHNX.js +2 -0
  78. supython/admin/static/assets/useConfirm-tMjvBFXR.js +2 -0
  79. supython/admin/static/assets/useResource-C_rJCY8C.js +2 -0
  80. supython/admin/static/assets/useTable-CnZc5zhi.js +363 -0
  81. supython/admin/static/assets/useTable-Dg0XlRlq.css +1 -0
  82. supython/admin/static/assets/useToast-DsZKx0IX.js +2 -0
  83. supython/admin/static/assets/utils-sbXoq7Ir.js +2 -0
  84. supython/admin/static/favicon.svg +1 -0
  85. supython/admin/static/icons.svg +24 -0
  86. supython/admin/static/index.html +24 -0
  87. supython/app.py +149 -0
  88. supython/auth/__init__.py +3 -0
  89. supython/auth/_email_job.py +11 -0
  90. supython/auth/providers/__init__.py +34 -0
  91. supython/auth/providers/github.py +22 -0
  92. supython/auth/providers/google.py +19 -0
  93. supython/auth/providers/oauth.py +56 -0
  94. supython/auth/providers/registry.py +16 -0
  95. supython/auth/ratelimit.py +39 -0
  96. supython/auth/router.py +282 -0
  97. supython/auth/schemas.py +79 -0
  98. supython/auth/service.py +587 -0
  99. supython/body_size.py +184 -0
  100. supython/cli.py +1653 -0
  101. supython/client/__init__.py +67 -0
  102. supython/client/_auth.py +249 -0
  103. supython/client/_client.py +145 -0
  104. supython/client/_config.py +92 -0
  105. supython/client/_functions.py +69 -0
  106. supython/client/_storage.py +255 -0
  107. supython/client/py.typed +0 -0
  108. supython/db.py +151 -0
  109. supython/db_admin.py +8 -0
  110. supython/functions/__init__.py +19 -0
  111. supython/functions/context.py +262 -0
  112. supython/functions/loader.py +307 -0
  113. supython/functions/router.py +228 -0
  114. supython/functions/schemas.py +50 -0
  115. supython/gen/__init__.py +5 -0
  116. supython/gen/_introspect.py +137 -0
  117. supython/gen/types_py.py +270 -0
  118. supython/gen/types_ts.py +365 -0
  119. supython/health.py +229 -0
  120. supython/hooks.py +117 -0
  121. supython/jobs/__init__.py +31 -0
  122. supython/jobs/backends.py +97 -0
  123. supython/jobs/context.py +58 -0
  124. supython/jobs/cron.py +152 -0
  125. supython/jobs/cron_inproc.py +118 -0
  126. supython/jobs/decorators.py +76 -0
  127. supython/jobs/registry.py +79 -0
  128. supython/jobs/router.py +136 -0
  129. supython/jobs/schemas.py +92 -0
  130. supython/jobs/service.py +311 -0
  131. supython/jobs/worker.py +219 -0
  132. supython/jwks.py +257 -0
  133. supython/keyset.py +279 -0
  134. supython/logging_config.py +291 -0
  135. supython/mail.py +33 -0
  136. supython/mailer.py +65 -0
  137. supython/migrate.py +81 -0
  138. supython/migrations/0001_extensions_and_roles.sql +46 -0
  139. supython/migrations/0002_auth_schema.sql +66 -0
  140. supython/migrations/0003_demo_todos.sql +42 -0
  141. supython/migrations/0004_auth_v0_2.sql +47 -0
  142. supython/migrations/0005_storage_schema.sql +117 -0
  143. supython/migrations/0006_realtime_schema.sql +206 -0
  144. supython/migrations/0007_jobs_schema.sql +254 -0
  145. supython/migrations/0008_jobs_last_error.sql +56 -0
  146. supython/migrations/0009_auth_rate_limits.sql +33 -0
  147. supython/migrations/0010_worker_heartbeat.sql +14 -0
  148. supython/migrations/0011_admin_schema.sql +45 -0
  149. supython/migrations/0012_auth_banned_until.sql +10 -0
  150. supython/migrations/0013_email_templates.sql +19 -0
  151. supython/migrations/0014_realtime_payload_warning.sql +96 -0
  152. supython/migrations/0015_backups_schema.sql +14 -0
  153. supython/passwords.py +15 -0
  154. supython/realtime/__init__.py +6 -0
  155. supython/realtime/broker.py +814 -0
  156. supython/realtime/protocol.py +234 -0
  157. supython/realtime/router.py +184 -0
  158. supython/realtime/schemas.py +207 -0
  159. supython/realtime/service.py +261 -0
  160. supython/realtime/topics.py +175 -0
  161. supython/realtime/websocket.py +586 -0
  162. supython/scaffold/__init__.py +5 -0
  163. supython/scaffold/init_project.py +133 -0
  164. supython/scaffold/templates/Caddyfile.tmpl +4 -0
  165. supython/scaffold/templates/README.md.tmpl +22 -0
  166. supython/scaffold/templates/docker-compose.prod.yml.tmpl +84 -0
  167. supython/scaffold/templates/docker-compose.yml.tmpl +41 -0
  168. supython/scaffold/templates/docker_postgres_Dockerfile.tmpl +9 -0
  169. supython/scaffold/templates/docker_postgres_postgresql.conf.tmpl +3 -0
  170. supython/scaffold/templates/env.example.tmpl +149 -0
  171. supython/scaffold/templates/functions_README.md.tmpl +21 -0
  172. supython/scaffold/templates/gitignore.tmpl +14 -0
  173. supython/scaffold/templates/migrations/.gitkeep +0 -0
  174. supython/secretset.py +347 -0
  175. supython/security_headers.py +78 -0
  176. supython/settings.py +198 -0
  177. supython/storage/__init__.py +5 -0
  178. supython/storage/backends.py +392 -0
  179. supython/storage/router.py +341 -0
  180. supython/storage/schemas.py +50 -0
  181. supython/storage/service.py +445 -0
  182. supython/storage/signing.py +119 -0
  183. supython/tokens.py +85 -0
  184. supython-0.5.0.dist-info/METADATA +714 -0
  185. supython-0.5.0.dist-info/RECORD +188 -0
  186. supython-0.5.0.dist-info/WHEEL +4 -0
  187. supython-0.5.0.dist-info/entry_points.txt +2 -0
  188. supython-0.5.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,307 @@
1
+ """Discovery + hot-reload of user functions on disk.
2
+
3
+ Walks ``settings.functions_dir`` and turns every valid ``*.py`` into a
4
+ :class:`~.schemas.FunctionMeta` keyed by its route name (the relative path
5
+ minus the ``.py`` suffix).
6
+
7
+ Naming rules per path segment: ``[a-z0-9][a-z0-9_-]*``. Anything starting
8
+ with ``_`` (e.g. ``__init__.py``, ``_helpers.py``) and ``__pycache__`` are
9
+ ignored — handlers don't need to be importable as a package, only walkable
10
+ as files.
11
+
12
+ Each module imported under a synthetic ``supython._functions.<dotted>``
13
+ package via ``importlib.util.spec_from_file_location`` so user code does not
14
+ need to be on ``sys.path``. In hot-reload mode every ``get(name)`` ``stat()``s
15
+ the file and ``importlib.reload()``s if the mtime moved — no watcher
16
+ threads, deterministic in tests.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import importlib
22
+ import importlib.util
23
+ import inspect
24
+ import logging
25
+ import re
26
+ import sys
27
+ import time
28
+ from collections.abc import Iterable
29
+ from pathlib import Path
30
+ from types import ModuleType
31
+ from typing import Any
32
+
33
+ from .schemas import ALLOWED_METHODS, AuthMode, FunctionMeta
34
+
35
+ logger = logging.getLogger(__name__)
36
+
37
+
38
+ _SEGMENT_RE = re.compile(r"^[a-z0-9][a-z0-9_-]*$")
39
+ _SYNTHETIC_PKG = "supython._functions"
40
+ # How often a hot-reload `get()` may rescan the tree for *new* files.
41
+ _RESCAN_DEBOUNCE_S = 1.0
42
+
43
+
44
+ class FunctionLoadError(Exception):
45
+ """Raised at startup (when hot reload is off) for a malformed module."""
46
+
47
+
48
+ # ---------------------------------------------------------------------------
49
+ # Helpers
50
+ # ---------------------------------------------------------------------------
51
+
52
+
53
+ def _ensure_synthetic_pkg() -> None:
54
+ """Make ``supython._functions`` resolvable without it existing on disk."""
55
+ if _SYNTHETIC_PKG in sys.modules:
56
+ return
57
+ spec = importlib.util.spec_from_loader(_SYNTHETIC_PKG, loader=None)
58
+ pkg = importlib.util.module_from_spec(spec) # type: ignore[arg-type]
59
+ pkg.__path__ = [] # marks it as a package
60
+ sys.modules[_SYNTHETIC_PKG] = pkg
61
+
62
+
63
+ def _route_name_for(root: Path, file: Path) -> str | None:
64
+ """Return the route name for ``file`` under ``root``, or None if invalid."""
65
+ rel = file.relative_to(root).with_suffix("")
66
+ parts = rel.parts
67
+ if not parts:
68
+ return None
69
+ for seg in parts:
70
+ if seg.startswith("_") or seg == "__pycache__":
71
+ return None
72
+ if not _SEGMENT_RE.match(seg):
73
+ return None
74
+ return "/".join(parts)
75
+
76
+
77
+ def _module_name_for(name: str) -> str:
78
+ safe = name.replace("/", ".").replace("-", "_")
79
+ return f"{_SYNTHETIC_PKG}.{safe}"
80
+
81
+
82
+ def _validate_module(mod: ModuleType, file: Path) -> tuple[list[str], AuthMode]:
83
+ handler = getattr(mod, "handler", None)
84
+ if handler is None or not inspect.iscoroutinefunction(handler):
85
+ raise FunctionLoadError(
86
+ f"{file}: must define `async def handler(req, ctx)`"
87
+ )
88
+
89
+ raw_methods: Any = getattr(mod, "methods", None)
90
+ methods: list[str]
91
+ if raw_methods is None:
92
+ methods = ["POST"]
93
+ else:
94
+ if not isinstance(raw_methods, Iterable) or isinstance(raw_methods, (str, bytes)):
95
+ raise FunctionLoadError(
96
+ f"{file}: `methods` must be a list of HTTP verbs"
97
+ )
98
+ upper = [str(m).upper() for m in raw_methods]
99
+ unknown = [m for m in upper if m not in ALLOWED_METHODS]
100
+ if unknown:
101
+ raise FunctionLoadError(
102
+ f"{file}: unsupported method(s) in `methods`: {unknown}"
103
+ )
104
+ if not upper:
105
+ raise FunctionLoadError(f"{file}: `methods` must not be empty")
106
+ methods = upper
107
+
108
+ raw_auth: Any = getattr(mod, "auth", "authenticated")
109
+ if raw_auth not in ("authenticated", "anon"):
110
+ raise FunctionLoadError(
111
+ f"{file}: `auth` must be 'authenticated' or 'anon', got {raw_auth!r}"
112
+ )
113
+
114
+ return methods, raw_auth # type: ignore[return-value]
115
+
116
+
117
+ def _import_file(module_name: str, file: Path) -> ModuleType:
118
+ spec = importlib.util.spec_from_file_location(module_name, file)
119
+ if spec is None or spec.loader is None:
120
+ raise FunctionLoadError(f"{file}: could not build import spec")
121
+ module = importlib.util.module_from_spec(spec)
122
+ sys.modules[module_name] = module
123
+ try:
124
+ spec.loader.exec_module(module)
125
+ except BaseException:
126
+ sys.modules.pop(module_name, None)
127
+ raise
128
+ return module
129
+
130
+
131
+ # ---------------------------------------------------------------------------
132
+ # Registry
133
+ # ---------------------------------------------------------------------------
134
+
135
+
136
+ class FunctionRegistry:
137
+ """In-process registry of discovered functions.
138
+
139
+ Construct once per app (the ``db.lifespan`` extension calls ``discover``
140
+ after ``init_pool``). In hot-reload mode ``get`` is the per-request entry
141
+ point; otherwise the snapshot taken at ``discover`` time is final.
142
+ """
143
+
144
+ def __init__(self, root: Path, *, hot_reload: bool = True) -> None:
145
+ self._root = Path(root)
146
+ self._hot_reload = hot_reload
147
+ self._metas: dict[str, FunctionMeta] = {}
148
+ self._last_rescan: float = 0.0
149
+
150
+ # ------------------------------------------------------------------ public
151
+
152
+ @property
153
+ def root(self) -> Path:
154
+ return self._root
155
+
156
+ @property
157
+ def hot_reload(self) -> bool:
158
+ return self._hot_reload
159
+
160
+ def discover(self) -> None:
161
+ """Walk the tree once. Safe to call repeatedly; idempotent."""
162
+ _ensure_synthetic_pkg()
163
+ if not self._root.exists():
164
+ logger.info("functions: directory %s does not exist; skipping", self._root)
165
+ self._metas = {}
166
+ return
167
+
168
+ seen: set[str] = set()
169
+ for file in sorted(self._root.rglob("*.py")):
170
+ name = _route_name_for(self._root, file)
171
+ if name is None:
172
+ continue
173
+ seen.add(name)
174
+ self._load_or_skip(name, file)
175
+
176
+ # Drop entries whose files vanished between discoveries.
177
+ for stale in set(self._metas) - seen:
178
+ self._drop(stale)
179
+ self._last_rescan = time.monotonic()
180
+
181
+ def get(self, name: str) -> FunctionMeta | None:
182
+ """Return meta for ``name``, applying hot-reload if enabled.
183
+
184
+ Returns ``None`` if the function is not (or no longer) registered.
185
+ Validation errors during reload demote to None and log; this matches
186
+ dev ergonomics — a broken save shouldn't kill unrelated routes.
187
+ """
188
+ if self._hot_reload:
189
+ self._maybe_rescan_for_new_files()
190
+
191
+ meta = self._metas.get(name)
192
+ if meta is None:
193
+ return None
194
+
195
+ if not self._hot_reload:
196
+ return meta
197
+
198
+ try:
199
+ stat = meta.path.stat()
200
+ except FileNotFoundError:
201
+ self._drop(name)
202
+ return None
203
+
204
+ if stat.st_mtime > meta.mtime:
205
+ try:
206
+ self._reload(meta)
207
+ except FunctionLoadError as exc:
208
+ logger.warning("functions: reload failed for %s: %s", name, exc)
209
+ self._drop(name)
210
+ return None
211
+ return self._metas.get(name)
212
+
213
+ def list(self) -> list[FunctionMeta]:
214
+ return sorted(self._metas.values(), key=lambda m: m.name)
215
+
216
+ # ---------------------------------------------------------------- internal
217
+
218
+ def _maybe_rescan_for_new_files(self) -> None:
219
+ now = time.monotonic()
220
+ if now - self._last_rescan < _RESCAN_DEBOUNCE_S:
221
+ return
222
+ self._last_rescan = now
223
+ if not self._root.exists():
224
+ return
225
+ for file in self._root.rglob("*.py"):
226
+ name = _route_name_for(self._root, file)
227
+ if name is None or name in self._metas:
228
+ continue
229
+ self._load_or_skip(name, file)
230
+
231
+ def _load_or_skip(self, name: str, file: Path) -> None:
232
+ try:
233
+ self._load(name, file)
234
+ except FunctionLoadError as exc:
235
+ if self._hot_reload:
236
+ logger.warning("functions: skipping %s: %s", name, exc)
237
+ return
238
+ raise
239
+
240
+ def _load(self, name: str, file: Path) -> None:
241
+ module_name = _module_name_for(name)
242
+ # Drop any stale module entry so re-import from the file actually runs.
243
+ sys.modules.pop(module_name, None)
244
+ module = _import_file(module_name, file)
245
+ methods, auth = _validate_module(module, file)
246
+ self._metas[name] = FunctionMeta(
247
+ name=name,
248
+ path=file.resolve(),
249
+ module_name=module_name,
250
+ methods=methods,
251
+ auth=auth,
252
+ mtime=file.stat().st_mtime,
253
+ handler=module.handler,
254
+ )
255
+
256
+ def _reload(self, meta: FunctionMeta) -> None:
257
+ # importlib.reload() cannot locate modules under the synthetic package
258
+ # via _find_spec; always use the pop-then-reimport pattern instead.
259
+ sys.modules.pop(meta.module_name, None)
260
+ module = _import_file(meta.module_name, meta.path)
261
+ methods, auth = _validate_module(module, meta.path)
262
+ self._metas[meta.name] = FunctionMeta(
263
+ name=meta.name,
264
+ path=meta.path,
265
+ module_name=meta.module_name,
266
+ methods=methods,
267
+ auth=auth,
268
+ mtime=meta.path.stat().st_mtime,
269
+ handler=module.handler,
270
+ )
271
+
272
+ def _drop(self, name: str) -> None:
273
+ meta = self._metas.pop(name, None)
274
+ if meta is not None:
275
+ sys.modules.pop(meta.module_name, None)
276
+
277
+
278
+ # ---------------------------------------------------------------------------
279
+ # Process-wide singleton
280
+ # ---------------------------------------------------------------------------
281
+
282
+
283
+ _registry: FunctionRegistry | None = None
284
+
285
+
286
+ def get_registry() -> FunctionRegistry:
287
+ """Return the process-wide registry (built lazily from settings)."""
288
+ from ..settings import get_settings
289
+
290
+ global _registry
291
+ if _registry is None:
292
+ s = get_settings()
293
+ _registry = FunctionRegistry(
294
+ Path(s.functions_dir),
295
+ hot_reload=s.functions_hot_reload,
296
+ )
297
+ return _registry
298
+
299
+
300
+ def set_registry(registry: FunctionRegistry | None) -> None:
301
+ """Override the singleton (tests use this; lifespan calls with a fresh one)."""
302
+ global _registry
303
+ _registry = registry
304
+
305
+
306
+ def reset_registry() -> None:
307
+ set_registry(None)
@@ -0,0 +1,228 @@
1
+ """HTTP dispatcher for filesystem-loaded functions.
2
+
3
+ Single ``/functions/{name:path}`` route that:
4
+
5
+ 1. Looks the function up in the registry (mtime-checked when hot reload is on).
6
+ 2. Enforces ``methods`` and ``auth`` declared by the module.
7
+ 3. Body-size guards via ``settings.functions_max_body_bytes`` (the cap is on
8
+ what the dispatcher will eagerly buffer; handlers can still consume
9
+ ``request.stream()`` directly for true streaming uploads).
10
+ 4. Builds a :class:`~.context.Ctx` against a role-scoped connection from
11
+ ``db.as_role`` and invokes the handler.
12
+ 5. Translates the return value into a FastAPI response.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import asyncio
18
+ import logging
19
+ from typing import Any
20
+
21
+ import jwt
22
+ from fastapi import APIRouter, HTTPException, Request, Response, status
23
+ from fastapi.responses import JSONResponse
24
+ from pydantic import BaseModel
25
+
26
+ from .. import db, tokens
27
+ from ..settings import get_settings
28
+ from ..storage import service as storage_service
29
+ from .context import FunctionUser, build_ctx
30
+ from .loader import get_registry
31
+ from .schemas import (
32
+ ERR_BODY_TOO_LARGE,
33
+ ERR_FUNCTION_ERROR,
34
+ ERR_FUNCTION_TIMEOUT,
35
+ ERR_INVALID_RETURN,
36
+ ERR_INVALID_TOKEN,
37
+ ERR_METHOD_NOT_ALLOWED,
38
+ ERR_NOT_FOUND,
39
+ FunctionMeta,
40
+ )
41
+
42
+ logger = logging.getLogger(__name__)
43
+
44
+
45
+ router = APIRouter(prefix="/functions", tags=["functions"])
46
+
47
+
48
+ # ---------------------------------------------------------------------------
49
+ # Helpers
50
+ # ---------------------------------------------------------------------------
51
+
52
+
53
+ def _err(status_code: int, code: str, message: str) -> HTTPException:
54
+ return HTTPException(
55
+ status_code=status_code,
56
+ detail={"code": code, "message": message},
57
+ )
58
+
59
+
60
+ def _extract_bearer(request: Request) -> str | None:
61
+ auth = request.headers.get("authorization")
62
+ if not auth or not auth.lower().startswith("bearer "):
63
+ return None
64
+ return auth.split(" ", 1)[1].strip() or None
65
+
66
+
67
+ def _decode_or_none(raw: str | None) -> dict[str, Any] | None:
68
+ if raw is None:
69
+ return None
70
+ try:
71
+ return tokens.decode_access_token(raw)
72
+ except jwt.PyJWTError:
73
+ return None
74
+
75
+
76
+ def _enforce_body_limit(request: Request, max_bytes: int) -> None:
77
+ cl = request.headers.get("content-length")
78
+ if cl is None:
79
+ return
80
+ try:
81
+ n = int(cl)
82
+ except ValueError:
83
+ return
84
+ if n > max_bytes:
85
+ raise _err(
86
+ status.HTTP_413_CONTENT_TOO_LARGE,
87
+ ERR_BODY_TOO_LARGE,
88
+ f"Body exceeds {max_bytes} bytes",
89
+ )
90
+
91
+
92
+ def _translate(result: Any) -> Response:
93
+ """Map handler returns to a Response. Order matters — Response first."""
94
+ if isinstance(result, Response):
95
+ return result
96
+ if isinstance(result, tuple) and len(result) == 2 and isinstance(result[0], int):
97
+ status_code, payload = result
98
+ inner = _translate(payload)
99
+ inner.status_code = status_code
100
+ return inner
101
+ if isinstance(result, BaseModel):
102
+ return JSONResponse(content=result.model_dump(mode="json"))
103
+ if isinstance(result, (dict, list)) or result is None:
104
+ return JSONResponse(content=result)
105
+ if isinstance(result, (str, int, bool)):
106
+ return JSONResponse(content=result)
107
+ if isinstance(result, bytes):
108
+ return Response(content=result, media_type="application/octet-stream")
109
+ raise _err(
110
+ status.HTTP_500_INTERNAL_SERVER_ERROR,
111
+ ERR_INVALID_RETURN,
112
+ f"Handler returned unsupported type {type(result).__name__!r}",
113
+ )
114
+
115
+
116
+ # ---------------------------------------------------------------------------
117
+ # Dispatcher
118
+ # ---------------------------------------------------------------------------
119
+
120
+
121
+ @router.api_route(
122
+ "/{name:path}",
123
+ methods=["GET", "POST", "PUT", "PATCH", "DELETE"],
124
+ )
125
+ async def dispatch(name: str, request: Request) -> Response:
126
+ meta = get_registry().get(name)
127
+ if meta is None:
128
+ raise _err(status.HTTP_404_NOT_FOUND, ERR_NOT_FOUND, name)
129
+ if request.method not in meta.methods:
130
+ raise _err(
131
+ status.HTTP_405_METHOD_NOT_ALLOWED,
132
+ ERR_METHOD_NOT_ALLOWED,
133
+ request.method,
134
+ )
135
+ return await _invoke(meta, request)
136
+
137
+
138
+ async def _invoke(meta: FunctionMeta, request: Request) -> Response:
139
+ settings = get_settings()
140
+ _enforce_body_limit(request, settings.functions_max_body_bytes)
141
+
142
+ raw_jwt = _extract_bearer(request)
143
+ claims = _decode_or_none(raw_jwt)
144
+
145
+ if meta.auth == "authenticated" and (raw_jwt is None or claims is None):
146
+ raise _err(
147
+ status.HTTP_401_UNAUTHORIZED,
148
+ ERR_INVALID_TOKEN,
149
+ "Missing or invalid bearer token",
150
+ )
151
+
152
+ if claims is not None:
153
+ allowed = settings.db_allowed_roles
154
+ role = claims.get("role") if isinstance(claims.get("role"), str) else "anon"
155
+
156
+ if role not in allowed:
157
+ raise _err(
158
+ status.HTTP_401_UNAUTHORIZED,
159
+ ERR_INVALID_TOKEN,
160
+ f"Invalid role: {role!r}",
161
+ )
162
+ user: FunctionUser | None = FunctionUser.from_claims(claims)
163
+ ctx_claims = claims
164
+ else:
165
+ role = "anon"
166
+ user = None
167
+ ctx_claims = {"role": "anon"}
168
+
169
+ handler = meta.handler
170
+ assert handler is not None # validated at load time
171
+
172
+ try:
173
+ async with db.as_role(role, ctx_claims) as conn:
174
+ ctx = build_ctx(
175
+ conn=conn,
176
+ user=user,
177
+ request=request,
178
+ raw_jwt=raw_jwt if meta.auth == "authenticated" or raw_jwt else None,
179
+ settings=settings,
180
+ )
181
+ try:
182
+ # wait_for wraps only the handler so a timeout cancels user
183
+ # code while still letting `as_role`'s transaction roll back
184
+ # cleanly on the way out.
185
+ result = await asyncio.wait_for(
186
+ handler(request, ctx),
187
+ timeout=settings.functions_max_handler_seconds,
188
+ )
189
+ finally:
190
+ try:
191
+ await ctx.postgrest.aclose()
192
+ except Exception:
193
+ logger.warning("functions: postgrest close failed for %s", meta.name, exc_info=True)
194
+ except HTTPException:
195
+ raise
196
+ except storage_service.StorageError as exc:
197
+ raise HTTPException(
198
+ status_code=exc.status,
199
+ detail={"code": exc.code, "message": exc.message},
200
+ ) from exc
201
+ except TimeoutError:
202
+ logger.warning(
203
+ "functions: handler %s exceeded %.2fs timeout",
204
+ meta.name,
205
+ settings.functions_max_handler_seconds,
206
+ )
207
+ raise _err(
208
+ status.HTTP_504_GATEWAY_TIMEOUT,
209
+ ERR_FUNCTION_TIMEOUT,
210
+ f"Function exceeded {settings.functions_max_handler_seconds}s",
211
+ ) from None
212
+ except (KeyboardInterrupt, SystemExit):
213
+ # Lifecycle signals must reach the server, never become a 500.
214
+ raise
215
+ except asyncio.CancelledError:
216
+ # Real client-disconnect / shutdown cancellation. wait_for converts
217
+ # its internal cancellation to TimeoutError above, so this branch
218
+ # only fires for cancellations originating outside the dispatcher.
219
+ raise
220
+ except Exception:
221
+ logger.warning("functions: handler %s raised", meta.name, exc_info=True)
222
+ raise _err(
223
+ status.HTTP_500_INTERNAL_SERVER_ERROR,
224
+ ERR_FUNCTION_ERROR,
225
+ "Function raised an unhandled exception",
226
+ ) from None
227
+
228
+ return _translate(result)
@@ -0,0 +1,50 @@
1
+ """Shared types for the functions subsystem.
2
+
3
+ Kept dependency-free so the loader and the dispatcher can both import from
4
+ here without cycling through ``context.py`` (which pulls in storage/mailer).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections.abc import Awaitable, Callable
10
+ from dataclasses import dataclass, field
11
+ from pathlib import Path
12
+ from typing import TYPE_CHECKING, Any, Literal
13
+
14
+ if TYPE_CHECKING: # pragma: no cover - only for type checking
15
+ from fastapi import Request
16
+
17
+ from .context import Ctx
18
+
19
+
20
+ HandlerFn = Callable[["Request", "Ctx"], Awaitable[Any]]
21
+
22
+ AuthMode = Literal["authenticated", "anon"]
23
+
24
+ ALLOWED_METHODS: frozenset[str] = frozenset({"GET", "POST", "PUT", "PATCH", "DELETE"})
25
+
26
+
27
+ @dataclass
28
+ class FunctionMeta:
29
+ """Everything the dispatcher needs to invoke one user function."""
30
+
31
+ name: str # route name, e.g. "payments/webhook"
32
+ path: Path # absolute file path on disk
33
+ module_name: str # synthetic dotted name under supython._functions
34
+ methods: list[str] = field(default_factory=lambda: ["POST"])
35
+ auth: AuthMode = "authenticated"
36
+ mtime: float = 0.0
37
+ handler: HandlerFn | None = None
38
+
39
+
40
+ # ---------------------------------------------------------------------------
41
+ # Error codes used by the dispatcher (kept here so tests can import them).
42
+ # ---------------------------------------------------------------------------
43
+
44
+ ERR_NOT_FOUND = "function_not_found"
45
+ ERR_METHOD_NOT_ALLOWED = "method_not_allowed"
46
+ ERR_BODY_TOO_LARGE = "body_too_large"
47
+ ERR_INVALID_TOKEN = "invalid_token"
48
+ ERR_INVALID_RETURN = "invalid_function_return"
49
+ ERR_FUNCTION_ERROR = "function_error"
50
+ ERR_FUNCTION_TIMEOUT = "function_timeout"
@@ -0,0 +1,5 @@
1
+ """Code generation for `supython gen`."""
2
+ from .types_py import render_types_py
3
+ from .types_ts import render_types_ts
4
+
5
+ __all__ = ["render_types_py", "render_types_ts"]