objectstore-client 0.2.0__tar.gz → 0.2.2__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: objectstore-client
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
4
  Summary: Client SDK for Objectstore, the Sentry object storage platform
5
5
  Author: Sentry
6
6
  Author-email: Sentry <oss@sentry.io>
@@ -195,11 +195,15 @@ session.put(b"payload", origin="203.0.113.42")
195
195
 
196
196
  ### Compression
197
197
 
198
- Uploads are compressed with Zstd by default. Downloads are transparently decompressed.
199
- You can override compression per-upload for pre-compressed or uncompressible data.
198
+ Uploads are compressed with Zstd by default, and downloads are transparently decompressed.
199
+ Compression can be overridden per-upload:
200
200
 
201
201
  ```python
202
- session.put(already_compressed_data, compression="none")
202
+ # upload as-is and record no encoding:
203
+ session.put(video_data, compress="none")
204
+
205
+ # upload as-is, but record the encoding so that downloads still decompress:
206
+ session.put(zstd_data, precompressed="zstd")
203
207
  ```
204
208
 
205
209
  ### Custom Metadata
@@ -216,8 +220,8 @@ For large objects, use multipart uploads to upload parts independently and then
216
220
  assemble them into a final object.
217
221
 
218
222
  **Important:** unlike single-object uploads, multipart uploads do **not** auto-compress.
219
- The caller must pre-compress each part according to the compression set as part of the metadata
220
- when initiating the upload.
223
+ `compression` on `initiate_multipart_upload` behaves like `precompressed` above: it only
224
+ records the algorithm, and the caller must pre-compress each part accordingly.
221
225
 
222
226
  ```python
223
227
  from concurrent.futures import ThreadPoolExecutor
@@ -76,11 +76,15 @@ session.put(b"payload", origin="203.0.113.42")
76
76
 
77
77
  ### Compression
78
78
 
79
- Uploads are compressed with Zstd by default. Downloads are transparently decompressed.
80
- You can override compression per-upload for pre-compressed or uncompressible data.
79
+ Uploads are compressed with Zstd by default, and downloads are transparently decompressed.
80
+ Compression can be overridden per-upload:
81
81
 
82
82
  ```python
83
- session.put(already_compressed_data, compression="none")
83
+ # upload as-is and record no encoding:
84
+ session.put(video_data, compress="none")
85
+
86
+ # upload as-is, but record the encoding so that downloads still decompress:
87
+ session.put(zstd_data, precompressed="zstd")
84
88
  ```
85
89
 
86
90
  ### Custom Metadata
@@ -97,8 +101,8 @@ For large objects, use multipart uploads to upload parts independently and then
97
101
  assemble them into a final object.
98
102
 
99
103
  **Important:** unlike single-object uploads, multipart uploads do **not** auto-compress.
100
- The caller must pre-compress each part according to the compression set as part of the metadata
101
- when initiating the upload.
104
+ `compression` on `initiate_multipart_upload` behaves like `precompressed` above: it only
105
+ records the algorithm, and the caller must pre-compress each part accordingly.
102
106
 
103
107
  ```python
104
108
  from concurrent.futures import ThreadPoolExecutor
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "objectstore-client"
3
- version = "0.2.0"
3
+ version = "0.2.2"
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,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import math
4
+ import warnings
4
5
  from collections.abc import Mapping, Sequence
5
6
  from dataclasses import asdict, dataclass
6
7
  from datetime import UTC, datetime, timedelta
@@ -310,7 +311,9 @@ class Session:
310
311
  self,
311
312
  contents: bytes | IO[bytes],
312
313
  key: str | None = None,
314
+ compress: Compression | None = None,
313
315
  compression: Compression | Literal["none"] | None = None,
316
+ precompressed: Compression | None = None,
314
317
  content_type: str | None = None,
315
318
  metadata: dict[str, str] | None = None,
316
319
  expiration_policy: ExpirationPolicy | None = None,
@@ -324,27 +327,49 @@ class Session:
324
327
  from this function.
325
328
 
326
329
  The client will select the configured `default_compression` if none is given
327
- explicitly.
328
- This can be overridden by explicitly giving a `compression` argument.
330
+ explicitly. This can be overridden by explicitly giving a `compress` argument.
329
331
  Providing `"none"` as the argument will instruct the client to not apply
330
332
  any compression to this upload, which is useful for uncompressible formats.
331
333
 
334
+ If `contents` is already compressed, pass the algorithm as `precompressed`
335
+ instead. The payload is then uploaded verbatim and the algorithm is recorded
336
+ in the object's metadata, so downloads decompress it transparently.
337
+
332
338
  You can use the utility function `objectstore_client.utils.guess_mime_type`
333
339
  to attempt to guess a `content_type` based on magic bytes.
340
+
341
+ `compression` is deprecated in favor of `compress`.
334
342
  """
335
- if compression and compression not in ("none", "zstd"):
336
- raise ValueError(f"Invalid compression: {compression}")
343
+ if compression is not None:
344
+ warnings.warn(
345
+ "`compression` is deprecated, use `compress` instead",
346
+ DeprecationWarning,
347
+ stacklevel=2,
348
+ )
349
+ if compress is not None:
350
+ raise ValueError("Cannot pass both `compress` and `compression`")
351
+ compress = compression
352
+
353
+ if compress is not None and precompressed is not None:
354
+ raise ValueError("Cannot pass both `compress` and `precompressed`")
355
+ if compress and compress not in ("none", "zstd"):
356
+ raise ValueError(f"Invalid compression: {compress}")
357
+ if precompressed and precompressed != "zstd":
358
+ raise ValueError(f"Invalid compression: {precompressed}")
337
359
 
338
360
  headers = self._make_headers()
339
361
  body = BytesIO(contents) if isinstance(contents, bytes) else contents
340
362
  original_body: IO[bytes] = body
341
363
 
342
- compression = compression or self._usecase._compression
343
- if compression == "zstd":
364
+ encoding = precompressed or compress or self._usecase._compression
365
+ if encoding != "none":
366
+ headers["Content-Encoding"] = encoding
367
+
368
+ compress_with = encoding if precompressed is None else "none"
369
+ if compress_with == "zstd":
344
370
  cctx = zstandard.ZstdCompressor()
345
371
  body = cctx.stream_reader(original_body)
346
372
  body = cast(IO[bytes], utils._ZstdCompressionReaderWrapper(body))
347
- headers["Content-Encoding"] = "zstd"
348
373
 
349
374
  if content_type:
350
375
  headers["Content-Type"] = content_type
@@ -354,14 +379,14 @@ class Session:
354
379
  headers[HEADER_EXPIRATION] = format_expiration(expiration_policy)
355
380
 
356
381
  if origin:
357
- headers[HEADER_ORIGIN] = origin
382
+ headers[HEADER_ORIGIN] = utils.encode_header_value(origin)
358
383
 
359
384
  if filename is not None:
360
- headers[HEADER_FILENAME] = filename
385
+ headers[HEADER_FILENAME] = utils.encode_header_value(filename)
361
386
 
362
387
  if metadata:
363
388
  for k, v in metadata.items():
364
- headers[f"{HEADER_META_PREFIX}{k}"] = v
389
+ headers[f"{HEADER_META_PREFIX}{k}"] = utils.encode_header_value(v)
365
390
 
366
391
  if key == "":
367
392
  key = None
@@ -370,8 +395,8 @@ class Session:
370
395
  self._metrics_backend, "put", self._usecase.name
371
396
  ) as metric_emitter:
372
397
  retries = None # by default use the pool's value, set by the Client
373
- if compression == "zstd":
374
- # For compressed bodies, don't attempt read retries,
398
+ if compress_with != "none":
399
+ # For on-the-fly compression, don't attempt read retries,
375
400
  # as the stream cannot be rewound after data has been consumed.
376
401
  pool_retries = self._pool.retries
377
402
  if isinstance(pool_retries, urllib3.Retry):
@@ -393,9 +418,10 @@ class Session:
393
418
 
394
419
  # Must do this after streaming `body` as that's what is responsible
395
420
  # for advancing the seek position in both streams
396
- metric_emitter.record_uncompressed_size(original_body.tell())
397
- if compression and compression != "none":
398
- metric_emitter.record_compressed_size(body.tell(), compression)
421
+ if precompressed is None:
422
+ metric_emitter.record_uncompressed_size(original_body.tell())
423
+ if encoding != "none":
424
+ metric_emitter.record_compressed_size(body.tell(), encoding)
399
425
  return res["key"]
400
426
 
401
427
  def get(
@@ -619,14 +645,14 @@ class Session:
619
645
  headers[HEADER_EXPIRATION] = format_expiration(expiration_policy)
620
646
 
621
647
  if origin:
622
- headers[HEADER_ORIGIN] = origin
648
+ headers[HEADER_ORIGIN] = utils.encode_header_value(origin)
623
649
 
624
650
  if filename is not None:
625
- headers[HEADER_FILENAME] = filename
651
+ headers[HEADER_FILENAME] = utils.encode_header_value(filename)
626
652
 
627
653
  if metadata:
628
654
  for k, v in metadata.items():
629
- headers[f"{HEADER_META_PREFIX}{k}"] = v
655
+ headers[f"{HEADER_META_PREFIX}{k}"] = utils.encode_header_value(v)
630
656
 
631
657
  if key == "":
632
658
  key = None
@@ -7,6 +7,8 @@ from dataclasses import dataclass
7
7
  from datetime import datetime, timedelta
8
8
  from typing import Literal, TypeVar, cast
9
9
 
10
+ from objectstore_client.utils import decode_header_value
11
+
10
12
  Compression = Literal["zstd"] | Literal["none"]
11
13
 
12
14
  HEADER_EXPIRATION = "x-sn-expiration"
@@ -14,6 +16,7 @@ HEADER_TIME_CREATED = "x-sn-time-created"
14
16
  HEADER_TIME_EXPIRES = "x-sn-time-expires"
15
17
  HEADER_ORIGIN = "x-sn-origin"
16
18
  HEADER_FILENAME = "x-sn-filename"
19
+ HEADER_SIZE = "x-sn-size"
17
20
  HEADER_META_PREFIX = "x-snme-"
18
21
 
19
22
 
@@ -70,6 +73,17 @@ class Metadata:
70
73
  prompting browsers and download tools to save the file under this name.
71
74
  """
72
75
 
76
+ size: int | None
77
+ """
78
+ The size of the complete stored object in bytes.
79
+
80
+ This is always the size of the whole object, even when only a range of it was
81
+ requested. For a compressed object it is the compressed size, matching the bytes on
82
+ the wire.
83
+
84
+ This field is computed by the server, it cannot be set by clients.
85
+ """
86
+
73
87
  custom: dict[str, str]
74
88
 
75
89
  @classmethod
@@ -81,6 +95,7 @@ class Metadata:
81
95
  time_expires = None
82
96
  origin = None
83
97
  filename = None
98
+ size = None
84
99
  custom_metadata = {}
85
100
 
86
101
  for k, v in headers.items():
@@ -95,11 +110,13 @@ class Metadata:
95
110
  elif k == HEADER_TIME_EXPIRES:
96
111
  time_expires = datetime.fromisoformat(v)
97
112
  elif k == HEADER_ORIGIN:
98
- origin = v
113
+ origin = decode_header_value(v)
99
114
  elif k == HEADER_FILENAME:
100
- filename = v
115
+ filename = decode_header_value(v)
116
+ elif k == HEADER_SIZE:
117
+ size = int(v)
101
118
  elif k.startswith(HEADER_META_PREFIX):
102
- custom_metadata[k[len(HEADER_META_PREFIX) :]] = v
119
+ custom_metadata[k[len(HEADER_META_PREFIX) :]] = decode_header_value(v)
103
120
 
104
121
  return Metadata(
105
122
  content_type=content_type,
@@ -109,6 +126,7 @@ class Metadata:
109
126
  time_expires=time_expires,
110
127
  origin=origin,
111
128
  filename=filename,
129
+ size=size,
112
130
  custom=custom_metadata,
113
131
  )
114
132
 
@@ -2,7 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  import io
4
4
  from typing import IO, Any
5
- from urllib.parse import quote
5
+ from urllib.parse import quote, unquote
6
6
 
7
7
  import filetype # type: ignore[import-untyped]
8
8
  from zstandard import ZstdCompressionReader
@@ -22,6 +22,16 @@ from zstandard import ZstdCompressionReader
22
22
  _PATH_SAFE = "/:@!$&'()*+,;="
23
23
  _QUERY_SAFE = _PATH_SAFE + "?"
24
24
 
25
+ # Characters left unescaped in a metadata header value: every visible ASCII
26
+ # character except "%". Non-ASCII, the C0 controls, and DEL are escaped because a
27
+ # header value cannot carry them; "%" is escaped so that a literal percent sign in
28
+ # a value can never be confused with an escape sequence when decoding.
29
+ #
30
+ # This is byte-for-byte identical to the Rust `HEADER_ESCAPE` set (see
31
+ # `objectstore-types/src/headers.rs`), so both clients and the server agree on the
32
+ # wire form. Values that are already plain ASCII are left untouched.
33
+ _HEADER_VALUE_SAFE = "".join(chr(b) for b in range(0x20, 0x7F) if b != ord("%"))
34
+
25
35
 
26
36
  def encode_path(path: str) -> str:
27
37
  """Percent-encodes a request path as it will appear on the wire.
@@ -42,6 +52,27 @@ def encode_query(query: str) -> str:
42
52
  return quote(query, safe=_QUERY_SAFE)
43
53
 
44
54
 
55
+ def encode_header_value(value: str) -> str:
56
+ """Percent-encodes a free-form metadata string for an HTTP header value.
57
+
58
+ HTTP header values carry only visible ASCII, so anything outside that range has
59
+ to be escaped to survive the transport (see :data:`_HEADER_VALUE_SAFE`). This is
60
+ the inverse of :func:`decode_header_value`; only the transport representation is
61
+ escaped, never the logical string held in :class:`Metadata`.
62
+ """
63
+ return quote(value, safe=_HEADER_VALUE_SAFE)
64
+
65
+
66
+ def decode_header_value(value: str) -> str:
67
+ """Decodes a percent-encoded HTTP header value into its logical string.
68
+
69
+ Inverse of :func:`encode_header_value`. Values without escape sequences are
70
+ returned unchanged, so headers written before the encoding existed still read
71
+ back as-is.
72
+ """
73
+ return unquote(value, errors="strict")
74
+
75
+
45
76
  def parse_accept_encoding(header: str) -> list[str]:
46
77
  """Parse an Accept-Encoding header value for use in objectstore GET requests.
47
78