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,117 @@
1
+ """Load a user-defined Python settings module (Django-style).
2
+
3
+ Conventional uppercase attributes read from the module:
4
+ EXTENSIONS: list[str] — dotted module paths (additive on top of
5
+ the env-driven ``EXTENSIONS`` list)
6
+ EXTRA_ROUTERS: list[str] — ``"module.path:router_symbol"`` strings,
7
+ resolved to FastAPI ``APIRouter`` instances
8
+ EXTRA_MIDDLEWARE: list[str] — ``"module.path:ClassName"`` strings,
9
+ resolved to ASGI middleware classes
10
+
11
+ The settings module is purely additive on top of env-driven Settings. It
12
+ never replaces Pydantic-managed values (DATABASE_URL, JWT keys, etc.) —
13
+ those continue to come from env / .env and are typed.
14
+ """
15
+
16
+ import importlib
17
+ import logging
18
+ import os
19
+ import sys
20
+ from collections.abc import Sequence
21
+ from dataclasses import dataclass, field
22
+ from typing import Any
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+
27
+ def _ensure_cwd_on_sys_path() -> None:
28
+ """Make the current working directory importable.
29
+
30
+ When supython is invoked through ``python manage.py``, Python prepends
31
+ the script's directory (the project root) to ``sys.path`` automatically.
32
+ When invoked through the installed ``supython`` console script, ``sys.path[0]``
33
+ is the venv's bin directory instead, so the user's project package is not
34
+ importable. Mirror Django/Flask convention by adding the CWD ourselves.
35
+ """
36
+ cwd = os.getcwd()
37
+ if cwd not in sys.path and "" not in sys.path:
38
+ sys.path.insert(0, cwd)
39
+
40
+
41
+ @dataclass
42
+ class UserSettings:
43
+ """Resolved attributes from a user settings module."""
44
+
45
+ extensions: list[str] = field(default_factory=list)
46
+ extra_routers: list[Any] = field(default_factory=list)
47
+ extra_middleware: list[type] = field(default_factory=list)
48
+
49
+
50
+ def load_user_settings(module_path: str) -> UserSettings:
51
+ """Import the user's settings module and resolve the conventional names.
52
+
53
+ Raises ``ImportError`` if the module itself cannot be imported, or if any
54
+ referenced ``module.path:symbol`` cannot be resolved. Raises ``ValueError``
55
+ if a referenced spec is malformed.
56
+ """
57
+ _ensure_cwd_on_sys_path()
58
+ try:
59
+ mod = importlib.import_module(module_path)
60
+ except ImportError as exc:
61
+ raise ImportError(
62
+ f"settings module {module_path!r} could not be imported. "
63
+ f"Set SUPYTHON_SETTINGS_MODULE to an importable dotted path."
64
+ ) from exc
65
+
66
+ extensions = list(_as_str_list(getattr(mod, "EXTENSIONS", []), "EXTENSIONS"))
67
+ extra_routers = [
68
+ _resolve(p) for p in _as_str_list(getattr(mod, "EXTRA_ROUTERS", []), "EXTRA_ROUTERS")
69
+ ]
70
+ extra_middleware = [
71
+ _resolve(p)
72
+ for p in _as_str_list(getattr(mod, "EXTRA_MIDDLEWARE", []), "EXTRA_MIDDLEWARE")
73
+ ]
74
+
75
+ logger.info(
76
+ "settings module loaded: %s (extensions=%d routers=%d middleware=%d)",
77
+ module_path,
78
+ len(extensions),
79
+ len(extra_routers),
80
+ len(extra_middleware),
81
+ )
82
+ return UserSettings(
83
+ extensions=extensions,
84
+ extra_routers=extra_routers,
85
+ extra_middleware=extra_middleware,
86
+ )
87
+
88
+
89
+ def _as_str_list(value: object, name: str) -> Sequence[str]:
90
+ if not isinstance(value, (list, tuple)):
91
+ raise TypeError(f"{name} must be a list of strings, got {type(value).__name__}")
92
+ for item in value:
93
+ if not isinstance(item, str):
94
+ raise TypeError(f"{name} entries must be strings, got {type(item).__name__}")
95
+ return list(value)
96
+
97
+
98
+ def _resolve(spec: str) -> Any:
99
+ """Resolve ``module.path:symbol`` to the imported object."""
100
+ if ":" not in spec:
101
+ raise ValueError(
102
+ f"expected 'module.path:symbol', got {spec!r}. Use a colon to "
103
+ f"separate the module path from the attribute name."
104
+ )
105
+ mod_path, _, symbol = spec.partition(":")
106
+ try:
107
+ mod = importlib.import_module(mod_path)
108
+ except ImportError as exc:
109
+ raise ImportError(
110
+ f"settings module references {spec!r} but {mod_path!r} could not be imported."
111
+ ) from exc
112
+ try:
113
+ return getattr(mod, symbol)
114
+ except AttributeError as exc:
115
+ raise ImportError(
116
+ f"settings module references {spec!r} but {symbol!r} is not in {mod_path!r}."
117
+ ) from exc
@@ -0,0 +1,5 @@
1
+ """Storage module: buckets, objects, backends, signed URLs."""
2
+
3
+ from .router import router
4
+
5
+ __all__ = ["router"]
@@ -0,0 +1,392 @@
1
+ """Storage backends.
2
+
3
+ A backend is the *bytes* layer. Object metadata (ownership, RLS, mime) lives
4
+ in `storage.objects` in Postgres; the backend only knows about opaque keys.
5
+
6
+ Two implementations:
7
+
8
+ - ``LocalBackend`` writes files under a configurable root using the stdlib.
9
+ - ``S3Backend`` proxies to S3-compatible object storage via the optional
10
+ ``aioboto3`` dependency (``pip install supython[s3]``).
11
+
12
+ Both stream bytes — nothing is ever fully buffered in memory.
13
+ """
14
+
15
+ import contextlib
16
+ import hashlib
17
+ import os
18
+ from collections.abc import AsyncIterator
19
+ from dataclasses import dataclass
20
+ from pathlib import Path
21
+ from typing import IO, Protocol
22
+
23
+ from ..settings import get_settings
24
+
25
+
26
+ @dataclass
27
+ class ObjectStat:
28
+ key: str
29
+ size: int
30
+ etag: str
31
+ content_type: str | None
32
+
33
+
34
+ @dataclass
35
+ class ObjectStream:
36
+ iterator: AsyncIterator[bytes]
37
+ content_length: int
38
+ content_type: str | None
39
+ etag: str
40
+ status_code: int
41
+ content_range: str | None
42
+
43
+
44
+ class BackendError(Exception):
45
+ """Raised for backend-level failures (missing key, IO error, etc.)."""
46
+
47
+
48
+ class StorageBackend(Protocol):
49
+ async def put(
50
+ self,
51
+ key: str,
52
+ data: AsyncIterator[bytes],
53
+ content_type: str | None,
54
+ ) -> ObjectStat: ...
55
+
56
+ async def get(
57
+ self,
58
+ key: str,
59
+ *,
60
+ byte_range: tuple[int, int | None] | None = None,
61
+ ) -> ObjectStream: ...
62
+
63
+ async def stat(self, key: str) -> ObjectStat | None: ...
64
+
65
+ async def delete(self, key: str) -> None: ...
66
+
67
+
68
+ # ---------------------------------------------------------------------------
69
+ # Local backend
70
+ # ---------------------------------------------------------------------------
71
+
72
+
73
+ _CHUNK = 64 * 1024
74
+
75
+
76
+ class LocalBackend:
77
+ """Bytes on the local filesystem under ``root``.
78
+
79
+ Keys are joined with ``root`` and resolved; any key that escapes the root
80
+ (via ``..`` or absolute paths) is rejected.
81
+ """
82
+
83
+ def __init__(self, root: str | os.PathLike[str]) -> None:
84
+ self._root = Path(root).resolve()
85
+ self._root.mkdir(parents=True, exist_ok=True)
86
+
87
+ def _resolve(self, key: str) -> Path:
88
+ if not key or key.startswith("/") or ".." in Path(key).parts:
89
+ raise BackendError(f"Invalid key: {key!r}")
90
+ candidate = (self._root / key).resolve()
91
+ try:
92
+ candidate.relative_to(self._root)
93
+ except ValueError as exc:
94
+ raise BackendError(f"Key escapes root: {key!r}") from exc
95
+ return candidate
96
+
97
+ async def put(
98
+ self,
99
+ key: str,
100
+ data: AsyncIterator[bytes],
101
+ content_type: str | None,
102
+ ) -> ObjectStat:
103
+ import asyncio
104
+
105
+ path = self._resolve(key)
106
+ path.parent.mkdir(parents=True, exist_ok=True)
107
+ hasher = hashlib.sha256()
108
+ size = 0
109
+
110
+ def _open() -> IO[bytes]:
111
+ return open(path, "wb")
112
+
113
+ f = await asyncio.to_thread(_open)
114
+ try:
115
+ async for chunk in data:
116
+ if not chunk:
117
+ continue
118
+ hasher.update(chunk)
119
+ size += len(chunk)
120
+ await asyncio.to_thread(f.write, chunk)
121
+ except BaseException:
122
+ await asyncio.to_thread(f.close)
123
+ with contextlib.suppress(FileNotFoundError):
124
+ await asyncio.to_thread(path.unlink)
125
+ raise
126
+ else:
127
+ await asyncio.to_thread(f.close)
128
+
129
+ return ObjectStat(
130
+ key=key,
131
+ size=size,
132
+ etag=hasher.hexdigest(),
133
+ content_type=content_type,
134
+ )
135
+
136
+ async def get(
137
+ self,
138
+ key: str,
139
+ *,
140
+ byte_range: tuple[int, int | None] | None = None,
141
+ ) -> ObjectStream:
142
+ import asyncio
143
+
144
+ path = self._resolve(key)
145
+ if not path.exists():
146
+ raise BackendError(f"Object not found: {key!r}")
147
+
148
+ total = path.stat().st_size
149
+ start = 0
150
+ end = total - 1
151
+ status = 200
152
+ content_range: str | None = None
153
+
154
+ if byte_range is not None:
155
+ start, end_opt = byte_range
156
+ end = total - 1 if end_opt is None else min(end_opt, total - 1)
157
+ if start < 0 or start > end:
158
+ raise BackendError(f"Invalid range {byte_range} for size {total}")
159
+ status = 206
160
+ content_range = f"bytes {start}-{end}/{total}"
161
+
162
+ length = end - start + 1
163
+
164
+ async def _iter() -> AsyncIterator[bytes]:
165
+ remaining = length
166
+
167
+ def _open() -> IO[bytes]:
168
+ fh = open(path, "rb") # noqa: SIM115 — closed in finally below
169
+ fh.seek(start)
170
+ return fh
171
+
172
+ fh = await asyncio.to_thread(_open)
173
+ try:
174
+ while remaining > 0:
175
+ to_read = min(_CHUNK, remaining)
176
+ chunk = await asyncio.to_thread(fh.read, to_read)
177
+ if not chunk:
178
+ break
179
+ remaining -= len(chunk)
180
+ yield chunk
181
+ finally:
182
+ await asyncio.to_thread(fh.close)
183
+
184
+ return ObjectStream(
185
+ iterator=_iter(),
186
+ content_length=length,
187
+ content_type=None,
188
+ etag="",
189
+ status_code=status,
190
+ content_range=content_range,
191
+ )
192
+
193
+ async def stat(self, key: str) -> ObjectStat | None:
194
+ path = self._resolve(key)
195
+ if not path.exists():
196
+ return None
197
+ return ObjectStat(
198
+ key=key,
199
+ size=path.stat().st_size,
200
+ etag="",
201
+ content_type=None,
202
+ )
203
+
204
+ async def delete(self, key: str) -> None:
205
+ import asyncio
206
+
207
+ path = self._resolve(key)
208
+ try:
209
+ await asyncio.to_thread(path.unlink)
210
+ except FileNotFoundError:
211
+ return
212
+
213
+
214
+ # ---------------------------------------------------------------------------
215
+ # S3 backend (optional)
216
+ # ---------------------------------------------------------------------------
217
+
218
+
219
+ class S3Backend:
220
+ """S3-compatible backend.
221
+
222
+ All logical buckets are prefixed into a single physical bucket configured
223
+ via ``storage_s3_bucket``. ``aioboto3`` is imported lazily so the cost is
224
+ only paid when the backend is actually selected.
225
+ """
226
+
227
+ def __init__(
228
+ self,
229
+ *,
230
+ bucket: str,
231
+ endpoint_url: str | None,
232
+ region: str,
233
+ access_key_id: str,
234
+ secret_access_key: str,
235
+ ) -> None:
236
+ if not bucket:
237
+ raise BackendError("S3Backend requires storage_s3_bucket")
238
+ self._bucket = bucket
239
+ self._endpoint_url = endpoint_url
240
+ self._region = region
241
+ self._access_key_id = access_key_id
242
+ self._secret_access_key = secret_access_key
243
+ self._session = None
244
+
245
+ def _client(self):
246
+ try:
247
+ import aioboto3
248
+ except ImportError as exc:
249
+ raise BackendError(
250
+ "S3Backend requires aioboto3. Install with `pip install supython[s3]`."
251
+ ) from exc
252
+ if self._session is None:
253
+ self._session = aioboto3.Session(
254
+ aws_access_key_id=self._access_key_id or None,
255
+ aws_secret_access_key=self._secret_access_key or None,
256
+ region_name=self._region,
257
+ )
258
+ return self._session.client(
259
+ "s3",
260
+ endpoint_url=self._endpoint_url,
261
+ )
262
+
263
+ async def put(
264
+ self,
265
+ key: str,
266
+ data: AsyncIterator[bytes],
267
+ content_type: str | None,
268
+ ) -> ObjectStat:
269
+ hasher = hashlib.sha256()
270
+ chunks: list[bytes] = []
271
+ size = 0
272
+ async for chunk in data:
273
+ if not chunk:
274
+ continue
275
+ hasher.update(chunk)
276
+ chunks.append(chunk)
277
+ size += len(chunk)
278
+ body = b"".join(chunks)
279
+
280
+ kwargs: dict = {"Bucket": self._bucket, "Key": key, "Body": body}
281
+ if content_type:
282
+ kwargs["ContentType"] = content_type
283
+
284
+ async with self._client() as s3:
285
+ resp = await s3.put_object(**kwargs)
286
+
287
+ etag = (resp.get("ETag") or "").strip('"') or hasher.hexdigest()
288
+ return ObjectStat(key=key, size=size, etag=etag, content_type=content_type)
289
+
290
+ async def get(
291
+ self,
292
+ key: str,
293
+ *,
294
+ byte_range: tuple[int, int | None] | None = None,
295
+ ) -> ObjectStream:
296
+ kwargs: dict = {"Bucket": self._bucket, "Key": key}
297
+ status = 200
298
+ content_range: str | None = None
299
+ if byte_range is not None:
300
+ start, end_opt = byte_range
301
+ end_part = "" if end_opt is None else str(end_opt)
302
+ kwargs["Range"] = f"bytes={start}-{end_part}"
303
+ status = 206
304
+
305
+ client_ctx = self._client()
306
+ s3 = await client_ctx.__aenter__()
307
+ try:
308
+ resp = await s3.get_object(**kwargs)
309
+ except Exception:
310
+ await client_ctx.__aexit__(None, None, None)
311
+ raise
312
+
313
+ body = resp["Body"]
314
+ length = int(resp.get("ContentLength", 0))
315
+ content_type = resp.get("ContentType")
316
+ etag = (resp.get("ETag") or "").strip('"')
317
+ content_range = resp.get("ContentRange")
318
+ if status == 206 and content_range and not content_range.startswith("bytes "):
319
+ content_range = f"bytes {content_range}"
320
+
321
+ async def _iter() -> AsyncIterator[bytes]:
322
+ try:
323
+ async for chunk in body.iter_chunks(_CHUNK):
324
+ yield chunk
325
+ finally:
326
+ await client_ctx.__aexit__(None, None, None)
327
+
328
+ return ObjectStream(
329
+ iterator=_iter(),
330
+ content_length=length,
331
+ content_type=content_type,
332
+ etag=etag,
333
+ status_code=status,
334
+ content_range=content_range,
335
+ )
336
+
337
+ async def stat(self, key: str) -> ObjectStat | None:
338
+ async with self._client() as s3:
339
+ try:
340
+ resp = await s3.head_object(Bucket=self._bucket, Key=key)
341
+ except Exception:
342
+ return None
343
+ return ObjectStat(
344
+ key=key,
345
+ size=int(resp.get("ContentLength", 0)),
346
+ etag=(resp.get("ETag") or "").strip('"'),
347
+ content_type=resp.get("ContentType"),
348
+ )
349
+
350
+ async def delete(self, key: str) -> None:
351
+ async with self._client() as s3:
352
+ await s3.delete_object(Bucket=self._bucket, Key=key)
353
+
354
+
355
+ # ---------------------------------------------------------------------------
356
+ # Selection
357
+ # ---------------------------------------------------------------------------
358
+
359
+
360
+ _backend: StorageBackend | None = None
361
+
362
+
363
+ def get_backend() -> StorageBackend:
364
+ """Return the process-wide backend chosen by settings."""
365
+ global _backend
366
+ if _backend is not None:
367
+ return _backend
368
+ s = get_settings()
369
+ if s.storage_backend == "local":
370
+ _backend = LocalBackend(s.storage_local_root)
371
+ elif s.storage_backend == "s3":
372
+ _backend = S3Backend(
373
+ bucket=s.storage_s3_bucket,
374
+ endpoint_url=s.storage_s3_endpoint,
375
+ region=s.storage_s3_region,
376
+ access_key_id=s.storage_s3_access_key_id,
377
+ secret_access_key=s.storage_s3_secret_access_key,
378
+ )
379
+ else:
380
+ raise BackendError(f"Unknown storage backend: {s.storage_backend!r}")
381
+ return _backend
382
+
383
+
384
+ def reset_backend() -> None:
385
+ """Drop the cached backend; tests re-init with overridden settings."""
386
+ global _backend
387
+ _backend = None
388
+
389
+
390
+ def make_object_key(bucket_name: str, path: str) -> str:
391
+ """Compose the backend key for a logical (bucket, path) pair."""
392
+ return f"{bucket_name}/{path.lstrip('/')}"