Skip to content

Commit

Permalink
feat: add Bucket.move_blob() for HNS-enabled buckets (#1431)
Browse files Browse the repository at this point in the history
  • Loading branch information
andrewsg authored Feb 12, 2025
1 parent f2cc9c5 commit 24c000f
Show file tree
Hide file tree
Showing 3 changed files with 221 additions and 0 deletions.
124 changes: 124 additions & 0 deletions google/cloud/storage/bucket.py
Original file line number Diff line number Diff line change
Expand Up @@ -2236,6 +2236,130 @@ def rename_blob(
)
return new_blob

@create_trace_span(name="Storage.Bucket.moveBlob")
def move_blob(
self,
blob,
new_name,
client=None,
if_generation_match=None,
if_generation_not_match=None,
if_metageneration_match=None,
if_metageneration_not_match=None,
if_source_generation_match=None,
if_source_generation_not_match=None,
if_source_metageneration_match=None,
if_source_metageneration_not_match=None,
timeout=_DEFAULT_TIMEOUT,
retry=DEFAULT_RETRY_IF_GENERATION_SPECIFIED,
):
"""Move a blob to a new name within a single HNS bucket.
*This feature is currently only supported for HNS (Heirarchical
Namespace) buckets.*
If :attr:`user_project` is set on the bucket, bills the API request to that project.
:type blob: :class:`google.cloud.storage.blob.Blob`
:param blob: The blob to be renamed.
:type new_name: str
:param new_name: The new name for this blob.
:type client: :class:`~google.cloud.storage.client.Client` or
``NoneType``
:param client: (Optional) The client to use. If not passed, falls back
to the ``client`` stored on the current bucket.
:type if_generation_match: int
:param if_generation_match:
(Optional) See :ref:`using-if-generation-match`
Note that the generation to be matched is that of the
``destination`` blob.
:type if_generation_not_match: int
:param if_generation_not_match:
(Optional) See :ref:`using-if-generation-not-match`
Note that the generation to be matched is that of the
``destination`` blob.
:type if_metageneration_match: int
:param if_metageneration_match:
(Optional) See :ref:`using-if-metageneration-match`
Note that the metageneration to be matched is that of the
``destination`` blob.
:type if_metageneration_not_match: int
:param if_metageneration_not_match:
(Optional) See :ref:`using-if-metageneration-not-match`
Note that the metageneration to be matched is that of the
``destination`` blob.
:type if_source_generation_match: int
:param if_source_generation_match:
(Optional) Makes the operation conditional on whether the source
object's generation matches the given value.
:type if_source_generation_not_match: int
:param if_source_generation_not_match:
(Optional) Makes the operation conditional on whether the source
object's generation does not match the given value.
:type if_source_metageneration_match: int
:param if_source_metageneration_match:
(Optional) Makes the operation conditional on whether the source
object's current metageneration matches the given value.
:type if_source_metageneration_not_match: int
:param if_source_metageneration_not_match:
(Optional) Makes the operation conditional on whether the source
object's current metageneration does not match the given value.
: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
:param retry:
(Optional) How to retry the RPC.
See [Configuring Retries](https://cloud.google.com/python/docs/reference/storage/latest/retry_timeout).
:rtype: :class:`Blob`
:returns: The newly-moved blob.
"""
client = self._require_client(client)
query_params = {}

if self.user_project is not None:
query_params["userProject"] = self.user_project

_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,
if_source_generation_match=if_source_generation_match,
if_source_generation_not_match=if_source_generation_not_match,
if_source_metageneration_match=if_source_metageneration_match,
if_source_metageneration_not_match=if_source_metageneration_not_match,
)

new_blob = Blob(bucket=self, name=new_name)
api_path = blob.path + "/moveTo/o/" + new_blob.name
move_result = client._post_resource(
api_path,
None,
query_params=query_params,
timeout=timeout,
retry=retry,
_target_object=new_blob,
)

new_blob._set_properties(move_result)
return new_blob

@create_trace_span(name="Storage.Bucket.restore_blob")
def restore_blob(
self,
Expand Down
34 changes: 34 additions & 0 deletions tests/system/test_bucket.py
Original file line number Diff line number Diff line change
Expand Up @@ -433,6 +433,40 @@ def test_bucket_copy_blob_w_metageneration_match(
assert new_blob.download_as_bytes() == payload


def test_bucket_move_blob_hns(
storage_client,
buckets_to_delete,
blobs_to_delete,
):
payload = b"move_blob_test"

# Feature currently only works on HNS buckets, so create one here
bucket_name = _helpers.unique_name("move-blob-hns-enabled")
bucket_obj = storage_client.bucket(bucket_name)
bucket_obj.hierarchical_namespace_enabled = True
bucket_obj.iam_configuration.uniform_bucket_level_access_enabled = True
created = _helpers.retry_429_503(storage_client.create_bucket)(bucket_obj)
buckets_to_delete.append(created)
assert created.hierarchical_namespace_enabled is True

source = created.blob("source")
source_gen = source.generation
source.upload_from_string(payload)
blobs_to_delete.append(source)

dest = created.move_blob(
source,
"dest",
if_source_generation_match=source.generation,
if_source_metageneration_match=source.metageneration,
)
blobs_to_delete.append(dest)

assert dest.download_as_bytes() == payload
assert dest.generation is not None
assert source_gen != dest.generation


def test_bucket_get_blob_with_user_project(
storage_client,
buckets_to_delete,
Expand Down
63 changes: 63 additions & 0 deletions tests/unit/test_bucket.py
Original file line number Diff line number Diff line change
Expand Up @@ -2289,6 +2289,69 @@ def test_copy_blob_w_name_and_user_project(self):
_target_object=new_blob,
)

def test_move_blob_w_no_retry_timeout_and_generation_match(self):
source_name = "source"
blob_name = "blob-name"
new_name = "new_name"
api_response = {}
client = mock.Mock(spec=["_post_resource"])
client._post_resource.return_value = api_response
source = self._make_one(client=client, name=source_name)
blob = self._make_blob(source_name, blob_name)

new_blob = source.move_blob(
blob, new_name, if_generation_match=0, retry=None, timeout=30
)

self.assertIs(new_blob.bucket, source)
self.assertEqual(new_blob.name, new_name)

expected_path = "/b/{}/o/{}/moveTo/o/{}".format(
source_name, blob_name, new_name
)
expected_data = None
expected_query_params = {"ifGenerationMatch": 0}
client._post_resource.assert_called_once_with(
expected_path,
expected_data,
query_params=expected_query_params,
timeout=30,
retry=None,
_target_object=new_blob,
)

def test_move_blob_w_user_project(self):
source_name = "source"
blob_name = "blob-name"
new_name = "new_name"
user_project = "user-project-123"
api_response = {}
client = mock.Mock(spec=["_post_resource"])
client._post_resource.return_value = api_response
source = self._make_one(
client=client, name=source_name, user_project=user_project
)
blob = self._make_blob(source_name, blob_name)

new_blob = source.move_blob(blob, new_name)

self.assertIs(new_blob.bucket, source)
self.assertEqual(new_blob.name, new_name)

expected_path = "/b/{}/o/{}/moveTo/o/{}".format(
source_name, blob_name, new_name
)
expected_data = None
expected_query_params = {"userProject": user_project}
client._post_resource.assert_called_once_with(
expected_path,
expected_data,
query_params=expected_query_params,
timeout=self._get_default_timeout(),
retry=DEFAULT_RETRY_IF_GENERATION_SPECIFIED,
_target_object=new_blob,
)

def _rename_blob_helper(self, explicit_client=False, same_name=False, **kw):
bucket_name = "BUCKET_NAME"
blob_name = "blob-name"
Expand Down

0 comments on commit 24c000f

Please sign in to comment.