objectstore-client 0.2.5__tar.gz → 0.2.7__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.5 → objectstore_client-0.2.7}/PKG-INFO +14 -1
  2. {objectstore_client-0.2.5 → objectstore_client-0.2.7}/README.md +13 -0
  3. {objectstore_client-0.2.5 → objectstore_client-0.2.7}/pyproject.toml +1 -1
  4. {objectstore_client-0.2.5 → objectstore_client-0.2.7}/src/objectstore_client/__init__.py +7 -1
  5. {objectstore_client-0.2.5 → objectstore_client-0.2.7}/src/objectstore_client/client.py +90 -1
  6. objectstore_client-0.2.7/src/objectstore_client/errors.py +39 -0
  7. {objectstore_client-0.2.5 → objectstore_client-0.2.7}/src/objectstore_client/metadata.py +2 -2
  8. objectstore_client-0.2.5/src/objectstore_client/errors.py +0 -22
  9. {objectstore_client-0.2.5 → objectstore_client-0.2.7}/LICENSE.md +0 -0
  10. {objectstore_client-0.2.5 → objectstore_client-0.2.7}/src/objectstore_client/auth.py +0 -0
  11. {objectstore_client-0.2.5 → objectstore_client-0.2.7}/src/objectstore_client/metrics.py +0 -0
  12. {objectstore_client-0.2.5 → objectstore_client-0.2.7}/src/objectstore_client/multipart.py +0 -0
  13. {objectstore_client-0.2.5 → objectstore_client-0.2.7}/src/objectstore_client/presign.py +0 -0
  14. {objectstore_client-0.2.5 → objectstore_client-0.2.7}/src/objectstore_client/py.typed +0 -0
  15. {objectstore_client-0.2.5 → objectstore_client-0.2.7}/src/objectstore_client/scope.py +0 -0
  16. {objectstore_client-0.2.5 → objectstore_client-0.2.7}/src/objectstore_client/tracing.py +0 -0
  17. {objectstore_client-0.2.5 → objectstore_client-0.2.7}/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.5
3
+ Version: 0.2.7
4
4
  Summary: Client SDK for Objectstore, the Sentry object storage platform
5
5
  Author: Sentry
6
6
  Author-email: Sentry <oss@sentry.io>
@@ -183,6 +183,19 @@ usecase = Usecase("attachments", expiration_policy=TimeToIdle(timedelta(days=30)
183
183
  session.put(b"payload", expiration_policy=TimeToLive(timedelta(hours=1)))
184
184
  ```
185
185
 
186
+ Use `Session.extend_expiry` to keep an existing object longer.
187
+
188
+ ```python
189
+ from objectstore_client import ExpiryExtensionRejected
190
+
191
+ try:
192
+ session.extend_expiry(key, from_now=timedelta(days=30))
193
+ except ExpiryExtensionRejected:
194
+ print("Object could not be extended")
195
+ ```
196
+
197
+ See the `Session.extend_expiry` docs for target options and error handling.
198
+
186
199
  ### Origin Tracking
187
200
 
188
201
  We encourage setting the `origin` on every upload to track where the payload was
@@ -64,6 +64,19 @@ usecase = Usecase("attachments", expiration_policy=TimeToIdle(timedelta(days=30)
64
64
  session.put(b"payload", expiration_policy=TimeToLive(timedelta(hours=1)))
65
65
  ```
66
66
 
67
+ Use `Session.extend_expiry` to keep an existing object longer.
68
+
69
+ ```python
70
+ from objectstore_client import ExpiryExtensionRejected
71
+
72
+ try:
73
+ session.extend_expiry(key, from_now=timedelta(days=30))
74
+ except ExpiryExtensionRejected:
75
+ print("Object could not be extended")
76
+ ```
77
+
78
+ See the `Session.extend_expiry` docs for target options and error handling.
79
+
67
80
  ### Origin Tracking
68
81
 
69
82
  We encourage setting the `origin` on every upload to track where the payload was
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "objectstore-client"
3
- version = "0.2.5"
3
+ version = "0.2.7"
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" }]
@@ -5,7 +5,11 @@ from objectstore_client.client import (
5
5
  Session,
6
6
  Usecase,
7
7
  )
8
- from objectstore_client.errors import RequestError
8
+ from objectstore_client.errors import (
9
+ ExpiryExtensionRejected,
10
+ ObjectNotFound,
11
+ RequestError,
12
+ )
9
13
  from objectstore_client.metadata import (
10
14
  Compression,
11
15
  ExpirationPolicy,
@@ -22,6 +26,8 @@ __all__ = [
22
26
  "Session",
23
27
  "GetResponse",
24
28
  "RequestError",
29
+ "ObjectNotFound",
30
+ "ExpiryExtensionRejected",
25
31
  "Compression",
26
32
  "ExpirationPolicy",
27
33
  "Metadata",
@@ -17,7 +17,12 @@ from urllib3.connectionpool import HTTPConnectionPool
17
17
 
18
18
  from objectstore_client import presign, utils
19
19
  from objectstore_client.auth import Permission, SecretKey, TokenProvider
20
- from objectstore_client.errors import raise_for_status
20
+ from objectstore_client.errors import (
21
+ ExpiryExtensionRejected,
22
+ ObjectNotFound,
23
+ RequestError,
24
+ raise_for_status,
25
+ )
21
26
  from objectstore_client.metadata import (
22
27
  HEADER_EXPIRATION,
23
28
  HEADER_FILENAME,
@@ -27,6 +32,7 @@ from objectstore_client.metadata import (
27
32
  ExpirationPolicy,
28
33
  Metadata,
29
34
  format_expiration,
35
+ format_timedelta,
30
36
  )
31
37
  from objectstore_client.metrics import (
32
38
  MetricsBackend,
@@ -671,6 +677,89 @@ class Session:
671
677
  span.set_attribute("objectstore.found", True)
672
678
  return Metadata.from_headers(response.headers)
673
679
 
680
+ def extend_expiry(
681
+ self,
682
+ key: str,
683
+ at: datetime | None = None,
684
+ from_creation: timedelta | None = None,
685
+ from_now: timedelta | None = None,
686
+ ) -> None:
687
+ """Extend an object's expiration deadline, preserving its payload.
688
+
689
+ Supply exactly one target: ``at`` is a timezone-aware absolute datetime;
690
+ ``from_creation`` is total lifetime since creation or replacement; and
691
+ ``from_now`` is lifetime from server request start. Relative targets are
692
+ resolved by the server, not added to the existing deadline. Fractional
693
+ duration seconds are truncated; absolute deadlines round upward to seconds.
694
+
695
+ An already-sufficient deadline succeeds without being shortened. Success
696
+ returns no value and does not indicate whether the deadline changed.
697
+ Requires object-write permission.
698
+
699
+ Actual extensions adjust TTL duration to approximately match the total
700
+ lifetime since creation; TTI duration remains unchanged.
701
+
702
+ Raises ``ValueError`` for missing or multiple targets, naive datetimes,
703
+ or negative durations. Zero durations are valid. Objects observed absent
704
+ or expired raise ``ObjectNotFound``. Rejected extensions raise
705
+ ``ExpiryExtensionRejected`` for non-expiring or concurrently changed
706
+ objects, or missing creation metadata needed for a creation-relative
707
+ target. Both exceptions subclass ``RequestError``. Rejection does not
708
+ guarantee that the object still exists, since it may be deleted concurrently.
709
+ Other HTTP errors propagate normally. Retrying ``from_now`` establishes
710
+ a new server-time anchor and can extend the deadline further.
711
+
712
+ Example::
713
+
714
+ from objectstore_client import ExpiryExtensionRejected, ObjectNotFound
715
+
716
+ try:
717
+ session.extend_expiry(key, from_now=timedelta(days=30))
718
+ except ObjectNotFound:
719
+ print("Object is missing or expired")
720
+ except ExpiryExtensionRejected:
721
+ print("Extension was rejected")
722
+ """
723
+
724
+ if sum(value is not None for value in (at, from_creation, from_now)) != 1:
725
+ raise ValueError("Supply exactly one of at, from_creation, or from_now")
726
+
727
+ extension: dict[str, str]
728
+ if at is not None:
729
+ if at.utcoffset() is None:
730
+ raise ValueError("at must be a timezone-aware datetime")
731
+ extension = {"at": at.astimezone(UTC).isoformat()}
732
+ else:
733
+ delta = from_creation if from_creation is not None else from_now
734
+ assert delta is not None
735
+ if delta < timedelta(0):
736
+ raise ValueError("Expiry extension duration must not be negative")
737
+ extension = {
738
+ "after": format_timedelta(delta),
739
+ "from": "creation" if from_creation is not None else "now",
740
+ }
741
+
742
+ headers = self._make_headers()
743
+ with (
744
+ storage_span("extend_expiry", self._usecase, self._scope, key=key),
745
+ measure_storage_operation(
746
+ self._metrics_backend, "extend_expiry", self._usecase.name
747
+ ),
748
+ ):
749
+ response = self._pool.request(
750
+ "PATCH",
751
+ self._make_url(key),
752
+ headers=headers,
753
+ json={"extend_expiry": extension},
754
+ preload_content=True,
755
+ )
756
+ error_type: type[RequestError] = RequestError
757
+ if response.status == 404:
758
+ error_type = ObjectNotFound
759
+ elif response.status == 409:
760
+ error_type = ExpiryExtensionRejected
761
+ raise_for_status(response, error_type=error_type)
762
+
674
763
  def delete(self, key: str) -> None:
675
764
  """
676
765
  Deletes the blob with the given `key`.
@@ -0,0 +1,39 @@
1
+ from __future__ import annotations
2
+
3
+ import urllib3
4
+
5
+
6
+ class RequestError(Exception):
7
+ """Exception raised if an API call to Objectstore fails."""
8
+
9
+ def __init__(self, message: str, status: int, response: str):
10
+ super().__init__(message)
11
+ self.status = status
12
+ self.response = response
13
+
14
+
15
+ class ObjectNotFound(RequestError):
16
+ """The object was observed to be absent or expired."""
17
+
18
+
19
+ class ExpiryExtensionRejected(RequestError):
20
+ """The object's expiration deadline could not be extended.
21
+
22
+ The object is non-expiring, lacks required creation metadata, or conflicts
23
+ with a conditional update. A conflict can result from concurrent deletion,
24
+ so this does not guarantee that the object still exists.
25
+ """
26
+
27
+
28
+ def raise_for_status(
29
+ response: urllib3.BaseHTTPResponse,
30
+ *,
31
+ error_type: type[RequestError] = RequestError,
32
+ ) -> None:
33
+ if response.status >= 400:
34
+ res = (response.data or response.read() or b"").decode("utf-8", "replace")
35
+ raise error_type(
36
+ f"Objectstore request failed with status {response.status}",
37
+ response.status,
38
+ res,
39
+ )
@@ -53,8 +53,8 @@ class Metadata:
53
53
  When using a Time To Idle expiration policy, this value will reflect the expiration
54
54
  timestamp present prior to the current access to the object.
55
55
 
56
- This field is computed by the server, it cannot be set by clients.
57
- Use `expiration_policy` to set an expiration policy instead.
56
+ This field is computed by the server. Use `expiration_policy` to set the
57
+ policy on upload, or `Session.extend_expiry` to extend an existing deadline.
58
58
  """
59
59
 
60
60
  origin: str | None
@@ -1,22 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import urllib3
4
-
5
-
6
- class RequestError(Exception):
7
- """Exception raised if an API call to Objectstore fails."""
8
-
9
- def __init__(self, message: str, status: int, response: str):
10
- super().__init__(message)
11
- self.status = status
12
- self.response = response
13
-
14
-
15
- def raise_for_status(response: urllib3.BaseHTTPResponse) -> None:
16
- if response.status >= 400:
17
- res = (response.data or response.read() or b"").decode("utf-8", "replace")
18
- raise RequestError(
19
- f"Objectstore request failed with status {response.status}",
20
- response.status,
21
- res,
22
- )