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.
Files changed (18) hide show
  1. {objectstore_client-0.2.9 → objectstore_client-0.3.0}/PKG-INFO +46 -78
  2. {objectstore_client-0.2.9 → objectstore_client-0.3.0}/README.md +45 -77
  3. {objectstore_client-0.2.9 → objectstore_client-0.3.0}/pyproject.toml +1 -1
  4. {objectstore_client-0.2.9 → 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.3.0/src/objectstore_client/_resumable.py +385 -0
  7. {objectstore_client-0.2.9 → objectstore_client-0.3.0}/src/objectstore_client/auth.py +51 -37
  8. {objectstore_client-0.2.9 → objectstore_client-0.3.0}/src/objectstore_client/client.py +308 -206
  9. {objectstore_client-0.2.9 → objectstore_client-0.3.0}/src/objectstore_client/errors.py +1 -1
  10. {objectstore_client-0.2.9 → objectstore_client-0.3.0}/src/objectstore_client/metadata.py +1 -1
  11. {objectstore_client-0.2.9 → objectstore_client-0.3.0}/src/objectstore_client/utils.py +1 -28
  12. objectstore_client-0.2.9/src/objectstore_client/multipart.py +0 -284
  13. {objectstore_client-0.2.9 → objectstore_client-0.3.0}/LICENSE.md +0 -0
  14. {objectstore_client-0.2.9 → objectstore_client-0.3.0}/src/objectstore_client/metrics.py +0 -0
  15. {objectstore_client-0.2.9 → objectstore_client-0.3.0}/src/objectstore_client/presign.py +0 -0
  16. {objectstore_client-0.2.9 → objectstore_client-0.3.0}/src/objectstore_client/py.typed +0 -0
  17. {objectstore_client-0.2.9 → objectstore_client-0.3.0}/src/objectstore_client/scope.py +0 -0
  18. {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.2.9
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
- 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:
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 **`SecretKey`** — for internal services that have access to an EdDSA keypair.
296
- The key signs a fresh JWT for each request, scoped to the specific usecase
297
- 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.
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 SecretKey.token_for_scope() to obtain a static token from an
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 = SecretKey(
317
- kid="my-service", secret_key="<private key>",
318
- ).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
+ )
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 `SecretKey` authentication - see above section).
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 `SecretKey`,
352
- 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.
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
- 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:
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 **`SecretKey`** — for internal services that have access to an EdDSA keypair.
177
- The key signs a fresh JWT for each request, scoped to the specific usecase
178
- 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.
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 SecretKey.token_for_scope() to obtain a static token from an
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 = SecretKey(
198
- kid="my-service", secret_key="<private key>",
199
- ).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
+ )
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 `SecretKey` authentication - see above section).
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 `SecretKey`,
233
- 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.
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,6 +1,6 @@
1
1
  [project]
2
2
  name = "objectstore-client"
3
- version = "0.2.9"
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
+ )