Skip to content

feat: add Bucket.move_blob() for HNS-enabled buckets #1431

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

Merged
merged 3 commits into from
Feb 12, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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