Skip to content

Commit 06ed15b

Browse files
authored
feat: add support for restore token (#1369)
* feat: add support for restore token * add unit tests coverage * update docstrings * fix docs
1 parent ab94efd commit 06ed15b

File tree

6 files changed

+137
-6
lines changed

6 files changed

+137
-6
lines changed

google/cloud/storage/_helpers.py

+10
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,7 @@ def reload(
226226
timeout=_DEFAULT_TIMEOUT,
227227
retry=DEFAULT_RETRY,
228228
soft_deleted=None,
229+
restore_token=None,
229230
):
230231
"""Reload properties from Cloud Storage.
231232
@@ -278,6 +279,13 @@ def reload(
278279
the object metadata if the object exists and is in a soft-deleted state.
279280
:attr:`generation` is required to be set on the blob if ``soft_deleted`` is set to True.
280281
See: https://cloud.google.com/storage/docs/soft-delete
282+
283+
:type restore_token: str
284+
:param restore_token:
285+
(Optional) The restore_token is required to retrieve a soft-deleted object only if
286+
its name and generation value do not uniquely identify it, and hierarchical namespace
287+
is enabled on the bucket. Otherwise, this parameter is optional.
288+
See: https://cloud.google.com/storage/docs/json_api/v1/objects/get
281289
"""
282290
client = self._require_client(client)
283291
query_params = self._query_params
@@ -296,6 +304,8 @@ def reload(
296304
# Soft delete reload requires a generation, even for targets
297305
# that don't include them in default query params (buckets).
298306
query_params["generation"] = self.generation
307+
if restore_token is not None:
308+
query_params["restoreToken"] = restore_token
299309
headers = self._encryption_headers()
300310
_add_etag_match_headers(
301311
headers, if_etag_match=if_etag_match, if_etag_not_match=if_etag_not_match

google/cloud/storage/blob.py

+23
Original file line numberDiff line numberDiff line change
@@ -653,6 +653,7 @@ def exists(
653653
timeout=_DEFAULT_TIMEOUT,
654654
retry=DEFAULT_RETRY,
655655
soft_deleted=None,
656+
restore_token=None,
656657
):
657658
"""Determines whether or not this blob exists.
658659
@@ -704,6 +705,13 @@ def exists(
704705
:attr:`generation` is required to be set on the blob if ``soft_deleted`` is set to True.
705706
See: https://cloud.google.com/storage/docs/soft-delete
706707
708+
:type restore_token: str
709+
:param restore_token:
710+
(Optional) The restore_token is required to retrieve a soft-deleted object only if
711+
its name and generation value do not uniquely identify it, and hierarchical namespace
712+
is enabled on the bucket. Otherwise, this parameter is optional.
713+
See: https://cloud.google.com/storage/docs/json_api/v1/objects/get
714+
707715
:rtype: bool
708716
:returns: True if the blob exists in Cloud Storage.
709717
"""
@@ -714,6 +722,8 @@ def exists(
714722
query_params["fields"] = "name"
715723
if soft_deleted is not None:
716724
query_params["softDeleted"] = soft_deleted
725+
if restore_token is not None:
726+
query_params["restoreToken"] = restore_token
717727

718728
_add_generation_match_parameters(
719729
query_params,
@@ -4794,6 +4804,19 @@ def hard_delete_time(self):
47944804
if hard_delete_time is not None:
47954805
return _rfc3339_nanos_to_datetime(hard_delete_time)
47964806

4807+
@property
4808+
def restore_token(self):
4809+
"""The restore token, a universally unique identifier (UUID), along with the object's
4810+
name and generation value, uniquely identifies a soft-deleted object.
4811+
This field is only returned for soft-deleted objects in hierarchical namespace buckets.
4812+
4813+
:rtype: string or ``NoneType``
4814+
:returns:
4815+
(readonly) The restore token used to differentiate soft-deleted objects with the same name and generation.
4816+
This field is only returned for soft-deleted objects in hierarchical namespace buckets.
4817+
"""
4818+
return self._properties.get("restoreToken")
4819+
47974820

47984821
def _get_host_name(connection):
47994822
"""Returns the host name from the given connection.

google/cloud/storage/bucket.py

+19
Original file line numberDiff line numberDiff line change
@@ -1256,6 +1256,7 @@ def get_blob(
12561256
timeout=_DEFAULT_TIMEOUT,
12571257
retry=DEFAULT_RETRY,
12581258
soft_deleted=None,
1259+
restore_token=None,
12591260
**kwargs,
12601261
):
12611262
"""Get a blob object by name.
@@ -1323,6 +1324,13 @@ def get_blob(
13231324
Object ``generation`` is required if ``soft_deleted`` is set to True.
13241325
See: https://cloud.google.com/storage/docs/soft-delete
13251326
1327+
:type restore_token: str
1328+
:param restore_token:
1329+
(Optional) The restore_token is required to retrieve a soft-deleted object only if
1330+
its name and generation value do not uniquely identify it, and hierarchical namespace
1331+
is enabled on the bucket. Otherwise, this parameter is optional.
1332+
See: https://cloud.google.com/storage/docs/json_api/v1/objects/get
1333+
13261334
:param kwargs: Keyword arguments to pass to the
13271335
:class:`~google.cloud.storage.blob.Blob` constructor.
13281336
@@ -1351,6 +1359,7 @@ def get_blob(
13511359
if_metageneration_not_match=if_metageneration_not_match,
13521360
retry=retry,
13531361
soft_deleted=soft_deleted,
1362+
restore_token=restore_token,
13541363
)
13551364
except NotFound:
13561365
return None
@@ -2199,6 +2208,7 @@ def restore_blob(
21992208
generation=None,
22002209
copy_source_acl=None,
22012210
projection=None,
2211+
restore_token=None,
22022212
if_generation_match=None,
22032213
if_generation_not_match=None,
22042214
if_metageneration_match=None,
@@ -2229,6 +2239,13 @@ def restore_blob(
22292239
:param projection: (Optional) Specifies the set of properties to return.
22302240
If used, must be 'full' or 'noAcl'.
22312241
2242+
:type restore_token: str
2243+
:param restore_token:
2244+
(Optional) The restore_token is required to restore a soft-deleted object
2245+
only if its name and generation value do not uniquely identify it, and hierarchical namespace
2246+
is enabled on the bucket. Otherwise, this parameter is optional.
2247+
See: https://cloud.google.com/storage/docs/json_api/v1/objects/restore
2248+
22322249
:type if_generation_match: long
22332250
:param if_generation_match:
22342251
(Optional) See :ref:`using-if-generation-match`
@@ -2276,6 +2293,8 @@ def restore_blob(
22762293
query_params["copySourceAcl"] = copy_source_acl
22772294
if projection is not None:
22782295
query_params["projection"] = projection
2296+
if restore_token is not None:
2297+
query_params["restoreToken"] = restore_token
22792298

22802299
_add_generation_match_parameters(
22812300
query_params,

tests/system/test_bucket.py

+53-1
Original file line numberDiff line numberDiff line change
@@ -1232,7 +1232,7 @@ def test_soft_delete_policy(
12321232
assert restored_blob.generation != gen
12331233

12341234
# Patch the soft delete policy on an existing bucket.
1235-
new_duration_secs = 10 * 86400
1235+
new_duration_secs = 0
12361236
bucket.soft_delete_policy.retention_duration_seconds = new_duration_secs
12371237
bucket.patch()
12381238
assert bucket.soft_delete_policy.retention_duration_seconds == new_duration_secs
@@ -1265,3 +1265,55 @@ def test_new_bucket_with_hierarchical_namespace(
12651265
bucket = storage_client.create_bucket(bucket_obj)
12661266
buckets_to_delete.append(bucket)
12671267
assert bucket.hierarchical_namespace_enabled is True
1268+
1269+
1270+
def test_restore_token(
1271+
storage_client,
1272+
buckets_to_delete,
1273+
blobs_to_delete,
1274+
):
1275+
# Create HNS bucket with soft delete policy.
1276+
duration_secs = 7 * 86400
1277+
bucket = storage_client.bucket(_helpers.unique_name("w-soft-delete"))
1278+
bucket.hierarchical_namespace_enabled = True
1279+
bucket.iam_configuration.uniform_bucket_level_access_enabled = True
1280+
bucket.soft_delete_policy.retention_duration_seconds = duration_secs
1281+
bucket = _helpers.retry_429_503(storage_client.create_bucket)(bucket)
1282+
buckets_to_delete.append(bucket)
1283+
1284+
# Insert an object and delete it to enter soft-deleted state.
1285+
payload = b"DEADBEEF"
1286+
blob_name = _helpers.unique_name("soft-delete")
1287+
blob = bucket.blob(blob_name)
1288+
blob.upload_from_string(payload)
1289+
# blob = bucket.get_blob(blob_name)
1290+
gen = blob.generation
1291+
blob.delete()
1292+
1293+
# Get the soft-deleted object and restore token.
1294+
blob = bucket.get_blob(blob_name, generation=gen, soft_deleted=True)
1295+
restore_token = blob.restore_token
1296+
1297+
# List and get soft-deleted object that includes restore token.
1298+
all_blobs = list(bucket.list_blobs(soft_deleted=True))
1299+
assert all_blobs[0].restore_token is not None
1300+
blob_w_restore_token = bucket.get_blob(
1301+
blob_name, generation=gen, soft_deleted=True, restore_token=restore_token
1302+
)
1303+
assert blob_w_restore_token.soft_delete_time is not None
1304+
assert blob_w_restore_token.hard_delete_time is not None
1305+
assert blob_w_restore_token.restore_token is not None
1306+
1307+
# Restore the soft-deleted object using the restore token.
1308+
restored_blob = bucket.restore_blob(
1309+
blob_name, generation=gen, restore_token=restore_token
1310+
)
1311+
blobs_to_delete.append(restored_blob)
1312+
assert restored_blob.exists() is True
1313+
assert restored_blob.generation != gen
1314+
1315+
# Patch the soft delete policy on the bucket.
1316+
new_duration_secs = 0
1317+
bucket.soft_delete_policy.retention_duration_seconds = new_duration_secs
1318+
bucket.patch()
1319+
assert bucket.soft_delete_policy.retention_duration_seconds == new_duration_secs

tests/unit/test_blob.py

+16-2
Original file line numberDiff line numberDiff line change
@@ -784,21 +784,25 @@ def test_exists_hit_w_generation_w_retry(self):
784784
_target_object=None,
785785
)
786786

787-
def test_exists_hit_w_generation_w_soft_deleted(self):
787+
def test_exists_hit_w_gen_soft_deleted_restore_token(self):
788788
blob_name = "blob-name"
789789
generation = 123456
790+
restore_token = "88ba0d97-639e-5902"
790791
api_response = {"name": blob_name}
791792
client = mock.Mock(spec=["_get_resource"])
792793
client._get_resource.return_value = api_response
793794
bucket = _Bucket(client)
794795
blob = self._make_one(blob_name, bucket=bucket, generation=generation)
795796

796-
self.assertTrue(blob.exists(retry=None, soft_deleted=True))
797+
self.assertTrue(
798+
blob.exists(retry=None, soft_deleted=True, restore_token=restore_token)
799+
)
797800

798801
expected_query_params = {
799802
"fields": "name",
800803
"generation": generation,
801804
"softDeleted": True,
805+
"restoreToken": restore_token,
802806
}
803807
expected_headers = {}
804808
client._get_resource.assert_called_once_with(
@@ -5870,6 +5874,16 @@ def test_soft_hard_delete_time_getter(self):
58705874
self.assertEqual(blob.soft_delete_time, soft_timstamp)
58715875
self.assertEqual(blob.hard_delete_time, hard_timstamp)
58725876

5877+
def test_restore_token_getter(self):
5878+
BLOB_NAME = "blob-name"
5879+
bucket = _Bucket()
5880+
restore_token = "88ba0d97-639e-5902"
5881+
properties = {
5882+
"restoreToken": restore_token,
5883+
}
5884+
blob = self._make_one(BLOB_NAME, bucket=bucket, properties=properties)
5885+
self.assertEqual(blob.restore_token, restore_token)
5886+
58735887
def test_soft_hard_delte_time_unset(self):
58745888
BUCKET = object()
58755889
blob = self._make_one("blob-name", bucket=BUCKET)

tests/unit/test_bucket.py

+16-3
Original file line numberDiff line numberDiff line change
@@ -1018,18 +1018,24 @@ def test_get_blob_hit_w_user_project(self):
10181018
_target_object=blob,
10191019
)
10201020

1021-
def test_get_blob_hit_w_generation_w_soft_deleted(self):
1021+
def test_get_blob_hit_w_gen_soft_deleted_restore_token(self):
10221022
from google.cloud.storage.blob import Blob
10231023

10241024
name = "name"
10251025
blob_name = "blob-name"
10261026
generation = 1512565576797178
1027+
restore_token = "88ba0d97-639e-5902"
10271028
api_response = {"name": blob_name, "generation": generation}
10281029
client = mock.Mock(spec=["_get_resource"])
10291030
client._get_resource.return_value = api_response
10301031
bucket = self._make_one(client, name=name)
10311032

1032-
blob = bucket.get_blob(blob_name, generation=generation, soft_deleted=True)
1033+
blob = bucket.get_blob(
1034+
blob_name,
1035+
generation=generation,
1036+
soft_deleted=True,
1037+
restore_token=restore_token,
1038+
)
10331039

10341040
self.assertIsInstance(blob, Blob)
10351041
self.assertIs(blob.bucket, bucket)
@@ -1041,6 +1047,7 @@ def test_get_blob_hit_w_generation_w_soft_deleted(self):
10411047
"generation": generation,
10421048
"projection": "noAcl",
10431049
"softDeleted": True,
1050+
"restoreToken": restore_token,
10441051
}
10451052
expected_headers = {}
10461053
client._get_resource.assert_called_once_with(
@@ -4217,8 +4224,10 @@ def test_restore_blob_w_explicit(self):
42174224
user_project = "user-project-123"
42184225
bucket_name = "restore_bucket"
42194226
blob_name = "restore_blob"
4227+
new_generation = 987655
42204228
generation = 123456
4221-
api_response = {"name": blob_name, "generation": generation}
4229+
restore_token = "88ba0d97-639e-5902"
4230+
api_response = {"name": blob_name, "generation": new_generation}
42224231
client = mock.Mock(spec=["_post_resource"])
42234232
client._post_resource.return_value = api_response
42244233
bucket = self._make_one(
@@ -4233,6 +4242,8 @@ def test_restore_blob_w_explicit(self):
42334242
restored_blob = bucket.restore_blob(
42344243
blob_name,
42354244
client=client,
4245+
generation=generation,
4246+
restore_token=restore_token,
42364247
if_generation_match=if_generation_match,
42374248
if_generation_not_match=if_generation_not_match,
42384249
if_metageneration_match=if_metageneration_match,
@@ -4245,6 +4256,8 @@ def test_restore_blob_w_explicit(self):
42454256
expected_path = f"/b/{bucket_name}/o/{blob_name}/restore"
42464257
expected_data = None
42474258
expected_query_params = {
4259+
"generation": generation,
4260+
"restoreToken": restore_token,
42484261
"userProject": user_project,
42494262
"projection": projection,
42504263
"ifGenerationMatch": if_generation_match,

0 commit comments

Comments
 (0)