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,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)