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.
@@ -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"]