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,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('/')}"