objectstore-client 0.2.9__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.9 → objectstore_client-0.3.0}/PKG-INFO +46 -78
- {objectstore_client-0.2.9 → objectstore_client-0.3.0}/README.md +45 -77
- {objectstore_client-0.2.9 → objectstore_client-0.3.0}/pyproject.toml +1 -1
- {objectstore_client-0.2.9 → 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.3.0/src/objectstore_client/_resumable.py +385 -0
- {objectstore_client-0.2.9 → objectstore_client-0.3.0}/src/objectstore_client/auth.py +51 -37
- {objectstore_client-0.2.9 → objectstore_client-0.3.0}/src/objectstore_client/client.py +308 -206
- {objectstore_client-0.2.9 → objectstore_client-0.3.0}/src/objectstore_client/errors.py +1 -1
- {objectstore_client-0.2.9 → objectstore_client-0.3.0}/src/objectstore_client/metadata.py +1 -1
- {objectstore_client-0.2.9 → objectstore_client-0.3.0}/src/objectstore_client/utils.py +1 -28
- objectstore_client-0.2.9/src/objectstore_client/multipart.py +0 -284
- {objectstore_client-0.2.9 → objectstore_client-0.3.0}/LICENSE.md +0 -0
- {objectstore_client-0.2.9 → objectstore_client-0.3.0}/src/objectstore_client/metrics.py +0 -0
- {objectstore_client-0.2.9 → objectstore_client-0.3.0}/src/objectstore_client/presign.py +0 -0
- {objectstore_client-0.2.9 → objectstore_client-0.3.0}/src/objectstore_client/py.typed +0 -0
- {objectstore_client-0.2.9 → objectstore_client-0.3.0}/src/objectstore_client/scope.py +0 -0
- {objectstore_client-0.2.9 → 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>
|
|
@@ -147,6 +147,8 @@ session.delete(key)
|
|
|
147
147
|
### Usecases and Scopes
|
|
148
148
|
|
|
149
149
|
A `Usecase` represents a server-side namespace with its own configuration defaults.
|
|
150
|
+
Its name must follow the naming rules documented on [`Usecase`]; invalid names
|
|
151
|
+
raise `ValueError` during construction.
|
|
150
152
|
Within a Usecase, Scopes provide further isolation — typically keyed by organization
|
|
151
153
|
and project IDs. A Session ties a Client to a specific Usecase + Scope for operations.
|
|
152
154
|
|
|
@@ -209,9 +211,13 @@ session.put(b"payload", origin="203.0.113.42")
|
|
|
209
211
|
### Compression
|
|
210
212
|
|
|
211
213
|
Uploads are compressed with Zstd by default, and downloads are transparently decompressed.
|
|
212
|
-
|
|
214
|
+
The supported algorithms are `"zstd"` and `"gzip"`, or `"none"` to disable compression.
|
|
215
|
+
Compression can be set per-usecase or overridden per-upload:
|
|
213
216
|
|
|
214
217
|
```python
|
|
218
|
+
# compress all uploads of this usecase with zstd (the default):
|
|
219
|
+
usecase = Usecase("my-usecase", compression="zstd")
|
|
220
|
+
|
|
215
221
|
# upload as-is and record no encoding:
|
|
216
222
|
session.put(video_data, compress="none")
|
|
217
223
|
|
|
@@ -219,6 +225,12 @@ session.put(video_data, compress="none")
|
|
|
219
225
|
session.put(zstd_data, precompressed="zstd")
|
|
220
226
|
```
|
|
221
227
|
|
|
228
|
+
### Resumable Uploads
|
|
229
|
+
|
|
230
|
+
The client automatically uses resumable uploads when eligible and may create
|
|
231
|
+
temporary files for spooling. See [`Session.put()`](src/objectstore_client/client.py)
|
|
232
|
+
for eligibility, configuration, and upload behavior.
|
|
233
|
+
|
|
222
234
|
### Custom Metadata
|
|
223
235
|
|
|
224
236
|
Arbitrary key-value pairs can be attached to objects and retrieved on download.
|
|
@@ -227,95 +239,36 @@ Arbitrary key-value pairs can be attached to objects and retrieved on download.
|
|
|
227
239
|
session.put(b"payload", metadata={"source": "upload-service"})
|
|
228
240
|
```
|
|
229
241
|
|
|
230
|
-
### Multipart Upload API
|
|
231
|
-
|
|
232
|
-
For large objects, use multipart uploads to upload parts independently and then
|
|
233
|
-
assemble them into a final object.
|
|
234
|
-
|
|
235
|
-
**Important:** unlike single-object uploads, multipart uploads do **not** auto-compress.
|
|
236
|
-
`compression` on `initiate_multipart_upload` behaves like `precompressed` above: it only
|
|
237
|
-
records the algorithm, and the caller must pre-compress each part accordingly.
|
|
238
|
-
|
|
239
|
-
```python
|
|
240
|
-
from concurrent.futures import ThreadPoolExecutor
|
|
241
|
-
|
|
242
|
-
import zstandard
|
|
243
|
-
|
|
244
|
-
from objectstore_client.multipart import MultipartCompleteError
|
|
245
|
-
|
|
246
|
-
upload = session.initiate_multipart_upload(
|
|
247
|
-
key="my-large-object",
|
|
248
|
-
compression="zstd",
|
|
249
|
-
metadata={"source": "upload-service"},
|
|
250
|
-
)
|
|
251
|
-
|
|
252
|
-
compressor = zstandard.ZstdCompressor()
|
|
253
|
-
chunks = [b"part1", b"part2", b"part3", b"part4"]
|
|
254
|
-
|
|
255
|
-
def upload_part(part_number: int, data: bytes):
|
|
256
|
-
compressed = compressor.compress(data)
|
|
257
|
-
return upload.put_part(
|
|
258
|
-
compressed, part_number=part_number, content_length=len(compressed)
|
|
259
|
-
)
|
|
260
|
-
|
|
261
|
-
with ThreadPoolExecutor(max_workers=4) as executor:
|
|
262
|
-
futures = [
|
|
263
|
-
executor.submit(upload_part, i + 1, chunk)
|
|
264
|
-
for i, chunk in enumerate(chunks)
|
|
265
|
-
]
|
|
266
|
-
parts = [f.result() for f in futures]
|
|
267
|
-
|
|
268
|
-
try:
|
|
269
|
-
key = upload.complete(parts)
|
|
270
|
-
except MultipartCompleteError:
|
|
271
|
-
upload.abort()
|
|
272
|
-
raise
|
|
273
|
-
```
|
|
274
|
-
|
|
275
|
-
To resume an in-progress multipart upload after a process restart, persist the
|
|
276
|
-
`key` and `upload_id`, then reconstruct the upload handle later:
|
|
277
|
-
|
|
278
|
-
```python
|
|
279
|
-
saved_key = upload.key
|
|
280
|
-
saved_upload_id = upload.upload_id
|
|
281
|
-
|
|
282
|
-
resumed = session.resume_multipart_upload(saved_key, saved_upload_id)
|
|
283
|
-
existing_parts = resumed.list_parts()
|
|
284
|
-
|
|
285
|
-
# Upload missing parts...
|
|
286
|
-
|
|
287
|
-
key = resumed.complete(new_parts + existing_parts)
|
|
288
|
-
```
|
|
289
|
-
|
|
290
242
|
### Authentication
|
|
291
243
|
|
|
292
244
|
If your Objectstore instance enforces authorization, you must configure authentication
|
|
293
245
|
via the `token` parameter on `Client`. It accepts either:
|
|
294
246
|
|
|
295
|
-
- A **`
|
|
296
|
-
|
|
297
|
-
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.
|
|
298
252
|
- A **`str`** — a pre-signed JWT, used as-is for every request.
|
|
299
253
|
Use this for external services that receive a token from another source.
|
|
300
254
|
|
|
301
255
|
```python
|
|
302
256
|
from objectstore_client import Client, Usecase
|
|
303
|
-
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>")
|
|
304
260
|
|
|
305
261
|
# Option 1: Internal service with a keypair
|
|
306
|
-
client = Client(
|
|
307
|
-
"http://localhost:8888",
|
|
308
|
-
token=SecretKey(kid="my-service", secret_key="<private key>"),
|
|
309
|
-
)
|
|
262
|
+
client = Client("http://localhost:8888", token=TokenGenerator(key))
|
|
310
263
|
|
|
311
264
|
# Option 2: External service with a pre-signed JWT
|
|
312
|
-
# Use
|
|
265
|
+
# Use TokenGenerator.token_for_scope() to obtain a static token from an
|
|
313
266
|
# internal service, then pass it to the external consumer:
|
|
314
267
|
from objectstore_client.scope import Scope
|
|
315
268
|
|
|
316
|
-
token =
|
|
317
|
-
|
|
318
|
-
)
|
|
269
|
+
token = TokenGenerator(key, expiry_seconds=300).token_for_scope(
|
|
270
|
+
"my_app", Scope(org=42, project=1337)
|
|
271
|
+
)
|
|
319
272
|
|
|
320
273
|
client = Client("http://localhost:8888", token=token)
|
|
321
274
|
```
|
|
@@ -325,7 +278,7 @@ client = Client("http://localhost:8888", token=token)
|
|
|
325
278
|
`Session.object_url` returns a GET URL for an object. By default the URL carries
|
|
326
279
|
no auth, so the recipient needs their own key to generate a token for it. Pass
|
|
327
280
|
`token_validity` to embed a read-only token in the URL instead, valid for the
|
|
328
|
-
given duration (requires `
|
|
281
|
+
given duration (requires `TokenGenerator` authentication - see above section).
|
|
329
282
|
|
|
330
283
|
```python
|
|
331
284
|
from datetime import timedelta
|
|
@@ -348,8 +301,8 @@ Pre-signed URLS currently only support `GET` and `HEAD` (a pre-signed URL for
|
|
|
348
301
|
`GET` authorizes `HEAD` too, and viceversa), so for uploads you should use
|
|
349
302
|
scoped JWTs, as described above.
|
|
350
303
|
|
|
351
|
-
`Session.presigned_object_url` signs the URL with the session's
|
|
352
|
-
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.
|
|
353
306
|
Only `GET` and `HEAD` may be pre-signed, the granted permissions are those
|
|
354
307
|
configured server-side for the signing key, and the
|
|
355
308
|
validity may not exceed one week.
|
|
@@ -365,6 +318,22 @@ with urllib.request.urlopen(url) as resp:
|
|
|
365
318
|
content = resp.read()
|
|
366
319
|
```
|
|
367
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
|
+
|
|
368
337
|
## Configuration
|
|
369
338
|
|
|
370
339
|
In production, store the `Client` and `Usecase` at module level and reuse them.
|
|
@@ -375,7 +344,6 @@ from objectstore_client import Client, Usecase
|
|
|
375
344
|
|
|
376
345
|
client = Client(
|
|
377
346
|
"http://localhost:8888",
|
|
378
|
-
propagate_traces=False, # default
|
|
379
347
|
retries=3, # default: 3 connect retries, no read retries
|
|
380
348
|
timeout_ms=None, # default: no read timeout (connect: 100ms)
|
|
381
349
|
connection_kwargs={}, # default: empty (override urllib3.HTTPConnectionPool kwargs)
|
|
@@ -28,6 +28,8 @@ session.delete(key)
|
|
|
28
28
|
### Usecases and Scopes
|
|
29
29
|
|
|
30
30
|
A `Usecase` represents a server-side namespace with its own configuration defaults.
|
|
31
|
+
Its name must follow the naming rules documented on [`Usecase`]; invalid names
|
|
32
|
+
raise `ValueError` during construction.
|
|
31
33
|
Within a Usecase, Scopes provide further isolation — typically keyed by organization
|
|
32
34
|
and project IDs. A Session ties a Client to a specific Usecase + Scope for operations.
|
|
33
35
|
|
|
@@ -90,9 +92,13 @@ session.put(b"payload", origin="203.0.113.42")
|
|
|
90
92
|
### Compression
|
|
91
93
|
|
|
92
94
|
Uploads are compressed with Zstd by default, and downloads are transparently decompressed.
|
|
93
|
-
|
|
95
|
+
The supported algorithms are `"zstd"` and `"gzip"`, or `"none"` to disable compression.
|
|
96
|
+
Compression can be set per-usecase or overridden per-upload:
|
|
94
97
|
|
|
95
98
|
```python
|
|
99
|
+
# compress all uploads of this usecase with zstd (the default):
|
|
100
|
+
usecase = Usecase("my-usecase", compression="zstd")
|
|
101
|
+
|
|
96
102
|
# upload as-is and record no encoding:
|
|
97
103
|
session.put(video_data, compress="none")
|
|
98
104
|
|
|
@@ -100,6 +106,12 @@ session.put(video_data, compress="none")
|
|
|
100
106
|
session.put(zstd_data, precompressed="zstd")
|
|
101
107
|
```
|
|
102
108
|
|
|
109
|
+
### Resumable Uploads
|
|
110
|
+
|
|
111
|
+
The client automatically uses resumable uploads when eligible and may create
|
|
112
|
+
temporary files for spooling. See [`Session.put()`](src/objectstore_client/client.py)
|
|
113
|
+
for eligibility, configuration, and upload behavior.
|
|
114
|
+
|
|
103
115
|
### Custom Metadata
|
|
104
116
|
|
|
105
117
|
Arbitrary key-value pairs can be attached to objects and retrieved on download.
|
|
@@ -108,95 +120,36 @@ Arbitrary key-value pairs can be attached to objects and retrieved on download.
|
|
|
108
120
|
session.put(b"payload", metadata={"source": "upload-service"})
|
|
109
121
|
```
|
|
110
122
|
|
|
111
|
-
### Multipart Upload API
|
|
112
|
-
|
|
113
|
-
For large objects, use multipart uploads to upload parts independently and then
|
|
114
|
-
assemble them into a final object.
|
|
115
|
-
|
|
116
|
-
**Important:** unlike single-object uploads, multipart uploads do **not** auto-compress.
|
|
117
|
-
`compression` on `initiate_multipart_upload` behaves like `precompressed` above: it only
|
|
118
|
-
records the algorithm, and the caller must pre-compress each part accordingly.
|
|
119
|
-
|
|
120
|
-
```python
|
|
121
|
-
from concurrent.futures import ThreadPoolExecutor
|
|
122
|
-
|
|
123
|
-
import zstandard
|
|
124
|
-
|
|
125
|
-
from objectstore_client.multipart import MultipartCompleteError
|
|
126
|
-
|
|
127
|
-
upload = session.initiate_multipart_upload(
|
|
128
|
-
key="my-large-object",
|
|
129
|
-
compression="zstd",
|
|
130
|
-
metadata={"source": "upload-service"},
|
|
131
|
-
)
|
|
132
|
-
|
|
133
|
-
compressor = zstandard.ZstdCompressor()
|
|
134
|
-
chunks = [b"part1", b"part2", b"part3", b"part4"]
|
|
135
|
-
|
|
136
|
-
def upload_part(part_number: int, data: bytes):
|
|
137
|
-
compressed = compressor.compress(data)
|
|
138
|
-
return upload.put_part(
|
|
139
|
-
compressed, part_number=part_number, content_length=len(compressed)
|
|
140
|
-
)
|
|
141
|
-
|
|
142
|
-
with ThreadPoolExecutor(max_workers=4) as executor:
|
|
143
|
-
futures = [
|
|
144
|
-
executor.submit(upload_part, i + 1, chunk)
|
|
145
|
-
for i, chunk in enumerate(chunks)
|
|
146
|
-
]
|
|
147
|
-
parts = [f.result() for f in futures]
|
|
148
|
-
|
|
149
|
-
try:
|
|
150
|
-
key = upload.complete(parts)
|
|
151
|
-
except MultipartCompleteError:
|
|
152
|
-
upload.abort()
|
|
153
|
-
raise
|
|
154
|
-
```
|
|
155
|
-
|
|
156
|
-
To resume an in-progress multipart upload after a process restart, persist the
|
|
157
|
-
`key` and `upload_id`, then reconstruct the upload handle later:
|
|
158
|
-
|
|
159
|
-
```python
|
|
160
|
-
saved_key = upload.key
|
|
161
|
-
saved_upload_id = upload.upload_id
|
|
162
|
-
|
|
163
|
-
resumed = session.resume_multipart_upload(saved_key, saved_upload_id)
|
|
164
|
-
existing_parts = resumed.list_parts()
|
|
165
|
-
|
|
166
|
-
# Upload missing parts...
|
|
167
|
-
|
|
168
|
-
key = resumed.complete(new_parts + existing_parts)
|
|
169
|
-
```
|
|
170
|
-
|
|
171
123
|
### Authentication
|
|
172
124
|
|
|
173
125
|
If your Objectstore instance enforces authorization, you must configure authentication
|
|
174
126
|
via the `token` parameter on `Client`. It accepts either:
|
|
175
127
|
|
|
176
|
-
- A **`
|
|
177
|
-
|
|
178
|
-
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.
|
|
179
133
|
- A **`str`** — a pre-signed JWT, used as-is for every request.
|
|
180
134
|
Use this for external services that receive a token from another source.
|
|
181
135
|
|
|
182
136
|
```python
|
|
183
137
|
from objectstore_client import Client, Usecase
|
|
184
|
-
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>")
|
|
185
141
|
|
|
186
142
|
# Option 1: Internal service with a keypair
|
|
187
|
-
client = Client(
|
|
188
|
-
"http://localhost:8888",
|
|
189
|
-
token=SecretKey(kid="my-service", secret_key="<private key>"),
|
|
190
|
-
)
|
|
143
|
+
client = Client("http://localhost:8888", token=TokenGenerator(key))
|
|
191
144
|
|
|
192
145
|
# Option 2: External service with a pre-signed JWT
|
|
193
|
-
# Use
|
|
146
|
+
# Use TokenGenerator.token_for_scope() to obtain a static token from an
|
|
194
147
|
# internal service, then pass it to the external consumer:
|
|
195
148
|
from objectstore_client.scope import Scope
|
|
196
149
|
|
|
197
|
-
token =
|
|
198
|
-
|
|
199
|
-
)
|
|
150
|
+
token = TokenGenerator(key, expiry_seconds=300).token_for_scope(
|
|
151
|
+
"my_app", Scope(org=42, project=1337)
|
|
152
|
+
)
|
|
200
153
|
|
|
201
154
|
client = Client("http://localhost:8888", token=token)
|
|
202
155
|
```
|
|
@@ -206,7 +159,7 @@ client = Client("http://localhost:8888", token=token)
|
|
|
206
159
|
`Session.object_url` returns a GET URL for an object. By default the URL carries
|
|
207
160
|
no auth, so the recipient needs their own key to generate a token for it. Pass
|
|
208
161
|
`token_validity` to embed a read-only token in the URL instead, valid for the
|
|
209
|
-
given duration (requires `
|
|
162
|
+
given duration (requires `TokenGenerator` authentication - see above section).
|
|
210
163
|
|
|
211
164
|
```python
|
|
212
165
|
from datetime import timedelta
|
|
@@ -229,8 +182,8 @@ Pre-signed URLS currently only support `GET` and `HEAD` (a pre-signed URL for
|
|
|
229
182
|
`GET` authorizes `HEAD` too, and viceversa), so for uploads you should use
|
|
230
183
|
scoped JWTs, as described above.
|
|
231
184
|
|
|
232
|
-
`Session.presigned_object_url` signs the URL with the session's
|
|
233
|
-
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.
|
|
234
187
|
Only `GET` and `HEAD` may be pre-signed, the granted permissions are those
|
|
235
188
|
configured server-side for the signing key, and the
|
|
236
189
|
validity may not exceed one week.
|
|
@@ -246,6 +199,22 @@ with urllib.request.urlopen(url) as resp:
|
|
|
246
199
|
content = resp.read()
|
|
247
200
|
```
|
|
248
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
|
+
|
|
249
218
|
## Configuration
|
|
250
219
|
|
|
251
220
|
In production, store the `Client` and `Usecase` at module level and reuse them.
|
|
@@ -256,7 +225,6 @@ from objectstore_client import Client, Usecase
|
|
|
256
225
|
|
|
257
226
|
client = Client(
|
|
258
227
|
"http://localhost:8888",
|
|
259
|
-
propagate_traces=False, # default
|
|
260
228
|
retries=3, # default: 3 connect retries, no read retries
|
|
261
229
|
timeout_ms=None, # default: no read timeout (connect: 100ms)
|
|
262
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
|
+
)
|