diff --git a/google/cloud/storage/bucket.py b/google/cloud/storage/bucket.py index 10156c795..88356d316 100644 --- a/google/cloud/storage/bucket.py +++ b/google/cloud/storage/bucket.py @@ -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, diff --git a/tests/system/test_bucket.py b/tests/system/test_bucket.py index 270a77ad1..f06de8e8c 100644 --- a/tests/system/test_bucket.py +++ b/tests/system/test_bucket.py @@ -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, diff --git a/tests/unit/test_bucket.py b/tests/unit/test_bucket.py index 7129232a0..ac9a5ede6 100644 --- a/tests/unit/test_bucket.py +++ b/tests/unit/test_bucket.py @@ -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"