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.
- {objectstore_client-0.2.0 → objectstore_client-0.2.2}/PKG-INFO +10 -6
- {objectstore_client-0.2.0 → objectstore_client-0.2.2}/README.md +9 -5
- {objectstore_client-0.2.0 → objectstore_client-0.2.2}/pyproject.toml +1 -1
- {objectstore_client-0.2.0 → objectstore_client-0.2.2}/src/objectstore_client/client.py +44 -18
- {objectstore_client-0.2.0 → objectstore_client-0.2.2}/src/objectstore_client/metadata.py +21 -3
- {objectstore_client-0.2.0 → objectstore_client-0.2.2}/src/objectstore_client/utils.py +32 -1
- {objectstore_client-0.2.0 → objectstore_client-0.2.2}/LICENSE.md +0 -0
- {objectstore_client-0.2.0 → objectstore_client-0.2.2}/src/objectstore_client/__init__.py +0 -0
- {objectstore_client-0.2.0 → objectstore_client-0.2.2}/src/objectstore_client/auth.py +0 -0
- {objectstore_client-0.2.0 → objectstore_client-0.2.2}/src/objectstore_client/errors.py +0 -0
- {objectstore_client-0.2.0 → objectstore_client-0.2.2}/src/objectstore_client/metrics.py +0 -0
- {objectstore_client-0.2.0 → objectstore_client-0.2.2}/src/objectstore_client/multipart.py +0 -0
- {objectstore_client-0.2.0 → objectstore_client-0.2.2}/src/objectstore_client/presign.py +0 -0
- {objectstore_client-0.2.0 → objectstore_client-0.2.2}/src/objectstore_client/py.typed +0 -0
- {objectstore_client-0.2.0 → objectstore_client-0.2.2}/src/objectstore_client/scope.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: objectstore-client
|
|
3
|
-
Version: 0.2.
|
|
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
|
|
199
|
-
|
|
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
|
-
|
|
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
|
-
|
|
220
|
-
|
|
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
|
|
80
|
-
|
|
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
|
-
|
|
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
|
-
|
|
101
|
-
|
|
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,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
|
|
336
|
-
|
|
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
|
-
|
|
343
|
-
if
|
|
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
|
|
374
|
-
# For
|
|
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
|
-
|
|
397
|
-
|
|
398
|
-
|
|
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
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|