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
supython/keyset.py ADDED
@@ -0,0 +1,279 @@
1
+ """JWT key rotation manifest.
2
+
3
+ Multi-kid keyset lifecycle: tracks per-kid PEM files in a directory
4
+ (``settings.jwt_keys_dir``, default ``./.supython/keys/``) governed by a
5
+ JSON manifest (``settings.jwt_keyset_manifest_path``, default
6
+ ``./.supython/keyset.json``). The manifest is the source of truth for
7
+ the active signing kid; ``settings.jwt_kid`` (the ``JWT_KID`` env var)
8
+ remains as an explicit override for read-only-FS deployments.
9
+
10
+ When the manifest is absent, ``jwks.load_signing_key`` /
11
+ ``load_verification_keyset`` continue to behave as in Phase 5 (single
12
+ key from ``JWT_PRIVATE_KEY_PATH`` / ``JWT_PRIVATE_KEY``). The first
13
+ ``supython keygen rotate`` invocation calls
14
+ ``import_legacy_single_key`` to seed the manifest from the legacy
15
+ single-key environment.
16
+ """
17
+
18
+ import json
19
+ import logging
20
+ import os
21
+ from dataclasses import dataclass
22
+ from datetime import datetime, timezone
23
+ from pathlib import Path
24
+ from typing import Any, Literal
25
+
26
+ from cryptography.hazmat.primitives import serialization
27
+
28
+ from . import jwks
29
+ from .settings import get_settings
30
+
31
+ logger = logging.getLogger(__name__)
32
+
33
+ _DEFAULT_KEYS_DIR = Path("./.supython/keys")
34
+
35
+ KeyStatus = Literal["active", "verifying", "retired"]
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class KeyEntry:
40
+ kid: str
41
+ alg: str
42
+ pem_path: Path
43
+ created_at: datetime
44
+ retired_at: datetime | None
45
+ status: KeyStatus
46
+
47
+
48
+ def _now() -> datetime:
49
+ return datetime.now(tz=timezone.utc)
50
+
51
+
52
+ def keys_dir() -> Path:
53
+ s = get_settings()
54
+ return s.jwt_keys_dir if s.jwt_keys_dir is not None else _DEFAULT_KEYS_DIR
55
+
56
+
57
+ def manifest_path() -> Path:
58
+ return get_settings().jwt_keyset_manifest_path
59
+
60
+
61
+ def has_manifest() -> bool:
62
+ return manifest_path().exists()
63
+
64
+
65
+ def _to_iso(dt: datetime) -> str:
66
+ return dt.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
67
+
68
+
69
+ def _from_iso(value: str | None) -> datetime | None:
70
+ if value is None:
71
+ return None
72
+ return datetime.strptime(value, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc)
73
+
74
+
75
+ def _entry_to_dict(entry: KeyEntry) -> dict[str, Any]:
76
+ return {
77
+ "kid": entry.kid,
78
+ "alg": entry.alg,
79
+ "created_at": _to_iso(entry.created_at),
80
+ "retired_at": _to_iso(entry.retired_at) if entry.retired_at else None,
81
+ "status": entry.status,
82
+ }
83
+
84
+
85
+ def _entry_from_dict(data: dict[str, Any], kdir: Path) -> KeyEntry:
86
+ return KeyEntry(
87
+ kid=data["kid"],
88
+ alg=data["alg"],
89
+ pem_path=kdir / f"{data['kid']}.pem",
90
+ created_at=_from_iso(data["created_at"]) or _now(),
91
+ retired_at=_from_iso(data.get("retired_at")),
92
+ status=data.get("status", "verifying"),
93
+ )
94
+
95
+
96
+ def load_manifest() -> dict[str, Any] | None:
97
+ path = manifest_path()
98
+ if not path.exists():
99
+ return None
100
+ with path.open("r") as f:
101
+ return json.load(f)
102
+
103
+
104
+ def write_manifest(manifest: dict[str, Any]) -> None:
105
+ path = manifest_path()
106
+ path.parent.mkdir(parents=True, exist_ok=True)
107
+ tmp_path = path.with_suffix(path.suffix + ".tmp")
108
+ tmp_path.write_text(json.dumps(manifest, sort_keys=True, indent=2) + "\n")
109
+ os.replace(tmp_path, path)
110
+
111
+
112
+ def list_keys() -> list[KeyEntry]:
113
+ manifest = load_manifest()
114
+ if manifest is None:
115
+ return []
116
+ kdir = keys_dir()
117
+ return [_entry_from_dict(d, kdir) for d in manifest.get("keys", [])]
118
+
119
+
120
+ def get_entry(kid: str) -> KeyEntry | None:
121
+ for entry in list_keys():
122
+ if entry.kid == kid:
123
+ return entry
124
+ return None
125
+
126
+
127
+ def active_kid() -> str | None:
128
+ """Return the kid the auth process should sign with.
129
+
130
+ ``JWT_KID`` env var wins over the manifest so a deployment with a
131
+ read-only filesystem can pin a kid without touching the manifest.
132
+ """
133
+ s = get_settings()
134
+ if s.jwt_kid is not None:
135
+ return s.jwt_kid
136
+ manifest = load_manifest()
137
+ if manifest is None:
138
+ return None
139
+ return manifest.get("active")
140
+
141
+
142
+ def add_key(
143
+ alg: Literal["RS256", "ES256"] = "RS256",
144
+ *,
145
+ status: KeyStatus = "verifying",
146
+ activate_immediately: bool = False,
147
+ ) -> KeyEntry:
148
+ """Generate a new keypair, persist its PEM, and append it to the manifest.
149
+
150
+ When the manifest has no active kid yet, the new key is promoted to
151
+ ``active`` regardless of ``status`` so the keyset is always usable.
152
+ """
153
+ kdir = keys_dir()
154
+ kdir.mkdir(parents=True, exist_ok=True)
155
+
156
+ key = jwks.generate_private_key(alg)
157
+ signer = jwks.signing_key_from_private_key(key, alg)
158
+ pem_path = kdir / f"{signer.kid}.pem"
159
+ if not pem_path.exists():
160
+ jwks.write_private_key_pem(pem_path, jwks.private_key_to_pem(key), force=False)
161
+
162
+ manifest = load_manifest() or {"active": None, "keys": []}
163
+
164
+ if not any(e["kid"] == signer.kid for e in manifest["keys"]):
165
+ entry = KeyEntry(
166
+ kid=signer.kid,
167
+ alg=alg,
168
+ pem_path=pem_path,
169
+ created_at=_now(),
170
+ retired_at=None,
171
+ status=status,
172
+ )
173
+ manifest["keys"].append(_entry_to_dict(entry))
174
+
175
+ if activate_immediately or manifest.get("active") is None:
176
+ manifest["active"] = signer.kid
177
+ for e in manifest["keys"]:
178
+ if e["kid"] == signer.kid:
179
+ e["status"] = "active"
180
+ e["retired_at"] = None
181
+
182
+ write_manifest(manifest)
183
+ record = next(e for e in manifest["keys"] if e["kid"] == signer.kid)
184
+ return _entry_from_dict(record, kdir)
185
+
186
+
187
+ def activate(kid: str) -> None:
188
+ """Flip the active signing kid; previously-active kid becomes ``retired``."""
189
+ manifest = load_manifest()
190
+ if manifest is None:
191
+ raise FileNotFoundError(f"keyset manifest not found: {manifest_path()}")
192
+ target = next((e for e in manifest["keys"] if e["kid"] == kid), None)
193
+ if target is None:
194
+ raise KeyError(f"kid not in keyset: {kid!r}")
195
+ previous = manifest.get("active")
196
+ now_iso = _to_iso(_now())
197
+ for e in manifest["keys"]:
198
+ if e["kid"] == previous and previous != kid:
199
+ e["status"] = "retired"
200
+ e["retired_at"] = now_iso
201
+ target["status"] = "active"
202
+ target["retired_at"] = None
203
+ manifest["active"] = kid
204
+ write_manifest(manifest)
205
+
206
+
207
+ def prune(*, force_all: bool = False) -> list[str]:
208
+ """Drop retired kids whose grace window has elapsed; return removed kids."""
209
+ manifest = load_manifest()
210
+ if manifest is None:
211
+ return []
212
+ grace = get_settings().jwt_rotation_grace_seconds
213
+ now = _now()
214
+ kdir = keys_dir()
215
+ removed: list[str] = []
216
+ surviving: list[dict[str, Any]] = []
217
+ for e in manifest["keys"]:
218
+ if e.get("status") != "retired":
219
+ surviving.append(e)
220
+ continue
221
+ retired_at = _from_iso(e.get("retired_at"))
222
+ elapsed = (now - retired_at).total_seconds() if retired_at else 0.0
223
+ if force_all or (retired_at is not None and elapsed >= grace):
224
+ pem_path = kdir / f"{e['kid']}.pem"
225
+ try:
226
+ pem_path.unlink()
227
+ except FileNotFoundError:
228
+ pass
229
+ removed.append(e["kid"])
230
+ else:
231
+ surviving.append(e)
232
+ manifest["keys"] = surviving
233
+ if manifest.get("active") in removed:
234
+ manifest["active"] = None
235
+ write_manifest(manifest)
236
+ return removed
237
+
238
+
239
+ def import_legacy_single_key() -> KeyEntry | None:
240
+ """Seed the manifest from ``JWT_PRIVATE_KEY_PATH`` / ``JWT_PRIVATE_KEY``.
241
+
242
+ Returns ``None`` if the manifest already exists or if the legacy
243
+ single-key environment is empty. Idempotent: re-running on a
244
+ non-empty manifest is a no-op.
245
+ """
246
+ if has_manifest():
247
+ return None
248
+ s = get_settings()
249
+ if s.jwt_private_key is None and s.jwt_private_key_path is None:
250
+ return None
251
+ if s.jwt_private_key is not None:
252
+ pem_bytes = s.jwt_private_key.encode()
253
+ elif s.jwt_private_key_path is not None and s.jwt_private_key_path.exists():
254
+ pem_bytes = s.jwt_private_key_path.read_bytes()
255
+ else:
256
+ return None
257
+
258
+ key = serialization.load_pem_private_key(pem_bytes, password=None)
259
+ signer = jwks.signing_key_from_private_key(key, s.jwt_alg, s.jwt_kid)
260
+
261
+ kdir = keys_dir()
262
+ kdir.mkdir(parents=True, exist_ok=True)
263
+ pem_path = kdir / f"{signer.kid}.pem"
264
+ if not pem_path.exists():
265
+ jwks.write_private_key_pem(pem_path, jwks.private_key_to_pem(key), force=False)
266
+
267
+ entry = KeyEntry(
268
+ kid=signer.kid,
269
+ alg=s.jwt_alg,
270
+ pem_path=pem_path,
271
+ created_at=_now(),
272
+ retired_at=None,
273
+ status="active",
274
+ )
275
+ write_manifest({
276
+ "active": signer.kid,
277
+ "keys": [_entry_to_dict(entry)],
278
+ })
279
+ return entry
@@ -0,0 +1,291 @@
1
+ import contextvars
2
+ import json
3
+ import logging
4
+ import sys
5
+ import time
6
+ import traceback
7
+ import uuid
8
+ from collections import deque
9
+ from collections.abc import Awaitable, Callable
10
+ from datetime import UTC, datetime
11
+ from typing import Any
12
+
13
+ request_id: contextvars.ContextVar[str] = contextvars.ContextVar("request_id")
14
+
15
+
16
+ def get_request_id() -> str | None:
17
+ return request_id.get(None)
18
+
19
+
20
+ class JsonFormatter(logging.Formatter):
21
+ def format(self, record: logging.LogRecord) -> str:
22
+ entry: dict[str, Any] = {
23
+ "timestamp": datetime.fromtimestamp(record.created, tz=UTC).isoformat(),
24
+ "level": record.levelname,
25
+ "logger": record.name,
26
+ "message": record.getMessage(),
27
+ }
28
+ rid = get_request_id()
29
+ if rid is not None:
30
+ entry["request_id"] = rid
31
+ if record.exc_info and record.exc_info[1] is not None:
32
+ entry["exc_info"] = "".join(traceback.format_exception(*record.exc_info))
33
+ if hasattr(record, "extra_fields"):
34
+ entry.update(record.extra_fields)
35
+ return json.dumps(entry, default=str)
36
+
37
+
38
+ _PLAIN_FORMAT = "%(levelname)s %(name)s %(message)s"
39
+
40
+
41
+ class BoundedLogRingHandler(logging.Handler):
42
+ """In-memory ring buffer of structured log records.
43
+
44
+ Each entry is a dict with 'timestamp', 'level', 'logger', 'message',
45
+ and optionally 'request_id' and 'exc_info'. The buffer is bounded so
46
+ it never grows without limit.
47
+
48
+ Access the buffer via the module-level ``log_ring`` tuple:
49
+
50
+ from supython.logging_config import log_ring
51
+ entries = log_ring.get() # list[dict[str, Any]]
52
+ """
53
+
54
+ def __init__(self, capacity: int = 5000) -> None:
55
+ super().__init__()
56
+ self._buffer: deque[dict[str, Any]] = deque(maxlen=capacity)
57
+
58
+ def emit(self, record: logging.LogRecord) -> None:
59
+ entry: dict[str, Any] = {
60
+ "timestamp": datetime.fromtimestamp(record.created, tz=UTC).isoformat(),
61
+ "level": record.levelname,
62
+ "logger": record.name,
63
+ "message": record.getMessage(),
64
+ }
65
+ rid = get_request_id()
66
+ if rid is not None:
67
+ entry["request_id"] = rid
68
+ if record.exc_info and record.exc_info[1] is not None:
69
+ entry["exc_info"] = "".join(traceback.format_exception(*record.exc_info))
70
+ if hasattr(record, "extra_fields"):
71
+ entry.update(record.extra_fields) # type: ignore[attr-defined]
72
+ self._buffer.append(entry)
73
+
74
+ def get(self) -> list[dict[str, Any]]:
75
+ return list(self._buffer)
76
+
77
+
78
+ # Module-level ring buffer instance — populated by configure_logging() below.
79
+ _log_ring_handler: BoundedLogRingHandler | None = None
80
+
81
+
82
+ def get_log_ring() -> list[dict[str, Any]]:
83
+ """Return a snapshot of the in-memory log ring buffer.
84
+
85
+ Returns an empty list before ``configure_logging()`` has been called.
86
+ """
87
+ if _log_ring_handler is None:
88
+ return []
89
+ return _log_ring_handler.get()
90
+
91
+
92
+ def configure_logging(level: str = "INFO", *, json_format: bool = True) -> None:
93
+ global _log_ring_handler
94
+
95
+ root = logging.getLogger()
96
+ numeric = getattr(logging, level.upper(), logging.INFO)
97
+ root.setLevel(numeric)
98
+
99
+ # Stdout handler
100
+ stdout_handler = logging.StreamHandler(sys.stdout)
101
+ if json_format:
102
+ stdout_handler.setFormatter(JsonFormatter())
103
+ else:
104
+ stdout_handler.setFormatter(logging.Formatter(_PLAIN_FORMAT))
105
+
106
+ # Ring-buffer handler (always structured, regardless of json_format)
107
+ ring_handler = BoundedLogRingHandler(capacity=5000)
108
+ ring_handler.setLevel(numeric)
109
+
110
+ root.handlers = [
111
+ h
112
+ for h in root.handlers
113
+ if not isinstance(h, (logging.StreamHandler, BoundedLogRingHandler))
114
+ ]
115
+ root.addHandler(stdout_handler)
116
+ root.addHandler(ring_handler)
117
+ _log_ring_handler = ring_handler
118
+
119
+
120
+ class RequestIdMiddleware:
121
+ def __init__(self, app: Any) -> None:
122
+ self.app = app
123
+
124
+ async def __call__(
125
+ self,
126
+ scope: dict[str, Any],
127
+ receive: Callable[[], Awaitable[dict[str, Any]]],
128
+ send: Callable[[dict[str, Any]], Awaitable[None]],
129
+ ) -> None:
130
+ if scope["type"] not in ("http", "websocket"):
131
+ await self.app(scope, receive, send)
132
+ return
133
+
134
+ req_id: str | None = None
135
+ for name, value in scope.get("headers", []):
136
+ if name == b"x-request-id":
137
+ req_id = value.decode("ascii", errors="replace")
138
+ break
139
+ if not req_id:
140
+ req_id = uuid.uuid4().hex
141
+
142
+ token = request_id.set(req_id)
143
+ try:
144
+ await self.app(scope, receive, send)
145
+ finally:
146
+ request_id.reset(token)
147
+
148
+
149
+ _REQUEST_LOG_MAX_BODY_BYTES = 10 * 1024
150
+ _AUTH_HEADER = b"authorization"
151
+ _REDACTED = "***REDACTED***"
152
+
153
+ _access_logger = logging.getLogger("supython.access")
154
+
155
+
156
+ class RequestLoggingMiddleware:
157
+ def __init__(self, app: Any) -> None:
158
+ self.app = app
159
+
160
+ async def __call__(
161
+ self,
162
+ scope: dict[str, Any],
163
+ receive: Callable[[], Awaitable[dict[str, Any]]],
164
+ send: Callable[[dict[str, Any]], Awaitable[None]],
165
+ ) -> None:
166
+ if scope["type"] != "http":
167
+ await self.app(scope, receive, send)
168
+ return
169
+
170
+ start = time.monotonic()
171
+ method = scope.get("method", "")
172
+ path = scope.get("path", "")
173
+
174
+ user_agent: str | None = None
175
+ for name, value in scope.get("headers", []):
176
+ if name == b"user-agent":
177
+ user_agent = value.decode("utf-8", errors="replace")
178
+ break
179
+
180
+ rid = get_request_id()
181
+
182
+ body_chunks: list[bytes] = []
183
+ body_size = 0
184
+ body_truncated = False
185
+
186
+ async def _drain_body() -> None:
187
+ nonlocal body_size, body_truncated
188
+ while True:
189
+ msg = await receive()
190
+ if msg["type"] != "http.request":
191
+ continue
192
+ chunk = msg.get("body", b"")
193
+ more = msg.get("more_body", False)
194
+ if chunk and body_size < _REQUEST_LOG_MAX_BODY_BYTES:
195
+ space = _REQUEST_LOG_MAX_BODY_BYTES - body_size
196
+ if len(chunk) > space:
197
+ body_chunks.append(chunk[:space])
198
+ body_size += space
199
+ body_truncated = True
200
+ else:
201
+ body_chunks.append(chunk)
202
+ body_size += len(chunk)
203
+ if not more:
204
+ break
205
+
206
+ await _drain_body()
207
+
208
+ full_body = b"".join(body_chunks)
209
+ body_consumed = False
210
+
211
+ async def _receive() -> dict[str, Any]:
212
+ nonlocal body_consumed
213
+ if not body_consumed:
214
+ body_consumed = True
215
+ return {
216
+ "type": "http.request",
217
+ "body": full_body,
218
+ "more_body": False,
219
+ }
220
+ # Body already delivered. Forward subsequent calls to the real
221
+ # receive() so callers awaiting http.disconnect (e.g. Starlette's
222
+ # StreamingResponse.listen_for_disconnect) actually block on the
223
+ # event loop instead of busy-looping on synchronous returns,
224
+ # which would starve the streaming task and hang the request.
225
+ return await receive()
226
+
227
+ status_code: int = 0
228
+ response_size: int = 0
229
+
230
+ async def _send(message: dict[str, Any]) -> None:
231
+ nonlocal status_code, response_size
232
+ if message["type"] == "http.response.start":
233
+ status_code = message.get("status", 0)
234
+ elif message["type"] == "http.response.body":
235
+ response_size += len(message.get("body", b""))
236
+ await send(message)
237
+
238
+ exc_info: tuple[type, BaseException, Any] | None = None
239
+ try:
240
+ await self.app(scope, _receive, _send)
241
+ except Exception:
242
+ exc_info = sys.exc_info()
243
+ raise
244
+ finally:
245
+ duration_ms = round((time.monotonic() - start) * 1000, 2)
246
+
247
+ redacted_headers: list[list[str]] = []
248
+ for name, value in scope.get("headers", []):
249
+ if name == _AUTH_HEADER:
250
+ redacted_headers.append([name.decode("ascii", errors="replace"), _REDACTED])
251
+ else:
252
+ redacted_headers.append(
253
+ [
254
+ name.decode("ascii", errors="replace"),
255
+ value.decode("utf-8", errors="replace"),
256
+ ]
257
+ )
258
+
259
+ fields: dict[str, Any] = {
260
+ "duration_ms": duration_ms,
261
+ "status": status_code,
262
+ "method": method,
263
+ "path": path,
264
+ "request_id": rid,
265
+ "user_agent": user_agent,
266
+ "response_size": response_size,
267
+ "headers": redacted_headers,
268
+ }
269
+
270
+ is_server_error = 500 <= status_code < 600 or exc_info is not None
271
+
272
+ if is_server_error:
273
+ fields["request_body"] = full_body.decode("utf-8", errors="replace")
274
+ if body_truncated:
275
+ fields["body_truncated"] = True
276
+ if exc_info is not None and exc_info[1] is not None:
277
+ fields["traceback"] = "".join(traceback.format_exception(*exc_info))
278
+
279
+ level = logging.ERROR if is_server_error else logging.INFO
280
+ record = logging.LogRecord(
281
+ name="supython.access",
282
+ level=level,
283
+ pathname=__file__,
284
+ lineno=0,
285
+ msg="request completed",
286
+ args=(),
287
+ exc_info=None,
288
+ )
289
+ record.extra_fields = fields # type: ignore[attr-defined]
290
+
291
+ _access_logger.handle(record)
supython/mail.py ADDED
@@ -0,0 +1,33 @@
1
+ """Shared email dispatcher.
2
+
3
+ Routes email through the jobs queue when jobs are enabled and a caller
4
+ connection is available, otherwise falls back to synchronous send.
5
+ """
6
+
7
+ import asyncpg
8
+
9
+ from .mailer import EmailMessage, get_mailer
10
+ from .settings import get_settings
11
+
12
+
13
+ async def dispatch(
14
+ conn: asyncpg.Connection | None,
15
+ msg: EmailMessage,
16
+ *,
17
+ job_name: str = "send_email",
18
+ ) -> None:
19
+ """Send an email, via the jobs queue when enabled and a conn is available.
20
+
21
+ Falls back to synchronous ``mailer.send()`` otherwise.
22
+ """
23
+ s = get_settings()
24
+ if s.jobs_enabled and conn is not None:
25
+ from .jobs import service as _jobs_svc
26
+
27
+ await _jobs_svc.enqueue(
28
+ conn,
29
+ name=job_name,
30
+ payload=msg.model_dump(),
31
+ )
32
+ return
33
+ await get_mailer().send(msg)
supython/mailer.py ADDED
@@ -0,0 +1,65 @@
1
+ import logging
2
+ from email.message import EmailMessage as MimeMessage
3
+ from typing import Protocol
4
+
5
+ import aiosmtplib
6
+ from pydantic import BaseModel, Field
7
+
8
+ from .settings import Settings, get_settings
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ class EmailMessage(BaseModel):
14
+ to: list[str] = Field(min_length=1)
15
+ subject: str
16
+ text: str
17
+ html: str | None = None
18
+
19
+
20
+ class EmailBackend(Protocol):
21
+ async def send(self, msg: EmailMessage) -> None: ...
22
+
23
+
24
+ class ConsoleBackend:
25
+ async def send(self, msg: EmailMessage) -> None:
26
+ logger.info(
27
+ "email (console): to=%s subject=%s text=%r html=%r",
28
+ msg.to,
29
+ msg.subject,
30
+ msg.text,
31
+ msg.html,
32
+ )
33
+
34
+
35
+ class SmtpBackend:
36
+ def __init__(self, settings: Settings) -> None:
37
+ self._settings = settings
38
+
39
+ async def send(self, msg: EmailMessage) -> None:
40
+ mime = MimeMessage()
41
+ mime["From"] = self._settings.email_from
42
+ mime["To"] = ", ".join(msg.to)
43
+ mime["Subject"] = msg.subject
44
+ mime.set_content(msg.text)
45
+ if msg.html is not None:
46
+ mime.add_alternative(msg.html, subtype="html")
47
+
48
+ username = self._settings.smtp_username or None
49
+ password = self._settings.smtp_password or None
50
+
51
+ await aiosmtplib.send(
52
+ mime,
53
+ hostname=self._settings.smtp_host,
54
+ port=self._settings.smtp_port,
55
+ username=username,
56
+ password=password,
57
+ start_tls=self._settings.smtp_starttls,
58
+ )
59
+
60
+
61
+ def get_mailer() -> EmailBackend:
62
+ settings = get_settings()
63
+ if settings.email_backend == "smtp":
64
+ return SmtpBackend(settings)
65
+ return ConsoleBackend()