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.
- supython/__init__.py +8 -0
- supython/admin/__init__.py +3 -0
- supython/admin/api/__init__.py +24 -0
- supython/admin/api/auth.py +118 -0
- supython/admin/api/auth_templates.py +67 -0
- supython/admin/api/auth_users.py +225 -0
- supython/admin/api/db.py +174 -0
- supython/admin/api/functions.py +92 -0
- supython/admin/api/jobs.py +192 -0
- supython/admin/api/ops.py +224 -0
- supython/admin/api/realtime.py +281 -0
- supython/admin/api/service_auth.py +49 -0
- supython/admin/api/service_auth_templates.py +83 -0
- supython/admin/api/service_auth_users.py +346 -0
- supython/admin/api/service_db.py +214 -0
- supython/admin/api/service_functions.py +287 -0
- supython/admin/api/service_jobs.py +282 -0
- supython/admin/api/service_ops.py +213 -0
- supython/admin/api/service_realtime.py +30 -0
- supython/admin/api/service_storage.py +220 -0
- supython/admin/api/storage.py +117 -0
- supython/admin/api/system.py +37 -0
- supython/admin/audit.py +29 -0
- supython/admin/deps.py +22 -0
- supython/admin/errors.py +16 -0
- supython/admin/schemas.py +310 -0
- supython/admin/session.py +52 -0
- supython/admin/spa.py +38 -0
- supython/admin/static/assets/Alert-dluGVkos.js +49 -0
- supython/admin/static/assets/Audit-Njung3HI.js +2 -0
- supython/admin/static/assets/Backups-DzPlFgrm.js +2 -0
- supython/admin/static/assets/Buckets-ByacGkU1.js +2 -0
- supython/admin/static/assets/Channels-BoIuTtam.js +353 -0
- supython/admin/static/assets/ChevronRight-CtQH1EQ1.js +2 -0
- supython/admin/static/assets/CodeViewer-Bqy7-wvH.js +2 -0
- supython/admin/static/assets/Crons-B67vc39F.js +2 -0
- supython/admin/static/assets/DashboardView-CUTFVL6k.js +2 -0
- supython/admin/static/assets/DataTable-COAAWEft.js +747 -0
- supython/admin/static/assets/DescriptionsItem-P8JUDaBs.js +75 -0
- supython/admin/static/assets/DrawerContent-TpYTFgF1.js +139 -0
- supython/admin/static/assets/Empty-cr2r7e2u.js +25 -0
- supython/admin/static/assets/EmptyState-DeDck-OL.js +2 -0
- supython/admin/static/assets/Grid-hFkp9F4P.js +2 -0
- supython/admin/static/assets/Input-DppYTq9C.js +259 -0
- supython/admin/static/assets/Invoke-DW3Nveeh.js +2 -0
- supython/admin/static/assets/JsonField-DibyJgun.js +2 -0
- supython/admin/static/assets/LoginView-BjLyE3Ds.css +1 -0
- supython/admin/static/assets/LoginView-CoOjECT_.js +111 -0
- supython/admin/static/assets/Logs-D9WYrnIT.js +2 -0
- supython/admin/static/assets/Logs-DS1XPa0h.css +1 -0
- supython/admin/static/assets/Migrations-DOSC2ddQ.js +2 -0
- supython/admin/static/assets/ObjectBrowser-_5w8vOX8.js +2 -0
- supython/admin/static/assets/Queue-CywZs6vI.js +2 -0
- supython/admin/static/assets/RefreshTokens-Ccjr53jg.js +2 -0
- supython/admin/static/assets/RlsEditor-BSlH9vSc.js +2 -0
- supython/admin/static/assets/Routes-BiLXE49D.js +2 -0
- supython/admin/static/assets/Routes-C-ianIGD.css +1 -0
- supython/admin/static/assets/SchemaBrowser-DKy2_KQi.css +1 -0
- supython/admin/static/assets/SchemaBrowser-XFvFbtDB.js +2 -0
- supython/admin/static/assets/Select-DIzZyRZb.js +434 -0
- supython/admin/static/assets/Space-n5-XcguU.js +400 -0
- supython/admin/static/assets/SqlEditor-b8pTsILY.js +3 -0
- supython/admin/static/assets/SqlWorkspace-BUS7IntH.js +104 -0
- supython/admin/static/assets/TableData-CQIagLKn.js +2 -0
- supython/admin/static/assets/Tag-D1fOKpTH.js +72 -0
- supython/admin/static/assets/Templates-BS-ugkdq.js +2 -0
- supython/admin/static/assets/Thing-CEAniuMg.js +107 -0
- supython/admin/static/assets/Users-wzwajhlh.js +2 -0
- supython/admin/static/assets/_plugin-vue_export-helper-DGA9ry_j.js +1 -0
- supython/admin/static/assets/dist-VXIJLCYq.js +13 -0
- supython/admin/static/assets/format-length-CGCY1rMh.js +2 -0
- supython/admin/static/assets/get-Ca6unauB.js +2 -0
- supython/admin/static/assets/index-CeE6v959.js +951 -0
- supython/admin/static/assets/pinia-COXwfrOX.js +2 -0
- supython/admin/static/assets/resources-Bt6thQCD.js +44 -0
- supython/admin/static/assets/use-locale-mtgM0a3a.js +2 -0
- supython/admin/static/assets/use-merged-state-BvhkaHNX.js +2 -0
- supython/admin/static/assets/useConfirm-tMjvBFXR.js +2 -0
- supython/admin/static/assets/useResource-C_rJCY8C.js +2 -0
- supython/admin/static/assets/useTable-CnZc5zhi.js +363 -0
- supython/admin/static/assets/useTable-Dg0XlRlq.css +1 -0
- supython/admin/static/assets/useToast-DsZKx0IX.js +2 -0
- supython/admin/static/assets/utils-sbXoq7Ir.js +2 -0
- supython/admin/static/favicon.svg +1 -0
- supython/admin/static/icons.svg +24 -0
- supython/admin/static/index.html +24 -0
- supython/app.py +149 -0
- supython/auth/__init__.py +3 -0
- supython/auth/_email_job.py +11 -0
- supython/auth/providers/__init__.py +34 -0
- supython/auth/providers/github.py +22 -0
- supython/auth/providers/google.py +19 -0
- supython/auth/providers/oauth.py +56 -0
- supython/auth/providers/registry.py +16 -0
- supython/auth/ratelimit.py +39 -0
- supython/auth/router.py +282 -0
- supython/auth/schemas.py +79 -0
- supython/auth/service.py +587 -0
- supython/body_size.py +184 -0
- supython/cli.py +1653 -0
- supython/client/__init__.py +67 -0
- supython/client/_auth.py +249 -0
- supython/client/_client.py +145 -0
- supython/client/_config.py +92 -0
- supython/client/_functions.py +69 -0
- supython/client/_storage.py +255 -0
- supython/client/py.typed +0 -0
- supython/db.py +151 -0
- supython/db_admin.py +8 -0
- supython/functions/__init__.py +19 -0
- supython/functions/context.py +262 -0
- supython/functions/loader.py +307 -0
- supython/functions/router.py +228 -0
- supython/functions/schemas.py +50 -0
- supython/gen/__init__.py +5 -0
- supython/gen/_introspect.py +137 -0
- supython/gen/types_py.py +270 -0
- supython/gen/types_ts.py +365 -0
- supython/health.py +229 -0
- supython/hooks.py +117 -0
- supython/jobs/__init__.py +31 -0
- supython/jobs/backends.py +97 -0
- supython/jobs/context.py +58 -0
- supython/jobs/cron.py +152 -0
- supython/jobs/cron_inproc.py +118 -0
- supython/jobs/decorators.py +76 -0
- supython/jobs/registry.py +79 -0
- supython/jobs/router.py +136 -0
- supython/jobs/schemas.py +92 -0
- supython/jobs/service.py +311 -0
- supython/jobs/worker.py +219 -0
- supython/jwks.py +257 -0
- supython/keyset.py +279 -0
- supython/logging_config.py +291 -0
- supython/mail.py +33 -0
- supython/mailer.py +65 -0
- supython/migrate.py +81 -0
- supython/migrations/0001_extensions_and_roles.sql +46 -0
- supython/migrations/0002_auth_schema.sql +66 -0
- supython/migrations/0003_demo_todos.sql +42 -0
- supython/migrations/0004_auth_v0_2.sql +47 -0
- supython/migrations/0005_storage_schema.sql +117 -0
- supython/migrations/0006_realtime_schema.sql +206 -0
- supython/migrations/0007_jobs_schema.sql +254 -0
- supython/migrations/0008_jobs_last_error.sql +56 -0
- supython/migrations/0009_auth_rate_limits.sql +33 -0
- supython/migrations/0010_worker_heartbeat.sql +14 -0
- supython/migrations/0011_admin_schema.sql +45 -0
- supython/migrations/0012_auth_banned_until.sql +10 -0
- supython/migrations/0013_email_templates.sql +19 -0
- supython/migrations/0014_realtime_payload_warning.sql +96 -0
- supython/migrations/0015_backups_schema.sql +14 -0
- supython/passwords.py +15 -0
- supython/realtime/__init__.py +6 -0
- supython/realtime/broker.py +814 -0
- supython/realtime/protocol.py +234 -0
- supython/realtime/router.py +184 -0
- supython/realtime/schemas.py +207 -0
- supython/realtime/service.py +261 -0
- supython/realtime/topics.py +175 -0
- supython/realtime/websocket.py +586 -0
- supython/scaffold/__init__.py +5 -0
- supython/scaffold/init_project.py +133 -0
- supython/scaffold/templates/Caddyfile.tmpl +4 -0
- supython/scaffold/templates/README.md.tmpl +22 -0
- supython/scaffold/templates/docker-compose.prod.yml.tmpl +84 -0
- supython/scaffold/templates/docker-compose.yml.tmpl +41 -0
- supython/scaffold/templates/docker_postgres_Dockerfile.tmpl +9 -0
- supython/scaffold/templates/docker_postgres_postgresql.conf.tmpl +3 -0
- supython/scaffold/templates/env.example.tmpl +149 -0
- supython/scaffold/templates/functions_README.md.tmpl +21 -0
- supython/scaffold/templates/gitignore.tmpl +14 -0
- supython/scaffold/templates/migrations/.gitkeep +0 -0
- supython/secretset.py +347 -0
- supython/security_headers.py +78 -0
- supython/settings.py +198 -0
- supython/storage/__init__.py +5 -0
- supython/storage/backends.py +392 -0
- supython/storage/router.py +341 -0
- supython/storage/schemas.py +50 -0
- supython/storage/service.py +445 -0
- supython/storage/signing.py +119 -0
- supython/tokens.py +85 -0
- supython-0.5.0.dist-info/METADATA +714 -0
- supython-0.5.0.dist-info/RECORD +188 -0
- supython-0.5.0.dist-info/WHEEL +4 -0
- supython-0.5.0.dist-info/entry_points.txt +2 -0
- 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('/')}"
|