supython 0.5.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 (188) hide show
  1. supython/__init__.py +8 -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 +149 -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/body_size.py +184 -0
  100. supython/cli.py +1653 -0
  101. supython/client/__init__.py +67 -0
  102. supython/client/_auth.py +249 -0
  103. supython/client/_client.py +145 -0
  104. supython/client/_config.py +92 -0
  105. supython/client/_functions.py +69 -0
  106. supython/client/_storage.py +255 -0
  107. supython/client/py.typed +0 -0
  108. supython/db.py +151 -0
  109. supython/db_admin.py +8 -0
  110. supython/functions/__init__.py +19 -0
  111. supython/functions/context.py +262 -0
  112. supython/functions/loader.py +307 -0
  113. supython/functions/router.py +228 -0
  114. supython/functions/schemas.py +50 -0
  115. supython/gen/__init__.py +5 -0
  116. supython/gen/_introspect.py +137 -0
  117. supython/gen/types_py.py +270 -0
  118. supython/gen/types_ts.py +365 -0
  119. supython/health.py +229 -0
  120. supython/hooks.py +117 -0
  121. supython/jobs/__init__.py +31 -0
  122. supython/jobs/backends.py +97 -0
  123. supython/jobs/context.py +58 -0
  124. supython/jobs/cron.py +152 -0
  125. supython/jobs/cron_inproc.py +118 -0
  126. supython/jobs/decorators.py +76 -0
  127. supython/jobs/registry.py +79 -0
  128. supython/jobs/router.py +136 -0
  129. supython/jobs/schemas.py +92 -0
  130. supython/jobs/service.py +311 -0
  131. supython/jobs/worker.py +219 -0
  132. supython/jwks.py +257 -0
  133. supython/keyset.py +279 -0
  134. supython/logging_config.py +291 -0
  135. supython/mail.py +33 -0
  136. supython/mailer.py +65 -0
  137. supython/migrate.py +81 -0
  138. supython/migrations/0001_extensions_and_roles.sql +46 -0
  139. supython/migrations/0002_auth_schema.sql +66 -0
  140. supython/migrations/0003_demo_todos.sql +42 -0
  141. supython/migrations/0004_auth_v0_2.sql +47 -0
  142. supython/migrations/0005_storage_schema.sql +117 -0
  143. supython/migrations/0006_realtime_schema.sql +206 -0
  144. supython/migrations/0007_jobs_schema.sql +254 -0
  145. supython/migrations/0008_jobs_last_error.sql +56 -0
  146. supython/migrations/0009_auth_rate_limits.sql +33 -0
  147. supython/migrations/0010_worker_heartbeat.sql +14 -0
  148. supython/migrations/0011_admin_schema.sql +45 -0
  149. supython/migrations/0012_auth_banned_until.sql +10 -0
  150. supython/migrations/0013_email_templates.sql +19 -0
  151. supython/migrations/0014_realtime_payload_warning.sql +96 -0
  152. supython/migrations/0015_backups_schema.sql +14 -0
  153. supython/passwords.py +15 -0
  154. supython/realtime/__init__.py +6 -0
  155. supython/realtime/broker.py +814 -0
  156. supython/realtime/protocol.py +234 -0
  157. supython/realtime/router.py +184 -0
  158. supython/realtime/schemas.py +207 -0
  159. supython/realtime/service.py +261 -0
  160. supython/realtime/topics.py +175 -0
  161. supython/realtime/websocket.py +586 -0
  162. supython/scaffold/__init__.py +5 -0
  163. supython/scaffold/init_project.py +133 -0
  164. supython/scaffold/templates/Caddyfile.tmpl +4 -0
  165. supython/scaffold/templates/README.md.tmpl +22 -0
  166. supython/scaffold/templates/docker-compose.prod.yml.tmpl +84 -0
  167. supython/scaffold/templates/docker-compose.yml.tmpl +41 -0
  168. supython/scaffold/templates/docker_postgres_Dockerfile.tmpl +9 -0
  169. supython/scaffold/templates/docker_postgres_postgresql.conf.tmpl +3 -0
  170. supython/scaffold/templates/env.example.tmpl +149 -0
  171. supython/scaffold/templates/functions_README.md.tmpl +21 -0
  172. supython/scaffold/templates/gitignore.tmpl +14 -0
  173. supython/scaffold/templates/migrations/.gitkeep +0 -0
  174. supython/secretset.py +347 -0
  175. supython/security_headers.py +78 -0
  176. supython/settings.py +198 -0
  177. supython/storage/__init__.py +5 -0
  178. supython/storage/backends.py +392 -0
  179. supython/storage/router.py +341 -0
  180. supython/storage/schemas.py +50 -0
  181. supython/storage/service.py +445 -0
  182. supython/storage/signing.py +119 -0
  183. supython/tokens.py +85 -0
  184. supython-0.5.0.dist-info/METADATA +714 -0
  185. supython-0.5.0.dist-info/RECORD +188 -0
  186. supython-0.5.0.dist-info/WHEEL +4 -0
  187. supython-0.5.0.dist-info/entry_points.txt +2 -0
  188. supython-0.5.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,365 @@
1
+ """Render a TypeScript ``Database`` interface from Postgres schema introspection.
2
+
3
+ Public entry point: :func:`render_types_ts`. The returned string is a full
4
+ TypeScript module exporting a ``Database`` interface compatible with
5
+ ``@supabase/postgrest-js`` and ``@supython/sdk``.
6
+
7
+ The generator opens its own asyncpg connection and closes it before returning;
8
+ it does not depend on the supython service running.
9
+ """
10
+
11
+ from datetime import UTC, datetime
12
+
13
+ import asyncpg
14
+
15
+ from ..settings import get_settings
16
+ from ._introspect import (
17
+ _fetch_columns,
18
+ _fetch_enums,
19
+ _fetch_relationships,
20
+ _fetch_tables,
21
+ )
22
+ from .types_py import _class_name
23
+
24
+ _TS_TYPE_MAP: dict[str, str] = {
25
+ # strings
26
+ "text": "string",
27
+ "varchar": "string",
28
+ "bpchar": "string",
29
+ "char": "string",
30
+ "citext": "string",
31
+ "name": "string",
32
+ "inet": "string",
33
+ "cidr": "string",
34
+ "macaddr": "string",
35
+ "tsvector": "string",
36
+ "tsquery": "string",
37
+ "bit": "string",
38
+ "varbit": "string",
39
+ "uuid": "string",
40
+ "bytea": "string",
41
+ "oid": "string",
42
+ # numbers
43
+ "int2": "number",
44
+ "int4": "number",
45
+ "int8": "number",
46
+ "float4": "number",
47
+ "float8": "number",
48
+ "numeric": "number",
49
+ "money": "number",
50
+ # boolean
51
+ "bool": "boolean",
52
+ # dates/times as ISO strings
53
+ "timestamptz": "string",
54
+ "timestamp": "string",
55
+ "date": "string",
56
+ "time": "string",
57
+ "timetz": "string",
58
+ "interval": "string",
59
+ # json
60
+ "json": "Record<string, unknown>",
61
+ "jsonb": "Record<string, unknown>",
62
+ }
63
+
64
+ # TypeScript reserved words that cannot be used as bare identifiers
65
+ _TS_KEYWORDS: set[str] = {
66
+ # strict mode reserved
67
+ "break",
68
+ "case",
69
+ "catch",
70
+ "class",
71
+ "const",
72
+ "continue",
73
+ "debugger",
74
+ "default",
75
+ "delete",
76
+ "do",
77
+ "else",
78
+ "enum",
79
+ "export",
80
+ "extends",
81
+ "false",
82
+ "finally",
83
+ "for",
84
+ "function",
85
+ "if",
86
+ "import",
87
+ "in",
88
+ "instanceof",
89
+ "new",
90
+ "null",
91
+ "return",
92
+ "super",
93
+ "switch",
94
+ "this",
95
+ "throw",
96
+ "true",
97
+ "try",
98
+ "typeof",
99
+ "var",
100
+ "void",
101
+ "while",
102
+ "with",
103
+ "implements",
104
+ "interface",
105
+ "let",
106
+ "package",
107
+ "private",
108
+ "protected",
109
+ "public",
110
+ "static",
111
+ "yield",
112
+ "await",
113
+ "abstract",
114
+ "as",
115
+ "asserts",
116
+ "async",
117
+ "constructor",
118
+ "declare",
119
+ "from",
120
+ "get",
121
+ "infer",
122
+ "intrinsic",
123
+ "is",
124
+ "keyof",
125
+ "module",
126
+ "namespace",
127
+ "never",
128
+ "of",
129
+ "readonly",
130
+ "require",
131
+ "set",
132
+ "type",
133
+ "unique",
134
+ "unknown",
135
+ }
136
+
137
+
138
+ def _safe_ts_col_name(name: str) -> str:
139
+ """Append _ if column name is a TS reserved word."""
140
+ if name in _TS_KEYWORDS:
141
+ return f"{name}_"
142
+ return name
143
+
144
+
145
+ def _pg_to_ts(
146
+ udt_schema: str,
147
+ udt_name: str,
148
+ data_type: str,
149
+ element: tuple[str, str, str] | None,
150
+ enum_types: set[tuple[str, str]],
151
+ ) -> str:
152
+ """Return TypeScript type annotation string."""
153
+ if data_type == "ARRAY" and element is not None:
154
+ elem_type = _pg_to_ts(element[1], element[2], element[0], None, enum_types)
155
+ return f"{elem_type}[]"
156
+
157
+ if data_type == "USER-DEFINED" and (udt_schema, udt_name) in enum_types:
158
+ # Enum type - will be rendered as "schema.name" union type
159
+ return f"{udt_schema}.{udt_name}"
160
+
161
+ if udt_name in _TS_TYPE_MAP:
162
+ return _TS_TYPE_MAP[udt_name]
163
+
164
+ return "unknown"
165
+
166
+
167
+ def _has_default(col: asyncpg.Record) -> bool:
168
+ """Return True if column has a default value, is generated, or is identity."""
169
+ return (
170
+ col["column_default"] is not None
171
+ or col["is_generated"] != "NEVER"
172
+ or col["is_identity"] == "YES"
173
+ )
174
+
175
+
176
+ def _render_table_shape(
177
+ cols: list[asyncpg.Record],
178
+ enum_types: set[tuple[str, str]],
179
+ relationships: list[dict],
180
+ ) -> tuple[list[str], list[str], list[str], list[str]]:
181
+ """Render Row, Insert, Update, and Relationships lines for a table.
182
+
183
+ Returns (row_lines, insert_lines, update_lines, relationships_lines).
184
+ """
185
+ row_lines: list[str] = []
186
+ insert_lines: list[str] = []
187
+ update_lines: list[str] = []
188
+
189
+ for c in cols:
190
+ element = None
191
+ if c["element_data_type"]:
192
+ element = (
193
+ c["element_data_type"],
194
+ c["element_udt_schema"],
195
+ c["element_udt_name"],
196
+ )
197
+ ts_type = _pg_to_ts(
198
+ c["udt_schema"],
199
+ c["udt_name"],
200
+ c["data_type"],
201
+ element,
202
+ enum_types,
203
+ )
204
+
205
+ col_name = _safe_ts_col_name(c["column_name"])
206
+ nullable = c["is_nullable"] == "YES"
207
+ has_default = _has_default(c)
208
+
209
+ # Row: required if NOT NULL
210
+ row_optional = nullable
211
+ row_lines.append(
212
+ f" {col_name}{'?' if row_optional else ''}: {ts_type};"
213
+ )
214
+
215
+ # Insert: required if NOT NULL and no default
216
+ insert_optional = nullable or has_default
217
+ insert_lines.append(
218
+ f" {col_name}{'?' if insert_optional else ''}: {ts_type};"
219
+ )
220
+
221
+ # Update: always optional
222
+ update_lines.append(f" {col_name}?: {ts_type};")
223
+
224
+ # Relationships
225
+ rel_lines: list[str] = []
226
+ if relationships:
227
+ for rel in relationships:
228
+ columns = rel["columns"]
229
+ referenced = rel["referencedRelation"]
230
+ ref_cols = rel["referencedColumns"]
231
+ rel_lines.append(" {")
232
+ fk_name = rel["foreignKeyName"]
233
+ rel_lines.append(f' foreignKeyName: "{fk_name}";')
234
+ cols_str = ", ".join(repr(c) for c in columns)
235
+ rel_lines.append(f" columns: [{cols_str}];")
236
+ rel_lines.append(f' referencedRelation: "{referenced}";')
237
+ ref_str = ", ".join(repr(c) for c in ref_cols)
238
+ rel_lines.append(f" referencedColumns: [{ref_str}];")
239
+ rel_lines.append(" },")
240
+
241
+ return row_lines, insert_lines, update_lines, rel_lines
242
+
243
+
244
+ def _render(
245
+ schemas: list[str],
246
+ enums: dict[tuple[str, str], list[str]],
247
+ tables: list[tuple[str, str]],
248
+ columns: dict[tuple[str, str], list[asyncpg.Record]],
249
+ relationships: dict[tuple[str, str], list[dict]],
250
+ ) -> str:
251
+ """Render the full TypeScript Database interface."""
252
+ # Build enum type names set
253
+ enum_types: set[tuple[str, str]] = set(enums.keys())
254
+
255
+ # Group tables by schema
256
+ by_schema: dict[str, list[tuple[str, str]]] = {}
257
+ for schema, table in tables:
258
+ by_schema.setdefault(schema, []).append((schema, table))
259
+
260
+ body: list[str] = []
261
+
262
+ # Emit enum type aliases at top level
263
+ for (schema, name), labels in sorted(enums.items()):
264
+ enum_name = _class_name(schema, name, schemas)
265
+ label_union = " | ".join(f'"{lbl}"' for lbl in labels)
266
+ body.append(f"export type {enum_name} = {label_union};")
267
+ body.append("")
268
+
269
+ # Start Database interface
270
+ body.append("export interface Database {")
271
+
272
+ for schema in sorted(schemas):
273
+ schema_tables = by_schema.get(schema, [])
274
+
275
+ body.append(f" {schema}: {{")
276
+
277
+ # Tables section
278
+ body.append(" Tables: {")
279
+ for s, table in schema_tables:
280
+ cols = columns.get((s, table), [])
281
+ if not cols:
282
+ continue
283
+
284
+ rels = relationships.get((s, table), [])
285
+
286
+ row_lines, insert_lines, update_lines, rel_lines = _render_table_shape(
287
+ cols, enum_types, rels
288
+ )
289
+
290
+ body.append(f" {table}: {{")
291
+ body.append(" Row: {")
292
+ body.extend(row_lines)
293
+ body.append(" };")
294
+ body.append(" Insert: {")
295
+ body.extend(insert_lines)
296
+ body.append(" };")
297
+ body.append(" Update: {")
298
+ body.extend(update_lines)
299
+ body.append(" };")
300
+
301
+ if rel_lines:
302
+ body.append(" Relationships: [")
303
+ body.extend(rel_lines)
304
+ body.append(" ];")
305
+
306
+ body.append(" };")
307
+ body.append(" };")
308
+
309
+ # Views section - deferred (needs table_type tracking to separate
310
+ # from BASE TABLE entries already rendered in Tables above)
311
+ body.append(" Views: {")
312
+ body.append(" };")
313
+
314
+ # Functions section (RPC) - deferred
315
+ body.append(" Functions: {")
316
+ body.append(" };")
317
+
318
+ # Enums section - export as type aliases at schema level
319
+ schema_enums = [
320
+ (s, n) for s, n in enums if s == schema
321
+ ]
322
+ if schema_enums:
323
+ body.append(" Enums: {")
324
+ for s, name in sorted(schema_enums):
325
+ enum_name = _class_name(s, name, schemas)
326
+ labels = enums[(s, name)]
327
+ label_union = " | ".join(f'"{lbl}"' for lbl in labels)
328
+ body.append(f" {name}: {label_union};")
329
+ body.append(" };")
330
+ else:
331
+ body.append(" Enums: {")
332
+ body.append(" };")
333
+
334
+ body.append(" };")
335
+
336
+ body.append("}")
337
+
338
+ # Build final output
339
+ lines: list[str] = []
340
+ lines.append("// Generated by `supython gen types --lang ts`. Do not edit.")
341
+ lines.append("//")
342
+ lines.append(f"// Schemas: {', '.join(schemas)}")
343
+ lines.append(f"// Generated at: {datetime.now(UTC).isoformat()}")
344
+ lines.append("")
345
+ lines.extend(body)
346
+ lines.append("")
347
+
348
+ return "\n".join(lines)
349
+
350
+
351
+ async def render_types_ts(schemas: list[str]) -> str:
352
+ """Connect to ``DATABASE_URL`` and return a rendered TypeScript ``Database`` interface."""
353
+ s = get_settings()
354
+ conn = await asyncpg.connect(s.database_url)
355
+ try:
356
+ enums = await _fetch_enums(conn, schemas)
357
+ tables = await _fetch_tables(conn, schemas)
358
+ columns = await _fetch_columns(conn, schemas)
359
+ relationships = await _fetch_relationships(conn, schemas)
360
+ finally:
361
+ await conn.close()
362
+ return _render(schemas, enums, tables, columns, relationships)
363
+
364
+
365
+ __all__ = ["render_types_ts"]
supython/health.py ADDED
@@ -0,0 +1,229 @@
1
+ """Deep health probes: /livez, /readyz, /health."""
2
+
3
+ import asyncio
4
+ import logging
5
+ from datetime import UTC, datetime
6
+ from time import perf_counter
7
+ from typing import Any
8
+
9
+ import httpx
10
+ from fastapi import APIRouter, Request
11
+ from fastapi.responses import JSONResponse
12
+ from pydantic import BaseModel
13
+
14
+ from . import __version__, db
15
+ from .settings import get_settings
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+ router = APIRouter(tags=["meta"])
20
+ CHECK_TIMEOUT_S = 2.0
21
+ WORKER_HEARTBEAT_MAX_AGE_S = 30.0
22
+
23
+ # ---------------------------------------------------------------------------
24
+ # Response models
25
+ # ---------------------------------------------------------------------------
26
+
27
+
28
+ class CheckResult(BaseModel):
29
+ status: str # "ok" | "fail"
30
+ detail: str | None = None
31
+ latency_ms: float | None = None
32
+
33
+
34
+ class ReadyzResponse(BaseModel):
35
+ status: str # "ok" | "fail"
36
+ checks: dict[str, CheckResult]
37
+
38
+
39
+ class HealthResponse(BaseModel):
40
+ status: str
41
+ version: str
42
+ checks: dict[str, CheckResult]
43
+ postgrest_url: str
44
+ timestamp: str
45
+
46
+
47
+ class LivezResponse(BaseModel):
48
+ status: str
49
+
50
+
51
+ # ---------------------------------------------------------------------------
52
+ # Check functions (pure async, no FastAPI deps)
53
+ # ---------------------------------------------------------------------------
54
+
55
+
56
+ async def _check_database() -> CheckResult:
57
+ """Run select 1 with one total timeout."""
58
+ started = perf_counter()
59
+ try:
60
+ async with asyncio.timeout(CHECK_TIMEOUT_S):
61
+ async with db.acquire() as conn:
62
+ await conn.fetchval("select 1")
63
+ except TimeoutError:
64
+ return CheckResult(status="fail", detail="timeout")
65
+ except Exception as exc:
66
+ return CheckResult(status="fail", detail=str(exc))
67
+ elapsed = (perf_counter() - started) * 1000
68
+ return CheckResult(status="ok", latency_ms=round(elapsed, 1))
69
+
70
+
71
+ async def _check_postgrest() -> CheckResult:
72
+ """HEAD / with one total timeout."""
73
+ settings = get_settings()
74
+ started = perf_counter()
75
+ try:
76
+ async with asyncio.timeout(CHECK_TIMEOUT_S):
77
+ async with httpx.AsyncClient(timeout=CHECK_TIMEOUT_S) as client:
78
+ resp = await client.head(f"{settings.postgrest_url.rstrip('/')}/")
79
+ if resp.status_code >= 500:
80
+ return CheckResult(
81
+ status="fail",
82
+ detail=f"postgrest_status={resp.status_code}",
83
+ )
84
+ except TimeoutError:
85
+ return CheckResult(status="fail", detail="timeout")
86
+ except Exception as exc:
87
+ return CheckResult(status="fail", detail=str(exc))
88
+ elapsed = (perf_counter() - started) * 1000
89
+ return CheckResult(status="ok", latency_ms=round(elapsed, 1))
90
+
91
+
92
+ async def _check_broker(broker: Any | None) -> CheckResult:
93
+ """Broker listener task alive and connection open."""
94
+ if broker is None:
95
+ return CheckResult(status="fail", detail="broker_not_started")
96
+ try:
97
+ async with asyncio.timeout(CHECK_TIMEOUT_S):
98
+ if not broker.is_healthy:
99
+ return CheckResult(status="fail", detail="listener_not_ready")
100
+ connection_count = broker.connection_count
101
+ except TimeoutError:
102
+ return CheckResult(status="fail", detail="timeout")
103
+ except Exception as exc:
104
+ return CheckResult(status="fail", detail=str(exc))
105
+ return CheckResult(status="ok", detail=f"connections={connection_count}")
106
+
107
+
108
+ async def _check_worker() -> CheckResult:
109
+ """Check freshness of the newest worker heartbeat row."""
110
+ started = perf_counter()
111
+ try:
112
+ async with asyncio.timeout(CHECK_TIMEOUT_S):
113
+ async with db.as_service_role() as conn:
114
+ row = await conn.fetchrow(
115
+ """
116
+ select
117
+ count(*)::int as workers,
118
+ coalesce(sum(inflight), 0)::int as inflight,
119
+ extract(epoch from now() - max(last_heartbeat))::float as age_s
120
+ from jobs.worker_heartbeats
121
+ """
122
+ )
123
+ except TimeoutError:
124
+ return CheckResult(status="fail", detail="timeout")
125
+ except Exception as exc:
126
+ return CheckResult(status="fail", detail=str(exc))
127
+
128
+ if row is None or row["workers"] == 0 or row["age_s"] is None:
129
+ return CheckResult(status="fail", detail="no_worker_heartbeat")
130
+
131
+ age_s = float(row["age_s"])
132
+ if age_s > WORKER_HEARTBEAT_MAX_AGE_S:
133
+ return CheckResult(status="fail", detail=f"heartbeat_stale age_s={age_s:.1f}")
134
+
135
+ elapsed = (perf_counter() - started) * 1000
136
+ return CheckResult(
137
+ status="ok",
138
+ detail=f"workers={row['workers']} inflight={row['inflight']} age_s={age_s:.1f}",
139
+ latency_ms=round(elapsed, 1),
140
+ )
141
+
142
+
143
+ async def _check_pg_cron() -> CheckResult:
144
+ """pg_cron extension present and cron.job readable."""
145
+ started = perf_counter()
146
+ try:
147
+ async with asyncio.timeout(CHECK_TIMEOUT_S):
148
+ async with db.as_service_role() as conn:
149
+ has_ext = await conn.fetchval(
150
+ "select exists(select 1 from pg_extension where extname = 'pg_cron')"
151
+ )
152
+ if not has_ext:
153
+ return CheckResult(status="fail", detail="pg_cron_extension_missing")
154
+ count = await conn.fetchval("select count(*) from cron.job")
155
+ except TimeoutError:
156
+ return CheckResult(status="fail", detail="timeout")
157
+ except Exception as exc:
158
+ return CheckResult(status="fail", detail=str(exc))
159
+ elapsed = (perf_counter() - started) * 1000
160
+ return CheckResult(
161
+ status="ok",
162
+ detail=f"scheduled_jobs={count}",
163
+ latency_ms=round(elapsed, 1),
164
+ )
165
+
166
+
167
+ # ---------------------------------------------------------------------------
168
+ # Orchestrator
169
+ # ---------------------------------------------------------------------------
170
+
171
+
172
+ async def run_checks(request: Request) -> dict[str, CheckResult]:
173
+ """Run all applicable checks concurrently and return results."""
174
+ settings = get_settings()
175
+ app_state = request.app.state
176
+
177
+ coros: dict[str, Any] = {
178
+ "database": _check_database(),
179
+ "postgrest": _check_postgrest(),
180
+ }
181
+ if settings.realtime_enabled:
182
+ coros["broker"] = _check_broker(getattr(app_state, "broker", None))
183
+ if settings.jobs_enabled:
184
+ coros["worker"] = _check_worker()
185
+ if settings.jobs_cron_backend == "pg_cron":
186
+ coros["pg_cron"] = _check_pg_cron()
187
+
188
+ # Gather all checks concurrently
189
+ names = list(coros.keys())
190
+ results = await asyncio.gather(*coros.values())
191
+ return dict(zip(names, results, strict=True))
192
+
193
+
194
+ # ---------------------------------------------------------------------------
195
+ # Endpoints
196
+ # ---------------------------------------------------------------------------
197
+
198
+
199
+ @router.get("/livez", response_model=LivezResponse)
200
+ async def livez() -> LivezResponse:
201
+ return LivezResponse(status="ok")
202
+
203
+
204
+ @router.get("/readyz")
205
+ async def readyz(request: Request) -> JSONResponse:
206
+ checks = await run_checks(request)
207
+ all_ok = all(c.status == "ok" for c in checks.values())
208
+ body = ReadyzResponse(
209
+ status="ok" if all_ok else "fail",
210
+ checks=checks,
211
+ )
212
+ return JSONResponse(
213
+ content=body.model_dump(mode="json"),
214
+ status_code=200 if all_ok else 503,
215
+ )
216
+
217
+
218
+ @router.get("/health", response_model=HealthResponse)
219
+ async def health(request: Request) -> HealthResponse:
220
+ settings = get_settings()
221
+ checks = await run_checks(request)
222
+ all_ok = all(c.status == "ok" for c in checks.values())
223
+ return HealthResponse(
224
+ status="ok" if all_ok else "degraded",
225
+ version=__version__,
226
+ checks=checks,
227
+ postgrest_url=settings.postgrest_url,
228
+ timestamp=datetime.now(UTC).isoformat(),
229
+ )
supython/hooks.py ADDED
@@ -0,0 +1,117 @@
1
+ """Generic hook system for supython lifecycle events.
2
+
3
+ Provides ``on(event, fn)`` to register a handler and ``fire(event, *args)``
4
+ to invoke all registered handlers for that event in registration order.
5
+
6
+ The ``fn=None`` default on ``on()`` makes both ``@app.on_signup`` (bare)
7
+ and ``@app.on_signup()`` (called) work correctly.
8
+
9
+ ``HookCtx`` / ``build_hook_ctx`` live here rather than in ``jobs/context.py``
10
+ so feature modules (auth, storage, realtime, jobs, ...) share one hook
11
+ contract. Keeping the type in ``hooks`` also breaks the ``auth → jobs``
12
+ import edge that would otherwise couple auth to the jobs package.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import logging
18
+ from collections import defaultdict
19
+ from collections.abc import Awaitable, Callable, Coroutine
20
+ from dataclasses import dataclass
21
+ from typing import TYPE_CHECKING, Any
22
+
23
+ if TYPE_CHECKING: # pragma: no cover
24
+ import asyncpg
25
+
26
+ from .mailer import EmailBackend
27
+ from .settings import Settings
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+ _handlers: dict[str, list[Callable[..., Coroutine[Any, Any, None]]]] = defaultdict(list)
32
+
33
+
34
+ def on(
35
+ event: str,
36
+ fn: Callable[..., Coroutine[Any, Any, None]] | None = None,
37
+ ) -> Callable[..., Coroutine[Any, Any, None]]:
38
+ """Register an async handler for *event*.
39
+
40
+ Usage::
41
+
42
+ @hooks.on("signup")
43
+ async def on_signup(user, ctx):
44
+ ...
45
+
46
+ Or imperatively::
47
+
48
+ hooks.on("signup", my_handler)
49
+ """
50
+
51
+ def _register(
52
+ f: Callable[..., Coroutine[Any, Any, None]],
53
+ ) -> Callable[..., Coroutine[Any, Any, None]]:
54
+ _handlers[event].append(f)
55
+ return f
56
+
57
+ if fn is not None:
58
+ return _register(fn)
59
+ return _register
60
+
61
+
62
+ async def fire(event: str, *args: Any, **kwargs: Any) -> None:
63
+ """Invoke all registered handlers for *event* in order.
64
+
65
+ Exceptions are logged but do not prevent subsequent handlers from running.
66
+ """
67
+ for handler in _handlers.get(event, []):
68
+ try:
69
+ await handler(*args, **kwargs)
70
+ except Exception:
71
+ logger.exception("hooks.fire error in %r handler %r", event, handler)
72
+
73
+
74
+ def reset() -> None:
75
+ _handlers.clear()
76
+
77
+
78
+ @dataclass
79
+ class HookCtx:
80
+ """Value passed to hook handlers (e.g. ``on_signup``).
81
+
82
+ Smaller than ``jobs.JobCtx`` and ``functions.Ctx`` on purpose: a hook
83
+ fires inline on the request/worker path and should only reach for the
84
+ things every lifecycle event is expected to need — the role-scoped DB
85
+ connection, settings, a mailer, and a logger. Anything heavier belongs
86
+ in an enqueued job.
87
+ """
88
+
89
+ db: asyncpg.Connection
90
+ settings: Settings
91
+ send_email: Callable[..., Awaitable[None]]
92
+ logger: logging.Logger
93
+
94
+
95
+ def build_hook_ctx(
96
+ *,
97
+ conn: asyncpg.Connection,
98
+ settings: Settings | None = None,
99
+ mailer: EmailBackend | None = None,
100
+ ) -> HookCtx:
101
+ """Assemble a ``HookCtx`` from a role-scoped connection.
102
+
103
+ The caller is expected to have entered ``db.as_role(...)`` already so
104
+ that ``conn`` carries the intended RLS posture; this factory does not
105
+ switch roles.
106
+ """
107
+ from .functions.context import _make_send_email
108
+ from .mailer import get_mailer
109
+ from .settings import get_settings
110
+
111
+ s = settings or get_settings()
112
+ return HookCtx(
113
+ db=conn,
114
+ settings=s,
115
+ send_email=_make_send_email(mailer or get_mailer()),
116
+ logger=logging.getLogger("supython.hooks"),
117
+ )