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.
Files changed (17) hide show
  1. {objectstore_client-0.2.8 → objectstore_client-0.2.10}/PKG-INFO +9 -1
  2. {objectstore_client-0.2.8 → objectstore_client-0.2.10}/README.md +8 -0
  3. {objectstore_client-0.2.8 → objectstore_client-0.2.10}/pyproject.toml +1 -1
  4. objectstore_client-0.2.10/src/objectstore_client/_resumable.py +385 -0
  5. {objectstore_client-0.2.8 → objectstore_client-0.2.10}/src/objectstore_client/client.py +292 -81
  6. {objectstore_client-0.2.8 → objectstore_client-0.2.10}/src/objectstore_client/errors.py +1 -1
  7. {objectstore_client-0.2.8 → objectstore_client-0.2.10}/LICENSE.md +0 -0
  8. {objectstore_client-0.2.8 → objectstore_client-0.2.10}/src/objectstore_client/__init__.py +0 -0
  9. {objectstore_client-0.2.8 → objectstore_client-0.2.10}/src/objectstore_client/auth.py +0 -0
  10. {objectstore_client-0.2.8 → objectstore_client-0.2.10}/src/objectstore_client/metadata.py +0 -0
  11. {objectstore_client-0.2.8 → objectstore_client-0.2.10}/src/objectstore_client/metrics.py +0 -0
  12. {objectstore_client-0.2.8 → objectstore_client-0.2.10}/src/objectstore_client/multipart.py +0 -0
  13. {objectstore_client-0.2.8 → objectstore_client-0.2.10}/src/objectstore_client/presign.py +0 -0
  14. {objectstore_client-0.2.8 → objectstore_client-0.2.10}/src/objectstore_client/py.typed +0 -0
  15. {objectstore_client-0.2.8 → objectstore_client-0.2.10}/src/objectstore_client/scope.py +0 -0
  16. {objectstore_client-0.2.8 → objectstore_client-0.2.10}/src/objectstore_client/tracing.py +0 -0
  17. {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.8
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.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "objectstore-client"
3
- version = "0.2.8"
3
+ version = "0.2.10"
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" }]
@@ -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
- name: str
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
- self.name = name
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 `default_compression` if none is given
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
- headers = self._make_headers()
408
- body = BytesIO(contents) if isinstance(contents, bytes) else contents
409
- original_body: IO[bytes] = body
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
- if filename is not None:
432
- headers[HEADER_FILENAME] = utils.encode_header_value(filename)
433
-
434
- if metadata:
435
- for k, v in metadata.items():
436
- headers[f"{HEADER_META_PREFIX}{k}"] = utils.encode_header_value(v)
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
- retries = None # by default use the pool's value, set by the Client
448
- if compress_with != "none":
449
- # For on-the-fly compression, don't attempt read retries,
450
- # as the stream cannot be rewound after data has been consumed.
451
- pool_retries = self._pool.retries
452
- if isinstance(pool_retries, urllib3.Retry):
453
- retries = pool_retries.new(read=0)
454
- elif isinstance(pool_retries, int):
455
- retries = urllib3.Retry(pool_retries, read=0)
456
-
457
- response = self._pool.request(
458
- "POST" if not key else "PUT",
459
- self._make_url(key),
460
- body=body,
461
- headers=headers,
462
- preload_content=True,
463
- decode_content=True,
464
- retries=retries,
465
- )
466
- raise_for_status(response)
467
- res = response.json()
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
- # Must do this after streaming `body` as that's what is responsible
470
- # for advancing the seek position in both streams
471
- if precompressed is None:
472
- metrics.record_uncompressed_size(original_body.tell())
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", res["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 res["key"]
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._make_headers()
809
-
810
- compression = compression or self._usecase._compression
811
- if compression and compression != "none":
812
- headers["Content-Encoding"] = compression
813
-
814
- if content_type:
815
- headers["Content-Type"] = content_type
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