Skip to content

Commit 24c000f

Browse files
authored
feat: add Bucket.move_blob() for HNS-enabled buckets (#1431)
1 parent f2cc9c5 commit 24c000f

File tree

3 files changed

+221
-0
lines changed

3 files changed

+221
-0
lines changed

google/cloud/storage/bucket.py

+124
Original file line numberDiff line numberDiff line change
@@ -2236,6 +2236,130 @@ def rename_blob(
22362236
)
22372237
return new_blob
22382238

2239+
@create_trace_span(name="Storage.Bucket.moveBlob")
2240+
def move_blob(
2241+
self,
2242+
blob,
2243+
new_name,
2244+
client=None,
2245+
if_generation_match=None,
2246+
if_generation_not_match=None,
2247+
if_metageneration_match=None,
2248+
if_metageneration_not_match=None,
2249+
if_source_generation_match=None,
2250+
if_source_generation_not_match=None,
2251+
if_source_metageneration_match=None,
2252+
if_source_metageneration_not_match=None,
2253+
timeout=_DEFAULT_TIMEOUT,
2254+
retry=DEFAULT_RETRY_IF_GENERATION_SPECIFIED,
2255+
):
2256+
"""Move a blob to a new name within a single HNS bucket.
2257+
2258+
*This feature is currently only supported for HNS (Heirarchical
2259+
Namespace) buckets.*
2260+
2261+
If :attr:`user_project` is set on the bucket, bills the API request to that project.
2262+
2263+
:type blob: :class:`google.cloud.storage.blob.Blob`
2264+
:param blob: The blob to be renamed.
2265+
2266+
:type new_name: str
2267+
:param new_name: The new name for this blob.
2268+
2269+
:type client: :class:`~google.cloud.storage.client.Client` or
2270+
``NoneType``
2271+
:param client: (Optional) The client to use. If not passed, falls back
2272+
to the ``client`` stored on the current bucket.
2273+
2274+
:type if_generation_match: int
2275+
:param if_generation_match:
2276+
(Optional) See :ref:`using-if-generation-match`
2277+
Note that the generation to be matched is that of the
2278+
``destination`` blob.
2279+
2280+
:type if_generation_not_match: int
2281+
:param if_generation_not_match:
2282+
(Optional) See :ref:`using-if-generation-not-match`
2283+
Note that the generation to be matched is that of the
2284+
``destination`` blob.
2285+
2286+
:type if_metageneration_match: int
2287+
:param if_metageneration_match:
2288+
(Optional) See :ref:`using-if-metageneration-match`
2289+
Note that the metageneration to be matched is that of the
2290+
``destination`` blob.
2291+
2292+
:type if_metageneration_not_match: int
2293+
:param if_metageneration_not_match:
2294+
(Optional) See :ref:`using-if-metageneration-not-match`
2295+
Note that the metageneration to be matched is that of the
2296+
``destination`` blob.
2297+
2298+
:type if_source_generation_match: int
2299+
:param if_source_generation_match:
2300+
(Optional) Makes the operation conditional on whether the source
2301+
object's generation matches the given value.
2302+
2303+
:type if_source_generation_not_match: int
2304+
:param if_source_generation_not_match:
2305+
(Optional) Makes the operation conditional on whether the source
2306+
object's generation does not match the given value.
2307+
2308+
:type if_source_metageneration_match: int
2309+
:param if_source_metageneration_match:
2310+
(Optional) Makes the operation conditional on whether the source
2311+
object's current metageneration matches the given value.
2312+
2313+
:type if_source_metageneration_not_match: int
2314+
:param if_source_metageneration_not_match:
2315+
(Optional) Makes the operation conditional on whether the source
2316+
object's current metageneration does not match the given value.
2317+
2318+
:type timeout: float or tuple
2319+
:param timeout:
2320+
(Optional) The amount of time, in seconds, to wait
2321+
for the server response. See: :ref:`configuring_timeouts`
2322+
2323+
:type retry: google.api_core.retry.Retry
2324+
:param retry:
2325+
(Optional) How to retry the RPC.
2326+
See [Configuring Retries](https://cloud.google.com/python/docs/reference/storage/latest/retry_timeout).
2327+
2328+
:rtype: :class:`Blob`
2329+
:returns: The newly-moved blob.
2330+
"""
2331+
client = self._require_client(client)
2332+
query_params = {}
2333+
2334+
if self.user_project is not None:
2335+
query_params["userProject"] = self.user_project
2336+
2337+
_add_generation_match_parameters(
2338+
query_params,
2339+
if_generation_match=if_generation_match,
2340+
if_generation_not_match=if_generation_not_match,
2341+
if_metageneration_match=if_metageneration_match,
2342+
if_metageneration_not_match=if_metageneration_not_match,
2343+
if_source_generation_match=if_source_generation_match,
2344+
if_source_generation_not_match=if_source_generation_not_match,
2345+
if_source_metageneration_match=if_source_metageneration_match,
2346+
if_source_metageneration_not_match=if_source_metageneration_not_match,
2347+
)
2348+
2349+
new_blob = Blob(bucket=self, name=new_name)
2350+
api_path = blob.path + "/moveTo/o/" + new_blob.name
2351+
move_result = client._post_resource(
2352+
api_path,
2353+
None,
2354+
query_params=query_params,
2355+
timeout=timeout,
2356+
retry=retry,
2357+
_target_object=new_blob,
2358+
)
2359+
2360+
new_blob._set_properties(move_result)
2361+
return new_blob
2362+
22392363
@create_trace_span(name="Storage.Bucket.restore_blob")
22402364
def restore_blob(
22412365
self,

tests/system/test_bucket.py

+34
Original file line numberDiff line numberDiff line change
@@ -433,6 +433,40 @@ def test_bucket_copy_blob_w_metageneration_match(
433433
assert new_blob.download_as_bytes() == payload
434434

435435

436+
def test_bucket_move_blob_hns(
437+
storage_client,
438+
buckets_to_delete,
439+
blobs_to_delete,
440+
):
441+
payload = b"move_blob_test"
442+
443+
# Feature currently only works on HNS buckets, so create one here
444+
bucket_name = _helpers.unique_name("move-blob-hns-enabled")
445+
bucket_obj = storage_client.bucket(bucket_name)
446+
bucket_obj.hierarchical_namespace_enabled = True
447+
bucket_obj.iam_configuration.uniform_bucket_level_access_enabled = True
448+
created = _helpers.retry_429_503(storage_client.create_bucket)(bucket_obj)
449+
buckets_to_delete.append(created)
450+
assert created.hierarchical_namespace_enabled is True
451+
452+
source = created.blob("source")
453+
source_gen = source.generation
454+
source.upload_from_string(payload)
455+
blobs_to_delete.append(source)
456+
457+
dest = created.move_blob(
458+
source,
459+
"dest",
460+
if_source_generation_match=source.generation,
461+
if_source_metageneration_match=source.metageneration,
462+
)
463+
blobs_to_delete.append(dest)
464+
465+
assert dest.download_as_bytes() == payload
466+
assert dest.generation is not None
467+
assert source_gen != dest.generation
468+
469+
436470
def test_bucket_get_blob_with_user_project(
437471
storage_client,
438472
buckets_to_delete,

tests/unit/test_bucket.py

+63
Original file line numberDiff line numberDiff line change
@@ -2289,6 +2289,69 @@ def test_copy_blob_w_name_and_user_project(self):
22892289
_target_object=new_blob,
22902290
)
22912291

2292+
def test_move_blob_w_no_retry_timeout_and_generation_match(self):
2293+
source_name = "source"
2294+
blob_name = "blob-name"
2295+
new_name = "new_name"
2296+
api_response = {}
2297+
client = mock.Mock(spec=["_post_resource"])
2298+
client._post_resource.return_value = api_response
2299+
source = self._make_one(client=client, name=source_name)
2300+
blob = self._make_blob(source_name, blob_name)
2301+
2302+
new_blob = source.move_blob(
2303+
blob, new_name, if_generation_match=0, retry=None, timeout=30
2304+
)
2305+
2306+
self.assertIs(new_blob.bucket, source)
2307+
self.assertEqual(new_blob.name, new_name)
2308+
2309+
expected_path = "/b/{}/o/{}/moveTo/o/{}".format(
2310+
source_name, blob_name, new_name
2311+
)
2312+
expected_data = None
2313+
expected_query_params = {"ifGenerationMatch": 0}
2314+
client._post_resource.assert_called_once_with(
2315+
expected_path,
2316+
expected_data,
2317+
query_params=expected_query_params,
2318+
timeout=30,
2319+
retry=None,
2320+
_target_object=new_blob,
2321+
)
2322+
2323+
def test_move_blob_w_user_project(self):
2324+
source_name = "source"
2325+
blob_name = "blob-name"
2326+
new_name = "new_name"
2327+
user_project = "user-project-123"
2328+
api_response = {}
2329+
client = mock.Mock(spec=["_post_resource"])
2330+
client._post_resource.return_value = api_response
2331+
source = self._make_one(
2332+
client=client, name=source_name, user_project=user_project
2333+
)
2334+
blob = self._make_blob(source_name, blob_name)
2335+
2336+
new_blob = source.move_blob(blob, new_name)
2337+
2338+
self.assertIs(new_blob.bucket, source)
2339+
self.assertEqual(new_blob.name, new_name)
2340+
2341+
expected_path = "/b/{}/o/{}/moveTo/o/{}".format(
2342+
source_name, blob_name, new_name
2343+
)
2344+
expected_data = None
2345+
expected_query_params = {"userProject": user_project}
2346+
client._post_resource.assert_called_once_with(
2347+
expected_path,
2348+
expected_data,
2349+
query_params=expected_query_params,
2350+
timeout=self._get_default_timeout(),
2351+
retry=DEFAULT_RETRY_IF_GENERATION_SPECIFIED,
2352+
_target_object=new_blob,
2353+
)
2354+
22922355
def _rename_blob_helper(self, explicit_client=False, same_name=False, **kw):
22932356
bucket_name = "BUCKET_NAME"
22942357
blob_name = "blob-name"

0 commit comments

Comments
 (0)