objectstore-client 0.2.0__tar.gz → 0.2.1__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.1
4
4
  Summary: Client SDK for Objectstore, the Sentry object storage platform
5
5
  Author: Sentry
6
6
  Author-email: Sentry <oss@sentry.io>
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "objectstore-client"
3
- version = "0.2.0"
3
+ version = "0.2.1"
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" }]
@@ -354,14 +354,14 @@ class Session:
354
354
  headers[HEADER_EXPIRATION] = format_expiration(expiration_policy)
355
355
 
356
356
  if origin:
357
- headers[HEADER_ORIGIN] = origin
357
+ headers[HEADER_ORIGIN] = utils.encode_header_value(origin)
358
358
 
359
359
  if filename is not None:
360
- headers[HEADER_FILENAME] = filename
360
+ headers[HEADER_FILENAME] = utils.encode_header_value(filename)
361
361
 
362
362
  if metadata:
363
363
  for k, v in metadata.items():
364
- headers[f"{HEADER_META_PREFIX}{k}"] = v
364
+ headers[f"{HEADER_META_PREFIX}{k}"] = utils.encode_header_value(v)
365
365
 
366
366
  if key == "":
367
367
  key = None
@@ -619,14 +619,14 @@ class Session:
619
619
  headers[HEADER_EXPIRATION] = format_expiration(expiration_policy)
620
620
 
621
621
  if origin:
622
- headers[HEADER_ORIGIN] = origin
622
+ headers[HEADER_ORIGIN] = utils.encode_header_value(origin)
623
623
 
624
624
  if filename is not None:
625
- headers[HEADER_FILENAME] = filename
625
+ headers[HEADER_FILENAME] = utils.encode_header_value(filename)
626
626
 
627
627
  if metadata:
628
628
  for k, v in metadata.items():
629
- headers[f"{HEADER_META_PREFIX}{k}"] = v
629
+ headers[f"{HEADER_META_PREFIX}{k}"] = utils.encode_header_value(v)
630
630
 
631
631
  if key == "":
632
632
  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