objectstore-client 0.2.8__tar.gz → 0.2.10__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.8 → objectstore_client-0.2.10}/PKG-INFO +9 -1
- {objectstore_client-0.2.8 → objectstore_client-0.2.10}/README.md +8 -0
- {objectstore_client-0.2.8 → objectstore_client-0.2.10}/pyproject.toml +1 -1
- objectstore_client-0.2.10/src/objectstore_client/_resumable.py +385 -0
- {objectstore_client-0.2.8 → objectstore_client-0.2.10}/src/objectstore_client/client.py +292 -81
- {objectstore_client-0.2.8 → objectstore_client-0.2.10}/src/objectstore_client/errors.py +1 -1
- {objectstore_client-0.2.8 → objectstore_client-0.2.10}/LICENSE.md +0 -0
- {objectstore_client-0.2.8 → objectstore_client-0.2.10}/src/objectstore_client/__init__.py +0 -0
- {objectstore_client-0.2.8 → objectstore_client-0.2.10}/src/objectstore_client/auth.py +0 -0
- {objectstore_client-0.2.8 → objectstore_client-0.2.10}/src/objectstore_client/metadata.py +0 -0
- {objectstore_client-0.2.8 → objectstore_client-0.2.10}/src/objectstore_client/metrics.py +0 -0
- {objectstore_client-0.2.8 → objectstore_client-0.2.10}/src/objectstore_client/multipart.py +0 -0
- {objectstore_client-0.2.8 → objectstore_client-0.2.10}/src/objectstore_client/presign.py +0 -0
- {objectstore_client-0.2.8 → objectstore_client-0.2.10}/src/objectstore_client/py.typed +0 -0
- {objectstore_client-0.2.8 → objectstore_client-0.2.10}/src/objectstore_client/scope.py +0 -0
- {objectstore_client-0.2.8 → objectstore_client-0.2.10}/src/objectstore_client/tracing.py +0 -0
- {objectstore_client-0.2.8 → objectstore_client-0.2.10}/src/objectstore_client/utils.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.10
|
|
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
|
|
|
@@ -219,6 +221,12 @@ session.put(video_data, compress="none")
|
|
|
219
221
|
session.put(zstd_data, precompressed="zstd")
|
|
220
222
|
```
|
|
221
223
|
|
|
224
|
+
### Resumable Uploads
|
|
225
|
+
|
|
226
|
+
The client automatically uses resumable uploads when eligible and may create
|
|
227
|
+
temporary files for spooling. See [`Session.put()`](src/objectstore_client/client.py)
|
|
228
|
+
for eligibility, configuration, and upload behavior.
|
|
229
|
+
|
|
222
230
|
### Custom Metadata
|
|
223
231
|
|
|
224
232
|
Arbitrary key-value pairs can be attached to objects and retrieved on download.
|
|
@@ -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
|
|
|
@@ -100,6 +102,12 @@ session.put(video_data, compress="none")
|
|
|
100
102
|
session.put(zstd_data, precompressed="zstd")
|
|
101
103
|
```
|
|
102
104
|
|
|
105
|
+
### Resumable Uploads
|
|
106
|
+
|
|
107
|
+
The client automatically uses resumable uploads when eligible and may create
|
|
108
|
+
temporary files for spooling. See [`Session.put()`](src/objectstore_client/client.py)
|
|
109
|
+
for eligibility, configuration, and upload behavior.
|
|
110
|
+
|
|
103
111
|
### Custom Metadata
|
|
104
112
|
|
|
105
113
|
Arbitrary key-value pairs can be attached to objects and retrieved on download.
|
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import random
|
|
4
|
+
import time
|
|
5
|
+
from collections.abc import Iterator
|
|
6
|
+
from contextlib import contextmanager, nullcontext
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from io import SEEK_END, BytesIO
|
|
9
|
+
from os import PathLike
|
|
10
|
+
from tempfile import TemporaryFile
|
|
11
|
+
from typing import IO, TYPE_CHECKING
|
|
12
|
+
from urllib.parse import urlencode
|
|
13
|
+
|
|
14
|
+
import urllib3
|
|
15
|
+
import zstandard
|
|
16
|
+
|
|
17
|
+
from objectstore_client.errors import RequestError, raise_for_status
|
|
18
|
+
from objectstore_client.metadata import Compression, ExpirationPolicy
|
|
19
|
+
from objectstore_client.metrics import measure_storage_operation
|
|
20
|
+
from objectstore_client.tracing import storage_span
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from objectstore_client.client import Session
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ResumableUploadUnavailable(RequestError):
|
|
27
|
+
"""The upload session expired, was canceled, or could not be found."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class UploadOffsetMismatch(RequestError):
|
|
31
|
+
"""The server rejected a chunk and reported its offset."""
|
|
32
|
+
|
|
33
|
+
def __init__(self, offset: int, response: urllib3.BaseHTTPResponse):
|
|
34
|
+
super().__init__(
|
|
35
|
+
f"Upload offset mismatch (server holds {offset} bytes)",
|
|
36
|
+
response.status,
|
|
37
|
+
response.data.decode("utf-8", "replace"),
|
|
38
|
+
)
|
|
39
|
+
self.offset = offset
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class ChunkTooSmall(ValueError):
|
|
43
|
+
"""A non-final chunk is shorter than the upload granularity."""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class NoUploadProgress(Exception):
|
|
47
|
+
"""A write completed without advancing the server's upload offset."""
|
|
48
|
+
|
|
49
|
+
def __init__(self) -> None:
|
|
50
|
+
super().__init__("Upload made no progress")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True)
|
|
54
|
+
class UploadIncomplete:
|
|
55
|
+
"""The upload expects more bytes, starting at the given offset."""
|
|
56
|
+
|
|
57
|
+
offset: int
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(frozen=True)
|
|
61
|
+
class UploadComplete:
|
|
62
|
+
"""The upload is complete."""
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
UploadProgress = UploadIncomplete | UploadComplete
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def parse_progress_response(response: urllib3.BaseHTTPResponse) -> UploadProgress:
|
|
69
|
+
if response.status == 409:
|
|
70
|
+
raise UploadOffsetMismatch(int(response.headers["Upload-Offset"]), response)
|
|
71
|
+
if response.status in (404, 410):
|
|
72
|
+
raise_for_status(response, error_type=ResumableUploadUnavailable)
|
|
73
|
+
raise_for_status(response)
|
|
74
|
+
if response.status == 201:
|
|
75
|
+
return UploadComplete()
|
|
76
|
+
return UploadIncomplete(int(response.headers["Upload-Offset"]))
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class ResumableUpload:
|
|
80
|
+
"""A handle bound to one resumable upload session."""
|
|
81
|
+
|
|
82
|
+
def __init__(
|
|
83
|
+
self,
|
|
84
|
+
session: Session,
|
|
85
|
+
key: str,
|
|
86
|
+
token: str,
|
|
87
|
+
total_length: int | None = None,
|
|
88
|
+
granularity: int | None = None,
|
|
89
|
+
):
|
|
90
|
+
self._session = session
|
|
91
|
+
self._key = key
|
|
92
|
+
self._token = token
|
|
93
|
+
self._total_length = total_length
|
|
94
|
+
self._granularity = granularity
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def key(self) -> str:
|
|
98
|
+
return self._key
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def token(self) -> str:
|
|
102
|
+
return self._token
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def granularity(self) -> int | None:
|
|
106
|
+
"""The persistence unit for non-final chunks, if known."""
|
|
107
|
+
return self._granularity
|
|
108
|
+
|
|
109
|
+
def progress(self) -> UploadProgress:
|
|
110
|
+
"""Query the server's authoritative offset.
|
|
111
|
+
|
|
112
|
+
Raises `ResumableUploadUnavailable` for an unavailable session.
|
|
113
|
+
"""
|
|
114
|
+
session = self._session
|
|
115
|
+
query = urlencode({"session": self.token})
|
|
116
|
+
headers = session._make_headers()
|
|
117
|
+
headers["Upload-Offset"] = "*"
|
|
118
|
+
with (
|
|
119
|
+
storage_span(
|
|
120
|
+
"resumable.progress", session._usecase, session._scope, key=self.key
|
|
121
|
+
),
|
|
122
|
+
measure_storage_operation(
|
|
123
|
+
session._metrics_backend, "resumable.progress", session._usecase.name
|
|
124
|
+
),
|
|
125
|
+
):
|
|
126
|
+
response = session._pool.request(
|
|
127
|
+
"PUT",
|
|
128
|
+
f"{session._make_url(self.key)}?{query}",
|
|
129
|
+
headers=headers,
|
|
130
|
+
preload_content=True,
|
|
131
|
+
decode_content=True,
|
|
132
|
+
)
|
|
133
|
+
return parse_progress_response(response)
|
|
134
|
+
|
|
135
|
+
def put(
|
|
136
|
+
self, offset: int, contents: bytes | tuple[IO[bytes], int]
|
|
137
|
+
) -> UploadProgress:
|
|
138
|
+
"""Uploads `contents` starting at `offset`.
|
|
139
|
+
|
|
140
|
+
For streaming payloads, supply a tuple of the stream and its exact length
|
|
141
|
+
in bytes.
|
|
142
|
+
|
|
143
|
+
This method doesn't perform any automatic compression of the payload, so
|
|
144
|
+
the caller is responsible for applying compression to the entire payload
|
|
145
|
+
beforehand and passing chunks of the already compressed payload.
|
|
146
|
+
|
|
147
|
+
Raises `UploadOffsetMismatch` when the server rejects the offset,
|
|
148
|
+
`ResumableUploadUnavailable` for an unavailable session, or `ChunkTooSmall`
|
|
149
|
+
for a non-final chunk shorter than the granularity.
|
|
150
|
+
"""
|
|
151
|
+
if isinstance(contents, bytes):
|
|
152
|
+
body: bytes | IO[bytes] = contents
|
|
153
|
+
length = len(contents)
|
|
154
|
+
else:
|
|
155
|
+
body, length = contents
|
|
156
|
+
|
|
157
|
+
if offset < 0 or length < 0:
|
|
158
|
+
raise ValueError("Chunk offset and length must not be negative")
|
|
159
|
+
if (
|
|
160
|
+
self._granularity is not None
|
|
161
|
+
and self._total_length is not None
|
|
162
|
+
and 0 < length < self._granularity
|
|
163
|
+
and offset + length < self._total_length
|
|
164
|
+
):
|
|
165
|
+
raise ChunkTooSmall(
|
|
166
|
+
f"Non-final chunk {length} is smaller than upload granularity "
|
|
167
|
+
f"{self._granularity}"
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
session = self._session
|
|
171
|
+
query = urlencode({"session": self.token})
|
|
172
|
+
headers = session._make_headers()
|
|
173
|
+
headers["Upload-Offset"] = str(offset)
|
|
174
|
+
headers["Content-Length"] = str(length)
|
|
175
|
+
# Disable pool retries that replay the body. The resumable upload loop
|
|
176
|
+
# probes the persisted offset before resending data; connection retries
|
|
177
|
+
# retain the pool's policy.
|
|
178
|
+
retries = urllib3.Retry.from_int(session._pool.retries).new(
|
|
179
|
+
read=0, status=0, other=0, raise_on_status=False
|
|
180
|
+
)
|
|
181
|
+
with (
|
|
182
|
+
storage_span(
|
|
183
|
+
"resumable.put",
|
|
184
|
+
session._usecase,
|
|
185
|
+
session._scope,
|
|
186
|
+
key=self.key,
|
|
187
|
+
offset=offset,
|
|
188
|
+
size=length,
|
|
189
|
+
),
|
|
190
|
+
measure_storage_operation(
|
|
191
|
+
session._metrics_backend, "resumable.put", session._usecase.name
|
|
192
|
+
) as metrics,
|
|
193
|
+
):
|
|
194
|
+
response = session._pool.request(
|
|
195
|
+
"PUT",
|
|
196
|
+
f"{session._make_url(self.key)}?{query}",
|
|
197
|
+
headers=headers,
|
|
198
|
+
body=body,
|
|
199
|
+
retries=retries,
|
|
200
|
+
preload_content=True,
|
|
201
|
+
decode_content=True,
|
|
202
|
+
)
|
|
203
|
+
progress = parse_progress_response(response)
|
|
204
|
+
metrics.record_size(length)
|
|
205
|
+
return progress
|
|
206
|
+
|
|
207
|
+
def cancel(self) -> None:
|
|
208
|
+
"""Cancels the upload session, discarding uploaded bytes."""
|
|
209
|
+
session = self._session
|
|
210
|
+
query = urlencode({"session": self.token})
|
|
211
|
+
headers = session._make_headers()
|
|
212
|
+
with (
|
|
213
|
+
storage_span(
|
|
214
|
+
"resumable.cancel", session._usecase, session._scope, key=self.key
|
|
215
|
+
),
|
|
216
|
+
measure_storage_operation(
|
|
217
|
+
session._metrics_backend, "resumable.cancel", session._usecase.name
|
|
218
|
+
),
|
|
219
|
+
):
|
|
220
|
+
response = session._pool.request(
|
|
221
|
+
"DELETE",
|
|
222
|
+
f"{session._make_url(self.key)}?{query}",
|
|
223
|
+
headers=headers,
|
|
224
|
+
preload_content=True,
|
|
225
|
+
decode_content=True,
|
|
226
|
+
)
|
|
227
|
+
error_type = (
|
|
228
|
+
ResumableUploadUnavailable
|
|
229
|
+
if response.status in (404, 410)
|
|
230
|
+
else RequestError
|
|
231
|
+
)
|
|
232
|
+
raise_for_status(response, error_type=error_type)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
@contextmanager
|
|
236
|
+
def prepare_body(
|
|
237
|
+
contents: bytes | IO[bytes],
|
|
238
|
+
source_size: int,
|
|
239
|
+
compress: bool,
|
|
240
|
+
spool_dir: str | PathLike[str] | None,
|
|
241
|
+
) -> Iterator[tuple[IO[bytes], int, int] | None]:
|
|
242
|
+
"""Yield the body, source size, and compressed size (equal if uncompressed).
|
|
243
|
+
|
|
244
|
+
Reuse sized, seekable input when no compression is needed. Otherwise spool
|
|
245
|
+
the compressed bytes without closing the caller's stream.
|
|
246
|
+
Yield None if temporary-file creation fails, before consuming any input.
|
|
247
|
+
"""
|
|
248
|
+
with (
|
|
249
|
+
BytesIO(contents) if isinstance(contents, bytes) else nullcontext(contents)
|
|
250
|
+
) as source:
|
|
251
|
+
if not compress:
|
|
252
|
+
yield source, source_size, source_size
|
|
253
|
+
return
|
|
254
|
+
|
|
255
|
+
try:
|
|
256
|
+
temporary = TemporaryFile(dir=spool_dir)
|
|
257
|
+
except OSError:
|
|
258
|
+
yield None
|
|
259
|
+
return
|
|
260
|
+
|
|
261
|
+
with temporary as body:
|
|
262
|
+
source_size, compressed_size = zstandard.ZstdCompressor().copy_stream(
|
|
263
|
+
source, body
|
|
264
|
+
)
|
|
265
|
+
body.seek(0)
|
|
266
|
+
yield body, source_size, compressed_size
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def get_size(source: bytes | IO[bytes]) -> int | None:
|
|
270
|
+
"""Return the buffer length or remaining stream size, or None if unsupported.
|
|
271
|
+
|
|
272
|
+
Streams are measured by seeking to the end, which may read data in transforming
|
|
273
|
+
streams. Failure to restore the original position propagates so callers cannot
|
|
274
|
+
upload from an unexpected position.
|
|
275
|
+
"""
|
|
276
|
+
if isinstance(source, bytes):
|
|
277
|
+
return len(source)
|
|
278
|
+
|
|
279
|
+
try:
|
|
280
|
+
if not source.seekable():
|
|
281
|
+
return None
|
|
282
|
+
start = source.tell()
|
|
283
|
+
except (AttributeError, OSError):
|
|
284
|
+
return None
|
|
285
|
+
|
|
286
|
+
try:
|
|
287
|
+
source.seek(0, SEEK_END)
|
|
288
|
+
return max(0, source.tell() - start)
|
|
289
|
+
except OSError:
|
|
290
|
+
return None
|
|
291
|
+
finally:
|
|
292
|
+
source.seek(start)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def is_transient(error: Exception) -> bool:
|
|
296
|
+
if isinstance(error, urllib3.exceptions.MaxRetryError):
|
|
297
|
+
# Exhausted status retries carry ResponseError rather than the response.
|
|
298
|
+
return isinstance(error.reason, urllib3.exceptions.ResponseError) or (
|
|
299
|
+
isinstance(error.reason, Exception) and is_transient(error.reason)
|
|
300
|
+
)
|
|
301
|
+
if isinstance(error, RequestError):
|
|
302
|
+
return error.status in (408, 429, 502, 503, 504)
|
|
303
|
+
return isinstance(
|
|
304
|
+
error,
|
|
305
|
+
(
|
|
306
|
+
NoUploadProgress,
|
|
307
|
+
urllib3.exceptions.ReadTimeoutError,
|
|
308
|
+
urllib3.exceptions.ProtocolError,
|
|
309
|
+
),
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def upload(
|
|
314
|
+
session: Session,
|
|
315
|
+
body: IO[bytes],
|
|
316
|
+
compressed_size: int,
|
|
317
|
+
key: str | None = None,
|
|
318
|
+
compression: Compression | None = None,
|
|
319
|
+
content_type: str | None = None,
|
|
320
|
+
metadata: dict[str, str] | None = None,
|
|
321
|
+
expiration_policy: ExpirationPolicy | None = None,
|
|
322
|
+
origin: str | None = None,
|
|
323
|
+
filename: str | None = None,
|
|
324
|
+
) -> str | None:
|
|
325
|
+
start = body.tell()
|
|
326
|
+
try:
|
|
327
|
+
handle = session._create_upload(
|
|
328
|
+
compressed_size,
|
|
329
|
+
key=key,
|
|
330
|
+
compression=compression,
|
|
331
|
+
content_type=content_type,
|
|
332
|
+
metadata=metadata,
|
|
333
|
+
expiration_policy=expiration_policy,
|
|
334
|
+
origin=origin,
|
|
335
|
+
filename=filename,
|
|
336
|
+
)
|
|
337
|
+
except Exception:
|
|
338
|
+
handle = None
|
|
339
|
+
if handle is None:
|
|
340
|
+
return None
|
|
341
|
+
|
|
342
|
+
try:
|
|
343
|
+
offset = 0
|
|
344
|
+
retries = 0
|
|
345
|
+
probe = False
|
|
346
|
+
while True:
|
|
347
|
+
try:
|
|
348
|
+
if probe:
|
|
349
|
+
result = handle.progress()
|
|
350
|
+
else:
|
|
351
|
+
body.seek(start + offset)
|
|
352
|
+
try:
|
|
353
|
+
result = handle.put(offset, (body, compressed_size - offset))
|
|
354
|
+
except UploadOffsetMismatch as error:
|
|
355
|
+
result = UploadIncomplete(error.offset)
|
|
356
|
+
if (
|
|
357
|
+
isinstance(result, UploadIncomplete)
|
|
358
|
+
and result.offset == offset
|
|
359
|
+
and not probe
|
|
360
|
+
):
|
|
361
|
+
raise NoUploadProgress()
|
|
362
|
+
except Exception as error:
|
|
363
|
+
if not is_transient(error) or retries >= 2:
|
|
364
|
+
raise
|
|
365
|
+
time.sleep(2 * 2**retries + random.uniform(0, 1))
|
|
366
|
+
retries += 1
|
|
367
|
+
probe = True
|
|
368
|
+
continue
|
|
369
|
+
|
|
370
|
+
if isinstance(result, UploadComplete):
|
|
371
|
+
return handle.key
|
|
372
|
+
if not offset <= result.offset <= compressed_size:
|
|
373
|
+
raise ValueError("Invalid upload offset")
|
|
374
|
+
if result.offset > offset:
|
|
375
|
+
retries = 0
|
|
376
|
+
offset = result.offset
|
|
377
|
+
probe = False
|
|
378
|
+
except Exception as error:
|
|
379
|
+
try:
|
|
380
|
+
handle.cancel()
|
|
381
|
+
except Exception:
|
|
382
|
+
pass
|
|
383
|
+
status = error.status if isinstance(error, RequestError) else None
|
|
384
|
+
response = error.response if isinstance(error, RequestError) else None
|
|
385
|
+
raise RequestError("upload failed", status, response) from error
|
|
@@ -3,10 +3,12 @@ from __future__ import annotations
|
|
|
3
3
|
import math
|
|
4
4
|
import warnings
|
|
5
5
|
from collections.abc import Mapping, Sequence
|
|
6
|
+
from contextlib import nullcontext
|
|
6
7
|
from dataclasses import asdict, dataclass
|
|
7
8
|
from datetime import UTC, datetime, timedelta
|
|
8
9
|
from importlib.metadata import version
|
|
9
10
|
from io import BytesIO
|
|
11
|
+
from os import PathLike
|
|
10
12
|
from typing import IO, Any, Literal, NamedTuple, cast
|
|
11
13
|
from urllib.parse import urlparse
|
|
12
14
|
|
|
@@ -15,7 +17,7 @@ import urllib3
|
|
|
15
17
|
import zstandard
|
|
16
18
|
from urllib3.connectionpool import HTTPConnectionPool
|
|
17
19
|
|
|
18
|
-
from objectstore_client import presign, utils
|
|
20
|
+
from objectstore_client import _resumable, presign, utils
|
|
19
21
|
from objectstore_client.auth import Permission, SecretKey, TokenProvider
|
|
20
22
|
from objectstore_client.errors import (
|
|
21
23
|
ExpiryExtensionRejected,
|
|
@@ -63,9 +65,13 @@ class Usecase:
|
|
|
63
65
|
Usecases need to be statically defined in Objectstore's configuration server-side.
|
|
64
66
|
Objectstore can make decisions based on the Usecase. For example, choosing the most
|
|
65
67
|
suitable storage backend.
|
|
68
|
+
|
|
69
|
+
Names must be 3–64 ASCII characters from ``[A-Za-z0-9_-]``. Any permitted
|
|
70
|
+
character may appear in any position. Names are case-sensitive and are never
|
|
71
|
+
normalized. The name is read-only after construction.
|
|
66
72
|
"""
|
|
67
73
|
|
|
68
|
-
|
|
74
|
+
_name: str
|
|
69
75
|
_compression: Compression
|
|
70
76
|
_expiration_policy: ExpirationPolicy | None
|
|
71
77
|
|
|
@@ -74,11 +80,36 @@ class Usecase:
|
|
|
74
80
|
name: str,
|
|
75
81
|
compression: Compression = "zstd",
|
|
76
82
|
expiration_policy: ExpirationPolicy | None = None,
|
|
83
|
+
resumable_threshold_bytes: int | Literal["none"] = 256 * 1024 * 1024,
|
|
77
84
|
):
|
|
78
|
-
|
|
85
|
+
"""Create a usecase.
|
|
86
|
+
|
|
87
|
+
Raises ``ValueError`` if the name violates the naming rules on ``Usecase``,
|
|
88
|
+
or if the ``resumable_threshold_bytes`` is invalid.
|
|
89
|
+
"""
|
|
90
|
+
if not 3 <= len(name) <= 64:
|
|
91
|
+
raise ValueError("usecase name must be 3–64 ASCII characters long")
|
|
92
|
+
if any(not (c.isascii() and c.isalnum()) and c not in "_-" for c in name):
|
|
93
|
+
raise ValueError(
|
|
94
|
+
"usecase name must contain only ASCII letters, digits, '_' or '-'"
|
|
95
|
+
)
|
|
96
|
+
self._name = name
|
|
97
|
+
|
|
98
|
+
if resumable_threshold_bytes != "none" and (
|
|
99
|
+
type(resumable_threshold_bytes) is not int or resumable_threshold_bytes < 0
|
|
100
|
+
):
|
|
101
|
+
raise ValueError(
|
|
102
|
+
'resumable_threshold_bytes must be a non-negative integer or "none"'
|
|
103
|
+
)
|
|
104
|
+
self._resumable_threshold_bytes = resumable_threshold_bytes
|
|
79
105
|
self._compression = compression
|
|
80
106
|
self._expiration_policy = expiration_policy
|
|
81
107
|
|
|
108
|
+
@property
|
|
109
|
+
def name(self) -> str:
|
|
110
|
+
"""The validated usecase name."""
|
|
111
|
+
return self._name
|
|
112
|
+
|
|
82
113
|
|
|
83
114
|
# Connect timeout used unless overridden in connection parameters.
|
|
84
115
|
DEFAULT_CONNECT_TIMEOUT = 0.1
|
|
@@ -146,6 +177,8 @@ class Client:
|
|
|
146
177
|
as-is for every request. Use a ``SecretKey`` for internal
|
|
147
178
|
services that have access to the signing key, and a string for
|
|
148
179
|
external services that receive a token from another source.
|
|
180
|
+
spool_dir: Directory that temporary files are created in when preparing
|
|
181
|
+
resumable uploads. ``None`` uses Python's default temporary directory.
|
|
149
182
|
"""
|
|
150
183
|
|
|
151
184
|
def __init__(
|
|
@@ -157,6 +190,7 @@ class Client:
|
|
|
157
190
|
timeout_ms: float | None = None,
|
|
158
191
|
connection_kwargs: Mapping[str, Any] | None = None,
|
|
159
192
|
token: TokenProvider | None = None,
|
|
193
|
+
spool_dir: str | PathLike[str] | None = None,
|
|
160
194
|
):
|
|
161
195
|
if propagate_traces:
|
|
162
196
|
warnings.warn(
|
|
@@ -168,7 +202,7 @@ class Client:
|
|
|
168
202
|
|
|
169
203
|
connection_kwargs_to_use = asdict(_ConnectionDefaults())
|
|
170
204
|
|
|
171
|
-
if retries:
|
|
205
|
+
if retries is not None:
|
|
172
206
|
connection_kwargs_to_use["retries"] = urllib3.Retry(
|
|
173
207
|
connect=retries,
|
|
174
208
|
# we only retry connection problems, as we cannot rewind our
|
|
@@ -195,6 +229,7 @@ class Client:
|
|
|
195
229
|
self._metrics_backend = metrics_backend or NoOpMetricsBackend()
|
|
196
230
|
self._propagate_traces = propagate_traces
|
|
197
231
|
self._token = token
|
|
232
|
+
self._spool_dir = spool_dir
|
|
198
233
|
|
|
199
234
|
def session(self, usecase: Usecase, **scopes: str | int | bool) -> Session:
|
|
200
235
|
"""
|
|
@@ -230,6 +265,7 @@ class Client:
|
|
|
230
265
|
usecase,
|
|
231
266
|
Scope(**scopes),
|
|
232
267
|
self._token,
|
|
268
|
+
self._spool_dir,
|
|
233
269
|
)
|
|
234
270
|
|
|
235
271
|
|
|
@@ -249,6 +285,7 @@ class Session:
|
|
|
249
285
|
usecase: Usecase,
|
|
250
286
|
scope: Scope,
|
|
251
287
|
token: TokenProvider | None = None,
|
|
288
|
+
spool_dir: str | PathLike[str] | None = None,
|
|
252
289
|
):
|
|
253
290
|
self._pool = pool
|
|
254
291
|
self._base_path = base_path
|
|
@@ -257,6 +294,7 @@ class Session:
|
|
|
257
294
|
self._usecase = usecase
|
|
258
295
|
self._scope = scope
|
|
259
296
|
self._token = token
|
|
297
|
+
self._spool_dir = spool_dir
|
|
260
298
|
|
|
261
299
|
def mint_token(
|
|
262
300
|
self,
|
|
@@ -320,6 +358,33 @@ class Session:
|
|
|
320
358
|
headers["x-os-auth"] = f"Bearer {token}"
|
|
321
359
|
return headers
|
|
322
360
|
|
|
361
|
+
def _metadata_headers(
|
|
362
|
+
self,
|
|
363
|
+
compression: Compression | None,
|
|
364
|
+
content_type: str | None = None,
|
|
365
|
+
metadata: dict[str, str] | None = None,
|
|
366
|
+
expiration_policy: ExpirationPolicy | None = None,
|
|
367
|
+
origin: str | None = None,
|
|
368
|
+
filename: str | None = None,
|
|
369
|
+
) -> dict[str, str]:
|
|
370
|
+
"""Build upload headers with the given compression and usecase expiration."""
|
|
371
|
+
headers = self._make_headers()
|
|
372
|
+
if compression and compression != "none":
|
|
373
|
+
headers["Content-Encoding"] = compression
|
|
374
|
+
if content_type:
|
|
375
|
+
headers["Content-Type"] = content_type
|
|
376
|
+
expiration_policy = expiration_policy or self._usecase._expiration_policy
|
|
377
|
+
if expiration_policy:
|
|
378
|
+
headers[HEADER_EXPIRATION] = format_expiration(expiration_policy)
|
|
379
|
+
if origin:
|
|
380
|
+
headers[HEADER_ORIGIN] = utils.encode_header_value(origin)
|
|
381
|
+
if filename is not None:
|
|
382
|
+
headers[HEADER_FILENAME] = utils.encode_header_value(filename)
|
|
383
|
+
if metadata:
|
|
384
|
+
for k, v in metadata.items():
|
|
385
|
+
headers[f"{HEADER_META_PREFIX}{k}"] = utils.encode_header_value(v)
|
|
386
|
+
return headers
|
|
387
|
+
|
|
323
388
|
def _base_url(self) -> str:
|
|
324
389
|
# urllib3 stores IPv6 hosts unbracketed (e.g. "::1"); bracket them so
|
|
325
390
|
# the result is a valid absolute URL.
|
|
@@ -366,6 +431,7 @@ class Session:
|
|
|
366
431
|
expiration_policy: ExpirationPolicy | None = None,
|
|
367
432
|
origin: str | None = None,
|
|
368
433
|
filename: str | None = None,
|
|
434
|
+
resumable_threshold_bytes: int | Literal["none"] | None = None,
|
|
369
435
|
) -> str:
|
|
370
436
|
"""
|
|
371
437
|
Uploads the given `contents` to blob storage.
|
|
@@ -373,7 +439,7 @@ class Session:
|
|
|
373
439
|
If no `key` is provided, one will be automatically generated and returned
|
|
374
440
|
from this function.
|
|
375
441
|
|
|
376
|
-
The client will select the configured
|
|
442
|
+
The client will select the Usecase's configured compression if none is given
|
|
377
443
|
explicitly. This can be overridden by explicitly giving a `compress` argument.
|
|
378
444
|
Providing `"none"` as the argument will instruct the client to not apply
|
|
379
445
|
any compression to this upload, which is useful for uncompressible formats.
|
|
@@ -386,6 +452,28 @@ class Session:
|
|
|
386
452
|
to attempt to guess a `content_type` based on magic bytes.
|
|
387
453
|
|
|
388
454
|
`compression` is deprecated in favor of `compress`.
|
|
455
|
+
|
|
456
|
+
.. rubric:: Resumable Uploads
|
|
457
|
+
|
|
458
|
+
This method uses Objectstore's Resumable Uploads API when the size of
|
|
459
|
+
``contents`` is at least ``resumable_threshold_bytes``.
|
|
460
|
+
Pass ``resumable_threshold_bytes="none"`` to disable Resumable Uploads.
|
|
461
|
+
When not overridden, this threshold defaults to the one set on the Usecase.
|
|
462
|
+
|
|
463
|
+
Transient failures and writes that make no progress are retried up to
|
|
464
|
+
twice. The retry count resets whenever the server reports a higher offset.
|
|
465
|
+
|
|
466
|
+
For seekable binary streams, the remaining size is measured by seeking to
|
|
467
|
+
the end and restoring the original position. Prefer streams with efficient
|
|
468
|
+
seeking: transforming streams may read their entire input to find the end.
|
|
469
|
+
Non-seekable streams and streams whose size cannot be determined use a
|
|
470
|
+
regular upload without spooling.
|
|
471
|
+
|
|
472
|
+
Resumable uploads reuse the input unless compression is needed, in which
|
|
473
|
+
case the compressed payload is spooled in a temporary file
|
|
474
|
+
(``tempfile.TemporaryFile``) on disk before sending.
|
|
475
|
+
Set ``Client(spool_dir=<path>)`` if you wish to customize the directory where
|
|
476
|
+
the temporary files are created in.
|
|
389
477
|
"""
|
|
390
478
|
if compression is not None:
|
|
391
479
|
warnings.warn(
|
|
@@ -404,36 +492,31 @@ class Session:
|
|
|
404
492
|
if precompressed and precompressed != "zstd":
|
|
405
493
|
raise ValueError(f"Invalid compression: {precompressed}")
|
|
406
494
|
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
495
|
+
if resumable_threshold_bytes is None:
|
|
496
|
+
resumable_threshold_bytes = self._usecase._resumable_threshold_bytes
|
|
497
|
+
if resumable_threshold_bytes != "none" and (
|
|
498
|
+
type(resumable_threshold_bytes) is not int or resumable_threshold_bytes < 0
|
|
499
|
+
):
|
|
500
|
+
raise ValueError(
|
|
501
|
+
'resumable_threshold_bytes must be a non-negative integer or "none"'
|
|
502
|
+
)
|
|
503
|
+
|
|
504
|
+
source_size = None
|
|
505
|
+
if resumable_threshold_bytes != "none":
|
|
506
|
+
source_size = _resumable.get_size(contents)
|
|
410
507
|
|
|
411
508
|
encoding = precompressed or compress or self._usecase._compression
|
|
412
|
-
if encoding != "none":
|
|
413
|
-
headers["Content-Encoding"] = encoding
|
|
414
509
|
|
|
415
510
|
compress_with = encoding if precompressed is None else "none"
|
|
416
|
-
if compress_with == "zstd":
|
|
417
|
-
cctx = zstandard.ZstdCompressor()
|
|
418
|
-
body = cctx.stream_reader(original_body)
|
|
419
|
-
body = cast(IO[bytes], utils._ZstdCompressionReaderWrapper(body))
|
|
420
|
-
|
|
421
|
-
if content_type:
|
|
422
|
-
headers["Content-Type"] = content_type
|
|
423
|
-
|
|
424
|
-
expiration_policy = expiration_policy or self._usecase._expiration_policy
|
|
425
|
-
if expiration_policy:
|
|
426
|
-
headers[HEADER_EXPIRATION] = format_expiration(expiration_policy)
|
|
427
|
-
|
|
428
|
-
if origin:
|
|
429
|
-
headers[HEADER_ORIGIN] = utils.encode_header_value(origin)
|
|
430
511
|
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
512
|
+
headers = self._metadata_headers(
|
|
513
|
+
compression=encoding,
|
|
514
|
+
content_type=content_type,
|
|
515
|
+
metadata=metadata,
|
|
516
|
+
expiration_policy=expiration_policy,
|
|
517
|
+
origin=origin,
|
|
518
|
+
filename=filename,
|
|
519
|
+
)
|
|
437
520
|
|
|
438
521
|
if key == "":
|
|
439
522
|
key = None
|
|
@@ -444,37 +527,56 @@ class Session:
|
|
|
444
527
|
self._metrics_backend, "put", self._usecase.name
|
|
445
528
|
) as metrics,
|
|
446
529
|
):
|
|
447
|
-
|
|
448
|
-
if
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
530
|
+
result_key: str | None
|
|
531
|
+
if (
|
|
532
|
+
resumable_threshold_bytes != "none"
|
|
533
|
+
and source_size is not None
|
|
534
|
+
and source_size >= resumable_threshold_bytes
|
|
535
|
+
):
|
|
536
|
+
with _resumable.prepare_body(
|
|
537
|
+
contents,
|
|
538
|
+
source_size,
|
|
539
|
+
compress_with != "none",
|
|
540
|
+
self._spool_dir,
|
|
541
|
+
) as prepared:
|
|
542
|
+
if prepared is None:
|
|
543
|
+
# Temporary-file creation failed before consuming the input.
|
|
544
|
+
result_key, source_size, compressed_size = self._put_direct(
|
|
545
|
+
contents, key, headers, compress=compress_with != "none"
|
|
546
|
+
)
|
|
547
|
+
else:
|
|
548
|
+
body, source_size, compressed_size = prepared
|
|
549
|
+
result_key = _resumable.upload(
|
|
550
|
+
self,
|
|
551
|
+
body,
|
|
552
|
+
compressed_size,
|
|
553
|
+
key=key,
|
|
554
|
+
compression=encoding,
|
|
555
|
+
content_type=content_type,
|
|
556
|
+
metadata=metadata,
|
|
557
|
+
expiration_policy=expiration_policy,
|
|
558
|
+
origin=origin,
|
|
559
|
+
filename=filename,
|
|
560
|
+
)
|
|
561
|
+
if result_key is None:
|
|
562
|
+
# Session creation failed or resumables are unsupported.
|
|
563
|
+
# Send the prepared body without compressing it again.
|
|
564
|
+
headers["Content-Length"] = str(compressed_size)
|
|
565
|
+
result_key, _, _ = self._put_direct(
|
|
566
|
+
body, key, headers, compress=False
|
|
567
|
+
)
|
|
568
|
+
else:
|
|
569
|
+
result_key, source_size, compressed_size = self._put_direct(
|
|
570
|
+
contents, key, headers, compress=compress_with != "none"
|
|
571
|
+
)
|
|
468
572
|
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
if
|
|
472
|
-
metrics.
|
|
473
|
-
if encoding != "none":
|
|
474
|
-
metrics.record_compressed_size(body.tell(), encoding)
|
|
573
|
+
if precompressed is None and source_size is not None:
|
|
574
|
+
metrics.record_uncompressed_size(source_size)
|
|
575
|
+
if encoding != "none" and compressed_size is not None:
|
|
576
|
+
metrics.record_compressed_size(compressed_size, encoding)
|
|
475
577
|
|
|
476
578
|
# Set after the response, since the key may be server-generated.
|
|
477
|
-
span.set_attribute("objectstore.key",
|
|
579
|
+
span.set_attribute("objectstore.key", result_key)
|
|
478
580
|
span.set_attribute("objectstore.compression", encoding)
|
|
479
581
|
if metrics.uncompressed_size is not None:
|
|
480
582
|
span.set_attribute(
|
|
@@ -484,7 +586,62 @@ class Session:
|
|
|
484
586
|
span.set_attribute(
|
|
485
587
|
"objectstore.compressed_size", metrics.compressed_size
|
|
486
588
|
)
|
|
487
|
-
return
|
|
589
|
+
return result_key
|
|
590
|
+
|
|
591
|
+
def _put_direct(
|
|
592
|
+
self,
|
|
593
|
+
contents: bytes | IO[bytes],
|
|
594
|
+
key: str | None,
|
|
595
|
+
headers: dict[str, str],
|
|
596
|
+
compress: bool,
|
|
597
|
+
) -> tuple[str, int | None, int | None]:
|
|
598
|
+
"""Return the key and final source/body positions when available."""
|
|
599
|
+
with (
|
|
600
|
+
BytesIO(contents) if isinstance(contents, bytes) else nullcontext(contents)
|
|
601
|
+
) as original_body:
|
|
602
|
+
body = original_body
|
|
603
|
+
retries = None # by default use the pool's value, set by the Client
|
|
604
|
+
try:
|
|
605
|
+
if compress:
|
|
606
|
+
cctx = zstandard.ZstdCompressor()
|
|
607
|
+
body = cctx.stream_reader(original_body, closefd=False)
|
|
608
|
+
body = cast(IO[bytes], utils._ZstdCompressionReaderWrapper(body))
|
|
609
|
+
|
|
610
|
+
if compress:
|
|
611
|
+
# For on-the-fly compression, don't attempt read retries,
|
|
612
|
+
# as the stream cannot be rewound after data has been consumed.
|
|
613
|
+
pool_retries = self._pool.retries
|
|
614
|
+
if isinstance(pool_retries, urllib3.Retry):
|
|
615
|
+
retries = pool_retries.new(read=0)
|
|
616
|
+
elif isinstance(pool_retries, int):
|
|
617
|
+
retries = urllib3.Retry(pool_retries, read=0)
|
|
618
|
+
|
|
619
|
+
response = self._pool.request(
|
|
620
|
+
"POST" if not key else "PUT",
|
|
621
|
+
self._make_url(key),
|
|
622
|
+
body=body,
|
|
623
|
+
headers=headers,
|
|
624
|
+
preload_content=True,
|
|
625
|
+
decode_content=True,
|
|
626
|
+
retries=retries,
|
|
627
|
+
)
|
|
628
|
+
raise_for_status(response)
|
|
629
|
+
res = response.json()
|
|
630
|
+
|
|
631
|
+
# Read positions after streaming, without failing an upload if
|
|
632
|
+
# the source does not support tell().
|
|
633
|
+
try:
|
|
634
|
+
source_size = original_body.tell()
|
|
635
|
+
except (AttributeError, OSError, ValueError):
|
|
636
|
+
source_size = None
|
|
637
|
+
try:
|
|
638
|
+
compressed_size = body.tell()
|
|
639
|
+
except (AttributeError, OSError, ValueError):
|
|
640
|
+
compressed_size = None
|
|
641
|
+
return res["key"], source_size, compressed_size
|
|
642
|
+
finally:
|
|
643
|
+
if body is not original_body:
|
|
644
|
+
body.close()
|
|
488
645
|
|
|
489
646
|
def get(
|
|
490
647
|
self,
|
|
@@ -779,6 +936,74 @@ class Session:
|
|
|
779
936
|
)
|
|
780
937
|
raise_for_status(response)
|
|
781
938
|
|
|
939
|
+
def _create_upload(
|
|
940
|
+
self,
|
|
941
|
+
object_length: int,
|
|
942
|
+
key: str | None = None,
|
|
943
|
+
compression: Compression | None = None,
|
|
944
|
+
content_type: str | None = None,
|
|
945
|
+
metadata: dict[str, str] | None = None,
|
|
946
|
+
expiration_policy: ExpirationPolicy | None = None,
|
|
947
|
+
origin: str | None = None,
|
|
948
|
+
filename: str | None = None,
|
|
949
|
+
) -> _resumable.ResumableUpload | None:
|
|
950
|
+
"""Create a resumable upload, or return None if the server declines.
|
|
951
|
+
|
|
952
|
+
`object_length` is the total length of the object to be uploaded.
|
|
953
|
+
Unlike regular uploads, the client does not automatically compress chunk
|
|
954
|
+
contents.
|
|
955
|
+
The caller must pre-compress the object according to `compression`, then
|
|
956
|
+
create the upload with the post-compression `object_length`. Offsets
|
|
957
|
+
similarly refer to the bytes after compression.
|
|
958
|
+
"""
|
|
959
|
+
if object_length < 0:
|
|
960
|
+
raise ValueError("Object length must not be negative")
|
|
961
|
+
if compression is not None and compression not in ("none", "zstd"):
|
|
962
|
+
raise ValueError(f"Invalid compression: {compression}")
|
|
963
|
+
|
|
964
|
+
headers = self._metadata_headers(
|
|
965
|
+
compression=compression,
|
|
966
|
+
content_type=content_type,
|
|
967
|
+
metadata=metadata,
|
|
968
|
+
expiration_policy=expiration_policy,
|
|
969
|
+
origin=origin,
|
|
970
|
+
filename=filename,
|
|
971
|
+
)
|
|
972
|
+
headers["Upload-Length"] = str(object_length)
|
|
973
|
+
|
|
974
|
+
key = key or None
|
|
975
|
+
with (
|
|
976
|
+
storage_span(
|
|
977
|
+
"resumable.create", self._usecase, self._scope, key=key
|
|
978
|
+
) as span,
|
|
979
|
+
measure_storage_operation(
|
|
980
|
+
self._metrics_backend, "resumable.create", self._usecase.name
|
|
981
|
+
),
|
|
982
|
+
):
|
|
983
|
+
response = self._pool.request(
|
|
984
|
+
"PUT" if key else "POST",
|
|
985
|
+
f"{self._make_url(key)}?upload_type=resumable",
|
|
986
|
+
headers=headers,
|
|
987
|
+
preload_content=True,
|
|
988
|
+
decode_content=True,
|
|
989
|
+
)
|
|
990
|
+
if response.status == 501:
|
|
991
|
+
return None
|
|
992
|
+
raise_for_status(response)
|
|
993
|
+
res = response.json()
|
|
994
|
+
span.set_attribute("objectstore.key", res["key"])
|
|
995
|
+
return _resumable.ResumableUpload(
|
|
996
|
+
self,
|
|
997
|
+
res["key"],
|
|
998
|
+
res["session"],
|
|
999
|
+
object_length,
|
|
1000
|
+
res.get("granularity", 0),
|
|
1001
|
+
)
|
|
1002
|
+
|
|
1003
|
+
def _resume_upload(self, key: str, token: str) -> _resumable.ResumableUpload:
|
|
1004
|
+
"""Reconstruct a resumable upload handle, without contacting the server."""
|
|
1005
|
+
return _resumable.ResumableUpload(self, key, token)
|
|
1006
|
+
|
|
782
1007
|
def initiate_multipart_upload(
|
|
783
1008
|
self,
|
|
784
1009
|
*,
|
|
@@ -805,28 +1030,14 @@ class Session:
|
|
|
805
1030
|
if compression and compression not in ("none", "zstd"):
|
|
806
1031
|
raise ValueError(f"Invalid compression: {compression}")
|
|
807
1032
|
|
|
808
|
-
headers = self.
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
expiration_policy = expiration_policy or self._usecase._expiration_policy
|
|
818
|
-
if expiration_policy:
|
|
819
|
-
headers[HEADER_EXPIRATION] = format_expiration(expiration_policy)
|
|
820
|
-
|
|
821
|
-
if origin:
|
|
822
|
-
headers[HEADER_ORIGIN] = utils.encode_header_value(origin)
|
|
823
|
-
|
|
824
|
-
if filename is not None:
|
|
825
|
-
headers[HEADER_FILENAME] = utils.encode_header_value(filename)
|
|
826
|
-
|
|
827
|
-
if metadata:
|
|
828
|
-
for k, v in metadata.items():
|
|
829
|
-
headers[f"{HEADER_META_PREFIX}{k}"] = utils.encode_header_value(v)
|
|
1033
|
+
headers = self._metadata_headers(
|
|
1034
|
+
compression=compression or self._usecase._compression,
|
|
1035
|
+
content_type=content_type,
|
|
1036
|
+
metadata=metadata,
|
|
1037
|
+
expiration_policy=expiration_policy,
|
|
1038
|
+
origin=origin,
|
|
1039
|
+
filename=filename,
|
|
1040
|
+
)
|
|
830
1041
|
|
|
831
1042
|
if key == "":
|
|
832
1043
|
key = None
|
|
@@ -6,7 +6,7 @@ import urllib3
|
|
|
6
6
|
class RequestError(Exception):
|
|
7
7
|
"""Exception raised if an API call to Objectstore fails."""
|
|
8
8
|
|
|
9
|
-
def __init__(self, message: str, status: int, response: str):
|
|
9
|
+
def __init__(self, message: str, status: int | None, response: str | None):
|
|
10
10
|
super().__init__(message)
|
|
11
11
|
self.status = status
|
|
12
12
|
self.response = response
|
|
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
|
|
File without changes
|
|
File without changes
|