diff --git a/packages/google-cloud-storage/google/cloud/storage/asyncio/async_grpc_client.py b/packages/google-cloud-storage/google/cloud/storage/asyncio/async_grpc_client.py index d48fa07c00d9..717a8724de47 100644 --- a/packages/google-cloud-storage/google/cloud/storage/asyncio/async_grpc_client.py +++ b/packages/google-cloud-storage/google/cloud/storage/asyncio/async_grpc_client.py @@ -26,6 +26,22 @@ _DEFAULT_HOST = "storage.googleapis.com" +def _validate_metadata(metadata): + """Validates that metadata is a sequence of (key, value) pairs.""" + if metadata is None: + return + if not isinstance(metadata, (list, tuple)): + raise TypeError("metadata must be a list or tuple of (key, value) pairs.") + for item in metadata: + if not isinstance(item, (list, tuple)) or len(item) != 2: + raise ValueError( + "Each element in metadata must be a list or tuple of exactly 2 strings (key, value)." + ) + key, value = item + if not isinstance(key, str) or not isinstance(value, str): + raise TypeError("Both key and value in metadata pairs must be strings.") + + class AsyncGrpcClient: """An asynchronous client for interacting with Google Cloud Storage using the gRPC API. @@ -154,6 +170,9 @@ async def delete_object( if_generation_not_match=None, if_metageneration_match=None, if_metageneration_not_match=None, + metadata=(), + timeout=None, + retry=None, **kwargs, ): """Deletes an object and its metadata. @@ -181,8 +200,18 @@ async def delete_object( :type if_metageneration_not_match: int :param if_metageneration_not_match: (Optional) + :type metadata: Sequence[Tuple[str, str]] + :param metadata: (Optional) Additional metadata that is provided to the method. + :type timeout: float or None + :param timeout: + (Optional) The amount of time, in seconds, to wait for the request to + complete. + + :type retry: :class:`~google.api_core.retry_async.AsyncRetry` or :class:`~google.api_core.retry.Retry` or None + :param retry: (Optional) Designation of what errors, if any, should be retried. """ + _validate_metadata(metadata) # The gRPC API requires the bucket name to be in the format "projects/_/buckets/bucket_name" bucket_path = f"projects/_/buckets/{bucket_name}" request = storage_v2.DeleteObjectRequest( @@ -195,7 +224,12 @@ async def delete_object( if_metageneration_not_match=if_metageneration_not_match, **kwargs, ) - await self._grpc_client.delete_object(request=request) + await self._grpc_client.delete_object( + request=request, + metadata=metadata, + timeout=timeout, + retry=retry, + ) async def get_object( self, @@ -207,6 +241,9 @@ async def get_object( if_metageneration_match=None, if_metageneration_not_match=None, soft_deleted=None, + metadata=(), + timeout=None, + retry=None, **kwargs, ): """Retrieves an object's metadata. @@ -240,9 +277,21 @@ async def get_object( :param soft_deleted: (Optional) If True, return the soft-deleted version of this object. + :type metadata: Sequence[Tuple[str, str]] + :param metadata: (Optional) Additional metadata that is provided to the method. + + :type timeout: float or None + :param timeout: + (Optional) The amount of time, in seconds, to wait for the request to + complete. + + :type retry: :class:`~google.api_core.retry_async.AsyncRetry` or :class:`~google.api_core.retry.Retry` or None + :param retry: (Optional) Designation of what errors, if any, should be retried. + :rtype: :class:`google.cloud._storage_v2.types.Object` :returns: The object metadata resource. """ + _validate_metadata(metadata) bucket_path = f"projects/_/buckets/{bucket_name}" request = storage_v2.GetObjectRequest( @@ -258,4 +307,9 @@ async def get_object( ) # Calls the underlying GAPIC StorageAsyncClient.get_object method - return await self._grpc_client.get_object(request=request) + return await self._grpc_client.get_object( + request=request, + metadata=metadata, + timeout=timeout, + retry=retry, + ) diff --git a/packages/google-cloud-storage/tests/unit/asyncio/test_async_grpc_client.py b/packages/google-cloud-storage/tests/unit/asyncio/test_async_grpc_client.py index cea0c785788f..24168dc137d4 100644 --- a/packages/google-cloud-storage/tests/unit/asyncio/test_async_grpc_client.py +++ b/packages/google-cloud-storage/tests/unit/asyncio/test_async_grpc_client.py @@ -285,6 +285,11 @@ async def test_delete_object(self, mock_async_storage_client): if_metageneration_match = 111 if_metageneration_not_match = 222 + # New parameters + metadata = (("x-goog-api-client", "test-ua"),) + timeout = 10.0 + retry = mock.Mock() + # Act await client.delete_object( bucket_name, @@ -294,6 +299,9 @@ async def test_delete_object(self, mock_async_storage_client): if_generation_not_match=if_generation_not_match, if_metageneration_match=if_metageneration_match, if_metageneration_not_match=if_metageneration_not_match, + metadata=metadata, + timeout=timeout, + retry=retry, ) # Assert @@ -306,6 +314,9 @@ async def test_delete_object(self, mock_async_storage_client): assert request.if_generation_not_match == if_generation_not_match assert request.if_metageneration_match == if_metageneration_match assert request.if_metageneration_not_match == if_metageneration_not_match + assert call_kwargs["metadata"] == metadata + assert call_kwargs["timeout"] == timeout + assert call_kwargs["retry"] == retry @mock.patch("google.cloud._storage_v2.StorageAsyncClient") @pytest.mark.asyncio @@ -354,6 +365,11 @@ async def test_get_object_with_all_parameters(self, mock_async_storage_client): if_metageneration_not_match = 222 soft_deleted = True + # New parameters + metadata = (("x-goog-api-client", "test-ua"),) + timeout = 10.0 + retry = mock.Mock() + # Act await client.get_object( bucket_name, @@ -364,6 +380,9 @@ async def test_get_object_with_all_parameters(self, mock_async_storage_client): if_metageneration_match=if_metageneration_match, if_metageneration_not_match=if_metageneration_not_match, soft_deleted=soft_deleted, + metadata=metadata, + timeout=timeout, + retry=retry, ) # Assert @@ -377,3 +396,38 @@ async def test_get_object_with_all_parameters(self, mock_async_storage_client): assert request.if_metageneration_match == if_metageneration_match assert request.if_metageneration_not_match == if_metageneration_not_match assert request.soft_deleted is True + assert call_kwargs["metadata"] == metadata + assert call_kwargs["timeout"] == timeout + assert call_kwargs["retry"] == retry + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "invalid_metadata, expected_exc", + [ + ("not-a-sequence", TypeError), + ([("key_only",)], ValueError), + ([("too", "many", "items")], ValueError), + ([(123, "val")], TypeError), + ([("key", 456)], TypeError), + ], + ) + async def test_delete_object_invalid_metadata(self, invalid_metadata, expected_exc): + client = async_grpc_client.AsyncGrpcClient(credentials=_make_credentials()) + with pytest.raises(expected_exc): + await client.delete_object("bucket", "object", metadata=invalid_metadata) + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "invalid_metadata, expected_exc", + [ + ("not-a-sequence", TypeError), + ([("key_only",)], ValueError), + ([("too", "many", "items")], ValueError), + ([(123, "val")], TypeError), + ([("key", 456)], TypeError), + ], + ) + async def test_get_object_invalid_metadata(self, invalid_metadata, expected_exc): + client = async_grpc_client.AsyncGrpcClient(credentials=_make_credentials()) + with pytest.raises(expected_exc): + await client.get_object("bucket", "object", metadata=invalid_metadata)