supython 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.
- supython/__init__.py +24 -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 +162 -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/backups/__init__.py +24 -0
- supython/backups/_backup_job.py +170 -0
- supython/backups/schemas.py +18 -0
- supython/backups/service.py +217 -0
- supython/body_size.py +184 -0
- supython/cli.py +1663 -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/extensions.py +36 -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 +119 -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 +144 -0
- supython/scaffold/templates/Caddyfile.tmpl +4 -0
- supython/scaffold/templates/README.md.tmpl +22 -0
- supython/scaffold/templates/apps_hooks.py.tmpl +11 -0
- supython/scaffold/templates/apps_jobs.py.tmpl +8 -0
- supython/scaffold/templates/asgi.py.tmpl +14 -0
- supython/scaffold/templates/docker-compose.prod.yml.tmpl +84 -0
- supython/scaffold/templates/docker-compose.yml.tmpl +45 -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 +168 -0
- supython/scaffold/templates/functions_README.md.tmpl +21 -0
- supython/scaffold/templates/gitignore.tmpl +14 -0
- supython/scaffold/templates/manage.py.tmpl +11 -0
- supython/scaffold/templates/migrations/.gitkeep +0 -0
- supython/scaffold/templates/package_init.py.tmpl +1 -0
- supython/scaffold/templates/settings.py.tmpl +31 -0
- supython/secretset.py +347 -0
- supython/security_headers.py +78 -0
- supython/settings.py +244 -0
- supython/settings_module.py +117 -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.1.0.dist-info/METADATA +756 -0
- supython-0.1.0.dist-info/RECORD +200 -0
- supython-0.1.0.dist-info/WHEEL +4 -0
- supython-0.1.0.dist-info/entry_points.txt +2 -0
- supython-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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"
|
supython/gen/__init__.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""Shared Postgres schema introspection queries used by type generators.
|
|
2
|
+
|
|
3
|
+
Both ``types_py.py`` (Python dataclasses) and ``types_ts.py`` (TypeScript
|
|
4
|
+
``Database`` interface) use these functions to fetch metadata from
|
|
5
|
+
``information_schema`` and ``pg_catalog``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import asyncpg
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
async def _fetch_enums(
|
|
12
|
+
conn: asyncpg.Connection, schemas: list[str]
|
|
13
|
+
) -> dict[tuple[str, str], list[str]]:
|
|
14
|
+
rows = await conn.fetch(
|
|
15
|
+
"""
|
|
16
|
+
select n.nspname as schema,
|
|
17
|
+
t.typname as name,
|
|
18
|
+
e.enumlabel as label
|
|
19
|
+
from pg_type t
|
|
20
|
+
join pg_enum e on e.enumtypid = t.oid
|
|
21
|
+
join pg_namespace n on n.oid = t.typnamespace
|
|
22
|
+
where n.nspname = any($1::text[])
|
|
23
|
+
order by n.nspname, t.typname, e.enumsortorder
|
|
24
|
+
""",
|
|
25
|
+
list(schemas),
|
|
26
|
+
)
|
|
27
|
+
out: dict[tuple[str, str], list[str]] = {}
|
|
28
|
+
for r in rows:
|
|
29
|
+
out.setdefault((r["schema"], r["name"]), []).append(r["label"])
|
|
30
|
+
return out
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
async def _fetch_tables(
|
|
34
|
+
conn: asyncpg.Connection, schemas: list[str]
|
|
35
|
+
) -> list[tuple[str, str]]:
|
|
36
|
+
rows = await conn.fetch(
|
|
37
|
+
"""
|
|
38
|
+
select table_schema, table_name
|
|
39
|
+
from information_schema.tables
|
|
40
|
+
where table_schema = any($1::text[])
|
|
41
|
+
and table_type in ('BASE TABLE', 'VIEW')
|
|
42
|
+
order by table_schema, table_name
|
|
43
|
+
""",
|
|
44
|
+
list(schemas),
|
|
45
|
+
)
|
|
46
|
+
return [(r["table_schema"], r["table_name"]) for r in rows]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
async def _fetch_columns(
|
|
50
|
+
conn: asyncpg.Connection, schemas: list[str]
|
|
51
|
+
) -> dict[tuple[str, str], list[asyncpg.Record]]:
|
|
52
|
+
rows = await conn.fetch(
|
|
53
|
+
"""
|
|
54
|
+
select c.table_schema,
|
|
55
|
+
c.table_name,
|
|
56
|
+
c.column_name,
|
|
57
|
+
c.ordinal_position,
|
|
58
|
+
c.is_nullable,
|
|
59
|
+
c.data_type,
|
|
60
|
+
c.udt_schema,
|
|
61
|
+
c.udt_name,
|
|
62
|
+
c.column_default,
|
|
63
|
+
c.is_generated,
|
|
64
|
+
c.is_identity,
|
|
65
|
+
e.data_type as element_data_type,
|
|
66
|
+
e.udt_schema as element_udt_schema,
|
|
67
|
+
e.udt_name as element_udt_name
|
|
68
|
+
from information_schema.columns c
|
|
69
|
+
join information_schema.tables t
|
|
70
|
+
on t.table_catalog = c.table_catalog
|
|
71
|
+
and t.table_schema = c.table_schema
|
|
72
|
+
and t.table_name = c.table_name
|
|
73
|
+
left join information_schema.element_types e
|
|
74
|
+
on e.object_catalog = c.table_catalog
|
|
75
|
+
and e.object_schema = c.table_schema
|
|
76
|
+
and e.object_name = c.table_name
|
|
77
|
+
and e.object_type = case t.table_type
|
|
78
|
+
when 'VIEW' then 'VIEW'
|
|
79
|
+
else 'TABLE'
|
|
80
|
+
end
|
|
81
|
+
and e.collection_type_identifier = c.dtd_identifier
|
|
82
|
+
where c.table_schema = any($1::text[])
|
|
83
|
+
and t.table_type in ('BASE TABLE', 'VIEW')
|
|
84
|
+
order by c.table_schema, c.table_name, c.ordinal_position
|
|
85
|
+
""",
|
|
86
|
+
list(schemas),
|
|
87
|
+
)
|
|
88
|
+
grouped: dict[tuple[str, str], list[asyncpg.Record]] = {}
|
|
89
|
+
for r in rows:
|
|
90
|
+
grouped.setdefault((r["table_schema"], r["table_name"]), []).append(r)
|
|
91
|
+
return grouped
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
async def _fetch_relationships(
|
|
95
|
+
conn: asyncpg.Connection, schemas: list[str]
|
|
96
|
+
) -> dict[tuple[str, str], list[dict[str, any]]]: # type: ignore[no-any-return]
|
|
97
|
+
rows = await conn.fetch(
|
|
98
|
+
"""
|
|
99
|
+
select
|
|
100
|
+
n.nspname as table_schema,
|
|
101
|
+
c.relname as table_name,
|
|
102
|
+
con.conname as constraint_name,
|
|
103
|
+
(
|
|
104
|
+
select array_agg(a.attname order by ord)
|
|
105
|
+
from unnest(con.conkey) with ordinality as t(attnum, ord)
|
|
106
|
+
join pg_attribute a
|
|
107
|
+
on a.attnum = t.attnum and a.attrelid = con.conrelid
|
|
108
|
+
) as columns,
|
|
109
|
+
ref_c.relname as foreign_table_name,
|
|
110
|
+
(
|
|
111
|
+
select array_agg(a.attname order by ord)
|
|
112
|
+
from unnest(con.confkey) with ordinality as t(attnum, ord)
|
|
113
|
+
join pg_attribute a
|
|
114
|
+
on a.attnum = t.attnum and a.attrelid = con.confrelid
|
|
115
|
+
) as foreign_columns
|
|
116
|
+
from pg_constraint con
|
|
117
|
+
join pg_class c on c.oid = con.conrelid
|
|
118
|
+
join pg_namespace n on n.oid = c.relnamespace
|
|
119
|
+
join pg_class ref_c on ref_c.oid = con.confrelid
|
|
120
|
+
where con.contype = 'f'
|
|
121
|
+
and n.nspname = any($1::text[])
|
|
122
|
+
order by n.nspname, c.relname, con.conname
|
|
123
|
+
""",
|
|
124
|
+
list(schemas),
|
|
125
|
+
)
|
|
126
|
+
out: dict[tuple[str, str], list[dict[str, any]]] = {} # type: ignore[no-any-return]
|
|
127
|
+
for r in rows:
|
|
128
|
+
key = (r["table_schema"], r["table_name"])
|
|
129
|
+
out.setdefault(key, []).append(
|
|
130
|
+
{
|
|
131
|
+
"foreignKeyName": r["constraint_name"],
|
|
132
|
+
"columns": r["columns"],
|
|
133
|
+
"referencedRelation": r["foreign_table_name"],
|
|
134
|
+
"referencedColumns": r["foreign_columns"],
|
|
135
|
+
}
|
|
136
|
+
)
|
|
137
|
+
return out
|
supython/gen/types_py.py
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
"""Render a typed ``types.py`` from Postgres schema introspection.
|
|
2
|
+
|
|
3
|
+
Public entry point: :func:`render_types_py`. The returned string is the full
|
|
4
|
+
contents of a self-contained Python module with one ``StrEnum`` per Postgres
|
|
5
|
+
enum and one ``@dataclass`` + matching ``TypedDict`` per table or view in the
|
|
6
|
+
selected schemas.
|
|
7
|
+
|
|
8
|
+
The generator opens its own asyncpg connection and closes it before returning;
|
|
9
|
+
it does not depend on the supython service running.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import keyword
|
|
13
|
+
import re
|
|
14
|
+
from datetime import UTC, datetime
|
|
15
|
+
|
|
16
|
+
import asyncpg
|
|
17
|
+
|
|
18
|
+
from ..settings import get_settings
|
|
19
|
+
from ._introspect import _fetch_columns, _fetch_enums, _fetch_tables
|
|
20
|
+
|
|
21
|
+
_SIMPLE_MAP: dict[str, tuple[str, str | None]] = {
|
|
22
|
+
"text": ("str", None),
|
|
23
|
+
"varchar": ("str", None),
|
|
24
|
+
"bpchar": ("str", None),
|
|
25
|
+
"char": ("str", None),
|
|
26
|
+
"citext": ("str", None),
|
|
27
|
+
"name": ("str", None),
|
|
28
|
+
"int2": ("int", None),
|
|
29
|
+
"int4": ("int", None),
|
|
30
|
+
"int8": ("int", None),
|
|
31
|
+
"float4": ("float", None),
|
|
32
|
+
"float8": ("float", None),
|
|
33
|
+
"numeric": ("Decimal", "Decimal"),
|
|
34
|
+
"money": ("Decimal", "Decimal"),
|
|
35
|
+
"bool": ("bool", None),
|
|
36
|
+
"uuid": ("UUID", "UUID"),
|
|
37
|
+
"timestamptz": ("datetime", "datetime"),
|
|
38
|
+
"timestamp": ("datetime", "datetime"),
|
|
39
|
+
"date": ("date", "date"),
|
|
40
|
+
"time": ("time", "time"),
|
|
41
|
+
"timetz": ("time", "time"),
|
|
42
|
+
"interval": ("timedelta", "timedelta"),
|
|
43
|
+
"bytea": ("bytes", None),
|
|
44
|
+
"json": ("dict[str, Any]", "Any"),
|
|
45
|
+
"jsonb": ("dict[str, Any]", "Any"),
|
|
46
|
+
"inet": ("str", None),
|
|
47
|
+
"cidr": ("str", None),
|
|
48
|
+
"macaddr": ("str", None),
|
|
49
|
+
"tsvector": ("str", None),
|
|
50
|
+
"tsquery": ("str", None),
|
|
51
|
+
"bit": ("str", None),
|
|
52
|
+
"varbit": ("str", None),
|
|
53
|
+
"oid": ("int", None),
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _safe_col_name(name: str) -> str:
|
|
58
|
+
if keyword.iskeyword(name) or keyword.issoftkeyword(name):
|
|
59
|
+
return f"{name}_"
|
|
60
|
+
return name
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
async def render_types_py(schemas: list[str]) -> str:
|
|
64
|
+
"""Connect to ``DATABASE_URL`` and return a rendered ``types.py`` module."""
|
|
65
|
+
s = get_settings()
|
|
66
|
+
conn = await asyncpg.connect(s.database_url)
|
|
67
|
+
try:
|
|
68
|
+
enums = await _fetch_enums(conn, schemas)
|
|
69
|
+
tables = await _fetch_tables(conn, schemas)
|
|
70
|
+
columns = await _fetch_columns(conn, schemas)
|
|
71
|
+
finally:
|
|
72
|
+
await conn.close()
|
|
73
|
+
return _render(schemas, enums, tables, columns)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _class_name(schema: str, table: str, schemas: list[str]) -> str:
|
|
78
|
+
base = "".join(part.capitalize() for part in re.split(r"[_\-]", table) if part)
|
|
79
|
+
if not base:
|
|
80
|
+
base = "Table"
|
|
81
|
+
if schema == "public" or len(schemas) == 1:
|
|
82
|
+
return base
|
|
83
|
+
return f"{schema.capitalize()}{base}"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _safe_enum_attr(label: str) -> str:
|
|
87
|
+
s = re.sub(r"[^A-Za-z0-9_]", "_", label).upper()
|
|
88
|
+
if not s or s[0].isdigit():
|
|
89
|
+
s = "_" + s
|
|
90
|
+
return s
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _pg_to_py(
|
|
94
|
+
udt_schema: str,
|
|
95
|
+
udt_name: str,
|
|
96
|
+
data_type: str,
|
|
97
|
+
element: tuple[str, str, str] | None,
|
|
98
|
+
enum_classes: dict[tuple[str, str], str],
|
|
99
|
+
imports: set[str],
|
|
100
|
+
) -> tuple[str, str | None]:
|
|
101
|
+
"""Return (annotation, unmapped_comment_or_None)."""
|
|
102
|
+
if data_type == "ARRAY" and element is not None:
|
|
103
|
+
elem_ann, elem_unmapped = _pg_to_py(
|
|
104
|
+
element[1], element[2], element[0], None, enum_classes, imports
|
|
105
|
+
)
|
|
106
|
+
return f"list[{elem_ann}]", elem_unmapped
|
|
107
|
+
|
|
108
|
+
if data_type == "USER-DEFINED" and (udt_schema, udt_name) in enum_classes:
|
|
109
|
+
return enum_classes[(udt_schema, udt_name)], None
|
|
110
|
+
|
|
111
|
+
if udt_name in _SIMPLE_MAP:
|
|
112
|
+
ann, imp = _SIMPLE_MAP[udt_name]
|
|
113
|
+
if imp:
|
|
114
|
+
imports.add(imp)
|
|
115
|
+
return ann, None
|
|
116
|
+
|
|
117
|
+
imports.add("Any")
|
|
118
|
+
return "Any", f"unmapped: {udt_schema}.{udt_name}"
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _render(
|
|
122
|
+
schemas: list[str],
|
|
123
|
+
enums: dict[tuple[str, str], list[str]],
|
|
124
|
+
tables: list[tuple[str, str]],
|
|
125
|
+
columns: dict[tuple[str, str], list[asyncpg.Record]],
|
|
126
|
+
) -> str:
|
|
127
|
+
imports: set[str] = set()
|
|
128
|
+
|
|
129
|
+
enum_classes: dict[tuple[str, str], str] = {
|
|
130
|
+
(schema, name): _class_name(schema, name, schemas)
|
|
131
|
+
for (schema, name) in enums
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
body: list[str] = []
|
|
135
|
+
|
|
136
|
+
for (schema, name), labels in sorted(enums.items()):
|
|
137
|
+
cls = enum_classes[(schema, name)]
|
|
138
|
+
body.append("")
|
|
139
|
+
body.append(f"# --- enum {schema}.{name} {'-' * (60 - len(schema) - len(name))}")
|
|
140
|
+
body.append("")
|
|
141
|
+
body.append(f"class {cls}(StrEnum):")
|
|
142
|
+
for lbl in labels:
|
|
143
|
+
body.append(f" {_safe_enum_attr(lbl)} = {lbl!r}")
|
|
144
|
+
|
|
145
|
+
has_table = False
|
|
146
|
+
for schema, table in tables:
|
|
147
|
+
cols = columns.get((schema, table), [])
|
|
148
|
+
if not cols:
|
|
149
|
+
continue
|
|
150
|
+
has_table = True
|
|
151
|
+
cls = _class_name(schema, table, schemas)
|
|
152
|
+
|
|
153
|
+
rendered_cols: list[tuple[str, str, bool, str | None]] = []
|
|
154
|
+
for c in cols:
|
|
155
|
+
element = None
|
|
156
|
+
if c["element_data_type"]:
|
|
157
|
+
element = (
|
|
158
|
+
c["element_data_type"],
|
|
159
|
+
c["element_udt_schema"],
|
|
160
|
+
c["element_udt_name"],
|
|
161
|
+
)
|
|
162
|
+
ann, unmapped = _pg_to_py(
|
|
163
|
+
c["udt_schema"],
|
|
164
|
+
c["udt_name"],
|
|
165
|
+
c["data_type"],
|
|
166
|
+
element,
|
|
167
|
+
enum_classes,
|
|
168
|
+
imports,
|
|
169
|
+
)
|
|
170
|
+
nullable = c["is_nullable"] == "YES"
|
|
171
|
+
col_name = _safe_col_name(c["column_name"])
|
|
172
|
+
rendered_cols.append((col_name, ann, nullable, unmapped))
|
|
173
|
+
|
|
174
|
+
body.append("")
|
|
175
|
+
body.append(f"# --- {schema}.{table} {'-' * (64 - len(schema) - len(table))}")
|
|
176
|
+
body.append("")
|
|
177
|
+
body.append("@dataclass(kw_only=True, slots=True)")
|
|
178
|
+
body.append(f"class {cls}:")
|
|
179
|
+
for col_name, ann, nullable, unmapped in rendered_cols:
|
|
180
|
+
line = (
|
|
181
|
+
f" {col_name}: {ann} | None = None"
|
|
182
|
+
if nullable
|
|
183
|
+
else f" {col_name}: {ann}"
|
|
184
|
+
)
|
|
185
|
+
if unmapped:
|
|
186
|
+
line += f" # {unmapped}"
|
|
187
|
+
body.append(line)
|
|
188
|
+
|
|
189
|
+
body.append("")
|
|
190
|
+
body.append(" @classmethod")
|
|
191
|
+
body.append(f' def from_record(cls, record: "object") -> "{cls}":')
|
|
192
|
+
body.append(" fields = cls.__dataclass_fields__")
|
|
193
|
+
body.append(
|
|
194
|
+
" return cls(**{f: v for f, v in record.items() if f in fields})"
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
has_kw = any(
|
|
198
|
+
_safe_col_name(c["column_name"]) != c["column_name"]
|
|
199
|
+
for c in cols
|
|
200
|
+
)
|
|
201
|
+
if has_kw:
|
|
202
|
+
_emit_typeddict_functional(body, cls, rendered_cols)
|
|
203
|
+
else:
|
|
204
|
+
_emit_typeddict_class(body, cls, rendered_cols)
|
|
205
|
+
|
|
206
|
+
header: list[str] = []
|
|
207
|
+
header.append('"""Generated by `supython gen types --lang py`. Do not edit.')
|
|
208
|
+
header.append("")
|
|
209
|
+
header.append(f"Schemas: {', '.join(schemas)}")
|
|
210
|
+
header.append(f"Generated at: {datetime.now(UTC).isoformat()}")
|
|
211
|
+
header.append('"""')
|
|
212
|
+
|
|
213
|
+
import_lines: list[str] = []
|
|
214
|
+
if has_table:
|
|
215
|
+
import_lines.append("from dataclasses import dataclass")
|
|
216
|
+
|
|
217
|
+
datetime_syms = sorted(
|
|
218
|
+
{i for i in imports if i in {"datetime", "date", "time", "timedelta"}}
|
|
219
|
+
)
|
|
220
|
+
if datetime_syms:
|
|
221
|
+
import_lines.append(f"from datetime import {', '.join(datetime_syms)}")
|
|
222
|
+
if "Decimal" in imports:
|
|
223
|
+
import_lines.append("from decimal import Decimal")
|
|
224
|
+
if enums:
|
|
225
|
+
import_lines.append("from enum import StrEnum")
|
|
226
|
+
typing_syms: set[str] = set()
|
|
227
|
+
if "Any" in imports:
|
|
228
|
+
typing_syms.add("Any")
|
|
229
|
+
if has_table:
|
|
230
|
+
typing_syms.add("TypedDict")
|
|
231
|
+
if typing_syms:
|
|
232
|
+
import_lines.append(f"from typing import {', '.join(sorted(typing_syms))}")
|
|
233
|
+
if "UUID" in imports:
|
|
234
|
+
import_lines.append("from uuid import UUID")
|
|
235
|
+
|
|
236
|
+
parts: list[str] = []
|
|
237
|
+
parts.extend(header)
|
|
238
|
+
if import_lines:
|
|
239
|
+
parts.append("")
|
|
240
|
+
parts.extend(import_lines)
|
|
241
|
+
parts.extend(body)
|
|
242
|
+
return "\n".join(parts).rstrip() + "\n"
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _emit_typeddict_class(
|
|
246
|
+
body: list[str],
|
|
247
|
+
cls: str,
|
|
248
|
+
rendered_cols: list[tuple[str, str, bool, str | None]],
|
|
249
|
+
) -> None:
|
|
250
|
+
body.append("")
|
|
251
|
+
body.append(f"class {cls}Row(TypedDict):")
|
|
252
|
+
for col_name, ann, nullable, _ in rendered_cols:
|
|
253
|
+
body.append(
|
|
254
|
+
f" {col_name}: {ann} | None" if nullable else f" {col_name}: {ann}"
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _emit_typeddict_functional(
|
|
259
|
+
body: list[str],
|
|
260
|
+
cls: str,
|
|
261
|
+
rendered_cols: list[tuple[str, str, bool, str | None]],
|
|
262
|
+
) -> None:
|
|
263
|
+
body.append(f"{cls}Row = TypedDict(\"{cls}Row\", {{")
|
|
264
|
+
for col_name, ann, nullable, _ in rendered_cols:
|
|
265
|
+
ann_str = f"{ann} | None" if nullable else ann
|
|
266
|
+
body.append(f' "{col_name}": {ann_str},')
|
|
267
|
+
body.append("})")
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
__all__ = ["render_types_py"]
|