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.
Files changed (18) hide show
  1. {objectstore_client-0.2.10 → objectstore_client-0.3.0}/PKG-INFO +38 -78
  2. {objectstore_client-0.2.10 → objectstore_client-0.3.0}/README.md +37 -77
  3. {objectstore_client-0.2.10 → objectstore_client-0.3.0}/pyproject.toml +1 -1
  4. {objectstore_client-0.2.10 → objectstore_client-0.3.0}/src/objectstore_client/__init__.py +8 -1
  5. objectstore_client-0.3.0/src/objectstore_client/_compression.py +175 -0
  6. {objectstore_client-0.2.10 → objectstore_client-0.3.0}/src/objectstore_client/_resumable.py +5 -5
  7. {objectstore_client-0.2.10 → objectstore_client-0.3.0}/src/objectstore_client/auth.py +51 -37
  8. {objectstore_client-0.2.10 → objectstore_client-0.3.0}/src/objectstore_client/client.py +66 -175
  9. {objectstore_client-0.2.10 → objectstore_client-0.3.0}/src/objectstore_client/metadata.py +1 -1
  10. {objectstore_client-0.2.10 → objectstore_client-0.3.0}/src/objectstore_client/utils.py +1 -28
  11. objectstore_client-0.2.10/src/objectstore_client/multipart.py +0 -284
  12. {objectstore_client-0.2.10 → objectstore_client-0.3.0}/LICENSE.md +0 -0
  13. {objectstore_client-0.2.10 → objectstore_client-0.3.0}/src/objectstore_client/errors.py +0 -0
  14. {objectstore_client-0.2.10 → objectstore_client-0.3.0}/src/objectstore_client/metrics.py +0 -0
  15. {objectstore_client-0.2.10 → objectstore_client-0.3.0}/src/objectstore_client/presign.py +0 -0
  16. {objectstore_client-0.2.10 → objectstore_client-0.3.0}/src/objectstore_client/py.typed +0 -0
  17. {objectstore_client-0.2.10 → objectstore_client-0.3.0}/src/objectstore_client/scope.py +0 -0
  18. {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.2.10
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
- Compression can be overridden per-upload:
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 **`SecretKey`** — for internal services that have access to an EdDSA keypair.
304
- The key signs a fresh JWT for each request, scoped to the specific usecase
305
- and scope being accessed, and can also sign pre-signed URLs.
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 SecretKey.token_for_scope() to obtain a static token from an
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 = SecretKey(
325
- kid="my-service", secret_key="<private key>",
326
- ).token_for_scope("my_app", Scope(org=42, project=1337))
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 `SecretKey` authentication - see above section).
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 `SecretKey`,
360
- so it requires a `SecretKey` and raises `ValueError` otherwise.
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
- Compression can be overridden per-upload:
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 **`SecretKey`** — for internal services that have access to an EdDSA keypair.
185
- The key signs a fresh JWT for each request, scoped to the specific usecase
186
- and scope being accessed, and can also sign pre-signed URLs.
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 SecretKey.token_for_scope() to obtain a static token from an
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 = SecretKey(
206
- kid="my-service", secret_key="<private key>",
207
- ).token_for_scope("my_app", Scope(org=42, project=1337))
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 `SecretKey` authentication - see above section).
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 `SecretKey`,
241
- so it requires a `SecretKey` and raises `ValueError` otherwise.
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,6 +1,6 @@
1
1
  [project]
2
2
  name = "objectstore-client"
3
- version = "0.2.10"
3
+ version = "0.3.0"
4
4
  description = "Client SDK for Objectstore, the Sentry object storage platform"
5
5
  readme = "README.md"
6
6
  authors = [{ name = "Sentry", email = "oss@sentry.io" }]
@@ -1,4 +1,10 @@
1
- from objectstore_client.auth import Permission, SecretKey, TokenGenerator, TokenProvider
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
- compress: bool,
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 not compress:
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 = zstandard.ZstdCompressor().copy_stream(
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