Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add support for soft delete #1229

Merged
merged 7 commits into from
Mar 18, 2024
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
3 changes: 3 additions & 0 deletions google/cloud/storage/_helpers.py
Expand Up @@ -225,6 +225,7 @@ def reload(
if_metageneration_not_match=None,
timeout=_DEFAULT_TIMEOUT,
retry=DEFAULT_RETRY,
**kwargs,
cojenco marked this conversation as resolved.
Show resolved Hide resolved
):
"""Reload properties from Cloud Storage.

Expand Down Expand Up @@ -283,6 +284,8 @@ def reload(
if_metageneration_match=if_metageneration_match,
if_metageneration_not_match=if_metageneration_not_match,
)
if kwargs.get("soft_deleted") is not None:
query_params["softDeleted"] = kwargs["soft_deleted"]
headers = self._encryption_headers()
_add_etag_match_headers(
headers, if_etag_match=if_etag_match, if_etag_not_match=if_etag_not_match
Expand Down
124 changes: 124 additions & 0 deletions google/cloud/storage/blob.py
Expand Up @@ -650,6 +650,7 @@ def exists(
if_metageneration_not_match=None,
timeout=_DEFAULT_TIMEOUT,
retry=DEFAULT_RETRY,
soft_deleted=None,
):
"""Determines whether or not this blob exists.

Expand Down Expand Up @@ -694,6 +695,11 @@ def exists(
:param retry:
(Optional) How to retry the RPC. See: :ref:`configuring_retries`

:type soft_deleted: bool
:param soft_deleted:
(Optional) If true, determines whether or not this soft-deleted object exists.
cojenco marked this conversation as resolved.
Show resolved Hide resolved
:attr:`generation` is required to be set on the blob if ``soft_deleted`` is set to True.

:rtype: bool
:returns: True if the blob exists in Cloud Storage.
"""
Expand All @@ -702,6 +708,8 @@ def exists(
# minimize the returned payload.
query_params = self._query_params
query_params["fields"] = "name"
if soft_deleted is not None:
query_params["softDeleted"] = soft_deleted

_add_generation_match_parameters(
query_params,
Expand Down Expand Up @@ -4140,6 +4148,96 @@ def open(
"Supported modes strings are 'r', 'rb', 'rt', 'w', 'wb', and 'wt' only."
)

def restore(
self,
client=None,
generation=None,
copy_source_acl=None,
projection=None,
if_generation_match=None,
if_generation_not_match=None,
if_metageneration_match=None,
if_metageneration_not_match=None,
timeout=_DEFAULT_TIMEOUT,
retry=DEFAULT_RETRY_IF_GENERATION_SPECIFIED,
):
"""Restores a soft-deleted object.

If :attr:`user_project` is set on the bucket, bills the API request
to that project.

:type client: :class:`~google.cloud.storage.client.Client`
:param client:
(Optional) The client to use. If not passed, falls back to the
``client`` stored on the blob's bucket.

:type generation: long
:param generation:
(Optional) If present, selects a specific revision of this object.

:type copy_source_acl: bool
:param copy_source_acl:
(Optional) If true, copy the soft-deleted object's access controls.

:type projection: str
:param projection:
(Optional) Specifies the set of properties to return.
If used, must be 'full' or 'noAcl'.

:type if_generation_match: long
:param if_generation_match:
(Optional) See :ref:`using-if-generation-match`

:type if_generation_not_match: long
:param if_generation_not_match:
(Optional) See :ref:`using-if-generation-not-match`

:type if_metageneration_match: long
:param if_metageneration_match:
(Optional) See :ref:`using-if-metageneration-match`

:type if_metageneration_not_match: long
:param if_metageneration_not_match:
(Optional) See :ref:`using-if-metageneration-not-match`

:type timeout: float or tuple
:param timeout:
(Optional) The amount of time, in seconds, to wait
for the server response. See: :ref:`configuring_timeouts`

:type retry: google.api_core.retry.Retry or google.cloud.storage.retry.ConditionalRetryPolicy
:param retry:
(Optional) How to retry the RPC. See: :ref:`configuring_retries`
"""

client = self._require_client(client)
query_params = self._query_params
if generation is not None:
query_params["generation"] = generation
if copy_source_acl is not None:
query_params["copySourceAcl"] = copy_source_acl
if projection is not None:
query_params["projection"] = projection

_add_generation_match_parameters(
query_params,
if_generation_match=if_generation_match,
if_generation_not_match=if_generation_not_match,
if_metageneration_match=if_metageneration_match,
if_metageneration_not_match=if_metageneration_not_match,
)

restored_blob = Blob(bucket=self.bucket, name=self.name)
api_response = client._post_resource(
f"{self.path}/restore",
None,
query_params=query_params,
timeout=timeout,
retry=retry,
)
restored_blob._set_properties(api_response)
return restored_blob

cache_control = _scalar_property("cacheControl")
"""HTTP 'Cache-Control' header for this object.

Expand Down Expand Up @@ -4700,6 +4798,32 @@ def retention(self):
info = self._properties.get("retention", {})
return Retention.from_api_repr(info, self)

@property
def soft_delete_time(self):
"""If this object has been soft-deleted, returns the time at which it became soft-deleted.

:rtype: :class:`datetime.datetime` or ``NoneType``
:returns:
(readonly) The time that the object became soft-deleted.
Note this property is only set for soft-deleted objects.
"""
soft_delete_time = self._properties.get("softDeleteTime")
if soft_delete_time is not None:
return _rfc3339_nanos_to_datetime(soft_delete_time)

@property
def hard_delete_time(self):
cojenco marked this conversation as resolved.
Show resolved Hide resolved
"""If this object has been soft-deleted, returns the time at which it will be permanently deleted.

:rtype: :class:`datetime.datetime` or ``NoneType``
:returns:
(readonly) The time that the object will be permanently deleted.
Note this property is only set for soft-deleted objects.
"""
hard_delete_time = self._properties.get("hardDeleteTime")
if hard_delete_time is not None:
return _rfc3339_nanos_to_datetime(hard_delete_time)


def _get_host_name(connection):
"""Returns the host name from the given connection.
Expand Down
57 changes: 57 additions & 0 deletions google/cloud/storage/bucket.py
Expand Up @@ -1188,6 +1188,7 @@ def get_blob(
if_metageneration_not_match=None,
timeout=_DEFAULT_TIMEOUT,
retry=DEFAULT_RETRY,
soft_deleted=None,
**kwargs,
):
"""Get a blob object by name.
Expand Down Expand Up @@ -1248,6 +1249,11 @@ def get_blob(
:param retry:
(Optional) How to retry the RPC. See: :ref:`configuring_retries`

:type soft_deleted: bool
:param soft_deleted:
(Optional) If true, returns the soft-deleted object.
Object ``generation`` is required if ``soft_deleted`` is set to True.

:param kwargs: Keyword arguments to pass to the
:class:`~google.cloud.storage.blob.Blob` constructor.

Expand Down Expand Up @@ -1275,6 +1281,7 @@ def get_blob(
if_metageneration_match=if_metageneration_match,
if_metageneration_not_match=if_metageneration_not_match,
retry=retry,
soft_deleted=soft_deleted,
)
except NotFound:
return None
Expand All @@ -1297,6 +1304,7 @@ def list_blobs(
timeout=_DEFAULT_TIMEOUT,
retry=DEFAULT_RETRY,
match_glob=None,
soft_deleted=None,
):
"""Return an iterator used to find blobs in the bucket.

Expand Down Expand Up @@ -1378,6 +1386,11 @@ def list_blobs(
The string value must be UTF-8 encoded. See:
https://cloud.google.com/storage/docs/json_api/v1/objects/list#list-object-glob

:type soft_deleted: bool
:param soft_deleted:
(Optional) If true, only soft-deleted object versions will be listed as distinct results in order
of generation number. Note ``soft_deleted`` and ``versions`` cannot be set to True simultaneously.

:rtype: :class:`~google.api_core.page_iterator.Iterator`
:returns: Iterator of all :class:`~google.cloud.storage.blob.Blob`
in this bucket matching the arguments.
Expand All @@ -1398,6 +1411,7 @@ def list_blobs(
timeout=timeout,
retry=retry,
match_glob=match_glob,
soft_deleted=soft_deleted,
)

def list_notifications(
Expand Down Expand Up @@ -2785,6 +2799,49 @@ def object_retention_mode(self):
if object_retention is not None:
return object_retention.get("mode")

@property
def soft_delete_retention_duration_seconds(self):
cojenco marked this conversation as resolved.
Show resolved Hide resolved
"""Retrieve the retention duration of the bucket's soft delete policy.

:rtype: int or ``NoneType``
:returns: The period of time in seconds that soft-deleted objects in the bucket
will be retained and cannot be permanently deleted; Or ``None`` if the
property is not set.
"""
policy = self._properties.get("softDeletePolicy")
if policy is not None:
duration = policy.get("retentionDurationSeconds")
if duration is not None:
return int(duration)

@soft_delete_retention_duration_seconds.setter
def soft_delete_retention_duration_seconds(self, value):
"""Set the retention duration of the bucket's soft delete policy.

:type value: int
:param value:
The period of time in seconds that soft-deleted objects in the bucket
will be retained and cannot be permanently deleted.
"""
policy = self._properties.setdefault("softDeletePolicy", {})
if value is not None:
policy["retentionDurationSeconds"] = str(value)
self._patch_property("softDeletePolicy", policy)

@property
def soft_delete_effective_time(self):
"""Retrieve the effective time of the bucket's soft delete policy.

:rtype: datetime.datetime or ``NoneType``
:returns: point-in time at which the bucket's soft delte policy is
effective, or ``None`` if the property is not set.
"""
policy = self._properties.get("softDeletePolicy")
if policy is not None:
timestamp = policy.get("effectiveTime")
if timestamp is not None:
return _rfc3339_nanos_to_datetime(timestamp)

def configure_website(self, main_page_suffix=None, not_found_page=None):
"""Configure website-related properties.

Expand Down
9 changes: 9 additions & 0 deletions google/cloud/storage/client.py
Expand Up @@ -1184,6 +1184,7 @@ def list_blobs(
timeout=_DEFAULT_TIMEOUT,
retry=DEFAULT_RETRY,
match_glob=None,
soft_deleted=None,
):
"""Return an iterator used to find blobs in the bucket.

Expand Down Expand Up @@ -1282,6 +1283,11 @@ def list_blobs(
The string value must be UTF-8 encoded. See:
https://cloud.google.com/storage/docs/json_api/v1/objects/list#list-object-glob

soft_deleted (bool):
(Optional) If true, only soft-deleted object versions will be listed as distinct results in order
of generation number. Note ``soft_deleted`` and ``versions`` cannot be set to True simultaneously.
https://cloud.google.com/storage/docs/json_api/v1/objects/list#list-object-glob

Returns:
Iterator of all :class:`~google.cloud.storage.blob.Blob`
in this bucket matching the arguments. The RPC call
Expand Down Expand Up @@ -1318,6 +1324,9 @@ def list_blobs(
if fields is not None:
extra_params["fields"] = fields

if soft_deleted is not None:
extra_params["softDeleted"] = soft_deleted

if bucket.user_project is not None:
extra_params["userProject"] = bucket.user_project

Expand Down
49 changes: 49 additions & 0 deletions tests/system/test_bucket.py
Expand Up @@ -1141,3 +1141,52 @@ def test_config_autoclass_w_existing_bucket(
assert (
bucket.autoclass_terminal_storage_class_update_time != previous_tsc_update_time
)


def test_soft_delete_policy(
storage_client,
buckets_to_delete,
):
# Create a bucket with soft delete policy.
duration_secs = 7 * 86400
bucket = storage_client.bucket(_helpers.unique_name("w-soft-delete"))
bucket.soft_delete_retention_duration_seconds = duration_secs
bucket = _helpers.retry_429_503(storage_client.create_bucket)(bucket)
buckets_to_delete.append(bucket)

assert bucket.soft_delete_retention_duration_seconds == duration_secs
assert isinstance(bucket.soft_delete_effective_time, datetime.datetime)

# Insert an object and get object metadata prior soft-deleted.
payload = b"DEADBEEF"
blob_name = _helpers.unique_name("soft-delete")
blob = bucket.blob(blob_name)
blob.upload_from_string(payload)

blob = bucket.get_blob(blob_name)
gen = blob.generation
assert blob.soft_delete_time is None
assert blob.hard_delete_time is None

# Delete the object to enter soft-deleted state.
blob.delete()

iter_default = bucket.list_blobs()
assert len(list(iter_default)) == 0
iter_w_soft_delete = bucket.list_blobs(soft_deleted=True)
assert len(list(iter_w_soft_delete)) > 0

# Get the soft-deleted object.
soft_deleted_blob = bucket.get_blob(blob_name, generation=gen, soft_deleted=True)
assert soft_deleted_blob.soft_delete_time is not None
assert soft_deleted_blob.hard_delete_time is not None

# Restore the soft-deleted object.
restored_blob = soft_deleted_blob.restore()
assert restored_blob.generation != gen

# Patch the soft delete policy on an existing bucket.
new_duration_secs = 10 * 86400
bucket.soft_delete_retention_duration_seconds = new_duration_secs
bucket.patch()
assert bucket.soft_delete_retention_duration_seconds == new_duration_secs