upstash-blob 0.1.0__tar.gz
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.
- upstash_blob-0.1.0/.gitignore +141 -0
- upstash_blob-0.1.0/LICENSE +21 -0
- upstash_blob-0.1.0/PKG-INFO +212 -0
- upstash_blob-0.1.0/README.md +182 -0
- upstash_blob-0.1.0/pyproject.toml +78 -0
- upstash_blob-0.1.0/upstash_blob/__init__.py +69 -0
- upstash_blob-0.1.0/upstash_blob/_async.py +883 -0
- upstash_blob-0.1.0/upstash_blob/_body.py +362 -0
- upstash_blob-0.1.0/upstash_blob/_core.py +696 -0
- upstash_blob-0.1.0/upstash_blob/_keys.py +99 -0
- upstash_blob-0.1.0/upstash_blob/_multipart.py +57 -0
- upstash_blob-0.1.0/upstash_blob/_sigv4.py +72 -0
- upstash_blob-0.1.0/upstash_blob/_sniff.py +204 -0
- upstash_blob-0.1.0/upstash_blob/_sync.py +912 -0
- upstash_blob-0.1.0/upstash_blob/_telemetry.py +31 -0
- upstash_blob-0.1.0/upstash_blob/_token.py +54 -0
- upstash_blob-0.1.0/upstash_blob/_unique_path.py +30 -0
- upstash_blob-0.1.0/upstash_blob/_units.py +172 -0
- upstash_blob-0.1.0/upstash_blob/_upload.py +859 -0
- upstash_blob-0.1.0/upstash_blob/_upload_async.py +491 -0
- upstash_blob-0.1.0/upstash_blob/_upload_sync.py +488 -0
- upstash_blob-0.1.0/upstash_blob/_version.py +6 -0
- upstash_blob-0.1.0/upstash_blob/django.py +73 -0
- upstash_blob-0.1.0/upstash_blob/errors.py +166 -0
- upstash_blob-0.1.0/upstash_blob/fastapi.py +52 -0
- upstash_blob-0.1.0/upstash_blob/flask.py +51 -0
- upstash_blob-0.1.0/upstash_blob/py.typed +0 -0
- upstash_blob-0.1.0/upstash_blob/types.py +133 -0
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# Byte-compiled / optimized / DLL files
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*$py.class
|
|
5
|
+
|
|
6
|
+
# C extensions
|
|
7
|
+
*.so
|
|
8
|
+
|
|
9
|
+
# Distribution / packaging
|
|
10
|
+
.Python
|
|
11
|
+
build/
|
|
12
|
+
develop-eggs/
|
|
13
|
+
dist/
|
|
14
|
+
downloads/
|
|
15
|
+
eggs/
|
|
16
|
+
.eggs/
|
|
17
|
+
lib/
|
|
18
|
+
lib64/
|
|
19
|
+
parts/
|
|
20
|
+
sdist/
|
|
21
|
+
var/
|
|
22
|
+
wheels/
|
|
23
|
+
pip-wheel-metadata/
|
|
24
|
+
share/python-wheels/
|
|
25
|
+
*.egg-info/
|
|
26
|
+
.installed.cfg
|
|
27
|
+
*.egg
|
|
28
|
+
MANIFEST
|
|
29
|
+
|
|
30
|
+
# PyInstaller
|
|
31
|
+
# Usually these files are written by a python script from a template
|
|
32
|
+
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
|
33
|
+
*.manifest
|
|
34
|
+
*.spec
|
|
35
|
+
|
|
36
|
+
# Installer logs
|
|
37
|
+
pip-log.txt
|
|
38
|
+
pip-delete-this-directory.txt
|
|
39
|
+
|
|
40
|
+
# Unit test / coverage reports
|
|
41
|
+
htmlcov/
|
|
42
|
+
.tox/
|
|
43
|
+
.nox/
|
|
44
|
+
.coverage
|
|
45
|
+
.coverage.*
|
|
46
|
+
.cache
|
|
47
|
+
nosetests.xml
|
|
48
|
+
coverage.xml
|
|
49
|
+
*.cover
|
|
50
|
+
*.py,cover
|
|
51
|
+
.hypothesis/
|
|
52
|
+
.pytest_cache/
|
|
53
|
+
|
|
54
|
+
# Translations
|
|
55
|
+
*.mo
|
|
56
|
+
*.pot
|
|
57
|
+
|
|
58
|
+
# Django stuff:
|
|
59
|
+
*.log
|
|
60
|
+
local_settings.py
|
|
61
|
+
db.sqlite3
|
|
62
|
+
db.sqlite3-journal
|
|
63
|
+
|
|
64
|
+
# Flask stuff:
|
|
65
|
+
instance/
|
|
66
|
+
.webassets-cache
|
|
67
|
+
|
|
68
|
+
# Scrapy stuff:
|
|
69
|
+
.scrapy
|
|
70
|
+
|
|
71
|
+
# Sphinx documentation
|
|
72
|
+
docs/_build/
|
|
73
|
+
|
|
74
|
+
# PyBuilder
|
|
75
|
+
target/
|
|
76
|
+
|
|
77
|
+
# Jupyter Notebook
|
|
78
|
+
.ipynb_checkpoints
|
|
79
|
+
|
|
80
|
+
# IPython
|
|
81
|
+
profile_default/
|
|
82
|
+
ipython_config.py
|
|
83
|
+
|
|
84
|
+
# pyenv
|
|
85
|
+
.python-version
|
|
86
|
+
|
|
87
|
+
# pipenv
|
|
88
|
+
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
|
89
|
+
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
|
90
|
+
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
|
91
|
+
# install all needed dependencies.
|
|
92
|
+
#Pipfile.lock
|
|
93
|
+
|
|
94
|
+
# PEP 582; used by e.g. github.com/David-OConnor/pyflow
|
|
95
|
+
__pypackages__/
|
|
96
|
+
|
|
97
|
+
# Celery stuff
|
|
98
|
+
celerybeat-schedule
|
|
99
|
+
celerybeat.pid
|
|
100
|
+
|
|
101
|
+
# SageMath parsed files
|
|
102
|
+
*.sage.py
|
|
103
|
+
|
|
104
|
+
# Environments
|
|
105
|
+
.env
|
|
106
|
+
.venv
|
|
107
|
+
env/
|
|
108
|
+
venv/
|
|
109
|
+
ENV/
|
|
110
|
+
env.bak/
|
|
111
|
+
venv.bak/
|
|
112
|
+
|
|
113
|
+
# Spyder project settings
|
|
114
|
+
.spyderproject
|
|
115
|
+
.spyproject
|
|
116
|
+
|
|
117
|
+
# Rope project settings
|
|
118
|
+
.ropeproject
|
|
119
|
+
|
|
120
|
+
# mkdocs documentation
|
|
121
|
+
/site
|
|
122
|
+
|
|
123
|
+
# mypy
|
|
124
|
+
.mypy_cache/
|
|
125
|
+
.dmypy.json
|
|
126
|
+
dmypy.json
|
|
127
|
+
|
|
128
|
+
# Pyre type checker
|
|
129
|
+
.pyre/
|
|
130
|
+
|
|
131
|
+
# PyCharm project settings
|
|
132
|
+
.idea
|
|
133
|
+
|
|
134
|
+
# VS Code settings
|
|
135
|
+
.vscode
|
|
136
|
+
|
|
137
|
+
# Poetry lock
|
|
138
|
+
poetry.lock
|
|
139
|
+
.env
|
|
140
|
+
.venv/
|
|
141
|
+
.ruff_cache/
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Upstash, Inc.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: upstash-blob
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python SDK for Upstash Blob
|
|
5
|
+
Project-URL: Homepage, https://upstash.com/docs/blob
|
|
6
|
+
Project-URL: Repository, https://github.com/upstash/blob-py
|
|
7
|
+
Project-URL: Issues, https://github.com/upstash/blob-py/issues
|
|
8
|
+
Author-email: Upstash <support@upstash.com>
|
|
9
|
+
Maintainer-email: Upstash <support@upstash.com>
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: Upstash,Upstash Blob,blob,r2,s3,storage,upload
|
|
13
|
+
Classifier: Development Status :: 3 - Alpha
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
24
|
+
Classifier: Programming Language :: Python :: Implementation :: CPython
|
|
25
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
26
|
+
Classifier: Typing :: Typed
|
|
27
|
+
Requires-Python: >=3.10
|
|
28
|
+
Requires-Dist: httpx<1,>=0.23.0
|
|
29
|
+
Description-Content-Type: text/markdown
|
|
30
|
+
|
|
31
|
+
# upstash-blob
|
|
32
|
+
|
|
33
|
+
Python SDK for [Upstash Blob](https://upstash.com/docs/blob). Sync and async, one dependency (`httpx`).
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pip install upstash-blob
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Usage
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
from pathlib import Path
|
|
43
|
+
|
|
44
|
+
from upstash_blob import Bucket
|
|
45
|
+
|
|
46
|
+
bucket = Bucket.from_env() # reads UPSTASH_BLOB_TOKEN, or Bucket(token)
|
|
47
|
+
|
|
48
|
+
blob = bucket.put("avatars/me.png", Path("me.png"))
|
|
49
|
+
blob.url # None on a private bucket
|
|
50
|
+
blob.etag
|
|
51
|
+
|
|
52
|
+
bucket.get("avatars/me.png").content # record + bytes
|
|
53
|
+
bucket.info("avatars/me.png") # same record, no bytes
|
|
54
|
+
bucket.exists("avatars/me.png") # False instead of a raise
|
|
55
|
+
bucket.list(prefix="avatars/", limit=100) # ListPage(blobs, cursor)
|
|
56
|
+
bucket.copy("tmp/9f3c", "avatars/7.png")
|
|
57
|
+
bucket.move("tmp/9f3c", "avatars/7.png", content_type="image/png")
|
|
58
|
+
bucket.delete("a.png") # or ["a.png", "b.png"], or prefix="tmp/"
|
|
59
|
+
bucket.update_json("u/7.json", lambda prev: {**(prev or {}), "theme": "dark"})
|
|
60
|
+
|
|
61
|
+
with bucket.stream("videos/intro.mp4") as video:
|
|
62
|
+
for chunk in video.iter_bytes():
|
|
63
|
+
...
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
`AsyncBucket` has the same methods, awaited, and also takes async iterables as a body:
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
from upstash_blob import AsyncBucket
|
|
70
|
+
|
|
71
|
+
async with AsyncBucket.from_env() as bucket:
|
|
72
|
+
await bucket.put("report.pdf", data, content_type="application/pdf")
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
- `put` takes bytes, str, a `pathlib.Path`, a binary file object, or an iterable of bytes. The content
|
|
76
|
+
type is guessed from a file name, else `application/octet-stream`. An iterable of unknown length
|
|
77
|
+
needs `size=` or `max_size=`.
|
|
78
|
+
- `content_types=["image/*"]` checks the declared type and the file's first bytes; `max_size="20mb"`
|
|
79
|
+
refuses anything larger, while streaming too.
|
|
80
|
+
- A body over 16 MB goes up as multipart, up to four parts at a time (fewer past 3 GB, to bound
|
|
81
|
+
memory); `multipart="100mb" | True | False` moves the line. A failed part aborts the upload.
|
|
82
|
+
`allow_overwrite=False` and `if_unchanged=etag` are single-PUT only.
|
|
83
|
+
- `get` and `info` raise `not_found` rather than returning None; `list` returns keys, sizes, etags
|
|
84
|
+
and urls, without metadata. `cursor` is set only while more remains.
|
|
85
|
+
- `update_json` is a compare-and-set loop, retried on conflict up to `max_attempts` times (default 6).
|
|
86
|
+
- `copy` and `move` take `content_type`, `cache`, `metadata`; whatever is not given is carried over
|
|
87
|
+
from the source. `move` is a copy plus a delete: a failed delete raises `move_left_a_copy`.
|
|
88
|
+
- `delete(prefix="")` needs `all=True`. A partial list delete raises `partial_delete`.
|
|
89
|
+
- Metadata keys are lowercase (storage reads them back lowercased) and values printable ASCII;
|
|
90
|
+
anything else is refused with `invalid_input`.
|
|
91
|
+
- `retry={"retries": 5, "backoff": lambda n: 100 * 2**n}` (milliseconds before retry `n`, from 0)
|
|
92
|
+
tunes how failed requests are retried, and `retry=False` sends each once. The default is 2 retries
|
|
93
|
+
with jittered backoff from 200 ms; a Retry-After from the service always wins.
|
|
94
|
+
- `cache` takes `"immutable"`, `"revalidate"`, `"no-store"`, a duration (`"15m"`, `3600`,
|
|
95
|
+
`timedelta`), or a verbatim header.
|
|
96
|
+
- Sizes count bytes. Decimal units (`KB`, `MB`, `GB`, `TB`) are powers of 1,000 and binary units
|
|
97
|
+
(`KiB`, `MiB`, `GiB`, `TiB`) powers of 1,024, case-insensitive: `"32MB"` is 32,000,000 bytes and
|
|
98
|
+
refuses a 32 MiB file, which needs `"32MiB"` or `32 * 1024 * 1024`.
|
|
99
|
+
|
|
100
|
+
### Signed URLs
|
|
101
|
+
|
|
102
|
+
```python
|
|
103
|
+
read = bucket.signed_read_url("private/report.pdf", download_as="report.pdf")
|
|
104
|
+
upload = bucket.signed_upload_url("u/7/report.pdf", content_type="application/pdf", size=size)
|
|
105
|
+
httpx.put(upload.url, headers=upload.headers, content=body)
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Links are signed by Upstash and live at most 10 minutes (read default: 5); `expires_at` is the answer
|
|
109
|
+
per link. `headers` on an upload URL are pinned into the signature. Whether a bucket is private is
|
|
110
|
+
decided in the console: a private bucket has no `url` on any blob and `public_url()` is None.
|
|
111
|
+
|
|
112
|
+
### Browser uploads
|
|
113
|
+
|
|
114
|
+
`UploadHandler` (sync) and `AsyncUploadHandler` serve the protocol of `@upstash/blob/browser` and
|
|
115
|
+
`@upstash/blob/react`, so a React app can upload straight to storage through a Python backend, with
|
|
116
|
+
multipart, resume, progress and a completion callback.
|
|
117
|
+
|
|
118
|
+
```python
|
|
119
|
+
from upstash_blob import AsyncUploadHandler, BeforeUploadResult, UploadRoute, unique_path
|
|
120
|
+
from upstash_blob.fastapi import upload_view
|
|
121
|
+
|
|
122
|
+
uploads = AsyncUploadHandler(
|
|
123
|
+
context=require_user, # its return is `ctx`; raise a 401 to refuse
|
|
124
|
+
owner=lambda user: user.id, # binds each upload to who began it
|
|
125
|
+
on_before_upload=lambda a: BeforeUploadResult(path=unique_path(f"u/{a.ctx.id}/{a.file.name}")),
|
|
126
|
+
on_upload_complete=lambda a: {"url": a.url}, # reaches the browser as upload.blob.data
|
|
127
|
+
routes={
|
|
128
|
+
"avatar": UploadRoute(content_types=["image/*"], max_size="5mb"),
|
|
129
|
+
"video": UploadRoute(content_types=["video/*"], max_size="2gb"),
|
|
130
|
+
},
|
|
131
|
+
)
|
|
132
|
+
app.add_api_route("/api/upload", upload_view(uploads), methods=["GET", "POST"])
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
```tsx
|
|
136
|
+
const upload = useUpload('avatar'); // POSTs to /api/upload?route=avatar
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
- Django: `path("api/upload", upload_view(uploads))` from `upstash_blob.django`, with either
|
|
140
|
+
handler. Flask: `app.add_url_rule("/api/upload", view_func=upload_view(uploads), methods=["GET", "POST"])`
|
|
141
|
+
from `upstash_blob.flask`, with `UploadHandler`. Anything else: `handler.handle(method, request=...,
|
|
142
|
+
route=..., body=..., headers=..., native=<framework response class>)` returns an `UploadResponse`.
|
|
143
|
+
- Omit `bucket=` to use `UPSTASH_BLOB_TOKEN`. Every handler option is a default a route may replace;
|
|
144
|
+
`content_types=None` or `max_size=None` on a route clears the handler's.
|
|
145
|
+
- `context` runs on every POST of an upload: check the session there. `owner` signs who began an
|
|
146
|
+
upload into it; a later request from anyone else is a 403, so one signed-in user cannot finish,
|
|
147
|
+
re-sign or cancel another's upload. An `owner` of None or `""` is a 401.
|
|
148
|
+
- The first upload from each page origin also sends storage the CORS preflight the browser will, and
|
|
149
|
+
logs a warning on the `upstash_blob` logger when storage refuses it: add the origin to the bucket's
|
|
150
|
+
CORS settings in the Upstash Console.
|
|
151
|
+
- `input=` validates what the browser sends as `input`: a pydantic model, a `TypeAdapter`, or a
|
|
152
|
+
callable that raises `ValueError`.
|
|
153
|
+
- `on_upload_complete` runs at least once per upload; `upload_id` is stable across deliveries.
|
|
154
|
+
Raising deletes the object. `state` rides in a signed but readable token: an id, never a secret.
|
|
155
|
+
- `on_error` sees every refusal; return a `BlobError` or a framework response to answer with it.
|
|
156
|
+
- `unique_path` adds an eight-character random suffix to the final filename and keeps everything else
|
|
157
|
+
as given: `u/7/Q3 Report.pdf` becomes `u/7/Q3 Report-<random>.pdf`. A path with a `.` or `..`
|
|
158
|
+
segment is refused as `invalid_input`. Store the returned path and authorize reads by it.
|
|
159
|
+
- The handler speaks the same versioned upload protocol as `@upstash/blob`'s `uploadHandler`, so
|
|
160
|
+
any release of the browser client works against it and either side can be upgraded first.
|
|
161
|
+
|
|
162
|
+
### Incomplete uploads
|
|
163
|
+
|
|
164
|
+
A multipart upload that was never finished is invisible to `list()` and blocks bucket deletion. The
|
|
165
|
+
SDK aborts the ones it starts; sweep the rest from a cron:
|
|
166
|
+
|
|
167
|
+
```python
|
|
168
|
+
bucket.abort_stale_multipart_uploads(older_than="1d", prefix="uploads/")
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
### The S3 escape hatch
|
|
172
|
+
|
|
173
|
+
```python
|
|
174
|
+
cfg = bucket.s3()
|
|
175
|
+
s3 = boto3.client("s3", **cfg.client_kwargs())
|
|
176
|
+
s3.list_objects_v2(Bucket=cfg.bucket)
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
The credentials are short-lived; call `s3()` again after `cfg.expires_at`.
|
|
180
|
+
|
|
181
|
+
## Errors
|
|
182
|
+
|
|
183
|
+
Everything raises a `BlobError` with a `code` from a closed list, a `status`, and a printable
|
|
184
|
+
message. `e.to_dict()` is the JSON shape the TypeScript SDK's browser half decodes.
|
|
185
|
+
|
|
186
|
+
```python
|
|
187
|
+
try:
|
|
188
|
+
bucket.put(path, body, max_size="5mb")
|
|
189
|
+
except BlobError as e:
|
|
190
|
+
if e.code == "too_large":
|
|
191
|
+
...
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
## Telemetry
|
|
195
|
+
|
|
196
|
+
Credential and signing requests carry the SDK version, Python version, and platform. Set
|
|
197
|
+
`UPSTASH_DISABLE_TELEMETRY` or pass `enable_telemetry=False`.
|
|
198
|
+
|
|
199
|
+
## Development
|
|
200
|
+
|
|
201
|
+
```bash
|
|
202
|
+
uv sync
|
|
203
|
+
uv run ruff check . && uv run ruff format --check . && uv run mypy upstash_blob
|
|
204
|
+
uv run pytest tests/unit
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
`uv run pytest tests/live` runs against real buckets and needs `UPSTASH_BLOB_PUBLIC_TOKEN` and
|
|
208
|
+
`UPSTASH_BLOB_PRIVATE_TOKEN` (a `.env` file works).
|
|
209
|
+
|
|
210
|
+
## License
|
|
211
|
+
|
|
212
|
+
[MIT](./LICENSE)
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
# upstash-blob
|
|
2
|
+
|
|
3
|
+
Python SDK for [Upstash Blob](https://upstash.com/docs/blob). Sync and async, one dependency (`httpx`).
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
pip install upstash-blob
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
## Usage
|
|
10
|
+
|
|
11
|
+
```python
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from upstash_blob import Bucket
|
|
15
|
+
|
|
16
|
+
bucket = Bucket.from_env() # reads UPSTASH_BLOB_TOKEN, or Bucket(token)
|
|
17
|
+
|
|
18
|
+
blob = bucket.put("avatars/me.png", Path("me.png"))
|
|
19
|
+
blob.url # None on a private bucket
|
|
20
|
+
blob.etag
|
|
21
|
+
|
|
22
|
+
bucket.get("avatars/me.png").content # record + bytes
|
|
23
|
+
bucket.info("avatars/me.png") # same record, no bytes
|
|
24
|
+
bucket.exists("avatars/me.png") # False instead of a raise
|
|
25
|
+
bucket.list(prefix="avatars/", limit=100) # ListPage(blobs, cursor)
|
|
26
|
+
bucket.copy("tmp/9f3c", "avatars/7.png")
|
|
27
|
+
bucket.move("tmp/9f3c", "avatars/7.png", content_type="image/png")
|
|
28
|
+
bucket.delete("a.png") # or ["a.png", "b.png"], or prefix="tmp/"
|
|
29
|
+
bucket.update_json("u/7.json", lambda prev: {**(prev or {}), "theme": "dark"})
|
|
30
|
+
|
|
31
|
+
with bucket.stream("videos/intro.mp4") as video:
|
|
32
|
+
for chunk in video.iter_bytes():
|
|
33
|
+
...
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
`AsyncBucket` has the same methods, awaited, and also takes async iterables as a body:
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
from upstash_blob import AsyncBucket
|
|
40
|
+
|
|
41
|
+
async with AsyncBucket.from_env() as bucket:
|
|
42
|
+
await bucket.put("report.pdf", data, content_type="application/pdf")
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
- `put` takes bytes, str, a `pathlib.Path`, a binary file object, or an iterable of bytes. The content
|
|
46
|
+
type is guessed from a file name, else `application/octet-stream`. An iterable of unknown length
|
|
47
|
+
needs `size=` or `max_size=`.
|
|
48
|
+
- `content_types=["image/*"]` checks the declared type and the file's first bytes; `max_size="20mb"`
|
|
49
|
+
refuses anything larger, while streaming too.
|
|
50
|
+
- A body over 16 MB goes up as multipart, up to four parts at a time (fewer past 3 GB, to bound
|
|
51
|
+
memory); `multipart="100mb" | True | False` moves the line. A failed part aborts the upload.
|
|
52
|
+
`allow_overwrite=False` and `if_unchanged=etag` are single-PUT only.
|
|
53
|
+
- `get` and `info` raise `not_found` rather than returning None; `list` returns keys, sizes, etags
|
|
54
|
+
and urls, without metadata. `cursor` is set only while more remains.
|
|
55
|
+
- `update_json` is a compare-and-set loop, retried on conflict up to `max_attempts` times (default 6).
|
|
56
|
+
- `copy` and `move` take `content_type`, `cache`, `metadata`; whatever is not given is carried over
|
|
57
|
+
from the source. `move` is a copy plus a delete: a failed delete raises `move_left_a_copy`.
|
|
58
|
+
- `delete(prefix="")` needs `all=True`. A partial list delete raises `partial_delete`.
|
|
59
|
+
- Metadata keys are lowercase (storage reads them back lowercased) and values printable ASCII;
|
|
60
|
+
anything else is refused with `invalid_input`.
|
|
61
|
+
- `retry={"retries": 5, "backoff": lambda n: 100 * 2**n}` (milliseconds before retry `n`, from 0)
|
|
62
|
+
tunes how failed requests are retried, and `retry=False` sends each once. The default is 2 retries
|
|
63
|
+
with jittered backoff from 200 ms; a Retry-After from the service always wins.
|
|
64
|
+
- `cache` takes `"immutable"`, `"revalidate"`, `"no-store"`, a duration (`"15m"`, `3600`,
|
|
65
|
+
`timedelta`), or a verbatim header.
|
|
66
|
+
- Sizes count bytes. Decimal units (`KB`, `MB`, `GB`, `TB`) are powers of 1,000 and binary units
|
|
67
|
+
(`KiB`, `MiB`, `GiB`, `TiB`) powers of 1,024, case-insensitive: `"32MB"` is 32,000,000 bytes and
|
|
68
|
+
refuses a 32 MiB file, which needs `"32MiB"` or `32 * 1024 * 1024`.
|
|
69
|
+
|
|
70
|
+
### Signed URLs
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
read = bucket.signed_read_url("private/report.pdf", download_as="report.pdf")
|
|
74
|
+
upload = bucket.signed_upload_url("u/7/report.pdf", content_type="application/pdf", size=size)
|
|
75
|
+
httpx.put(upload.url, headers=upload.headers, content=body)
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Links are signed by Upstash and live at most 10 minutes (read default: 5); `expires_at` is the answer
|
|
79
|
+
per link. `headers` on an upload URL are pinned into the signature. Whether a bucket is private is
|
|
80
|
+
decided in the console: a private bucket has no `url` on any blob and `public_url()` is None.
|
|
81
|
+
|
|
82
|
+
### Browser uploads
|
|
83
|
+
|
|
84
|
+
`UploadHandler` (sync) and `AsyncUploadHandler` serve the protocol of `@upstash/blob/browser` and
|
|
85
|
+
`@upstash/blob/react`, so a React app can upload straight to storage through a Python backend, with
|
|
86
|
+
multipart, resume, progress and a completion callback.
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
from upstash_blob import AsyncUploadHandler, BeforeUploadResult, UploadRoute, unique_path
|
|
90
|
+
from upstash_blob.fastapi import upload_view
|
|
91
|
+
|
|
92
|
+
uploads = AsyncUploadHandler(
|
|
93
|
+
context=require_user, # its return is `ctx`; raise a 401 to refuse
|
|
94
|
+
owner=lambda user: user.id, # binds each upload to who began it
|
|
95
|
+
on_before_upload=lambda a: BeforeUploadResult(path=unique_path(f"u/{a.ctx.id}/{a.file.name}")),
|
|
96
|
+
on_upload_complete=lambda a: {"url": a.url}, # reaches the browser as upload.blob.data
|
|
97
|
+
routes={
|
|
98
|
+
"avatar": UploadRoute(content_types=["image/*"], max_size="5mb"),
|
|
99
|
+
"video": UploadRoute(content_types=["video/*"], max_size="2gb"),
|
|
100
|
+
},
|
|
101
|
+
)
|
|
102
|
+
app.add_api_route("/api/upload", upload_view(uploads), methods=["GET", "POST"])
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
```tsx
|
|
106
|
+
const upload = useUpload('avatar'); // POSTs to /api/upload?route=avatar
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
- Django: `path("api/upload", upload_view(uploads))` from `upstash_blob.django`, with either
|
|
110
|
+
handler. Flask: `app.add_url_rule("/api/upload", view_func=upload_view(uploads), methods=["GET", "POST"])`
|
|
111
|
+
from `upstash_blob.flask`, with `UploadHandler`. Anything else: `handler.handle(method, request=...,
|
|
112
|
+
route=..., body=..., headers=..., native=<framework response class>)` returns an `UploadResponse`.
|
|
113
|
+
- Omit `bucket=` to use `UPSTASH_BLOB_TOKEN`. Every handler option is a default a route may replace;
|
|
114
|
+
`content_types=None` or `max_size=None` on a route clears the handler's.
|
|
115
|
+
- `context` runs on every POST of an upload: check the session there. `owner` signs who began an
|
|
116
|
+
upload into it; a later request from anyone else is a 403, so one signed-in user cannot finish,
|
|
117
|
+
re-sign or cancel another's upload. An `owner` of None or `""` is a 401.
|
|
118
|
+
- The first upload from each page origin also sends storage the CORS preflight the browser will, and
|
|
119
|
+
logs a warning on the `upstash_blob` logger when storage refuses it: add the origin to the bucket's
|
|
120
|
+
CORS settings in the Upstash Console.
|
|
121
|
+
- `input=` validates what the browser sends as `input`: a pydantic model, a `TypeAdapter`, or a
|
|
122
|
+
callable that raises `ValueError`.
|
|
123
|
+
- `on_upload_complete` runs at least once per upload; `upload_id` is stable across deliveries.
|
|
124
|
+
Raising deletes the object. `state` rides in a signed but readable token: an id, never a secret.
|
|
125
|
+
- `on_error` sees every refusal; return a `BlobError` or a framework response to answer with it.
|
|
126
|
+
- `unique_path` adds an eight-character random suffix to the final filename and keeps everything else
|
|
127
|
+
as given: `u/7/Q3 Report.pdf` becomes `u/7/Q3 Report-<random>.pdf`. A path with a `.` or `..`
|
|
128
|
+
segment is refused as `invalid_input`. Store the returned path and authorize reads by it.
|
|
129
|
+
- The handler speaks the same versioned upload protocol as `@upstash/blob`'s `uploadHandler`, so
|
|
130
|
+
any release of the browser client works against it and either side can be upgraded first.
|
|
131
|
+
|
|
132
|
+
### Incomplete uploads
|
|
133
|
+
|
|
134
|
+
A multipart upload that was never finished is invisible to `list()` and blocks bucket deletion. The
|
|
135
|
+
SDK aborts the ones it starts; sweep the rest from a cron:
|
|
136
|
+
|
|
137
|
+
```python
|
|
138
|
+
bucket.abort_stale_multipart_uploads(older_than="1d", prefix="uploads/")
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
### The S3 escape hatch
|
|
142
|
+
|
|
143
|
+
```python
|
|
144
|
+
cfg = bucket.s3()
|
|
145
|
+
s3 = boto3.client("s3", **cfg.client_kwargs())
|
|
146
|
+
s3.list_objects_v2(Bucket=cfg.bucket)
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
The credentials are short-lived; call `s3()` again after `cfg.expires_at`.
|
|
150
|
+
|
|
151
|
+
## Errors
|
|
152
|
+
|
|
153
|
+
Everything raises a `BlobError` with a `code` from a closed list, a `status`, and a printable
|
|
154
|
+
message. `e.to_dict()` is the JSON shape the TypeScript SDK's browser half decodes.
|
|
155
|
+
|
|
156
|
+
```python
|
|
157
|
+
try:
|
|
158
|
+
bucket.put(path, body, max_size="5mb")
|
|
159
|
+
except BlobError as e:
|
|
160
|
+
if e.code == "too_large":
|
|
161
|
+
...
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
## Telemetry
|
|
165
|
+
|
|
166
|
+
Credential and signing requests carry the SDK version, Python version, and platform. Set
|
|
167
|
+
`UPSTASH_DISABLE_TELEMETRY` or pass `enable_telemetry=False`.
|
|
168
|
+
|
|
169
|
+
## Development
|
|
170
|
+
|
|
171
|
+
```bash
|
|
172
|
+
uv sync
|
|
173
|
+
uv run ruff check . && uv run ruff format --check . && uv run mypy upstash_blob
|
|
174
|
+
uv run pytest tests/unit
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
`uv run pytest tests/live` runs against real buckets and needs `UPSTASH_BLOB_PUBLIC_TOKEN` and
|
|
178
|
+
`UPSTASH_BLOB_PRIVATE_TOKEN` (a `.env` file works).
|
|
179
|
+
|
|
180
|
+
## License
|
|
181
|
+
|
|
182
|
+
[MIT](./LICENSE)
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "upstash-blob"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Python SDK for Upstash Blob"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
authors = [{ name = "Upstash", email = "support@upstash.com" }]
|
|
9
|
+
maintainers = [{ name = "Upstash", email = "support@upstash.com" }]
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
dependencies = ["httpx>=0.23.0,<1"]
|
|
12
|
+
keywords = ["Upstash", "Upstash Blob", "blob", "storage", "s3", "r2", "upload"]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Development Status :: 3 - Alpha",
|
|
15
|
+
"Intended Audience :: Developers",
|
|
16
|
+
"Operating System :: OS Independent",
|
|
17
|
+
"Programming Language :: Python",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
20
|
+
"Programming Language :: Python :: 3.10",
|
|
21
|
+
"Programming Language :: Python :: 3.11",
|
|
22
|
+
"Programming Language :: Python :: 3.12",
|
|
23
|
+
"Programming Language :: Python :: 3.13",
|
|
24
|
+
"Programming Language :: Python :: 3.14",
|
|
25
|
+
"Programming Language :: Python :: Implementation :: CPython",
|
|
26
|
+
"Topic :: Software Development :: Libraries",
|
|
27
|
+
"Typing :: Typed",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[project.urls]
|
|
31
|
+
Homepage = "https://upstash.com/docs/blob"
|
|
32
|
+
Repository = "https://github.com/upstash/blob-py"
|
|
33
|
+
Issues = "https://github.com/upstash/blob-py/issues"
|
|
34
|
+
|
|
35
|
+
[dependency-groups]
|
|
36
|
+
dev = [
|
|
37
|
+
"boto3>=1.35",
|
|
38
|
+
"django>=4.2",
|
|
39
|
+
"fastapi>=0.110",
|
|
40
|
+
"flask>=3.0",
|
|
41
|
+
"mypy>=1.14",
|
|
42
|
+
"pytest>=8.3",
|
|
43
|
+
"pytest-asyncio>=0.24",
|
|
44
|
+
"python-dotenv>=1.0",
|
|
45
|
+
"ruff>=0.8",
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
[build-system]
|
|
49
|
+
requires = ["hatchling"]
|
|
50
|
+
build-backend = "hatchling.build"
|
|
51
|
+
|
|
52
|
+
[tool.hatch.build.targets.sdist]
|
|
53
|
+
include = ["/upstash_blob", "/README.md", "/LICENSE"]
|
|
54
|
+
|
|
55
|
+
[tool.uv]
|
|
56
|
+
exclude-newer = "7 days"
|
|
57
|
+
|
|
58
|
+
[tool.ruff]
|
|
59
|
+
line-length = 120
|
|
60
|
+
target-version = "py310"
|
|
61
|
+
|
|
62
|
+
[tool.ruff.lint]
|
|
63
|
+
select = ["E", "F", "I", "UP", "B"]
|
|
64
|
+
ignore = ["UP006", "UP007", "UP035", "UP045", "B008"]
|
|
65
|
+
per-file-ignores = { "tests/**" = ["E501"] }
|
|
66
|
+
|
|
67
|
+
[tool.mypy]
|
|
68
|
+
strict = true
|
|
69
|
+
python_version = "3.10"
|
|
70
|
+
|
|
71
|
+
[[tool.mypy.overrides]]
|
|
72
|
+
module = ["django.*"]
|
|
73
|
+
ignore_missing_imports = true
|
|
74
|
+
|
|
75
|
+
[tool.pytest.ini_options]
|
|
76
|
+
asyncio_mode = "auto"
|
|
77
|
+
testpaths = ["tests"]
|
|
78
|
+
markers = ["live: runs against real buckets; needs UPSTASH_BLOB_PUBLIC_TOKEN and UPSTASH_BLOB_PRIVATE_TOKEN"]
|