Skip to content

Commit 3928aa0

Browse files
authored
feat: add support for soft delete (#1229)
* feat: add support for soft delete * add restore, get object, list_objects, unit tests * integration test * update restore_blob * SoftDeletePolicy data class * update docstrings; address comments
1 parent 1d19423 commit 3928aa0

File tree

8 files changed

+570
-0
lines changed

8 files changed

+570
-0
lines changed

‎google/cloud/storage/_helpers.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,7 @@ def reload(
225225
if_metageneration_not_match=None,
226226
timeout=_DEFAULT_TIMEOUT,
227227
retry=DEFAULT_RETRY,
228+
soft_deleted=None,
228229
):
229230
"""Reload properties from Cloud Storage.
230231
@@ -270,6 +271,13 @@ def reload(
270271
:type retry: google.api_core.retry.Retry or google.cloud.storage.retry.ConditionalRetryPolicy
271272
:param retry:
272273
(Optional) How to retry the RPC. See: :ref:`configuring_retries`
274+
275+
:type soft_deleted: bool
276+
:param soft_deleted:
277+
(Optional) If True, looks for a soft-deleted object. Will only return
278+
the object metadata if the object exists and is in a soft-deleted state.
279+
:attr:`generation` is required to be set on the blob if ``soft_deleted`` is set to True.
280+
See: https://cloud.google.com/storage/docs/soft-delete
273281
"""
274282
client = self._require_client(client)
275283
query_params = self._query_params
@@ -283,6 +291,8 @@ def reload(
283291
if_metageneration_match=if_metageneration_match,
284292
if_metageneration_not_match=if_metageneration_not_match,
285293
)
294+
if soft_deleted is not None:
295+
query_params["softDeleted"] = soft_deleted
286296
headers = self._encryption_headers()
287297
_add_etag_match_headers(
288298
headers, if_etag_match=if_etag_match, if_etag_not_match=if_etag_not_match

‎google/cloud/storage/blob.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -650,6 +650,7 @@ def exists(
650650
if_metageneration_not_match=None,
651651
timeout=_DEFAULT_TIMEOUT,
652652
retry=DEFAULT_RETRY,
653+
soft_deleted=None,
653654
):
654655
"""Determines whether or not this blob exists.
655656
@@ -694,6 +695,13 @@ def exists(
694695
:param retry:
695696
(Optional) How to retry the RPC. See: :ref:`configuring_retries`
696697
698+
:type soft_deleted: bool
699+
:param soft_deleted:
700+
(Optional) If True, looks for a soft-deleted object. Will only return True
701+
if the object exists and is in a soft-deleted state.
702+
:attr:`generation` is required to be set on the blob if ``soft_deleted`` is set to True.
703+
See: https://cloud.google.com/storage/docs/soft-delete
704+
697705
:rtype: bool
698706
:returns: True if the blob exists in Cloud Storage.
699707
"""
@@ -702,6 +710,8 @@ def exists(
702710
# minimize the returned payload.
703711
query_params = self._query_params
704712
query_params["fields"] = "name"
713+
if soft_deleted is not None:
714+
query_params["softDeleted"] = soft_deleted
705715

706716
_add_generation_match_parameters(
707717
query_params,
@@ -4700,6 +4710,32 @@ def retention(self):
47004710
info = self._properties.get("retention", {})
47014711
return Retention.from_api_repr(info, self)
47024712

4713+
@property
4714+
def soft_delete_time(self):
4715+
"""If this object has been soft-deleted, returns the time at which it became soft-deleted.
4716+
4717+
:rtype: :class:`datetime.datetime` or ``NoneType``
4718+
:returns:
4719+
(readonly) The time that the object became soft-deleted.
4720+
Note this property is only set for soft-deleted objects.
4721+
"""
4722+
soft_delete_time = self._properties.get("softDeleteTime")
4723+
if soft_delete_time is not None:
4724+
return _rfc3339_nanos_to_datetime(soft_delete_time)
4725+
4726+
@property
4727+
def hard_delete_time(self):
4728+
"""If this object has been soft-deleted, returns the time at which it will be permanently deleted.
4729+
4730+
:rtype: :class:`datetime.datetime` or ``NoneType``
4731+
:returns:
4732+
(readonly) The time that the object will be permanently deleted.
4733+
Note this property is only set for soft-deleted objects.
4734+
"""
4735+
hard_delete_time = self._properties.get("hardDeleteTime")
4736+
if hard_delete_time is not None:
4737+
return _rfc3339_nanos_to_datetime(hard_delete_time)
4738+
47034739

47044740
def _get_host_name(connection):
47054741
"""Returns the host name from the given connection.

‎google/cloud/storage/bucket.py

Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1188,6 +1188,7 @@ def get_blob(
11881188
if_metageneration_not_match=None,
11891189
timeout=_DEFAULT_TIMEOUT,
11901190
retry=DEFAULT_RETRY,
1191+
soft_deleted=None,
11911192
**kwargs,
11921193
):
11931194
"""Get a blob object by name.
@@ -1248,6 +1249,13 @@ def get_blob(
12481249
:param retry:
12491250
(Optional) How to retry the RPC. See: :ref:`configuring_retries`
12501251
1252+
:type soft_deleted: bool
1253+
:param soft_deleted:
1254+
(Optional) If True, looks for a soft-deleted object. Will only return
1255+
the object metadata if the object exists and is in a soft-deleted state.
1256+
Object ``generation`` is required if ``soft_deleted`` is set to True.
1257+
See: https://cloud.google.com/storage/docs/soft-delete
1258+
12511259
:param kwargs: Keyword arguments to pass to the
12521260
:class:`~google.cloud.storage.blob.Blob` constructor.
12531261
@@ -1275,6 +1283,7 @@ def get_blob(
12751283
if_metageneration_match=if_metageneration_match,
12761284
if_metageneration_not_match=if_metageneration_not_match,
12771285
retry=retry,
1286+
soft_deleted=soft_deleted,
12781287
)
12791288
except NotFound:
12801289
return None
@@ -1297,6 +1306,7 @@ def list_blobs(
12971306
timeout=_DEFAULT_TIMEOUT,
12981307
retry=DEFAULT_RETRY,
12991308
match_glob=None,
1309+
soft_deleted=None,
13001310
):
13011311
"""Return an iterator used to find blobs in the bucket.
13021312
@@ -1378,6 +1388,13 @@ def list_blobs(
13781388
The string value must be UTF-8 encoded. See:
13791389
https://cloud.google.com/storage/docs/json_api/v1/objects/list#list-object-glob
13801390
1391+
:type soft_deleted: bool
1392+
:param soft_deleted:
1393+
(Optional) If true, only soft-deleted objects will be listed as distinct results in order of increasing
1394+
generation number. This parameter can only be used successfully if the bucket has a soft delete policy.
1395+
Note ``soft_deleted`` and ``versions`` cannot be set to True simultaneously. See:
1396+
https://cloud.google.com/storage/docs/soft-delete
1397+
13811398
:rtype: :class:`~google.api_core.page_iterator.Iterator`
13821399
:returns: Iterator of all :class:`~google.cloud.storage.blob.Blob`
13831400
in this bucket matching the arguments.
@@ -1398,6 +1415,7 @@ def list_blobs(
13981415
timeout=timeout,
13991416
retry=retry,
14001417
match_glob=match_glob,
1418+
soft_deleted=soft_deleted,
14011419
)
14021420

14031421
def list_notifications(
@@ -2060,6 +2078,110 @@ def rename_blob(
20602078
)
20612079
return new_blob
20622080

2081+
def restore_blob(
2082+
self,
2083+
blob_name,
2084+
client=None,
2085+
generation=None,
2086+
copy_source_acl=None,
2087+
projection=None,
2088+
if_generation_match=None,
2089+
if_generation_not_match=None,
2090+
if_metageneration_match=None,
2091+
if_metageneration_not_match=None,
2092+
timeout=_DEFAULT_TIMEOUT,
2093+
retry=DEFAULT_RETRY_IF_GENERATION_SPECIFIED,
2094+
):
2095+
"""Restores a soft-deleted object.
2096+
2097+
If :attr:`user_project` is set on the bucket, bills the API request to that project.
2098+
2099+
See [API reference docs](https://cloud.google.com/storage/docs/json_api/v1/objects/restore)
2100+
2101+
:type blob_name: str
2102+
:param blob_name: The name of the blob to be restored.
2103+
2104+
:type client: :class:`~google.cloud.storage.client.Client`
2105+
:param client: (Optional) The client to use. If not passed, falls back
2106+
to the ``client`` stored on the current bucket.
2107+
2108+
:type generation: long
2109+
:param generation: (Optional) If present, selects a specific revision of this object.
2110+
2111+
:type copy_source_acl: bool
2112+
:param copy_source_acl: (Optional) If true, copy the soft-deleted object's access controls.
2113+
2114+
:type projection: str
2115+
:param projection: (Optional) Specifies the set of properties to return.
2116+
If used, must be 'full' or 'noAcl'.
2117+
2118+
:type if_generation_match: long
2119+
:param if_generation_match:
2120+
(Optional) See :ref:`using-if-generation-match`
2121+
2122+
:type if_generation_not_match: long
2123+
:param if_generation_not_match:
2124+
(Optional) See :ref:`using-if-generation-not-match`
2125+
2126+
:type if_metageneration_match: long
2127+
:param if_metageneration_match:
2128+
(Optional) See :ref:`using-if-metageneration-match`
2129+
2130+
:type if_metageneration_not_match: long
2131+
:param if_metageneration_not_match:
2132+
(Optional) See :ref:`using-if-metageneration-not-match`
2133+
2134+
:type timeout: float or tuple
2135+
:param timeout:
2136+
(Optional) The amount of time, in seconds, to wait
2137+
for the server response. See: :ref:`configuring_timeouts`
2138+
2139+
:type retry: google.api_core.retry.Retry or google.cloud.storage.retry.ConditionalRetryPolicy
2140+
:param retry:
2141+
(Optional) How to retry the RPC.
2142+
The default value is ``DEFAULT_RETRY_IF_GENERATION_SPECIFIED``, which
2143+
only restore operations with ``if_generation_match`` or ``generation`` set
2144+
will be retried.
2145+
2146+
Users can configure non-default retry behavior. A ``None`` value will
2147+
disable retries. A ``DEFAULT_RETRY`` value will enable retries
2148+
even if restore operations are not guaranteed to be idempotent.
2149+
See [Configuring Retries](https://cloud.google.com/python/docs/reference/storage/latest/retry_timeout).
2150+
2151+
:rtype: :class:`google.cloud.storage.blob.Blob`
2152+
:returns: The restored Blob.
2153+
"""
2154+
client = self._require_client(client)
2155+
query_params = {}
2156+
2157+
if self.user_project is not None:
2158+
query_params["userProject"] = self.user_project
2159+
if generation is not None:
2160+
query_params["generation"] = generation
2161+
if copy_source_acl is not None:
2162+
query_params["copySourceAcl"] = copy_source_acl
2163+
if projection is not None:
2164+
query_params["projection"] = projection
2165+
2166+
_add_generation_match_parameters(
2167+
query_params,
2168+
if_generation_match=if_generation_match,
2169+
if_generation_not_match=if_generation_not_match,
2170+
if_metageneration_match=if_metageneration_match,
2171+
if_metageneration_not_match=if_metageneration_not_match,
2172+
)
2173+
2174+
blob = Blob(bucket=self, name=blob_name)
2175+
api_response = client._post_resource(
2176+
f"{blob.path}/restore",
2177+
None,
2178+
query_params=query_params,
2179+
timeout=timeout,
2180+
retry=retry,
2181+
)
2182+
blob._set_properties(api_response)
2183+
return blob
2184+
20632185
@property
20642186
def cors(self):
20652187
"""Retrieve or set CORS policies configured for this bucket.
@@ -2227,6 +2349,18 @@ def iam_configuration(self):
22272349
info = self._properties.get("iamConfiguration", {})
22282350
return IAMConfiguration.from_api_repr(info, self)
22292351

2352+
@property
2353+
def soft_delete_policy(self):
2354+
"""Retrieve the soft delete policy for this bucket.
2355+
2356+
See https://cloud.google.com/storage/docs/soft-delete
2357+
2358+
:rtype: :class:`SoftDeletePolicy`
2359+
:returns: an instance for managing the bucket's soft delete policy.
2360+
"""
2361+
policy = self._properties.get("softDeletePolicy", {})
2362+
return SoftDeletePolicy.from_api_repr(policy, self)
2363+
22302364
@property
22312365
def lifecycle_rules(self):
22322366
"""Retrieve or set lifecycle rules configured for this bucket.
@@ -3432,6 +3566,102 @@ def generate_signed_url(
34323566
)
34333567

34343568

3569+
class SoftDeletePolicy(dict):
3570+
"""Map a bucket's soft delete policy.
3571+
3572+
See https://cloud.google.com/storage/docs/soft-delete
3573+
3574+
:type bucket: :class:`Bucket`
3575+
:param bucket: Bucket for which this instance is the policy.
3576+
3577+
:type retention_duration_seconds: int
3578+
:param retention_duration_seconds:
3579+
(Optional) The period of time in seconds that soft-deleted objects in the bucket
3580+
will be retained and cannot be permanently deleted.
3581+
3582+
:type effective_time: :class:`datetime.datetime`
3583+
:param effective_time:
3584+
(Optional) When the bucket's soft delete policy is effective.
3585+
This value should normally only be set by the back-end API.
3586+
"""
3587+
3588+
def __init__(self, bucket, **kw):
3589+
data = {}
3590+
retention_duration_seconds = kw.get("retention_duration_seconds")
3591+
data["retentionDurationSeconds"] = retention_duration_seconds
3592+
3593+
effective_time = kw.get("effective_time")
3594+
if effective_time is not None:
3595+
effective_time = _datetime_to_rfc3339(effective_time)
3596+
data["effectiveTime"] = effective_time
3597+
3598+
super().__init__(data)
3599+
self._bucket = bucket
3600+
3601+
@classmethod
3602+
def from_api_repr(cls, resource, bucket):
3603+
"""Factory: construct instance from resource.
3604+
3605+
:type resource: dict
3606+
:param resource: mapping as returned from API call.
3607+
3608+
:type bucket: :class:`Bucket`
3609+
:params bucket: Bucket for which this instance is the policy.
3610+
3611+
:rtype: :class:`SoftDeletePolicy`
3612+
:returns: Instance created from resource.
3613+
"""
3614+
instance = cls(bucket)
3615+
instance.update(resource)
3616+
return instance
3617+
3618+
@property
3619+
def bucket(self):
3620+
"""Bucket for which this instance is the policy.
3621+
3622+
:rtype: :class:`Bucket`
3623+
:returns: the instance's bucket.
3624+
"""
3625+
return self._bucket
3626+
3627+
@property
3628+
def retention_duration_seconds(self):
3629+
"""Get the retention duration of the bucket's soft delete policy.
3630+
3631+
:rtype: int or ``NoneType``
3632+
:returns: The period of time in seconds that soft-deleted objects in the bucket
3633+
will be retained and cannot be permanently deleted; Or ``None`` if the
3634+
property is not set.
3635+
"""
3636+
duration = self.get("retentionDurationSeconds")
3637+
if duration is not None:
3638+
return int(duration)
3639+
3640+
@retention_duration_seconds.setter
3641+
def retention_duration_seconds(self, value):
3642+
"""Set the retention duration of the bucket's soft delete policy.
3643+
3644+
:type value: int
3645+
:param value:
3646+
The period of time in seconds that soft-deleted objects in the bucket
3647+
will be retained and cannot be permanently deleted.
3648+
"""
3649+
self["retentionDurationSeconds"] = value
3650+
self.bucket._patch_property("softDeletePolicy", self)
3651+
3652+
@property
3653+
def effective_time(self):
3654+
"""Get the effective time of the bucket's soft delete policy.
3655+
3656+
:rtype: datetime.datetime or ``NoneType``
3657+
:returns: point-in time at which the bucket's soft delte policy is
3658+
effective, or ``None`` if the property is not set.
3659+
"""
3660+
timestamp = self.get("effectiveTime")
3661+
if timestamp is not None:
3662+
return _rfc3339_nanos_to_datetime(timestamp)
3663+
3664+
34353665
def _raise_if_len_differs(expected_len, **generation_match_args):
34363666
"""
34373667
Raise an error if any generation match argument

0 commit comments

Comments
 (0)