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.
- supython/__init__.py +8 -0
- supython/admin/__init__.py +3 -0
- supython/admin/api/__init__.py +24 -0
- supython/admin/api/auth.py +118 -0
- supython/admin/api/auth_templates.py +67 -0
- supython/admin/api/auth_users.py +225 -0
- supython/admin/api/db.py +174 -0
- supython/admin/api/functions.py +92 -0
- supython/admin/api/jobs.py +192 -0
- supython/admin/api/ops.py +224 -0
- supython/admin/api/realtime.py +281 -0
- supython/admin/api/service_auth.py +49 -0
- supython/admin/api/service_auth_templates.py +83 -0
- supython/admin/api/service_auth_users.py +346 -0
- supython/admin/api/service_db.py +214 -0
- supython/admin/api/service_functions.py +287 -0
- supython/admin/api/service_jobs.py +282 -0
- supython/admin/api/service_ops.py +213 -0
- supython/admin/api/service_realtime.py +30 -0
- supython/admin/api/service_storage.py +220 -0
- supython/admin/api/storage.py +117 -0
- supython/admin/api/system.py +37 -0
- supython/admin/audit.py +29 -0
- supython/admin/deps.py +22 -0
- supython/admin/errors.py +16 -0
- supython/admin/schemas.py +310 -0
- supython/admin/session.py +52 -0
- supython/admin/spa.py +38 -0
- supython/admin/static/assets/Alert-dluGVkos.js +49 -0
- supython/admin/static/assets/Audit-Njung3HI.js +2 -0
- supython/admin/static/assets/Backups-DzPlFgrm.js +2 -0
- supython/admin/static/assets/Buckets-ByacGkU1.js +2 -0
- supython/admin/static/assets/Channels-BoIuTtam.js +353 -0
- supython/admin/static/assets/ChevronRight-CtQH1EQ1.js +2 -0
- supython/admin/static/assets/CodeViewer-Bqy7-wvH.js +2 -0
- supython/admin/static/assets/Crons-B67vc39F.js +2 -0
- supython/admin/static/assets/DashboardView-CUTFVL6k.js +2 -0
- supython/admin/static/assets/DataTable-COAAWEft.js +747 -0
- supython/admin/static/assets/DescriptionsItem-P8JUDaBs.js +75 -0
- supython/admin/static/assets/DrawerContent-TpYTFgF1.js +139 -0
- supython/admin/static/assets/Empty-cr2r7e2u.js +25 -0
- supython/admin/static/assets/EmptyState-DeDck-OL.js +2 -0
- supython/admin/static/assets/Grid-hFkp9F4P.js +2 -0
- supython/admin/static/assets/Input-DppYTq9C.js +259 -0
- supython/admin/static/assets/Invoke-DW3Nveeh.js +2 -0
- supython/admin/static/assets/JsonField-DibyJgun.js +2 -0
- supython/admin/static/assets/LoginView-BjLyE3Ds.css +1 -0
- supython/admin/static/assets/LoginView-CoOjECT_.js +111 -0
- supython/admin/static/assets/Logs-D9WYrnIT.js +2 -0
- supython/admin/static/assets/Logs-DS1XPa0h.css +1 -0
- supython/admin/static/assets/Migrations-DOSC2ddQ.js +2 -0
- supython/admin/static/assets/ObjectBrowser-_5w8vOX8.js +2 -0
- supython/admin/static/assets/Queue-CywZs6vI.js +2 -0
- supython/admin/static/assets/RefreshTokens-Ccjr53jg.js +2 -0
- supython/admin/static/assets/RlsEditor-BSlH9vSc.js +2 -0
- supython/admin/static/assets/Routes-BiLXE49D.js +2 -0
- supython/admin/static/assets/Routes-C-ianIGD.css +1 -0
- supython/admin/static/assets/SchemaBrowser-DKy2_KQi.css +1 -0
- supython/admin/static/assets/SchemaBrowser-XFvFbtDB.js +2 -0
- supython/admin/static/assets/Select-DIzZyRZb.js +434 -0
- supython/admin/static/assets/Space-n5-XcguU.js +400 -0
- supython/admin/static/assets/SqlEditor-b8pTsILY.js +3 -0
- supython/admin/static/assets/SqlWorkspace-BUS7IntH.js +104 -0
- supython/admin/static/assets/TableData-CQIagLKn.js +2 -0
- supython/admin/static/assets/Tag-D1fOKpTH.js +72 -0
- supython/admin/static/assets/Templates-BS-ugkdq.js +2 -0
- supython/admin/static/assets/Thing-CEAniuMg.js +107 -0
- supython/admin/static/assets/Users-wzwajhlh.js +2 -0
- supython/admin/static/assets/_plugin-vue_export-helper-DGA9ry_j.js +1 -0
- supython/admin/static/assets/dist-VXIJLCYq.js +13 -0
- supython/admin/static/assets/format-length-CGCY1rMh.js +2 -0
- supython/admin/static/assets/get-Ca6unauB.js +2 -0
- supython/admin/static/assets/index-CeE6v959.js +951 -0
- supython/admin/static/assets/pinia-COXwfrOX.js +2 -0
- supython/admin/static/assets/resources-Bt6thQCD.js +44 -0
- supython/admin/static/assets/use-locale-mtgM0a3a.js +2 -0
- supython/admin/static/assets/use-merged-state-BvhkaHNX.js +2 -0
- supython/admin/static/assets/useConfirm-tMjvBFXR.js +2 -0
- supython/admin/static/assets/useResource-C_rJCY8C.js +2 -0
- supython/admin/static/assets/useTable-CnZc5zhi.js +363 -0
- supython/admin/static/assets/useTable-Dg0XlRlq.css +1 -0
- supython/admin/static/assets/useToast-DsZKx0IX.js +2 -0
- supython/admin/static/assets/utils-sbXoq7Ir.js +2 -0
- supython/admin/static/favicon.svg +1 -0
- supython/admin/static/icons.svg +24 -0
- supython/admin/static/index.html +24 -0
- supython/app.py +149 -0
- supython/auth/__init__.py +3 -0
- supython/auth/_email_job.py +11 -0
- supython/auth/providers/__init__.py +34 -0
- supython/auth/providers/github.py +22 -0
- supython/auth/providers/google.py +19 -0
- supython/auth/providers/oauth.py +56 -0
- supython/auth/providers/registry.py +16 -0
- supython/auth/ratelimit.py +39 -0
- supython/auth/router.py +282 -0
- supython/auth/schemas.py +79 -0
- supython/auth/service.py +587 -0
- supython/body_size.py +184 -0
- supython/cli.py +1653 -0
- supython/client/__init__.py +67 -0
- supython/client/_auth.py +249 -0
- supython/client/_client.py +145 -0
- supython/client/_config.py +92 -0
- supython/client/_functions.py +69 -0
- supython/client/_storage.py +255 -0
- supython/client/py.typed +0 -0
- supython/db.py +151 -0
- supython/db_admin.py +8 -0
- supython/functions/__init__.py +19 -0
- supython/functions/context.py +262 -0
- supython/functions/loader.py +307 -0
- supython/functions/router.py +228 -0
- supython/functions/schemas.py +50 -0
- supython/gen/__init__.py +5 -0
- supython/gen/_introspect.py +137 -0
- supython/gen/types_py.py +270 -0
- supython/gen/types_ts.py +365 -0
- supython/health.py +229 -0
- supython/hooks.py +117 -0
- supython/jobs/__init__.py +31 -0
- supython/jobs/backends.py +97 -0
- supython/jobs/context.py +58 -0
- supython/jobs/cron.py +152 -0
- supython/jobs/cron_inproc.py +118 -0
- supython/jobs/decorators.py +76 -0
- supython/jobs/registry.py +79 -0
- supython/jobs/router.py +136 -0
- supython/jobs/schemas.py +92 -0
- supython/jobs/service.py +311 -0
- supython/jobs/worker.py +219 -0
- supython/jwks.py +257 -0
- supython/keyset.py +279 -0
- supython/logging_config.py +291 -0
- supython/mail.py +33 -0
- supython/mailer.py +65 -0
- supython/migrate.py +81 -0
- supython/migrations/0001_extensions_and_roles.sql +46 -0
- supython/migrations/0002_auth_schema.sql +66 -0
- supython/migrations/0003_demo_todos.sql +42 -0
- supython/migrations/0004_auth_v0_2.sql +47 -0
- supython/migrations/0005_storage_schema.sql +117 -0
- supython/migrations/0006_realtime_schema.sql +206 -0
- supython/migrations/0007_jobs_schema.sql +254 -0
- supython/migrations/0008_jobs_last_error.sql +56 -0
- supython/migrations/0009_auth_rate_limits.sql +33 -0
- supython/migrations/0010_worker_heartbeat.sql +14 -0
- supython/migrations/0011_admin_schema.sql +45 -0
- supython/migrations/0012_auth_banned_until.sql +10 -0
- supython/migrations/0013_email_templates.sql +19 -0
- supython/migrations/0014_realtime_payload_warning.sql +96 -0
- supython/migrations/0015_backups_schema.sql +14 -0
- supython/passwords.py +15 -0
- supython/realtime/__init__.py +6 -0
- supython/realtime/broker.py +814 -0
- supython/realtime/protocol.py +234 -0
- supython/realtime/router.py +184 -0
- supython/realtime/schemas.py +207 -0
- supython/realtime/service.py +261 -0
- supython/realtime/topics.py +175 -0
- supython/realtime/websocket.py +586 -0
- supython/scaffold/__init__.py +5 -0
- supython/scaffold/init_project.py +133 -0
- supython/scaffold/templates/Caddyfile.tmpl +4 -0
- supython/scaffold/templates/README.md.tmpl +22 -0
- supython/scaffold/templates/docker-compose.prod.yml.tmpl +84 -0
- supython/scaffold/templates/docker-compose.yml.tmpl +41 -0
- supython/scaffold/templates/docker_postgres_Dockerfile.tmpl +9 -0
- supython/scaffold/templates/docker_postgres_postgresql.conf.tmpl +3 -0
- supython/scaffold/templates/env.example.tmpl +149 -0
- supython/scaffold/templates/functions_README.md.tmpl +21 -0
- supython/scaffold/templates/gitignore.tmpl +14 -0
- supython/scaffold/templates/migrations/.gitkeep +0 -0
- supython/secretset.py +347 -0
- supython/security_headers.py +78 -0
- supython/settings.py +198 -0
- supython/storage/__init__.py +5 -0
- supython/storage/backends.py +392 -0
- supython/storage/router.py +341 -0
- supython/storage/schemas.py +50 -0
- supython/storage/service.py +445 -0
- supython/storage/signing.py +119 -0
- supython/tokens.py +85 -0
- supython-0.5.0.dist-info/METADATA +714 -0
- supython-0.5.0.dist-info/RECORD +188 -0
- supython-0.5.0.dist-info/WHEEL +4 -0
- supython-0.5.0.dist-info/entry_points.txt +2 -0
- 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"
|