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,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"
@@ -0,0 +1,5 @@
1
+ """Code generation for `supython gen`."""
2
+ from .types_py import render_types_py
3
+ from .types_ts import render_types_ts
4
+
5
+ __all__ = ["render_types_py", "render_types_ts"]
@@ -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
@@ -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"]