Skip to content

Commit 12649ff

Browse files
committed
exposes metadata parameter to be added in grpc calls to pass additional client info
1 parent 247e2ad commit 12649ff

6 files changed

Lines changed: 189 additions & 4 deletions

File tree

packages/google-cloud-storage/google/cloud/storage/asyncio/async_appendable_object_writer.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,7 @@ def __init__(
213213
self.object_resource: Optional[_storage_v2.Object] = None
214214
self._flush_count = 0
215215
self.blob: Optional[Blob] = None
216+
self.metadata: Optional[List[Tuple[str, str]]] = None
216217

217218
@classmethod
218219
def from_blob(
@@ -312,6 +313,8 @@ async def open(
312313
if self._is_stream_open:
313314
raise ValueError("Underlying bidi-gRPC stream is already open")
314315

316+
self.metadata = metadata
317+
315318
if retry_policy is None:
316319
retry_policy = AsyncRetry(
317320
predicate=_is_write_retryable, on_error=self._on_open_error
@@ -334,7 +337,7 @@ def combined_on_error(exc):
334337
)
335338

336339
async def _do_open():
337-
current_metadata = list(metadata) if metadata else []
340+
current_metadata = list(self.metadata) if self.metadata else []
338341

339342
# Cleanup stream from previous failed attempt, if any.
340343
if self.write_obj_stream:
@@ -408,6 +411,9 @@ async def append(
408411
409412
:raises ValueError: If the stream is not open.
410413
"""
414+
if metadata is None:
415+
metadata = self.metadata
416+
411417
if not self._is_stream_open:
412418
raise ValueError("Stream is not open. Call open() before append().")
413419
if not data:

packages/google-cloud-storage/google/cloud/storage/asyncio/async_grpc_client.py

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,9 @@ async def delete_object(
154154
if_generation_not_match=None,
155155
if_metageneration_match=None,
156156
if_metageneration_not_match=None,
157+
metadata=(),
158+
timeout=None,
159+
retry=None,
157160
**kwargs,
158161
):
159162
"""Deletes an object and its metadata.
@@ -195,7 +198,12 @@ async def delete_object(
195198
if_metageneration_not_match=if_metageneration_not_match,
196199
**kwargs,
197200
)
198-
await self._grpc_client.delete_object(request=request)
201+
await self._grpc_client.delete_object(
202+
request=request,
203+
metadata=metadata,
204+
timeout=timeout,
205+
retry=retry,
206+
)
199207

200208
async def get_object(
201209
self,
@@ -207,6 +215,9 @@ async def get_object(
207215
if_metageneration_match=None,
208216
if_metageneration_not_match=None,
209217
soft_deleted=None,
218+
metadata=(),
219+
timeout=None,
220+
retry=None,
210221
**kwargs,
211222
):
212223
"""Retrieves an object's metadata.
@@ -258,4 +269,9 @@ async def get_object(
258269
)
259270

260271
# Calls the underlying GAPIC StorageAsyncClient.get_object method
261-
return await self._grpc_client.get_object(request=request)
272+
return await self._grpc_client.get_object(
273+
request=request,
274+
metadata=metadata,
275+
timeout=timeout,
276+
retry=retry,
277+
)

packages/google-cloud-storage/google/cloud/storage/asyncio/async_multi_range_downloader.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,7 @@ def __init__(
233233
self._open_retries: int = 0
234234
self.is_finalized: bool = False
235235
self.full_obj_server_crc32c: Optional[int] = None
236+
self.metadata: Optional[List[Tuple[str, str]]] = None
236237

237238
async def __aenter__(self):
238239
"""Opens the underlying bidi-gRPC connection to read from the object."""
@@ -262,6 +263,8 @@ async def open(
262263
if self._is_stream_open:
263264
raise ValueError("Underlying bidi-gRPC stream is already open")
264265

266+
self.metadata = metadata
267+
265268
if retry_policy is None:
266269

267270
def on_error_wrapper(exc):
@@ -290,7 +293,7 @@ def combined_on_error(exc):
290293
)
291294

292295
async def _do_open():
293-
current_metadata = list(metadata) if metadata else []
296+
current_metadata = list(self.metadata) if self.metadata else []
294297

295298
# Cleanup stream from previous failed attempt, if any.
296299
if self.read_obj_str:
@@ -413,6 +416,9 @@ async def download_ranges(
413416
414417
"""
415418

419+
if metadata is None:
420+
metadata = self.metadata
421+
416422
if len(read_ranges) > 1000:
417423
raise ValueError(
418424
"Invalid input - length of read_ranges cannot be more than 1000"

packages/google-cloud-storage/tests/unit/asyncio/test_async_appendable_object_writer.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -503,3 +503,46 @@ async def test_methods_require_open_stream_raises(self, mock_appendable_writer):
503503
for coro in methods:
504504
with pytest.raises(ValueError, match="Stream is not open"):
505505
await coro
506+
507+
@pytest.mark.asyncio
508+
async def test_append_persists_metadata_on_resumption(self, mock_appendable_writer):
509+
# Arrange
510+
mock_client = mock_appendable_writer["mock_client"]
511+
mock_stream = mock_appendable_writer["mock_stream"]
512+
513+
test_metadata = [("custom-key", "custom-value")]
514+
writer = self._make_one(mock_client)
515+
516+
# Act - Open with metadata
517+
await writer.open(metadata=test_metadata)
518+
519+
# Assert first open used metadata
520+
mock_stream.open.assert_called_once_with(metadata=test_metadata)
521+
assert writer.metadata == test_metadata
522+
523+
# Setup resumption trigger
524+
retryable_exc = exceptions.ServiceUnavailable("Retry me")
525+
mock_stream.send.side_effect = retryable_exc
526+
527+
# Reset mock_stream.open call count to verify it is called again
528+
mock_stream.open.reset_mock()
529+
530+
# Setup a fast retry policy to fail quickly in test
531+
from google.api_core.retry_async import AsyncRetry
532+
fast_retry = AsyncRetry(
533+
predicate=lambda e: True,
534+
initial=0.01,
535+
maximum=0.01,
536+
multiplier=1.0,
537+
deadline=0.1
538+
)
539+
540+
# Act - append (should trigger retry and use stored metadata)
541+
from google.api_core.exceptions import RetryError
542+
try:
543+
await writer.append(b"data", retry_policy=fast_retry)
544+
except RetryError:
545+
pass
546+
547+
# Assert second open (during retry) used the same test_metadata
548+
mock_stream.open.assert_called_with(metadata=test_metadata)

packages/google-cloud-storage/tests/unit/asyncio/test_async_grpc_client.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,11 @@ async def test_delete_object(self, mock_async_storage_client):
285285
if_metageneration_match = 111
286286
if_metageneration_not_match = 222
287287

288+
# New parameters
289+
metadata = (("x-goog-api-client", "test-ua"),)
290+
timeout = 10.0
291+
retry = mock.Mock()
292+
288293
# Act
289294
await client.delete_object(
290295
bucket_name,
@@ -294,6 +299,9 @@ async def test_delete_object(self, mock_async_storage_client):
294299
if_generation_not_match=if_generation_not_match,
295300
if_metageneration_match=if_metageneration_match,
296301
if_metageneration_not_match=if_metageneration_not_match,
302+
metadata=metadata,
303+
timeout=timeout,
304+
retry=retry,
297305
)
298306

299307
# Assert
@@ -306,6 +314,9 @@ async def test_delete_object(self, mock_async_storage_client):
306314
assert request.if_generation_not_match == if_generation_not_match
307315
assert request.if_metageneration_match == if_metageneration_match
308316
assert request.if_metageneration_not_match == if_metageneration_not_match
317+
assert call_kwargs["metadata"] == metadata
318+
assert call_kwargs["timeout"] == timeout
319+
assert call_kwargs["retry"] == retry
309320

310321
@mock.patch("google.cloud._storage_v2.StorageAsyncClient")
311322
@pytest.mark.asyncio
@@ -354,6 +365,11 @@ async def test_get_object_with_all_parameters(self, mock_async_storage_client):
354365
if_metageneration_not_match = 222
355366
soft_deleted = True
356367

368+
# New parameters
369+
metadata = (("x-goog-api-client", "test-ua"),)
370+
timeout = 10.0
371+
retry = mock.Mock()
372+
357373
# Act
358374
await client.get_object(
359375
bucket_name,
@@ -364,6 +380,9 @@ async def test_get_object_with_all_parameters(self, mock_async_storage_client):
364380
if_metageneration_match=if_metageneration_match,
365381
if_metageneration_not_match=if_metageneration_not_match,
366382
soft_deleted=soft_deleted,
383+
metadata=metadata,
384+
timeout=timeout,
385+
retry=retry,
367386
)
368387

369388
# Assert
@@ -377,3 +396,6 @@ async def test_get_object_with_all_parameters(self, mock_async_storage_client):
377396
assert request.if_metageneration_match == if_metageneration_match
378397
assert request.if_metageneration_not_match == if_metageneration_not_match
379398
assert request.soft_deleted is True
399+
assert call_kwargs["metadata"] == metadata
400+
assert call_kwargs["timeout"] == timeout
401+
assert call_kwargs["retry"] == retry

packages/google-cloud-storage/tests/unit/asyncio/test_async_multi_range_downloader.py

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -707,3 +707,95 @@ async def test_download_ranges_closes_on_datacorruption(
707707
await mrd.download_ranges([(0, 0, BytesIO())])
708708

709709
mrd.close.assert_called_once()
710+
711+
@mock.patch(
712+
"google.cloud.storage.asyncio.async_multi_range_downloader.generate_random_56_bit_integer"
713+
)
714+
@mock.patch(
715+
"google.cloud.storage.asyncio.async_multi_range_downloader._AsyncReadObjectStream"
716+
)
717+
@pytest.mark.asyncio
718+
async def test_download_ranges_persists_metadata_on_resumption(
719+
self, mock_cls_async_read_object_stream, mock_random_int
720+
):
721+
# Arrange
722+
mock_client = mock.MagicMock()
723+
mock_client.grpc_client = mock.AsyncMock()
724+
725+
mock_stream_1 = mock.MagicMock()
726+
mock_stream_1.open = AsyncMock()
727+
mock_stream_1.generation_number = _TEST_GENERATION_NUMBER
728+
mock_stream_1.persisted_size = _TEST_OBJECT_SIZE
729+
mock_stream_1.read_handle = _TEST_READ_HANDLE
730+
mock_stream_1.is_stream_open = True
731+
732+
mock_stream_2 = mock.MagicMock()
733+
mock_stream_2.open = AsyncMock()
734+
mock_stream_2.generation_number = _TEST_GENERATION_NUMBER
735+
mock_stream_2.persisted_size = _TEST_OBJECT_SIZE
736+
mock_stream_2.read_handle = _TEST_READ_HANDLE
737+
mock_stream_2.is_stream_open = True
738+
739+
mock_cls_async_read_object_stream.side_effect = [mock_stream_1, mock_stream_2]
740+
741+
test_metadata = [("custom-key", "custom-value")]
742+
743+
# Act - Create MRD with metadata
744+
mrd = await AsyncMultiRangeDownloader.create_mrd(
745+
mock_client, _TEST_BUCKET_NAME, _TEST_OBJECT_NAME,
746+
metadata=test_metadata
747+
)
748+
749+
# Assert first open used metadata
750+
mock_stream_1.open.assert_called_once_with(metadata=test_metadata)
751+
assert mrd.metadata == test_metadata
752+
753+
# Setup resumption trigger
754+
from google.api_core import exceptions as core_exceptions
755+
retryable_exc = core_exceptions.ServiceUnavailable("Retry me")
756+
757+
# Mock multiplexer to trigger retry
758+
mrd._multiplexer = mock.MagicMock()
759+
mrd._multiplexer.stream_generation = 1
760+
761+
# Configure reopen_stream to execute the factory
762+
async def fake_reopen_stream(broken_gen, stream_factory):
763+
await stream_factory()
764+
mrd._multiplexer.reopen_stream = AsyncMock(side_effect=fake_reopen_stream)
765+
766+
mrd._multiplexer.send = AsyncMock(side_effect=retryable_exc)
767+
768+
# We need mock_multiplexer.register to return a queue
769+
mock_queue = mock.AsyncMock()
770+
mrd._multiplexer.register.return_value = mock_queue
771+
772+
# We need mock_queue.get to return a StreamError to propagate exception to retry manager
773+
from google.cloud.storage.asyncio._stream_multiplexer import _StreamError
774+
mock_queue.get.return_value = _StreamError(retryable_exc, generation=1)
775+
776+
mock_random_int.return_value = 123
777+
778+
# Setup a fast retry policy to fail quickly in test
779+
from google.api_core.retry_async import AsyncRetry
780+
fast_retry = AsyncRetry(
781+
predicate=lambda e: True,
782+
initial=0.01,
783+
maximum=0.01,
784+
multiplier=1.0,
785+
deadline=0.1
786+
)
787+
788+
# Act - download ranges (should trigger retry and use stored metadata)
789+
buffer = BytesIO()
790+
from google.api_core.exceptions import RetryError
791+
try:
792+
await mrd.download_ranges([(0, 10, buffer)], retry_policy=fast_retry)
793+
except RetryError:
794+
pass
795+
796+
# Assert that reopen_stream was called.
797+
mrd._multiplexer.reopen_stream.assert_called()
798+
799+
# Verify mock_stream_2 was opened with test_metadata
800+
# (It should have been opened during the retry attempts)
801+
mock_stream_2.open.assert_called_with(metadata=test_metadata)

0 commit comments

Comments
 (0)