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.
Files changed (200) hide show
  1. supython/__init__.py +24 -0
  2. supython/admin/__init__.py +3 -0
  3. supython/admin/api/__init__.py +24 -0
  4. supython/admin/api/auth.py +118 -0
  5. supython/admin/api/auth_templates.py +67 -0
  6. supython/admin/api/auth_users.py +225 -0
  7. supython/admin/api/db.py +174 -0
  8. supython/admin/api/functions.py +92 -0
  9. supython/admin/api/jobs.py +192 -0
  10. supython/admin/api/ops.py +224 -0
  11. supython/admin/api/realtime.py +281 -0
  12. supython/admin/api/service_auth.py +49 -0
  13. supython/admin/api/service_auth_templates.py +83 -0
  14. supython/admin/api/service_auth_users.py +346 -0
  15. supython/admin/api/service_db.py +214 -0
  16. supython/admin/api/service_functions.py +287 -0
  17. supython/admin/api/service_jobs.py +282 -0
  18. supython/admin/api/service_ops.py +213 -0
  19. supython/admin/api/service_realtime.py +30 -0
  20. supython/admin/api/service_storage.py +220 -0
  21. supython/admin/api/storage.py +117 -0
  22. supython/admin/api/system.py +37 -0
  23. supython/admin/audit.py +29 -0
  24. supython/admin/deps.py +22 -0
  25. supython/admin/errors.py +16 -0
  26. supython/admin/schemas.py +310 -0
  27. supython/admin/session.py +52 -0
  28. supython/admin/spa.py +38 -0
  29. supython/admin/static/assets/Alert-dluGVkos.js +49 -0
  30. supython/admin/static/assets/Audit-Njung3HI.js +2 -0
  31. supython/admin/static/assets/Backups-DzPlFgrm.js +2 -0
  32. supython/admin/static/assets/Buckets-ByacGkU1.js +2 -0
  33. supython/admin/static/assets/Channels-BoIuTtam.js +353 -0
  34. supython/admin/static/assets/ChevronRight-CtQH1EQ1.js +2 -0
  35. supython/admin/static/assets/CodeViewer-Bqy7-wvH.js +2 -0
  36. supython/admin/static/assets/Crons-B67vc39F.js +2 -0
  37. supython/admin/static/assets/DashboardView-CUTFVL6k.js +2 -0
  38. supython/admin/static/assets/DataTable-COAAWEft.js +747 -0
  39. supython/admin/static/assets/DescriptionsItem-P8JUDaBs.js +75 -0
  40. supython/admin/static/assets/DrawerContent-TpYTFgF1.js +139 -0
  41. supython/admin/static/assets/Empty-cr2r7e2u.js +25 -0
  42. supython/admin/static/assets/EmptyState-DeDck-OL.js +2 -0
  43. supython/admin/static/assets/Grid-hFkp9F4P.js +2 -0
  44. supython/admin/static/assets/Input-DppYTq9C.js +259 -0
  45. supython/admin/static/assets/Invoke-DW3Nveeh.js +2 -0
  46. supython/admin/static/assets/JsonField-DibyJgun.js +2 -0
  47. supython/admin/static/assets/LoginView-BjLyE3Ds.css +1 -0
  48. supython/admin/static/assets/LoginView-CoOjECT_.js +111 -0
  49. supython/admin/static/assets/Logs-D9WYrnIT.js +2 -0
  50. supython/admin/static/assets/Logs-DS1XPa0h.css +1 -0
  51. supython/admin/static/assets/Migrations-DOSC2ddQ.js +2 -0
  52. supython/admin/static/assets/ObjectBrowser-_5w8vOX8.js +2 -0
  53. supython/admin/static/assets/Queue-CywZs6vI.js +2 -0
  54. supython/admin/static/assets/RefreshTokens-Ccjr53jg.js +2 -0
  55. supython/admin/static/assets/RlsEditor-BSlH9vSc.js +2 -0
  56. supython/admin/static/assets/Routes-BiLXE49D.js +2 -0
  57. supython/admin/static/assets/Routes-C-ianIGD.css +1 -0
  58. supython/admin/static/assets/SchemaBrowser-DKy2_KQi.css +1 -0
  59. supython/admin/static/assets/SchemaBrowser-XFvFbtDB.js +2 -0
  60. supython/admin/static/assets/Select-DIzZyRZb.js +434 -0
  61. supython/admin/static/assets/Space-n5-XcguU.js +400 -0
  62. supython/admin/static/assets/SqlEditor-b8pTsILY.js +3 -0
  63. supython/admin/static/assets/SqlWorkspace-BUS7IntH.js +104 -0
  64. supython/admin/static/assets/TableData-CQIagLKn.js +2 -0
  65. supython/admin/static/assets/Tag-D1fOKpTH.js +72 -0
  66. supython/admin/static/assets/Templates-BS-ugkdq.js +2 -0
  67. supython/admin/static/assets/Thing-CEAniuMg.js +107 -0
  68. supython/admin/static/assets/Users-wzwajhlh.js +2 -0
  69. supython/admin/static/assets/_plugin-vue_export-helper-DGA9ry_j.js +1 -0
  70. supython/admin/static/assets/dist-VXIJLCYq.js +13 -0
  71. supython/admin/static/assets/format-length-CGCY1rMh.js +2 -0
  72. supython/admin/static/assets/get-Ca6unauB.js +2 -0
  73. supython/admin/static/assets/index-CeE6v959.js +951 -0
  74. supython/admin/static/assets/pinia-COXwfrOX.js +2 -0
  75. supython/admin/static/assets/resources-Bt6thQCD.js +44 -0
  76. supython/admin/static/assets/use-locale-mtgM0a3a.js +2 -0
  77. supython/admin/static/assets/use-merged-state-BvhkaHNX.js +2 -0
  78. supython/admin/static/assets/useConfirm-tMjvBFXR.js +2 -0
  79. supython/admin/static/assets/useResource-C_rJCY8C.js +2 -0
  80. supython/admin/static/assets/useTable-CnZc5zhi.js +363 -0
  81. supython/admin/static/assets/useTable-Dg0XlRlq.css +1 -0
  82. supython/admin/static/assets/useToast-DsZKx0IX.js +2 -0
  83. supython/admin/static/assets/utils-sbXoq7Ir.js +2 -0
  84. supython/admin/static/favicon.svg +1 -0
  85. supython/admin/static/icons.svg +24 -0
  86. supython/admin/static/index.html +24 -0
  87. supython/app.py +162 -0
  88. supython/auth/__init__.py +3 -0
  89. supython/auth/_email_job.py +11 -0
  90. supython/auth/providers/__init__.py +34 -0
  91. supython/auth/providers/github.py +22 -0
  92. supython/auth/providers/google.py +19 -0
  93. supython/auth/providers/oauth.py +56 -0
  94. supython/auth/providers/registry.py +16 -0
  95. supython/auth/ratelimit.py +39 -0
  96. supython/auth/router.py +282 -0
  97. supython/auth/schemas.py +79 -0
  98. supython/auth/service.py +587 -0
  99. supython/backups/__init__.py +24 -0
  100. supython/backups/_backup_job.py +170 -0
  101. supython/backups/schemas.py +18 -0
  102. supython/backups/service.py +217 -0
  103. supython/body_size.py +184 -0
  104. supython/cli.py +1663 -0
  105. supython/client/__init__.py +67 -0
  106. supython/client/_auth.py +249 -0
  107. supython/client/_client.py +145 -0
  108. supython/client/_config.py +92 -0
  109. supython/client/_functions.py +69 -0
  110. supython/client/_storage.py +255 -0
  111. supython/client/py.typed +0 -0
  112. supython/db.py +151 -0
  113. supython/db_admin.py +8 -0
  114. supython/extensions.py +36 -0
  115. supython/functions/__init__.py +19 -0
  116. supython/functions/context.py +262 -0
  117. supython/functions/loader.py +307 -0
  118. supython/functions/router.py +228 -0
  119. supython/functions/schemas.py +50 -0
  120. supython/gen/__init__.py +5 -0
  121. supython/gen/_introspect.py +137 -0
  122. supython/gen/types_py.py +270 -0
  123. supython/gen/types_ts.py +365 -0
  124. supython/health.py +229 -0
  125. supython/hooks.py +117 -0
  126. supython/jobs/__init__.py +31 -0
  127. supython/jobs/backends.py +97 -0
  128. supython/jobs/context.py +58 -0
  129. supython/jobs/cron.py +152 -0
  130. supython/jobs/cron_inproc.py +119 -0
  131. supython/jobs/decorators.py +76 -0
  132. supython/jobs/registry.py +79 -0
  133. supython/jobs/router.py +136 -0
  134. supython/jobs/schemas.py +92 -0
  135. supython/jobs/service.py +311 -0
  136. supython/jobs/worker.py +219 -0
  137. supython/jwks.py +257 -0
  138. supython/keyset.py +279 -0
  139. supython/logging_config.py +291 -0
  140. supython/mail.py +33 -0
  141. supython/mailer.py +65 -0
  142. supython/migrate.py +81 -0
  143. supython/migrations/0001_extensions_and_roles.sql +46 -0
  144. supython/migrations/0002_auth_schema.sql +66 -0
  145. supython/migrations/0003_demo_todos.sql +42 -0
  146. supython/migrations/0004_auth_v0_2.sql +47 -0
  147. supython/migrations/0005_storage_schema.sql +117 -0
  148. supython/migrations/0006_realtime_schema.sql +206 -0
  149. supython/migrations/0007_jobs_schema.sql +254 -0
  150. supython/migrations/0008_jobs_last_error.sql +56 -0
  151. supython/migrations/0009_auth_rate_limits.sql +33 -0
  152. supython/migrations/0010_worker_heartbeat.sql +14 -0
  153. supython/migrations/0011_admin_schema.sql +45 -0
  154. supython/migrations/0012_auth_banned_until.sql +10 -0
  155. supython/migrations/0013_email_templates.sql +19 -0
  156. supython/migrations/0014_realtime_payload_warning.sql +96 -0
  157. supython/migrations/0015_backups_schema.sql +14 -0
  158. supython/passwords.py +15 -0
  159. supython/realtime/__init__.py +6 -0
  160. supython/realtime/broker.py +814 -0
  161. supython/realtime/protocol.py +234 -0
  162. supython/realtime/router.py +184 -0
  163. supython/realtime/schemas.py +207 -0
  164. supython/realtime/service.py +261 -0
  165. supython/realtime/topics.py +175 -0
  166. supython/realtime/websocket.py +586 -0
  167. supython/scaffold/__init__.py +5 -0
  168. supython/scaffold/init_project.py +144 -0
  169. supython/scaffold/templates/Caddyfile.tmpl +4 -0
  170. supython/scaffold/templates/README.md.tmpl +22 -0
  171. supython/scaffold/templates/apps_hooks.py.tmpl +11 -0
  172. supython/scaffold/templates/apps_jobs.py.tmpl +8 -0
  173. supython/scaffold/templates/asgi.py.tmpl +14 -0
  174. supython/scaffold/templates/docker-compose.prod.yml.tmpl +84 -0
  175. supython/scaffold/templates/docker-compose.yml.tmpl +45 -0
  176. supython/scaffold/templates/docker_postgres_Dockerfile.tmpl +9 -0
  177. supython/scaffold/templates/docker_postgres_postgresql.conf.tmpl +3 -0
  178. supython/scaffold/templates/env.example.tmpl +168 -0
  179. supython/scaffold/templates/functions_README.md.tmpl +21 -0
  180. supython/scaffold/templates/gitignore.tmpl +14 -0
  181. supython/scaffold/templates/manage.py.tmpl +11 -0
  182. supython/scaffold/templates/migrations/.gitkeep +0 -0
  183. supython/scaffold/templates/package_init.py.tmpl +1 -0
  184. supython/scaffold/templates/settings.py.tmpl +31 -0
  185. supython/secretset.py +347 -0
  186. supython/security_headers.py +78 -0
  187. supython/settings.py +244 -0
  188. supython/settings_module.py +117 -0
  189. supython/storage/__init__.py +5 -0
  190. supython/storage/backends.py +392 -0
  191. supython/storage/router.py +341 -0
  192. supython/storage/schemas.py +50 -0
  193. supython/storage/service.py +445 -0
  194. supython/storage/signing.py +119 -0
  195. supython/tokens.py +85 -0
  196. supython-0.1.0.dist-info/METADATA +756 -0
  197. supython-0.1.0.dist-info/RECORD +200 -0
  198. supython-0.1.0.dist-info/WHEEL +4 -0
  199. supython-0.1.0.dist-info/entry_points.txt +2 -0
  200. supython-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,255 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any
5
+
6
+ import httpx
7
+
8
+ from ._auth import SupythonResponse
9
+ from ._config import _parse_error_detail
10
+
11
+
12
+ @dataclass
13
+ class StorageError:
14
+ code: str
15
+ message: str
16
+ status: int
17
+
18
+
19
+ @dataclass
20
+ class BucketResponse:
21
+ id: str
22
+ name: str
23
+ owner: str | None
24
+ public: bool
25
+ file_size_limit: int | None
26
+ allowed_mime_types: list[str] | None
27
+ created_at: str
28
+ updated_at: str
29
+
30
+
31
+ @dataclass
32
+ class ObjectResponse:
33
+ id: str
34
+ bucket_id: str
35
+ bucket: str
36
+ name: str
37
+ owner: str
38
+ size: int
39
+ mime_type: str | None
40
+ etag: str | None
41
+ created_at: str
42
+ updated_at: str
43
+
44
+
45
+ @dataclass
46
+ class SignedUrlResponse:
47
+ signed_url: str
48
+ token: str
49
+ expires_at: str
50
+ expires_in: int
51
+
52
+
53
+ def _make_storage_error(resp: httpx.Response) -> StorageError:
54
+ try:
55
+ body = resp.json()
56
+ except Exception:
57
+ msg = resp.text or f"HTTP {resp.status_code}"
58
+ return StorageError("network_error", msg, resp.status_code)
59
+ code, message = _parse_error_detail(body)
60
+ return StorageError(code, message, resp.status_code)
61
+
62
+
63
+ def _parse_bucket(body: dict[str, Any]) -> BucketResponse:
64
+ return BucketResponse(
65
+ id=str(body["id"]),
66
+ name=body["name"],
67
+ owner=str(body["owner"]) if body.get("owner") else None,
68
+ public=body["public"],
69
+ file_size_limit=body.get("file_size_limit"),
70
+ allowed_mime_types=body.get("allowed_mime_types"),
71
+ created_at=body["created_at"],
72
+ updated_at=body["updated_at"],
73
+ )
74
+
75
+
76
+ def _parse_object(body: dict[str, Any]) -> ObjectResponse:
77
+ return ObjectResponse(
78
+ id=str(body["id"]),
79
+ bucket_id=str(body["bucket_id"]),
80
+ bucket=body["bucket"],
81
+ name=body["name"],
82
+ owner=str(body["owner"]),
83
+ size=body["size"],
84
+ mime_type=body.get("mime_type"),
85
+ etag=body.get("etag"),
86
+ created_at=body["created_at"],
87
+ updated_at=body["updated_at"],
88
+ )
89
+
90
+
91
+ class StorageBucket:
92
+ def __init__(self, client: StorageClient, bucket_name: str) -> None:
93
+ self._client = client
94
+ self._bucket_name = bucket_name
95
+
96
+ def _headers(self) -> dict[str, str]:
97
+ return self._client._headers()
98
+
99
+ async def upload(
100
+ self,
101
+ path: str,
102
+ data: bytes,
103
+ *,
104
+ content_type: str | None = None,
105
+ ) -> SupythonResponse[ObjectResponse]:
106
+ url = f"{self._client._url}/object/{self._bucket_name}/{path}"
107
+ files = {"file": (path, data, content_type or "application/octet-stream")}
108
+ try:
109
+ resp = await self._client._http.post(
110
+ url, files=files, headers=self._headers()
111
+ )
112
+ except httpx.HTTPError as exc:
113
+ return SupythonResponse(error=StorageError("network_error", str(exc), 0))
114
+
115
+ if resp.status_code >= 400:
116
+ return SupythonResponse(error=_make_storage_error(resp))
117
+
118
+ return SupythonResponse(data=_parse_object(resp.json()))
119
+
120
+ async def download(self, path: str) -> SupythonResponse[bytes]:
121
+ url = f"{self._client._url}/object/{self._bucket_name}/{path}"
122
+ try:
123
+ resp = await self._client._http.get(url, headers=self._headers())
124
+ except httpx.HTTPError as exc:
125
+ return SupythonResponse(error=StorageError("network_error", str(exc), 0))
126
+
127
+ if resp.status_code >= 400:
128
+ return SupythonResponse(error=_make_storage_error(resp))
129
+
130
+ return SupythonResponse(data=resp.content)
131
+
132
+ async def remove(self, path: str) -> SupythonResponse[None]:
133
+ url = f"{self._client._url}/object/{self._bucket_name}/{path}"
134
+ try:
135
+ resp = await self._client._http.delete(url, headers=self._headers())
136
+ except httpx.HTTPError as exc:
137
+ return SupythonResponse(error=StorageError("network_error", str(exc), 0))
138
+
139
+ if resp.status_code >= 400:
140
+ return SupythonResponse(error=_make_storage_error(resp))
141
+
142
+ return SupythonResponse(data=None)
143
+
144
+ async def create_signed_url(
145
+ self, path: str, *, expires_in: int | None = None
146
+ ) -> SupythonResponse[SignedUrlResponse]:
147
+ url = f"{self._client._url}/object/sign/{self._bucket_name}/{path}"
148
+ body: dict[str, Any] = {}
149
+ if expires_in is not None:
150
+ body["expires_in"] = expires_in
151
+ try:
152
+ resp = await self._client._http.post(
153
+ url, json=body or None, headers=self._headers()
154
+ )
155
+ except httpx.HTTPError as exc:
156
+ return SupythonResponse(error=StorageError("network_error", str(exc), 0))
157
+
158
+ if resp.status_code >= 400:
159
+ return SupythonResponse(error=_make_storage_error(resp))
160
+
161
+ data = resp.json()
162
+ return SupythonResponse(
163
+ data=SignedUrlResponse(
164
+ signed_url=data["signed_url"],
165
+ token=data["token"],
166
+ expires_at=data["expires_at"],
167
+ expires_in=data["expires_in"],
168
+ )
169
+ )
170
+
171
+ def get_public_url(self, path: str) -> str:
172
+ return f"{self._client._url}/object/public/{self._bucket_name}/{path}"
173
+
174
+
175
+ class StorageClient:
176
+ def __init__(self, base_url: str, anon_key: str, client: Any) -> None:
177
+ self._url = base_url
178
+ self._anon_key = anon_key
179
+ self._client = client
180
+ self._http = httpx.AsyncClient()
181
+
182
+ def _headers(self) -> dict[str, str]:
183
+ headers: dict[str, str] = {}
184
+ if self._anon_key:
185
+ headers["apikey"] = self._anon_key
186
+ access_token = self._client._access_token
187
+ if access_token:
188
+ headers["Authorization"] = f"Bearer {access_token}"
189
+ return headers
190
+
191
+ def from_(self, bucket_name: str) -> StorageBucket:
192
+ return StorageBucket(self, bucket_name)
193
+
194
+ async def create_bucket(
195
+ self,
196
+ *,
197
+ name: str,
198
+ public: bool = False,
199
+ file_size_limit: int | None = None,
200
+ allowed_mime_types: list[str] | None = None,
201
+ ) -> SupythonResponse[BucketResponse]:
202
+ body: dict[str, Any] = {"name": name, "public": public}
203
+ if file_size_limit is not None:
204
+ body["file_size_limit"] = file_size_limit
205
+ if allowed_mime_types is not None:
206
+ body["allowed_mime_types"] = allowed_mime_types
207
+
208
+ try:
209
+ resp = await self._http.post(
210
+ f"{self._url}/bucket", json=body, headers=self._headers()
211
+ )
212
+ except httpx.HTTPError as exc:
213
+ return SupythonResponse(error=StorageError("network_error", str(exc), 0))
214
+
215
+ if resp.status_code >= 400:
216
+ return SupythonResponse(error=_make_storage_error(resp))
217
+
218
+ return SupythonResponse(data=_parse_bucket(resp.json()))
219
+
220
+ async def list_buckets(self) -> SupythonResponse[list[BucketResponse]]:
221
+ try:
222
+ resp = await self._http.get(f"{self._url}/bucket", headers=self._headers())
223
+ except httpx.HTTPError as exc:
224
+ return SupythonResponse(error=StorageError("network_error", str(exc), 0))
225
+
226
+ if resp.status_code >= 400:
227
+ return SupythonResponse(error=_make_storage_error(resp))
228
+
229
+ return SupythonResponse(data=[_parse_bucket(b) for b in resp.json()])
230
+
231
+ async def get_bucket(self, name: str) -> SupythonResponse[BucketResponse]:
232
+ try:
233
+ resp = await self._http.get(
234
+ f"{self._url}/bucket/{name}", headers=self._headers()
235
+ )
236
+ except httpx.HTTPError as exc:
237
+ return SupythonResponse(error=StorageError("network_error", str(exc), 0))
238
+
239
+ if resp.status_code >= 400:
240
+ return SupythonResponse(error=_make_storage_error(resp))
241
+
242
+ return SupythonResponse(data=_parse_bucket(resp.json()))
243
+
244
+ async def delete_bucket(self, name: str) -> SupythonResponse[None]:
245
+ try:
246
+ resp = await self._http.delete(
247
+ f"{self._url}/bucket/{name}", headers=self._headers()
248
+ )
249
+ except httpx.HTTPError as exc:
250
+ return SupythonResponse(error=StorageError("network_error", str(exc), 0))
251
+
252
+ if resp.status_code >= 400:
253
+ return SupythonResponse(error=_make_storage_error(resp))
254
+
255
+ return SupythonResponse(data=None)
File without changes
supython/db.py ADDED
@@ -0,0 +1,151 @@
1
+ import asyncio
2
+ import json
3
+ import logging
4
+ import os
5
+ from collections.abc import AsyncGenerator
6
+ from contextlib import asynccontextmanager
7
+ from typing import Any, cast
8
+
9
+ import asyncpg
10
+ from fastapi import FastAPI
11
+
12
+ from .settings import get_settings
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+ _pool: asyncpg.Pool | None = None
17
+
18
+
19
+ async def _connection_setup(conn: asyncpg.Connection) -> None:
20
+ timeout_ms = get_settings().db_statement_timeout_ms
21
+ if timeout_ms > 0:
22
+ await conn.execute(f"set statement_timeout = {int(timeout_ms)}")
23
+
24
+
25
+ async def init_pool() -> asyncpg.Pool:
26
+ global _pool
27
+ if _pool is None:
28
+ s = get_settings()
29
+ _pool = await asyncpg.create_pool(
30
+ s.database_url,
31
+ min_size=s.db_pool_min_size,
32
+ max_size=s.db_pool_max_size,
33
+ setup=_connection_setup,
34
+ )
35
+ return _pool
36
+
37
+
38
+ async def close_pool() -> None:
39
+ global _pool
40
+ if _pool is not None:
41
+ await _pool.close()
42
+ _pool = None
43
+
44
+
45
+ def get_pool() -> asyncpg.Pool:
46
+ if _pool is None:
47
+ raise RuntimeError("DB pool not initialised. Call init_pool() first.")
48
+ return _pool
49
+
50
+
51
+ @asynccontextmanager
52
+ async def acquire() -> AsyncGenerator[asyncpg.Connection, None]:
53
+ pool = get_pool()
54
+ async with pool.acquire() as conn:
55
+ yield cast(asyncpg.Connection, conn)
56
+
57
+
58
+ @asynccontextmanager
59
+ async def as_role(role: str, claims: dict[str, Any]) -> AsyncGenerator[asyncpg.Connection, None]:
60
+ """Yield a connection scoped to *role* with *claims* set as the JWT GUC.
61
+
62
+ Mirrors PostgREST's per-request role switch so that any SQL executed on
63
+ the yielded connection sees the same RLS verdict as a PostgREST request
64
+ would for the same JWT payload.
65
+ """
66
+ pool = get_pool()
67
+
68
+ allowed = get_settings().db_allowed_roles
69
+
70
+ if role not in allowed:
71
+ raise ValueError(f"role {role!r} not in {sorted(allowed)}")
72
+
73
+ async with pool.acquire() as conn, conn.transaction():
74
+ await conn.execute(f'set local role "{role}"')
75
+ await conn.execute(
76
+ "select set_config('request.jwt.claims', $1, true)",
77
+ json.dumps(claims),
78
+ )
79
+ yield cast(asyncpg.Connection, conn)
80
+
81
+
82
+ @asynccontextmanager
83
+ async def as_service_role(
84
+ *,
85
+ claims: dict[str, Any] | None = None,
86
+ ) -> AsyncGenerator[asyncpg.Connection, None]:
87
+ """Yield a pool connection running as ``service_role`` for the duration.
88
+
89
+ Framework-internal housekeeping primitive — distinct from :func:`as_role`,
90
+ which is the JWT-driven switch PostgREST mirrors. ``service_role`` bypasses
91
+ RLS, so the choice of role is made by internal code and never by a JWT's
92
+ ``role`` claim.
93
+
94
+ The optional ``claims`` argument sets ``request.jwt.claims`` on the
95
+ session via ``set_config('request.jwt.claims', …, true)`` so helpers
96
+ like ``auth.uid()`` see the caller's identity inside the block. This
97
+ is purely informational: ``service_role`` still bypasses RLS and
98
+ setting claims does not grant or restrict anything — it just makes
99
+ audit / stamping helpers return meaningful values during server-side
100
+ work performed on behalf of a specific user.
101
+
102
+ Uses ``SET LOCAL ROLE`` and ``set_config(..., true)`` inside a
103
+ transaction, so the role and GUC reset on ``COMMIT`` before the
104
+ connection returns to the pool.
105
+ """
106
+ pool = get_pool()
107
+ async with pool.acquire() as conn, conn.transaction():
108
+ await conn.execute("set local role service_role")
109
+ if claims is not None:
110
+ await conn.execute(
111
+ "select set_config('request.jwt.claims', $1, true)",
112
+ json.dumps(claims),
113
+ )
114
+ yield cast(asyncpg.Connection, conn)
115
+
116
+
117
+ def _maybe_enable_slow_callback_warnings() -> None:
118
+ """Turn on asyncio debug mode if SUPYTHON_SLOW_CALLBACK_MS is set.
119
+
120
+ The CLI's ``dev`` command sets this env var; production code paths leave
121
+ it unset so the (non-trivial) debug overhead is not paid in prod.
122
+ """
123
+ raw = os.environ.get("SUPYTHON_SLOW_CALLBACK_MS")
124
+ if not raw:
125
+ return
126
+ try:
127
+ threshold_ms = int(raw)
128
+ except ValueError:
129
+ logger.warning("SUPYTHON_SLOW_CALLBACK_MS=%r is not an int; ignoring", raw)
130
+ return
131
+ if threshold_ms <= 0:
132
+ return
133
+ loop = asyncio.get_running_loop()
134
+ loop.slow_callback_duration = threshold_ms / 1000.0
135
+ loop.set_debug(True)
136
+ logger.info(
137
+ "asyncio debug enabled; warning on callbacks > %dms (dev mode)",
138
+ threshold_ms,
139
+ )
140
+
141
+
142
+ @asynccontextmanager
143
+ async def lifespan(app: FastAPI):
144
+ _maybe_enable_slow_callback_warnings()
145
+ _created_pool = _pool is None
146
+ await init_pool()
147
+ try:
148
+ yield
149
+ finally:
150
+ if _created_pool:
151
+ await close_pool()
supython/db_admin.py ADDED
@@ -0,0 +1,8 @@
1
+ """Database administration helpers."""
2
+
3
+ import asyncpg
4
+
5
+
6
+ async def rotate_role_password(conn: asyncpg.Connection, role: str, password: str) -> None:
7
+ quoted = "'" + password.replace("'", "''") + "'"
8
+ await conn.execute(f"alter role {role} with password {quoted}")
supython/extensions.py ADDED
@@ -0,0 +1,36 @@
1
+ """Eager-import dotted module paths so user-side decorators register.
2
+
3
+ Called from ``create_app()`` before FastAPI() construction, and from the
4
+ ``supython worker run`` CLI command before ``Worker(settings)`` is
5
+ constructed. Intentionally fail-loud — silent failures here mean the
6
+ user's ``@job`` / ``@cron`` / ``@on`` decorators never ran, which is far
7
+ worse than a clear error at boot.
8
+ """
9
+
10
+ import importlib
11
+ import logging
12
+ from collections.abc import Sequence
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ def load_extensions(modules: Sequence[str]) -> None:
18
+ """Import each module by dotted path.
19
+
20
+ Empty strings are skipped. Duplicate entries are collapsed via
21
+ ``importlib.import_module``'s own caching (sys.modules), so re-listing
22
+ a module is safe but wasteful — keep the env clean.
23
+
24
+ Raises ``ImportError`` with a clear message on first missing module.
25
+ """
26
+ for name in modules:
27
+ if not name:
28
+ continue
29
+ try:
30
+ importlib.import_module(name)
31
+ except ImportError as exc:
32
+ raise ImportError(
33
+ f"extension {name!r} could not be imported. "
34
+ f"Set EXTENSIONS to comma-separated importable module paths."
35
+ ) from exc
36
+ logger.info("extension loaded: %s", name)
@@ -0,0 +1,19 @@
1
+ """Filesystem-loaded edge functions.
2
+
3
+ Layout: every ``*.py`` under ``settings.functions_dir`` becomes a route at
4
+ ``/functions/<relative path without .py>``. See :mod:`.loader` for discovery
5
+ rules and :mod:`.router` for the dispatcher contract.
6
+ """
7
+
8
+ from .context import Ctx, FunctionUser, PostgrestClient, StorageClient
9
+ from .loader import FunctionRegistry
10
+ from .schemas import FunctionMeta
11
+
12
+ __all__ = [
13
+ "Ctx",
14
+ "FunctionUser",
15
+ "FunctionMeta",
16
+ "FunctionRegistry",
17
+ "PostgrestClient",
18
+ "StorageClient",
19
+ ]