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.
@@ -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)})