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,262 @@
|
|
|
1
|
+
"""Per-request context object passed to every user function.
|
|
2
|
+
|
|
3
|
+
This is the *one* module in supython that is allowed to import across feature
|
|
4
|
+
packages: it composes ``storage``, ``mailer``, and ``postgrest`` into the
|
|
5
|
+
``Ctx`` value that handlers receive. Functions are the edge layer — they
|
|
6
|
+
exist precisely so user code can stitch sibling subsystems together — so the
|
|
7
|
+
loader/dispatcher pair is the deliberate exception to the no-cross-import
|
|
8
|
+
rule that applies elsewhere.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from collections.abc import AsyncIterator, Awaitable, Callable
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from typing import TYPE_CHECKING, Any
|
|
16
|
+
from uuid import UUID
|
|
17
|
+
|
|
18
|
+
import asyncpg
|
|
19
|
+
import httpx
|
|
20
|
+
|
|
21
|
+
from ..mailer import EmailBackend, EmailMessage, get_mailer
|
|
22
|
+
from ..settings import Settings, get_settings
|
|
23
|
+
from ..storage import service as storage_service
|
|
24
|
+
from ..storage.backends import StorageBackend, get_backend
|
|
25
|
+
from ..storage.schemas import ObjectResponse, SignedUrlResponse
|
|
26
|
+
|
|
27
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
28
|
+
from fastapi import Request
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# ---------------------------------------------------------------------------
|
|
32
|
+
# User
|
|
33
|
+
# ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class FunctionUser:
|
|
38
|
+
"""Caller identity decoded from the bearer token, or ``None`` for anon."""
|
|
39
|
+
|
|
40
|
+
id: UUID | None
|
|
41
|
+
email: str | None
|
|
42
|
+
role: str
|
|
43
|
+
claims: dict[str, Any] = field(default_factory=dict)
|
|
44
|
+
|
|
45
|
+
@classmethod
|
|
46
|
+
def from_claims(cls, claims: dict[str, Any]) -> FunctionUser:
|
|
47
|
+
sub = claims.get("sub")
|
|
48
|
+
try:
|
|
49
|
+
uid = UUID(sub) if isinstance(sub, str) else None
|
|
50
|
+
except ValueError:
|
|
51
|
+
uid = None
|
|
52
|
+
email = claims.get("email")
|
|
53
|
+
role = claims.get("role") or "anon"
|
|
54
|
+
return cls(
|
|
55
|
+
id=uid,
|
|
56
|
+
email=email if isinstance(email, str) else None,
|
|
57
|
+
role=role if isinstance(role, str) else "anon",
|
|
58
|
+
claims=claims,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# ---------------------------------------------------------------------------
|
|
63
|
+
# Storage facade
|
|
64
|
+
# ---------------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class StorageClient:
|
|
68
|
+
"""Short-call-site wrapper around ``storage.service`` bound to ``ctx.db``.
|
|
69
|
+
|
|
70
|
+
All authorization still flows through the role-scoped connection — the
|
|
71
|
+
wrapper exists purely so handlers can write
|
|
72
|
+
``await ctx.storage.upload(...)`` instead of threading ``conn`` and
|
|
73
|
+
``backend`` by hand.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
def __init__(
|
|
77
|
+
self, conn: asyncpg.Connection, backend: StorageBackend
|
|
78
|
+
) -> None:
|
|
79
|
+
self._conn = conn
|
|
80
|
+
self._backend = backend
|
|
81
|
+
|
|
82
|
+
async def upload(
|
|
83
|
+
self,
|
|
84
|
+
*,
|
|
85
|
+
bucket: str,
|
|
86
|
+
path: str,
|
|
87
|
+
data: AsyncIterator[bytes],
|
|
88
|
+
content_type: str | None = None,
|
|
89
|
+
) -> ObjectResponse:
|
|
90
|
+
return await storage_service.upload_object(
|
|
91
|
+
self._conn,
|
|
92
|
+
self._backend,
|
|
93
|
+
bucket_name=bucket,
|
|
94
|
+
path=path,
|
|
95
|
+
data=data,
|
|
96
|
+
content_type=content_type,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
async def download(
|
|
100
|
+
self,
|
|
101
|
+
*,
|
|
102
|
+
bucket: str,
|
|
103
|
+
path: str,
|
|
104
|
+
byte_range: tuple[int, int | None] | None = None,
|
|
105
|
+
):
|
|
106
|
+
return await storage_service.download_object(
|
|
107
|
+
self._conn,
|
|
108
|
+
self._backend,
|
|
109
|
+
bucket_name=bucket,
|
|
110
|
+
path=path,
|
|
111
|
+
byte_range=byte_range,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
async def delete(self, *, bucket: str, path: str) -> None:
|
|
115
|
+
await storage_service.delete_object(
|
|
116
|
+
self._conn,
|
|
117
|
+
self._backend,
|
|
118
|
+
bucket_name=bucket,
|
|
119
|
+
path=path,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
async def get_metadata(self, *, bucket: str, path: str) -> ObjectResponse:
|
|
123
|
+
return await storage_service.get_object_metadata(
|
|
124
|
+
self._conn, bucket, path
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
async def sign(
|
|
128
|
+
self, *, bucket: str, path: str, expires_in: int | None = None
|
|
129
|
+
) -> SignedUrlResponse:
|
|
130
|
+
return await storage_service.issue_signed_url(
|
|
131
|
+
self._conn,
|
|
132
|
+
bucket_name=bucket,
|
|
133
|
+
path=path,
|
|
134
|
+
expires_in=expires_in,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
# ---------------------------------------------------------------------------
|
|
139
|
+
# PostgREST forwarding client
|
|
140
|
+
# ---------------------------------------------------------------------------
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class PostgrestClient:
|
|
144
|
+
"""Request-scoped ``httpx.AsyncClient`` aimed at the configured PostgREST.
|
|
145
|
+
|
|
146
|
+
When the caller is authenticated, the bearer token is forwarded so the
|
|
147
|
+
upstream RLS verdict matches whatever ``ctx.db`` would see. For anon
|
|
148
|
+
callers no ``Authorization`` header is sent, which lets PostgREST resolve
|
|
149
|
+
the request to its ``anon`` role.
|
|
150
|
+
|
|
151
|
+
The dispatcher constructs one of these per request and ``aclose()``s it
|
|
152
|
+
in a ``finally`` block; user code should treat it like a borrowed handle.
|
|
153
|
+
"""
|
|
154
|
+
|
|
155
|
+
def __init__(self, base_url: str, jwt: str | None) -> None:
|
|
156
|
+
headers: dict[str, str] = {}
|
|
157
|
+
if jwt:
|
|
158
|
+
headers["Authorization"] = f"Bearer {jwt}"
|
|
159
|
+
self._client = httpx.AsyncClient(
|
|
160
|
+
base_url=base_url.rstrip("/"),
|
|
161
|
+
headers=headers,
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
async def get(self, url: str, **kw: Any) -> httpx.Response:
|
|
165
|
+
return await self._client.get(url, **kw)
|
|
166
|
+
|
|
167
|
+
async def post(self, url: str, **kw: Any) -> httpx.Response:
|
|
168
|
+
return await self._client.post(url, **kw)
|
|
169
|
+
|
|
170
|
+
async def patch(self, url: str, **kw: Any) -> httpx.Response:
|
|
171
|
+
return await self._client.patch(url, **kw)
|
|
172
|
+
|
|
173
|
+
async def put(self, url: str, **kw: Any) -> httpx.Response:
|
|
174
|
+
return await self._client.put(url, **kw)
|
|
175
|
+
|
|
176
|
+
async def delete(self, url: str, **kw: Any) -> httpx.Response:
|
|
177
|
+
return await self._client.delete(url, **kw)
|
|
178
|
+
|
|
179
|
+
async def request(self, method: str, url: str, **kw: Any) -> httpx.Response:
|
|
180
|
+
return await self._client.request(method, url, **kw)
|
|
181
|
+
|
|
182
|
+
async def aclose(self) -> None:
|
|
183
|
+
await self._client.aclose()
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
# ---------------------------------------------------------------------------
|
|
187
|
+
# send_email kwargs wrapper
|
|
188
|
+
# ---------------------------------------------------------------------------
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _make_send_email(
|
|
192
|
+
backend: EmailBackend,
|
|
193
|
+
) -> Callable[..., Awaitable[None]]:
|
|
194
|
+
"""Adapt ``backend.send(EmailMessage)`` to the kwargs form handlers want.
|
|
195
|
+
|
|
196
|
+
``await ctx.send_email(to="x@y", subject="Hi", text="...", html=None)``
|
|
197
|
+
is the documented surface; ``to`` may be a single address or a list.
|
|
198
|
+
"""
|
|
199
|
+
|
|
200
|
+
async def send_email(
|
|
201
|
+
*,
|
|
202
|
+
to: str | list[str],
|
|
203
|
+
subject: str,
|
|
204
|
+
text: str,
|
|
205
|
+
html: str | None = None,
|
|
206
|
+
) -> None:
|
|
207
|
+
recipients = [to] if isinstance(to, str) else list(to)
|
|
208
|
+
msg = EmailMessage(to=recipients, subject=subject, text=text, html=html)
|
|
209
|
+
await backend.send(msg)
|
|
210
|
+
|
|
211
|
+
return send_email
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
# ---------------------------------------------------------------------------
|
|
215
|
+
# Ctx
|
|
216
|
+
# ---------------------------------------------------------------------------
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
@dataclass
|
|
220
|
+
class Ctx:
|
|
221
|
+
"""The ``ctx`` argument every user handler receives.
|
|
222
|
+
|
|
223
|
+
Lifetime: one HTTP request. ``db`` is a live, role-scoped connection
|
|
224
|
+
already inside ``db.as_role(...)`` — handlers can call
|
|
225
|
+
``await ctx.db.fetchrow(...)`` directly. ``postgrest`` is closed by the
|
|
226
|
+
dispatcher in ``finally``; everything else is plain references.
|
|
227
|
+
"""
|
|
228
|
+
|
|
229
|
+
db: asyncpg.Connection
|
|
230
|
+
user: FunctionUser | None
|
|
231
|
+
storage: StorageClient
|
|
232
|
+
postgrest: PostgrestClient
|
|
233
|
+
send_email: Callable[..., Awaitable[None]]
|
|
234
|
+
request: Request
|
|
235
|
+
settings: Settings
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def build_ctx(
|
|
239
|
+
*,
|
|
240
|
+
conn: asyncpg.Connection,
|
|
241
|
+
user: FunctionUser | None,
|
|
242
|
+
request: Request,
|
|
243
|
+
raw_jwt: str | None,
|
|
244
|
+
backend: StorageBackend | None = None,
|
|
245
|
+
mailer: EmailBackend | None = None,
|
|
246
|
+
settings: Settings | None = None,
|
|
247
|
+
) -> Ctx:
|
|
248
|
+
"""Assemble a ``Ctx`` for one request.
|
|
249
|
+
|
|
250
|
+
Kept as a free function so tests can construct a ``Ctx`` directly with
|
|
251
|
+
fakes for ``backend`` / ``mailer`` without going through the dispatcher.
|
|
252
|
+
"""
|
|
253
|
+
s = settings or get_settings()
|
|
254
|
+
return Ctx(
|
|
255
|
+
db=conn,
|
|
256
|
+
user=user,
|
|
257
|
+
storage=StorageClient(conn, backend or get_backend()),
|
|
258
|
+
postgrest=PostgrestClient(s.postgrest_url, raw_jwt),
|
|
259
|
+
send_email=_make_send_email(mailer or get_mailer()),
|
|
260
|
+
request=request,
|
|
261
|
+
settings=s,
|
|
262
|
+
)
|
|
@@ -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)
|