objectstore-client 0.2.10__tar.gz → 0.3.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.
- {objectstore_client-0.2.10 → objectstore_client-0.3.0}/PKG-INFO +38 -78
- {objectstore_client-0.2.10 → objectstore_client-0.3.0}/README.md +37 -77
- {objectstore_client-0.2.10 → objectstore_client-0.3.0}/pyproject.toml +1 -1
- {objectstore_client-0.2.10 → objectstore_client-0.3.0}/src/objectstore_client/__init__.py +8 -1
- objectstore_client-0.3.0/src/objectstore_client/_compression.py +175 -0
- {objectstore_client-0.2.10 → objectstore_client-0.3.0}/src/objectstore_client/_resumable.py +5 -5
- {objectstore_client-0.2.10 → objectstore_client-0.3.0}/src/objectstore_client/auth.py +51 -37
- {objectstore_client-0.2.10 → objectstore_client-0.3.0}/src/objectstore_client/client.py +66 -175
- {objectstore_client-0.2.10 → objectstore_client-0.3.0}/src/objectstore_client/metadata.py +1 -1
- {objectstore_client-0.2.10 → objectstore_client-0.3.0}/src/objectstore_client/utils.py +1 -28
- objectstore_client-0.2.10/src/objectstore_client/multipart.py +0 -284
- {objectstore_client-0.2.10 → objectstore_client-0.3.0}/LICENSE.md +0 -0
- {objectstore_client-0.2.10 → objectstore_client-0.3.0}/src/objectstore_client/errors.py +0 -0
- {objectstore_client-0.2.10 → objectstore_client-0.3.0}/src/objectstore_client/metrics.py +0 -0
- {objectstore_client-0.2.10 → objectstore_client-0.3.0}/src/objectstore_client/presign.py +0 -0
- {objectstore_client-0.2.10 → objectstore_client-0.3.0}/src/objectstore_client/py.typed +0 -0
- {objectstore_client-0.2.10 → objectstore_client-0.3.0}/src/objectstore_client/scope.py +0 -0
- {objectstore_client-0.2.10 → objectstore_client-0.3.0}/src/objectstore_client/tracing.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: objectstore-client
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Client SDK for Objectstore, the Sentry object storage platform
|
|
5
5
|
Author: Sentry
|
|
6
6
|
Author-email: Sentry <oss@sentry.io>
|
|
@@ -211,9 +211,13 @@ session.put(b"payload", origin="203.0.113.42")
|
|
|
211
211
|
### Compression
|
|
212
212
|
|
|
213
213
|
Uploads are compressed with Zstd by default, and downloads are transparently decompressed.
|
|
214
|
-
|
|
214
|
+
The supported algorithms are `"zstd"` and `"gzip"`, or `"none"` to disable compression.
|
|
215
|
+
Compression can be set per-usecase or overridden per-upload:
|
|
215
216
|
|
|
216
217
|
```python
|
|
218
|
+
# compress all uploads of this usecase with zstd (the default):
|
|
219
|
+
usecase = Usecase("my-usecase", compression="zstd")
|
|
220
|
+
|
|
217
221
|
# upload as-is and record no encoding:
|
|
218
222
|
session.put(video_data, compress="none")
|
|
219
223
|
|
|
@@ -235,95 +239,36 @@ Arbitrary key-value pairs can be attached to objects and retrieved on download.
|
|
|
235
239
|
session.put(b"payload", metadata={"source": "upload-service"})
|
|
236
240
|
```
|
|
237
241
|
|
|
238
|
-
### Multipart Upload API
|
|
239
|
-
|
|
240
|
-
For large objects, use multipart uploads to upload parts independently and then
|
|
241
|
-
assemble them into a final object.
|
|
242
|
-
|
|
243
|
-
**Important:** unlike single-object uploads, multipart uploads do **not** auto-compress.
|
|
244
|
-
`compression` on `initiate_multipart_upload` behaves like `precompressed` above: it only
|
|
245
|
-
records the algorithm, and the caller must pre-compress each part accordingly.
|
|
246
|
-
|
|
247
|
-
```python
|
|
248
|
-
from concurrent.futures import ThreadPoolExecutor
|
|
249
|
-
|
|
250
|
-
import zstandard
|
|
251
|
-
|
|
252
|
-
from objectstore_client.multipart import MultipartCompleteError
|
|
253
|
-
|
|
254
|
-
upload = session.initiate_multipart_upload(
|
|
255
|
-
key="my-large-object",
|
|
256
|
-
compression="zstd",
|
|
257
|
-
metadata={"source": "upload-service"},
|
|
258
|
-
)
|
|
259
|
-
|
|
260
|
-
compressor = zstandard.ZstdCompressor()
|
|
261
|
-
chunks = [b"part1", b"part2", b"part3", b"part4"]
|
|
262
|
-
|
|
263
|
-
def upload_part(part_number: int, data: bytes):
|
|
264
|
-
compressed = compressor.compress(data)
|
|
265
|
-
return upload.put_part(
|
|
266
|
-
compressed, part_number=part_number, content_length=len(compressed)
|
|
267
|
-
)
|
|
268
|
-
|
|
269
|
-
with ThreadPoolExecutor(max_workers=4) as executor:
|
|
270
|
-
futures = [
|
|
271
|
-
executor.submit(upload_part, i + 1, chunk)
|
|
272
|
-
for i, chunk in enumerate(chunks)
|
|
273
|
-
]
|
|
274
|
-
parts = [f.result() for f in futures]
|
|
275
|
-
|
|
276
|
-
try:
|
|
277
|
-
key = upload.complete(parts)
|
|
278
|
-
except MultipartCompleteError:
|
|
279
|
-
upload.abort()
|
|
280
|
-
raise
|
|
281
|
-
```
|
|
282
|
-
|
|
283
|
-
To resume an in-progress multipart upload after a process restart, persist the
|
|
284
|
-
`key` and `upload_id`, then reconstruct the upload handle later:
|
|
285
|
-
|
|
286
|
-
```python
|
|
287
|
-
saved_key = upload.key
|
|
288
|
-
saved_upload_id = upload.upload_id
|
|
289
|
-
|
|
290
|
-
resumed = session.resume_multipart_upload(saved_key, saved_upload_id)
|
|
291
|
-
existing_parts = resumed.list_parts()
|
|
292
|
-
|
|
293
|
-
# Upload missing parts...
|
|
294
|
-
|
|
295
|
-
key = resumed.complete(new_parts + existing_parts)
|
|
296
|
-
```
|
|
297
|
-
|
|
298
242
|
### Authentication
|
|
299
243
|
|
|
300
244
|
If your Objectstore instance enforces authorization, you must configure authentication
|
|
301
245
|
via the `token` parameter on `Client`. It accepts either:
|
|
302
246
|
|
|
303
|
-
- A **`
|
|
304
|
-
|
|
305
|
-
and scope being accessed
|
|
247
|
+
- A **`TokenGenerator`** — for internal services that have access to an EdDSA keypair.
|
|
248
|
+
It wraps a `SecretKey` and signs a fresh JWT for each request, scoped to the
|
|
249
|
+
specific usecase and scope being accessed. Its key can also sign pre-signed URLs.
|
|
250
|
+
The generator's `expiry_seconds` and `permissions` are the defaults for every
|
|
251
|
+
token it signs; per-token overrides can only narrow them.
|
|
306
252
|
- A **`str`** — a pre-signed JWT, used as-is for every request.
|
|
307
253
|
Use this for external services that receive a token from another source.
|
|
308
254
|
|
|
309
255
|
```python
|
|
310
256
|
from objectstore_client import Client, Usecase
|
|
311
|
-
from objectstore_client.auth import SecretKey
|
|
257
|
+
from objectstore_client.auth import SecretKey, TokenGenerator
|
|
258
|
+
|
|
259
|
+
key = SecretKey(kid="my-service", secret_key="<private key>")
|
|
312
260
|
|
|
313
261
|
# Option 1: Internal service with a keypair
|
|
314
|
-
client = Client(
|
|
315
|
-
"http://localhost:8888",
|
|
316
|
-
token=SecretKey(kid="my-service", secret_key="<private key>"),
|
|
317
|
-
)
|
|
262
|
+
client = Client("http://localhost:8888", token=TokenGenerator(key))
|
|
318
263
|
|
|
319
264
|
# Option 2: External service with a pre-signed JWT
|
|
320
|
-
# Use
|
|
265
|
+
# Use TokenGenerator.token_for_scope() to obtain a static token from an
|
|
321
266
|
# internal service, then pass it to the external consumer:
|
|
322
267
|
from objectstore_client.scope import Scope
|
|
323
268
|
|
|
324
|
-
token =
|
|
325
|
-
|
|
326
|
-
)
|
|
269
|
+
token = TokenGenerator(key, expiry_seconds=300).token_for_scope(
|
|
270
|
+
"my_app", Scope(org=42, project=1337)
|
|
271
|
+
)
|
|
327
272
|
|
|
328
273
|
client = Client("http://localhost:8888", token=token)
|
|
329
274
|
```
|
|
@@ -333,7 +278,7 @@ client = Client("http://localhost:8888", token=token)
|
|
|
333
278
|
`Session.object_url` returns a GET URL for an object. By default the URL carries
|
|
334
279
|
no auth, so the recipient needs their own key to generate a token for it. Pass
|
|
335
280
|
`token_validity` to embed a read-only token in the URL instead, valid for the
|
|
336
|
-
given duration (requires `
|
|
281
|
+
given duration (requires `TokenGenerator` authentication - see above section).
|
|
337
282
|
|
|
338
283
|
```python
|
|
339
284
|
from datetime import timedelta
|
|
@@ -356,8 +301,8 @@ Pre-signed URLS currently only support `GET` and `HEAD` (a pre-signed URL for
|
|
|
356
301
|
`GET` authorizes `HEAD` too, and viceversa), so for uploads you should use
|
|
357
302
|
scoped JWTs, as described above.
|
|
358
303
|
|
|
359
|
-
`Session.presigned_object_url` signs the URL with the session's
|
|
360
|
-
so it requires a `
|
|
304
|
+
`Session.presigned_object_url` signs the URL with the `SecretKey` of the session's
|
|
305
|
+
`TokenGenerator`, so it requires a `TokenGenerator` and raises `ValueError` otherwise.
|
|
361
306
|
Only `GET` and `HEAD` may be pre-signed, the granted permissions are those
|
|
362
307
|
configured server-side for the signing key, and the
|
|
363
308
|
validity may not exceed one week.
|
|
@@ -373,6 +318,22 @@ with urllib.request.urlopen(url) as resp:
|
|
|
373
318
|
content = resp.read()
|
|
374
319
|
```
|
|
375
320
|
|
|
321
|
+
### Download response options
|
|
322
|
+
|
|
323
|
+
Both `Session.object_url` and `Session.presigned_object_url` accept a
|
|
324
|
+
`response_content_disposition` option:
|
|
325
|
+
|
|
326
|
+
```python
|
|
327
|
+
url = session.presigned_object_url(
|
|
328
|
+
"GET",
|
|
329
|
+
"my-key",
|
|
330
|
+
response_content_disposition="inline",
|
|
331
|
+
)
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
Disposition accepts `inline` or `attachment`, defaulting to `attachment` when omitted.
|
|
335
|
+
The stored filename is retained.
|
|
336
|
+
|
|
376
337
|
## Configuration
|
|
377
338
|
|
|
378
339
|
In production, store the `Client` and `Usecase` at module level and reuse them.
|
|
@@ -383,7 +344,6 @@ from objectstore_client import Client, Usecase
|
|
|
383
344
|
|
|
384
345
|
client = Client(
|
|
385
346
|
"http://localhost:8888",
|
|
386
|
-
propagate_traces=False, # default
|
|
387
347
|
retries=3, # default: 3 connect retries, no read retries
|
|
388
348
|
timeout_ms=None, # default: no read timeout (connect: 100ms)
|
|
389
349
|
connection_kwargs={}, # default: empty (override urllib3.HTTPConnectionPool kwargs)
|
|
@@ -92,9 +92,13 @@ session.put(b"payload", origin="203.0.113.42")
|
|
|
92
92
|
### Compression
|
|
93
93
|
|
|
94
94
|
Uploads are compressed with Zstd by default, and downloads are transparently decompressed.
|
|
95
|
-
|
|
95
|
+
The supported algorithms are `"zstd"` and `"gzip"`, or `"none"` to disable compression.
|
|
96
|
+
Compression can be set per-usecase or overridden per-upload:
|
|
96
97
|
|
|
97
98
|
```python
|
|
99
|
+
# compress all uploads of this usecase with zstd (the default):
|
|
100
|
+
usecase = Usecase("my-usecase", compression="zstd")
|
|
101
|
+
|
|
98
102
|
# upload as-is and record no encoding:
|
|
99
103
|
session.put(video_data, compress="none")
|
|
100
104
|
|
|
@@ -116,95 +120,36 @@ Arbitrary key-value pairs can be attached to objects and retrieved on download.
|
|
|
116
120
|
session.put(b"payload", metadata={"source": "upload-service"})
|
|
117
121
|
```
|
|
118
122
|
|
|
119
|
-
### Multipart Upload API
|
|
120
|
-
|
|
121
|
-
For large objects, use multipart uploads to upload parts independently and then
|
|
122
|
-
assemble them into a final object.
|
|
123
|
-
|
|
124
|
-
**Important:** unlike single-object uploads, multipart uploads do **not** auto-compress.
|
|
125
|
-
`compression` on `initiate_multipart_upload` behaves like `precompressed` above: it only
|
|
126
|
-
records the algorithm, and the caller must pre-compress each part accordingly.
|
|
127
|
-
|
|
128
|
-
```python
|
|
129
|
-
from concurrent.futures import ThreadPoolExecutor
|
|
130
|
-
|
|
131
|
-
import zstandard
|
|
132
|
-
|
|
133
|
-
from objectstore_client.multipart import MultipartCompleteError
|
|
134
|
-
|
|
135
|
-
upload = session.initiate_multipart_upload(
|
|
136
|
-
key="my-large-object",
|
|
137
|
-
compression="zstd",
|
|
138
|
-
metadata={"source": "upload-service"},
|
|
139
|
-
)
|
|
140
|
-
|
|
141
|
-
compressor = zstandard.ZstdCompressor()
|
|
142
|
-
chunks = [b"part1", b"part2", b"part3", b"part4"]
|
|
143
|
-
|
|
144
|
-
def upload_part(part_number: int, data: bytes):
|
|
145
|
-
compressed = compressor.compress(data)
|
|
146
|
-
return upload.put_part(
|
|
147
|
-
compressed, part_number=part_number, content_length=len(compressed)
|
|
148
|
-
)
|
|
149
|
-
|
|
150
|
-
with ThreadPoolExecutor(max_workers=4) as executor:
|
|
151
|
-
futures = [
|
|
152
|
-
executor.submit(upload_part, i + 1, chunk)
|
|
153
|
-
for i, chunk in enumerate(chunks)
|
|
154
|
-
]
|
|
155
|
-
parts = [f.result() for f in futures]
|
|
156
|
-
|
|
157
|
-
try:
|
|
158
|
-
key = upload.complete(parts)
|
|
159
|
-
except MultipartCompleteError:
|
|
160
|
-
upload.abort()
|
|
161
|
-
raise
|
|
162
|
-
```
|
|
163
|
-
|
|
164
|
-
To resume an in-progress multipart upload after a process restart, persist the
|
|
165
|
-
`key` and `upload_id`, then reconstruct the upload handle later:
|
|
166
|
-
|
|
167
|
-
```python
|
|
168
|
-
saved_key = upload.key
|
|
169
|
-
saved_upload_id = upload.upload_id
|
|
170
|
-
|
|
171
|
-
resumed = session.resume_multipart_upload(saved_key, saved_upload_id)
|
|
172
|
-
existing_parts = resumed.list_parts()
|
|
173
|
-
|
|
174
|
-
# Upload missing parts...
|
|
175
|
-
|
|
176
|
-
key = resumed.complete(new_parts + existing_parts)
|
|
177
|
-
```
|
|
178
|
-
|
|
179
123
|
### Authentication
|
|
180
124
|
|
|
181
125
|
If your Objectstore instance enforces authorization, you must configure authentication
|
|
182
126
|
via the `token` parameter on `Client`. It accepts either:
|
|
183
127
|
|
|
184
|
-
- A **`
|
|
185
|
-
|
|
186
|
-
and scope being accessed
|
|
128
|
+
- A **`TokenGenerator`** — for internal services that have access to an EdDSA keypair.
|
|
129
|
+
It wraps a `SecretKey` and signs a fresh JWT for each request, scoped to the
|
|
130
|
+
specific usecase and scope being accessed. Its key can also sign pre-signed URLs.
|
|
131
|
+
The generator's `expiry_seconds` and `permissions` are the defaults for every
|
|
132
|
+
token it signs; per-token overrides can only narrow them.
|
|
187
133
|
- A **`str`** — a pre-signed JWT, used as-is for every request.
|
|
188
134
|
Use this for external services that receive a token from another source.
|
|
189
135
|
|
|
190
136
|
```python
|
|
191
137
|
from objectstore_client import Client, Usecase
|
|
192
|
-
from objectstore_client.auth import SecretKey
|
|
138
|
+
from objectstore_client.auth import SecretKey, TokenGenerator
|
|
139
|
+
|
|
140
|
+
key = SecretKey(kid="my-service", secret_key="<private key>")
|
|
193
141
|
|
|
194
142
|
# Option 1: Internal service with a keypair
|
|
195
|
-
client = Client(
|
|
196
|
-
"http://localhost:8888",
|
|
197
|
-
token=SecretKey(kid="my-service", secret_key="<private key>"),
|
|
198
|
-
)
|
|
143
|
+
client = Client("http://localhost:8888", token=TokenGenerator(key))
|
|
199
144
|
|
|
200
145
|
# Option 2: External service with a pre-signed JWT
|
|
201
|
-
# Use
|
|
146
|
+
# Use TokenGenerator.token_for_scope() to obtain a static token from an
|
|
202
147
|
# internal service, then pass it to the external consumer:
|
|
203
148
|
from objectstore_client.scope import Scope
|
|
204
149
|
|
|
205
|
-
token =
|
|
206
|
-
|
|
207
|
-
)
|
|
150
|
+
token = TokenGenerator(key, expiry_seconds=300).token_for_scope(
|
|
151
|
+
"my_app", Scope(org=42, project=1337)
|
|
152
|
+
)
|
|
208
153
|
|
|
209
154
|
client = Client("http://localhost:8888", token=token)
|
|
210
155
|
```
|
|
@@ -214,7 +159,7 @@ client = Client("http://localhost:8888", token=token)
|
|
|
214
159
|
`Session.object_url` returns a GET URL for an object. By default the URL carries
|
|
215
160
|
no auth, so the recipient needs their own key to generate a token for it. Pass
|
|
216
161
|
`token_validity` to embed a read-only token in the URL instead, valid for the
|
|
217
|
-
given duration (requires `
|
|
162
|
+
given duration (requires `TokenGenerator` authentication - see above section).
|
|
218
163
|
|
|
219
164
|
```python
|
|
220
165
|
from datetime import timedelta
|
|
@@ -237,8 +182,8 @@ Pre-signed URLS currently only support `GET` and `HEAD` (a pre-signed URL for
|
|
|
237
182
|
`GET` authorizes `HEAD` too, and viceversa), so for uploads you should use
|
|
238
183
|
scoped JWTs, as described above.
|
|
239
184
|
|
|
240
|
-
`Session.presigned_object_url` signs the URL with the session's
|
|
241
|
-
so it requires a `
|
|
185
|
+
`Session.presigned_object_url` signs the URL with the `SecretKey` of the session's
|
|
186
|
+
`TokenGenerator`, so it requires a `TokenGenerator` and raises `ValueError` otherwise.
|
|
242
187
|
Only `GET` and `HEAD` may be pre-signed, the granted permissions are those
|
|
243
188
|
configured server-side for the signing key, and the
|
|
244
189
|
validity may not exceed one week.
|
|
@@ -254,6 +199,22 @@ with urllib.request.urlopen(url) as resp:
|
|
|
254
199
|
content = resp.read()
|
|
255
200
|
```
|
|
256
201
|
|
|
202
|
+
### Download response options
|
|
203
|
+
|
|
204
|
+
Both `Session.object_url` and `Session.presigned_object_url` accept a
|
|
205
|
+
`response_content_disposition` option:
|
|
206
|
+
|
|
207
|
+
```python
|
|
208
|
+
url = session.presigned_object_url(
|
|
209
|
+
"GET",
|
|
210
|
+
"my-key",
|
|
211
|
+
response_content_disposition="inline",
|
|
212
|
+
)
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
Disposition accepts `inline` or `attachment`, defaulting to `attachment` when omitted.
|
|
216
|
+
The stored filename is retained.
|
|
217
|
+
|
|
257
218
|
## Configuration
|
|
258
219
|
|
|
259
220
|
In production, store the `Client` and `Usecase` at module level and reuse them.
|
|
@@ -264,7 +225,6 @@ from objectstore_client import Client, Usecase
|
|
|
264
225
|
|
|
265
226
|
client = Client(
|
|
266
227
|
"http://localhost:8888",
|
|
267
|
-
propagate_traces=False, # default
|
|
268
228
|
retries=3, # default: 3 connect retries, no read retries
|
|
269
229
|
timeout_ms=None, # default: no read timeout (connect: 100ms)
|
|
270
230
|
connection_kwargs={}, # default: empty (override urllib3.HTTPConnectionPool kwargs)
|
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
from objectstore_client.auth import
|
|
1
|
+
from objectstore_client.auth import (
|
|
2
|
+
Permission,
|
|
3
|
+
SecretKey,
|
|
4
|
+
Token,
|
|
5
|
+
TokenGenerator,
|
|
6
|
+
TokenProvider,
|
|
7
|
+
)
|
|
2
8
|
from objectstore_client.client import (
|
|
3
9
|
Client,
|
|
4
10
|
GetResponse,
|
|
@@ -35,6 +41,7 @@ __all__ = [
|
|
|
35
41
|
"TimeToIdle",
|
|
36
42
|
"TimeToLive",
|
|
37
43
|
"TokenProvider",
|
|
44
|
+
"Token",
|
|
38
45
|
"SecretKey",
|
|
39
46
|
"TokenGenerator",
|
|
40
47
|
"MetricsBackend",
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""Streaming compression and decompression for the supported algorithms."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import gzip
|
|
6
|
+
import io
|
|
7
|
+
import shutil
|
|
8
|
+
import zlib
|
|
9
|
+
from typing import IO, Protocol, cast
|
|
10
|
+
|
|
11
|
+
import zstandard
|
|
12
|
+
|
|
13
|
+
from objectstore_client.metadata import Compression
|
|
14
|
+
|
|
15
|
+
SUPPORTED_COMPRESSIONS: tuple[Compression, ...] = ("zstd", "gzip")
|
|
16
|
+
"""Compression algorithms that the client can apply and transparently decode."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class _Compressor(Protocol):
|
|
20
|
+
"""A streaming compression object, such as `zlib.compressobj()`."""
|
|
21
|
+
|
|
22
|
+
def compress(self, data: bytes, /) -> bytes: ...
|
|
23
|
+
|
|
24
|
+
def flush(self) -> bytes: ...
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class _CompressionReader(io.RawIOBase):
|
|
28
|
+
"""
|
|
29
|
+
A readable stream that compresses `source` with `compressor` as it is read.
|
|
30
|
+
|
|
31
|
+
`tell()` reports the number of compressed bytes produced so far. `seek(0)` is
|
|
32
|
+
allowed as long as no data has been read yet, so that urllib3 can record the body
|
|
33
|
+
position. Closing the reader does not close `source`.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
_CHUNK_SIZE = 64 * 1024
|
|
37
|
+
|
|
38
|
+
def __init__(self, source: IO[bytes], compressor: _Compressor) -> None:
|
|
39
|
+
super().__init__()
|
|
40
|
+
self._source = source
|
|
41
|
+
self._compressor = compressor
|
|
42
|
+
self._buffer = bytearray()
|
|
43
|
+
self._finished = False
|
|
44
|
+
self._position = 0
|
|
45
|
+
|
|
46
|
+
def readable(self) -> bool:
|
|
47
|
+
return True
|
|
48
|
+
|
|
49
|
+
def read(self, size: int | None = -1, /) -> bytes:
|
|
50
|
+
if size is None or size < 0:
|
|
51
|
+
size = -1
|
|
52
|
+
while not self._finished and (size < 0 or len(self._buffer) < size):
|
|
53
|
+
if chunk := self._source.read(self._CHUNK_SIZE):
|
|
54
|
+
self._buffer += self._compressor.compress(chunk)
|
|
55
|
+
else:
|
|
56
|
+
self._buffer += self._compressor.flush()
|
|
57
|
+
self._finished = True
|
|
58
|
+
|
|
59
|
+
if size < 0:
|
|
60
|
+
size = len(self._buffer)
|
|
61
|
+
out = bytes(self._buffer[:size])
|
|
62
|
+
del self._buffer[:size]
|
|
63
|
+
self._position += len(out)
|
|
64
|
+
return out
|
|
65
|
+
|
|
66
|
+
def readall(self) -> bytes:
|
|
67
|
+
return self.read(-1)
|
|
68
|
+
|
|
69
|
+
def readinto(self, buffer: memoryview | bytearray) -> int: # type: ignore[override]
|
|
70
|
+
data = self.read(len(buffer))
|
|
71
|
+
buffer[: len(data)] = data
|
|
72
|
+
return len(data)
|
|
73
|
+
|
|
74
|
+
def tell(self) -> int:
|
|
75
|
+
return self._position
|
|
76
|
+
|
|
77
|
+
def seek(self, offset: int, whence: int = io.SEEK_SET, /) -> int:
|
|
78
|
+
if offset == 0 and whence == io.SEEK_SET and self._position == 0:
|
|
79
|
+
return 0
|
|
80
|
+
raise OSError("Cannot seek in a compressed stream after reading has started")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class _GzipReader(gzip.GzipFile):
|
|
84
|
+
"""Decode gzip from a stream, closing the source with the reader."""
|
|
85
|
+
|
|
86
|
+
def __init__(self, source: IO[bytes]) -> None:
|
|
87
|
+
super().__init__(fileobj=source, mode="rb")
|
|
88
|
+
self._source = source
|
|
89
|
+
|
|
90
|
+
def seekable(self) -> bool:
|
|
91
|
+
return False
|
|
92
|
+
|
|
93
|
+
def seek(self, offset: int, whence: int = io.SEEK_SET) -> int:
|
|
94
|
+
raise io.UnsupportedOperation("gzip download stream is not seekable")
|
|
95
|
+
|
|
96
|
+
def close(self) -> None:
|
|
97
|
+
if self.closed:
|
|
98
|
+
return
|
|
99
|
+
try:
|
|
100
|
+
super().close()
|
|
101
|
+
finally:
|
|
102
|
+
self._source.close()
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _gzip_compressor() -> _Compressor:
|
|
106
|
+
"""Return a zlib compressor that emits the gzip format.
|
|
107
|
+
|
|
108
|
+
By default, zlib wraps deflate data in the zlib format. Adding 16 to the window
|
|
109
|
+
size (`wbits`) selects the gzip header and trailer instead, which is what
|
|
110
|
+
`Content-Encoding: gzip` requires. See `zlib.compressobj`.
|
|
111
|
+
"""
|
|
112
|
+
return zlib.compressobj(wbits=16 + zlib.MAX_WBITS)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def validate(compression: str | None, *, allow_none: bool = True) -> None:
|
|
116
|
+
"""Raise `ValueError` if `compression` is not a supported algorithm.
|
|
117
|
+
|
|
118
|
+
`None` is always accepted. `"none"` is accepted only if `allow_none` is set.
|
|
119
|
+
"""
|
|
120
|
+
if compression is None or (allow_none and compression == "none"):
|
|
121
|
+
return
|
|
122
|
+
if compression not in SUPPORTED_COMPRESSIONS:
|
|
123
|
+
raise ValueError(f"Invalid compression: {compression}")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def compressing_reader(source: IO[bytes], compression: Compression) -> IO[bytes]:
|
|
127
|
+
"""Return a readable stream yielding `source` compressed with `compression`.
|
|
128
|
+
|
|
129
|
+
The returned stream compresses lazily as it is read, reports the number of
|
|
130
|
+
compressed bytes produced via `tell()`, and supports `seek(0)` only until
|
|
131
|
+
reading starts. Closing it does not close `source`.
|
|
132
|
+
"""
|
|
133
|
+
if compression == "zstd":
|
|
134
|
+
compressor = zstandard.ZstdCompressor().compressobj()
|
|
135
|
+
return cast(IO[bytes], _CompressionReader(source, compressor))
|
|
136
|
+
if compression == "gzip":
|
|
137
|
+
return cast(IO[bytes], _CompressionReader(source, _gzip_compressor()))
|
|
138
|
+
raise ValueError(f"Invalid compression: {compression}")
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def compress_into(
|
|
142
|
+
source: IO[bytes], dest: IO[bytes], compression: Compression
|
|
143
|
+
) -> tuple[int, int]:
|
|
144
|
+
"""Compress `source` into `dest`, returning the bytes read and written.
|
|
145
|
+
|
|
146
|
+
For gzip, `dest` must support `tell()` to measure the compressed size.
|
|
147
|
+
"""
|
|
148
|
+
if compression == "zstd":
|
|
149
|
+
read, written = zstandard.ZstdCompressor().copy_stream(source, dest)
|
|
150
|
+
return read, written
|
|
151
|
+
if compression == "gzip":
|
|
152
|
+
start = dest.tell()
|
|
153
|
+
with gzip.GzipFile(
|
|
154
|
+
filename="", fileobj=dest, mode="wb", compresslevel=6, mtime=0
|
|
155
|
+
) as writer:
|
|
156
|
+
shutil.copyfileobj(source, writer)
|
|
157
|
+
read = writer.tell()
|
|
158
|
+
return read, dest.tell() - start
|
|
159
|
+
raise ValueError(f"Invalid compression: {compression}")
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def decompressing_reader(stream: IO[bytes], compression: str) -> IO[bytes]:
|
|
163
|
+
"""Return a readable stream decoding `stream` compressed with `compression`.
|
|
164
|
+
|
|
165
|
+
Concatenated zstd frames and gzip members are decoded as a single payload.
|
|
166
|
+
The returned reader is non-seekable and closes the source when closed.
|
|
167
|
+
"""
|
|
168
|
+
if compression == "zstd":
|
|
169
|
+
dctx = zstandard.ZstdDecompressor()
|
|
170
|
+
return cast(IO[bytes], dctx.stream_reader(stream, read_across_frames=True))
|
|
171
|
+
if compression == "gzip":
|
|
172
|
+
return cast(IO[bytes], _GzipReader(stream))
|
|
173
|
+
raise NotImplementedError(
|
|
174
|
+
f"Transparent decoding of `{compression}` is not implemented"
|
|
175
|
+
)
|
|
@@ -12,8 +12,8 @@ from typing import IO, TYPE_CHECKING
|
|
|
12
12
|
from urllib.parse import urlencode
|
|
13
13
|
|
|
14
14
|
import urllib3
|
|
15
|
-
import zstandard
|
|
16
15
|
|
|
16
|
+
from objectstore_client import _compression
|
|
17
17
|
from objectstore_client.errors import RequestError, raise_for_status
|
|
18
18
|
from objectstore_client.metadata import Compression, ExpirationPolicy
|
|
19
19
|
from objectstore_client.metrics import measure_storage_operation
|
|
@@ -236,7 +236,7 @@ class ResumableUpload:
|
|
|
236
236
|
def prepare_body(
|
|
237
237
|
contents: bytes | IO[bytes],
|
|
238
238
|
source_size: int,
|
|
239
|
-
|
|
239
|
+
compression: Compression | None,
|
|
240
240
|
spool_dir: str | PathLike[str] | None,
|
|
241
241
|
) -> Iterator[tuple[IO[bytes], int, int] | None]:
|
|
242
242
|
"""Yield the body, source size, and compressed size (equal if uncompressed).
|
|
@@ -248,7 +248,7 @@ def prepare_body(
|
|
|
248
248
|
with (
|
|
249
249
|
BytesIO(contents) if isinstance(contents, bytes) else nullcontext(contents)
|
|
250
250
|
) as source:
|
|
251
|
-
if
|
|
251
|
+
if compression is None or compression == "none":
|
|
252
252
|
yield source, source_size, source_size
|
|
253
253
|
return
|
|
254
254
|
|
|
@@ -259,8 +259,8 @@ def prepare_body(
|
|
|
259
259
|
return
|
|
260
260
|
|
|
261
261
|
with temporary as body:
|
|
262
|
-
source_size, compressed_size =
|
|
263
|
-
source, body
|
|
262
|
+
source_size, compressed_size = _compression.compress_into(
|
|
263
|
+
source, body, compression
|
|
264
264
|
)
|
|
265
265
|
body.seek(0)
|
|
266
266
|
yield body, source_size, compressed_size
|