bucket-helper 0.2.2__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.
- bucket_helper/__init__.py +60 -0
- bucket_helper/api.py +407 -0
- bucket_helper/cli_argparse.py +369 -0
- bucket_helper/cli_click.py +230 -0
- bucket_helper/main.py +444 -0
- bucket_helper/mcp.py +92 -0
- bucket_helper-0.2.2.dist-info/METADATA +256 -0
- bucket_helper-0.2.2.dist-info/RECORD +12 -0
- bucket_helper-0.2.2.dist-info/WHEEL +5 -0
- bucket_helper-0.2.2.dist-info/entry_points.txt +4 -0
- bucket_helper-0.2.2.dist-info/licenses/LICENSE +29 -0
- bucket_helper-0.2.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Bucket Helper
|
|
3
|
+
|
|
4
|
+
Utility functions for AWS S3 and any S3-compatible object storage
|
|
5
|
+
(MinIO, Backblaze B2 S3 API, DigitalOcean Spaces, Cloudflare R2,
|
|
6
|
+
Wasabi, …) via :mod:`boto3`. Same shape as ``sftp-helper``:
|
|
7
|
+
``credentials()`` loader + ``upload`` / ``download`` / ``delete`` /
|
|
8
|
+
``exists`` / ``list_prefix`` / ``make_bucket`` + a ``remote_tempfile``
|
|
9
|
+
context manager for stage-and-share flows.
|
|
10
|
+
|
|
11
|
+
Also exposes multi-surface entry points:
|
|
12
|
+
|
|
13
|
+
- Library (this module) — the pure functions.
|
|
14
|
+
- argparse CLI — :mod:`bucket_helper.cli_argparse` (``bucket-helper``).
|
|
15
|
+
- click CLI — :mod:`bucket_helper.cli_click` (``bucket-helper-click``).
|
|
16
|
+
- FastAPI HTTP — :mod:`bucket_helper.api` (``uvicorn bucket_helper.api:app``).
|
|
17
|
+
- MCP tools — :mod:`bucket_helper.mcp` (``bucket-helper-mcp``).
|
|
18
|
+
|
|
19
|
+
Usage Example
|
|
20
|
+
-------------
|
|
21
|
+
>>> import bucket_helper as bh
|
|
22
|
+
>>> cred = bh.credentials("path/to/s3_config.json")
|
|
23
|
+
>>> uri = bh.upload("local.txt", cred, "folder/uploaded.txt")
|
|
24
|
+
>>> assert bh.exists(uri, cred)
|
|
25
|
+
>>> bh.download(uri, "back.txt", cred)
|
|
26
|
+
>>> bh.delete(uri, cred)
|
|
27
|
+
|
|
28
|
+
Author
|
|
29
|
+
------
|
|
30
|
+
Warith Harchaoui, Ph.D. — https://linkedin.com/in/warith-harchaoui/
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
__author__ = "Warith Harchaoui, Ph.D."
|
|
34
|
+
__email__ = "warithmetics@deraison.ai"
|
|
35
|
+
|
|
36
|
+
__all__ = [
|
|
37
|
+
"credentials",
|
|
38
|
+
"get_client_s3",
|
|
39
|
+
"upload",
|
|
40
|
+
"download",
|
|
41
|
+
"delete",
|
|
42
|
+
"exists",
|
|
43
|
+
"list_prefix",
|
|
44
|
+
"make_bucket",
|
|
45
|
+
"remote_tempfile",
|
|
46
|
+
"strip_s3_path",
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
from .main import (
|
|
50
|
+
credentials,
|
|
51
|
+
delete,
|
|
52
|
+
download,
|
|
53
|
+
exists,
|
|
54
|
+
get_client_s3,
|
|
55
|
+
list_prefix,
|
|
56
|
+
make_bucket,
|
|
57
|
+
remote_tempfile,
|
|
58
|
+
strip_s3_path,
|
|
59
|
+
upload,
|
|
60
|
+
)
|
bucket_helper/api.py
ADDED
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Bucket Helper — FastAPI HTTP surface.
|
|
3
|
+
|
|
4
|
+
Exposes every public function in :mod:`bucket_helper.main` as an HTTP
|
|
5
|
+
endpoint so ``bucket-helper`` can be dropped behind any reverse proxy
|
|
6
|
+
and consumed by other services. Kept intentionally minimal:
|
|
7
|
+
|
|
8
|
+
- Multipart uploads for the ``/upload`` endpoint (``UploadFile``),
|
|
9
|
+
streamed straight to a temporary file so large blobs don't blow up
|
|
10
|
+
memory.
|
|
11
|
+
- ``FileResponse`` for ``/download`` — bytes stream straight from S3 to
|
|
12
|
+
the client with cleanup after the response is fully sent.
|
|
13
|
+
- JSON responses for the CRUD reads (``/exists``, ``/list``, ``/tempfile``,
|
|
14
|
+
``/strip-path``).
|
|
15
|
+
- ``BackgroundTasks`` cleans temp files after the response has been
|
|
16
|
+
streamed — no leftover garbage on disk after a request.
|
|
17
|
+
|
|
18
|
+
Credentials flow
|
|
19
|
+
----------------
|
|
20
|
+
Two ways to feed credentials to the API:
|
|
21
|
+
|
|
22
|
+
- **Server-side default**: set ``BUCKET_HELPER_CONFIG`` in the process
|
|
23
|
+
env; every request loads the same credentials via
|
|
24
|
+
:func:`bucket_helper.credentials` (path or folder).
|
|
25
|
+
- **Per-request**: send ``s3_access_key`` / ``s3_secret_key`` /
|
|
26
|
+
``s3_bucket`` / ``s3_https`` (+ optional ``s3_endpoint_url``,
|
|
27
|
+
``s3_region``, ``s3_prefix``, ``s3_use_path_style``, ``s3_verify_ssl``)
|
|
28
|
+
as form fields. Per-request wins over server default.
|
|
29
|
+
|
|
30
|
+
Install the extra to get the runtime dependencies::
|
|
31
|
+
|
|
32
|
+
pip install 'bucket-helper[api]'
|
|
33
|
+
|
|
34
|
+
Then run the app with any ASGI server::
|
|
35
|
+
|
|
36
|
+
uvicorn bucket_helper.api:app --host 0.0.0.0 --port 8000
|
|
37
|
+
|
|
38
|
+
Usage Example
|
|
39
|
+
-------------
|
|
40
|
+
>>> # Start the server:
|
|
41
|
+
>>> # uvicorn bucket_helper.api:app --reload
|
|
42
|
+
>>> # Probe existence (server-side default cred loaded from BUCKET_HELPER_CONFIG):
|
|
43
|
+
>>> # curl -F 'key=folder/obj.txt' http://localhost:8000/exists
|
|
44
|
+
>>> # Upload a file (per-request cred inline, path-style MinIO):
|
|
45
|
+
>>> # curl -F 'file=@in.bin' -F 'key=folder/obj.bin' \\
|
|
46
|
+
>>> # -F 's3_access_key=minioadmin' -F 's3_secret_key=minioadmin' \\
|
|
47
|
+
>>> # -F 's3_bucket=uploads' -F 's3_https=http://minio.example.com:9000/uploads' \\
|
|
48
|
+
>>> # -F 's3_endpoint_url=http://minio.example.com:9000' \\
|
|
49
|
+
>>> # -F 's3_use_path_style=true' \\
|
|
50
|
+
>>> # http://localhost:8000/upload
|
|
51
|
+
>>> # Full OpenAPI docs at http://localhost:8000/docs
|
|
52
|
+
|
|
53
|
+
Author
|
|
54
|
+
------
|
|
55
|
+
Warith Harchaoui, Ph.D. — https://linkedin.com/in/warith-harchaoui/
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
from __future__ import annotations
|
|
59
|
+
|
|
60
|
+
import os
|
|
61
|
+
import shutil
|
|
62
|
+
import tempfile
|
|
63
|
+
from pathlib import Path
|
|
64
|
+
from typing import Optional
|
|
65
|
+
|
|
66
|
+
try:
|
|
67
|
+
from fastapi import BackgroundTasks, FastAPI, File, Form, HTTPException, UploadFile
|
|
68
|
+
from fastapi.responses import FileResponse, JSONResponse
|
|
69
|
+
except ImportError as exc: # pragma: no cover
|
|
70
|
+
raise ImportError(
|
|
71
|
+
"The FastAPI HTTP surface requires the [api] extra. "
|
|
72
|
+
"Install with: pip install 'bucket-helper[api]'"
|
|
73
|
+
) from exc
|
|
74
|
+
|
|
75
|
+
from . import (
|
|
76
|
+
credentials,
|
|
77
|
+
delete,
|
|
78
|
+
download,
|
|
79
|
+
exists,
|
|
80
|
+
list_prefix,
|
|
81
|
+
make_bucket,
|
|
82
|
+
remote_tempfile,
|
|
83
|
+
strip_s3_path,
|
|
84
|
+
upload,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# ---------------------------------------------------------------------------
|
|
89
|
+
# App factory + shared plumbing
|
|
90
|
+
# ---------------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
app = FastAPI(
|
|
94
|
+
title="Bucket Helper API",
|
|
95
|
+
description=(
|
|
96
|
+
"HTTP surface for the bucket-helper utilities: upload, download, "
|
|
97
|
+
"delete, exists, list, make-bucket, tempfile, strip-path. Works "
|
|
98
|
+
"against AWS S3 and any S3-compatible endpoint (MinIO, R2, B2, "
|
|
99
|
+
"Spaces, Wasabi)."
|
|
100
|
+
),
|
|
101
|
+
version="0.2.2",
|
|
102
|
+
docs_url="/docs",
|
|
103
|
+
redoc_url="/redoc",
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# The set of credential keys the API accepts as form fields. Kept in
|
|
108
|
+
# one place so every endpoint that mutates S3 uses the same schema.
|
|
109
|
+
_CRED_FIELDS = (
|
|
110
|
+
"s3_access_key",
|
|
111
|
+
"s3_secret_key",
|
|
112
|
+
"s3_bucket",
|
|
113
|
+
"s3_https",
|
|
114
|
+
"s3_region",
|
|
115
|
+
"s3_endpoint_url",
|
|
116
|
+
"s3_prefix",
|
|
117
|
+
"s3_use_path_style",
|
|
118
|
+
"s3_verify_ssl",
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _resolve_cred(overrides: dict) -> dict:
|
|
123
|
+
"""
|
|
124
|
+
Build the credentials dict for a single request.
|
|
125
|
+
|
|
126
|
+
Merging order (lowest → highest priority): server-side default
|
|
127
|
+
(loaded from ``BUCKET_HELPER_CONFIG``), then per-request form fields.
|
|
128
|
+
Empty / None values from the request do not override the server
|
|
129
|
+
defaults — so the client can send a partial override.
|
|
130
|
+
|
|
131
|
+
Parameters
|
|
132
|
+
----------
|
|
133
|
+
overrides : dict
|
|
134
|
+
Per-request credential fields (form data).
|
|
135
|
+
|
|
136
|
+
Returns
|
|
137
|
+
-------
|
|
138
|
+
dict
|
|
139
|
+
Merged credentials dict, ready for the library.
|
|
140
|
+
"""
|
|
141
|
+
# Start from the server-side default if configured.
|
|
142
|
+
base = {}
|
|
143
|
+
default_config = os.environ.get("BUCKET_HELPER_CONFIG")
|
|
144
|
+
if default_config:
|
|
145
|
+
try:
|
|
146
|
+
base = dict(credentials(default_config))
|
|
147
|
+
except Exception as exc: # pragma: no cover — surface as 500
|
|
148
|
+
raise HTTPException(
|
|
149
|
+
status_code=500,
|
|
150
|
+
detail=f"Server default credentials failed to load: {exc}",
|
|
151
|
+
) from exc
|
|
152
|
+
|
|
153
|
+
# Layer per-request overrides on top, skipping empty values so the
|
|
154
|
+
# client can send partial overrides without wiping defaults.
|
|
155
|
+
for k in _CRED_FIELDS:
|
|
156
|
+
v = overrides.get(k)
|
|
157
|
+
if v is not None and v != "":
|
|
158
|
+
base[k] = v
|
|
159
|
+
|
|
160
|
+
# Minimal sanity check — the library will re-check at call time but
|
|
161
|
+
# a fast fail here gives a nicer 400.
|
|
162
|
+
for required in ("s3_access_key", "s3_secret_key", "s3_bucket", "s3_https"):
|
|
163
|
+
if required not in base:
|
|
164
|
+
raise HTTPException(
|
|
165
|
+
status_code=400,
|
|
166
|
+
detail=(
|
|
167
|
+
f"Missing credential '{required}'. Either set "
|
|
168
|
+
"BUCKET_HELPER_CONFIG server-side or send the field with the request."
|
|
169
|
+
),
|
|
170
|
+
)
|
|
171
|
+
return base
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _spool(upload_file: UploadFile, dest_dir: Path) -> Path:
|
|
175
|
+
"""
|
|
176
|
+
Persist an ``UploadFile`` to a temp path on disk.
|
|
177
|
+
|
|
178
|
+
We copy the stream instead of holding the bytes in memory so a
|
|
179
|
+
multi-hundred-MB blob does not OOM the worker. The file inherits
|
|
180
|
+
the caller's suffix when available so downstream tooling picks
|
|
181
|
+
the right content type.
|
|
182
|
+
"""
|
|
183
|
+
ext = Path(upload_file.filename or "").suffix or ".bin"
|
|
184
|
+
out = dest_dir / f"upload{ext}"
|
|
185
|
+
with out.open("wb") as fp:
|
|
186
|
+
shutil.copyfileobj(upload_file.file, fp)
|
|
187
|
+
return out
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _cleanup(*paths) -> None:
|
|
191
|
+
"""Best-effort cleanup — never let a tidy-up failure kill a response."""
|
|
192
|
+
for p in paths:
|
|
193
|
+
try:
|
|
194
|
+
path = Path(p)
|
|
195
|
+
if path.is_dir():
|
|
196
|
+
shutil.rmtree(path, ignore_errors=True)
|
|
197
|
+
elif path.exists():
|
|
198
|
+
path.unlink(missing_ok=True)
|
|
199
|
+
except Exception:
|
|
200
|
+
pass
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _new_tmpdir() -> Path:
|
|
204
|
+
"""Create a request-scoped temp directory under the system temp root."""
|
|
205
|
+
return Path(tempfile.mkdtemp(prefix="bucket-helper-"))
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
# ---------------------------------------------------------------------------
|
|
209
|
+
# Meta
|
|
210
|
+
# ---------------------------------------------------------------------------
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
@app.get("/health", tags=["meta"])
|
|
214
|
+
def health() -> dict:
|
|
215
|
+
"""Simple liveness probe — no dependency check, just proves the app is up."""
|
|
216
|
+
return {"status": "ok"}
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
# ---------------------------------------------------------------------------
|
|
220
|
+
# Mutations — upload / delete / make-bucket
|
|
221
|
+
# ---------------------------------------------------------------------------
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
@app.post("/upload", tags=["actions"])
|
|
225
|
+
def upload_endpoint(
|
|
226
|
+
background: BackgroundTasks,
|
|
227
|
+
file: UploadFile = File(..., description="File to upload."),
|
|
228
|
+
key: Optional[str] = Form(None, description="Destination key or 's3://bucket/key' URI (empty = auto)."),
|
|
229
|
+
content_type: Optional[str] = Form(None, description="Override the S3 Content-Type header."),
|
|
230
|
+
# Per-request credentials (all optional — falls back to server default).
|
|
231
|
+
s3_access_key: Optional[str] = Form(None),
|
|
232
|
+
s3_secret_key: Optional[str] = Form(None),
|
|
233
|
+
s3_bucket: Optional[str] = Form(None),
|
|
234
|
+
s3_https: Optional[str] = Form(None),
|
|
235
|
+
s3_region: Optional[str] = Form(None),
|
|
236
|
+
s3_endpoint_url: Optional[str] = Form(None),
|
|
237
|
+
s3_prefix: Optional[str] = Form(None),
|
|
238
|
+
s3_use_path_style: Optional[str] = Form(None),
|
|
239
|
+
s3_verify_ssl: Optional[str] = Form(None),
|
|
240
|
+
) -> JSONResponse:
|
|
241
|
+
"""Upload a local file to S3. Returns the resulting ``s3://bucket/key`` URI."""
|
|
242
|
+
cred = _resolve_cred(locals())
|
|
243
|
+
tmp = _new_tmpdir()
|
|
244
|
+
try:
|
|
245
|
+
src = _spool(file, tmp)
|
|
246
|
+
uri = upload(local_path=str(src), cred=cred, s3_address=key or "", content_type=content_type)
|
|
247
|
+
finally:
|
|
248
|
+
# Small response, no need to defer cleanup.
|
|
249
|
+
_cleanup(tmp)
|
|
250
|
+
return JSONResponse({"s3_address": uri})
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
@app.post("/delete", tags=["actions"])
|
|
254
|
+
def delete_endpoint(
|
|
255
|
+
key: str = Form(..., description="'s3://bucket/key' URI or bare key."),
|
|
256
|
+
s3_access_key: Optional[str] = Form(None),
|
|
257
|
+
s3_secret_key: Optional[str] = Form(None),
|
|
258
|
+
s3_bucket: Optional[str] = Form(None),
|
|
259
|
+
s3_https: Optional[str] = Form(None),
|
|
260
|
+
s3_region: Optional[str] = Form(None),
|
|
261
|
+
s3_endpoint_url: Optional[str] = Form(None),
|
|
262
|
+
s3_prefix: Optional[str] = Form(None),
|
|
263
|
+
s3_use_path_style: Optional[str] = Form(None),
|
|
264
|
+
s3_verify_ssl: Optional[str] = Form(None),
|
|
265
|
+
) -> JSONResponse:
|
|
266
|
+
"""Delete an S3 object (idempotent)."""
|
|
267
|
+
cred = _resolve_cred(locals())
|
|
268
|
+
delete(s3_address=key, cred=cred)
|
|
269
|
+
return JSONResponse({"deleted": key})
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
@app.post("/make-bucket", tags=["actions"])
|
|
273
|
+
def make_bucket_endpoint(
|
|
274
|
+
bucket: str = Form(..., description="Bucket name to create."),
|
|
275
|
+
s3_access_key: Optional[str] = Form(None),
|
|
276
|
+
s3_secret_key: Optional[str] = Form(None),
|
|
277
|
+
s3_bucket: Optional[str] = Form(None),
|
|
278
|
+
s3_https: Optional[str] = Form(None),
|
|
279
|
+
s3_region: Optional[str] = Form(None),
|
|
280
|
+
s3_endpoint_url: Optional[str] = Form(None),
|
|
281
|
+
s3_prefix: Optional[str] = Form(None),
|
|
282
|
+
s3_use_path_style: Optional[str] = Form(None),
|
|
283
|
+
s3_verify_ssl: Optional[str] = Form(None),
|
|
284
|
+
) -> JSONResponse:
|
|
285
|
+
"""Create a bucket. No-op if it already belongs to the caller."""
|
|
286
|
+
cred = _resolve_cred(locals())
|
|
287
|
+
make_bucket(bucket=bucket, cred=cred)
|
|
288
|
+
return JSONResponse({"bucket": bucket, "created": True})
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
# ---------------------------------------------------------------------------
|
|
292
|
+
# Reads — exists / list / download / tempfile / strip-path
|
|
293
|
+
# ---------------------------------------------------------------------------
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
@app.post("/exists", tags=["reads"])
|
|
297
|
+
def exists_endpoint(
|
|
298
|
+
key: str = Form(..., description="'s3://bucket/key' URI or bare key."),
|
|
299
|
+
s3_access_key: Optional[str] = Form(None),
|
|
300
|
+
s3_secret_key: Optional[str] = Form(None),
|
|
301
|
+
s3_bucket: Optional[str] = Form(None),
|
|
302
|
+
s3_https: Optional[str] = Form(None),
|
|
303
|
+
s3_region: Optional[str] = Form(None),
|
|
304
|
+
s3_endpoint_url: Optional[str] = Form(None),
|
|
305
|
+
s3_prefix: Optional[str] = Form(None),
|
|
306
|
+
s3_use_path_style: Optional[str] = Form(None),
|
|
307
|
+
s3_verify_ssl: Optional[str] = Form(None),
|
|
308
|
+
) -> JSONResponse:
|
|
309
|
+
"""Return ``{"exists": true|false}`` for the given key."""
|
|
310
|
+
cred = _resolve_cred(locals())
|
|
311
|
+
return JSONResponse({"exists": exists(s3_address=key, cred=cred)})
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
@app.post("/list", tags=["reads"])
|
|
315
|
+
def list_endpoint(
|
|
316
|
+
prefix: str = Form(..., description="Key prefix in the default bucket (may be empty)."),
|
|
317
|
+
max_keys: int = Form(1000, description="Cap on the number of returned keys."),
|
|
318
|
+
s3_access_key: Optional[str] = Form(None),
|
|
319
|
+
s3_secret_key: Optional[str] = Form(None),
|
|
320
|
+
s3_bucket: Optional[str] = Form(None),
|
|
321
|
+
s3_https: Optional[str] = Form(None),
|
|
322
|
+
s3_region: Optional[str] = Form(None),
|
|
323
|
+
s3_endpoint_url: Optional[str] = Form(None),
|
|
324
|
+
s3_prefix: Optional[str] = Form(None),
|
|
325
|
+
s3_use_path_style: Optional[str] = Form(None),
|
|
326
|
+
s3_verify_ssl: Optional[str] = Form(None),
|
|
327
|
+
) -> JSONResponse:
|
|
328
|
+
"""List keys under ``prefix`` in the default bucket."""
|
|
329
|
+
cred = _resolve_cred(locals())
|
|
330
|
+
keys = list_prefix(prefix=prefix, cred=cred, max_keys=max_keys)
|
|
331
|
+
return JSONResponse({"keys": keys, "count": len(keys)})
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
@app.post("/download", tags=["reads"])
|
|
335
|
+
def download_endpoint(
|
|
336
|
+
background: BackgroundTasks,
|
|
337
|
+
key: str = Form(..., description="'s3://bucket/key' URI or bare key."),
|
|
338
|
+
s3_access_key: Optional[str] = Form(None),
|
|
339
|
+
s3_secret_key: Optional[str] = Form(None),
|
|
340
|
+
s3_bucket: Optional[str] = Form(None),
|
|
341
|
+
s3_https: Optional[str] = Form(None),
|
|
342
|
+
s3_region: Optional[str] = Form(None),
|
|
343
|
+
s3_endpoint_url: Optional[str] = Form(None),
|
|
344
|
+
s3_prefix: Optional[str] = Form(None),
|
|
345
|
+
s3_use_path_style: Optional[str] = Form(None),
|
|
346
|
+
s3_verify_ssl: Optional[str] = Form(None),
|
|
347
|
+
):
|
|
348
|
+
"""Download an S3 object; the file is streamed back and cleaned up after."""
|
|
349
|
+
cred = _resolve_cred(locals())
|
|
350
|
+
tmp = _new_tmpdir()
|
|
351
|
+
# Preserve the suffix — clients that stream the response benefit.
|
|
352
|
+
ext = Path(key).suffix or ".bin"
|
|
353
|
+
dst = tmp / f"download{ext}"
|
|
354
|
+
download(s3_address=key, local_path=str(dst), cred=cred)
|
|
355
|
+
# Clean the whole temp dir after the response has been sent.
|
|
356
|
+
background.add_task(_cleanup, tmp)
|
|
357
|
+
return FileResponse(
|
|
358
|
+
str(dst),
|
|
359
|
+
filename=Path(key).name or dst.name,
|
|
360
|
+
media_type="application/octet-stream",
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
@app.post("/tempfile", tags=["reads"])
|
|
365
|
+
def tempfile_endpoint(
|
|
366
|
+
ext: Optional[str] = Form(None, description="Extension for the generated name."),
|
|
367
|
+
prefix: Optional[str] = Form(None, description="Extra prefix under the default bucket."),
|
|
368
|
+
s3_access_key: Optional[str] = Form(None),
|
|
369
|
+
s3_secret_key: Optional[str] = Form(None),
|
|
370
|
+
s3_bucket: Optional[str] = Form(None),
|
|
371
|
+
s3_https: Optional[str] = Form(None),
|
|
372
|
+
s3_region: Optional[str] = Form(None),
|
|
373
|
+
s3_endpoint_url: Optional[str] = Form(None),
|
|
374
|
+
s3_prefix: Optional[str] = Form(None),
|
|
375
|
+
s3_use_path_style: Optional[str] = Form(None),
|
|
376
|
+
s3_verify_ssl: Optional[str] = Form(None),
|
|
377
|
+
) -> JSONResponse:
|
|
378
|
+
"""
|
|
379
|
+
Return ``{"s3_address": ..., "public_url": ...}`` for a fresh random key.
|
|
380
|
+
|
|
381
|
+
Does NOT upload anything. Since :func:`remote_tempfile` deletes the
|
|
382
|
+
key on exit and we never wrote anything to it, this is a pure name
|
|
383
|
+
generator — handy when the client wants to know *where* it will
|
|
384
|
+
write before doing the upload itself.
|
|
385
|
+
"""
|
|
386
|
+
cred = _resolve_cred(locals())
|
|
387
|
+
with remote_tempfile(cred, ext=ext or "", prefix=prefix or "") as (addr, url):
|
|
388
|
+
payload = {"s3_address": addr, "public_url": url}
|
|
389
|
+
return JSONResponse(payload)
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
@app.post("/strip-path", tags=["reads"])
|
|
393
|
+
def strip_path_endpoint(
|
|
394
|
+
address: str = Form(..., description="Full 's3://bucket/key' URI or bare key."),
|
|
395
|
+
s3_access_key: Optional[str] = Form(None),
|
|
396
|
+
s3_secret_key: Optional[str] = Form(None),
|
|
397
|
+
s3_bucket: Optional[str] = Form(None),
|
|
398
|
+
s3_https: Optional[str] = Form(None),
|
|
399
|
+
s3_region: Optional[str] = Form(None),
|
|
400
|
+
s3_endpoint_url: Optional[str] = Form(None),
|
|
401
|
+
s3_prefix: Optional[str] = Form(None),
|
|
402
|
+
s3_use_path_style: Optional[str] = Form(None),
|
|
403
|
+
s3_verify_ssl: Optional[str] = Form(None),
|
|
404
|
+
) -> JSONResponse:
|
|
405
|
+
"""Return the key part of an ``s3://bucket/key`` address."""
|
|
406
|
+
cred = _resolve_cred(locals())
|
|
407
|
+
return JSONResponse({"key": strip_s3_path(address, cred)})
|