From 70697a3e39c389768e724fddacb3c9b97d609384 Mon Sep 17 00:00:00 2001 From: Daniel Sanche Date: Wed, 19 Mar 2025 08:09:57 -0700 Subject: [PATCH 1/6] feat: support dynamic retry backoff values (#793) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: retry generates backoff value after completing on_error callbacks * added comment * use single sleep iterator for retries * fix tests * update variable name * adjusted docstring * 🦉 Updates from OwlBot post-processor See https://github.com/googleapis/repo-automation-bots/blob/main/packages/owl-bot/README.md * fixed mypy issues * added comment * added unit tests for dynamic backoff --------- Co-authored-by: Owl Bot Co-authored-by: Anthonios Partheniou --- google/api_core/retry/retry_base.py | 17 ++++++--- google/api_core/retry/retry_streaming.py | 13 +++---- .../api_core/retry/retry_streaming_async.py | 13 ++++--- google/api_core/retry/retry_unary.py | 13 +++---- google/api_core/retry/retry_unary_async.py | 13 +++---- .../retry/test_retry_streaming_async.py | 35 +++++++++++++++++-- tests/asyncio/retry/test_retry_unary_async.py | 27 +++++++++++++- tests/unit/retry/test_retry_streaming.py | 35 +++++++++++++++++-- tests/unit/retry/test_retry_unary.py | 28 ++++++++++++++- 9 files changed, 159 insertions(+), 35 deletions(-) diff --git a/google/api_core/retry/retry_base.py b/google/api_core/retry/retry_base.py index 1606e0fe..263b4ccf 100644 --- a/google/api_core/retry/retry_base.py +++ b/google/api_core/retry/retry_base.py @@ -25,7 +25,7 @@ import time from enum import Enum -from typing import Any, Callable, Optional, TYPE_CHECKING +from typing import Any, Callable, Optional, Iterator, TYPE_CHECKING import requests.exceptions @@ -174,7 +174,7 @@ def build_retry_error( def _retry_error_helper( exc: Exception, deadline: float | None, - next_sleep: float, + sleep_iterator: Iterator[float], error_list: list[Exception], predicate_fn: Callable[[Exception], bool], on_error_fn: Callable[[Exception], None] | None, @@ -183,7 +183,7 @@ def _retry_error_helper( tuple[Exception, Exception | None], ], original_timeout: float | None, -): +) -> float: """ Shared logic for handling an error for all retry implementations @@ -194,13 +194,15 @@ def _retry_error_helper( Args: - exc: the exception that was raised - deadline: the deadline for the retry, calculated as a diff from time.monotonic() - - next_sleep: the next sleep interval + - sleep_iterator: iterator to draw the next backoff value from - error_list: the list of exceptions that have been raised so far - predicate_fn: takes `exc` and returns true if the operation should be retried - on_error_fn: callback to execute when a retryable error occurs - exc_factory_fn: callback used to build the exception to be raised on terminal failure - original_timeout_val: the original timeout value for the retry (in seconds), to be passed to the exception factory for building an error message + Returns: + - the sleep value chosen before the next attempt """ error_list.append(exc) if not predicate_fn(exc): @@ -212,6 +214,12 @@ def _retry_error_helper( raise final_exc from source_exc if on_error_fn is not None: on_error_fn(exc) + # next_sleep is fetched after the on_error callback, to allow clients + # to update sleep_iterator values dynamically in response to errors + try: + next_sleep = next(sleep_iterator) + except StopIteration: + raise ValueError("Sleep generator stopped yielding sleep values.") from exc if deadline is not None and time.monotonic() + next_sleep > deadline: final_exc, source_exc = exc_factory_fn( error_list, @@ -222,6 +230,7 @@ def _retry_error_helper( _LOGGER.debug( "Retrying due to {}, sleeping {:.1f}s ...".format(error_list[-1], next_sleep) ) + return next_sleep class _BaseRetry(object): diff --git a/google/api_core/retry/retry_streaming.py b/google/api_core/retry/retry_streaming.py index 00666841..e4474c8a 100644 --- a/google/api_core/retry/retry_streaming.py +++ b/google/api_core/retry/retry_streaming.py @@ -107,8 +107,11 @@ def retry_target_stream( time.monotonic() + timeout if timeout is not None else None ) error_list: list[Exception] = [] + sleep_iter = iter(sleep_generator) - for sleep in sleep_generator: + # continue trying until an attempt completes, or a terminal exception is raised in _retry_error_helper + # TODO: support max_attempts argument: https://github.com/googleapis/python-api-core/issues/535 + while True: # Start a new retry loop try: # Note: in the future, we can add a ResumptionStrategy object @@ -121,10 +124,10 @@ def retry_target_stream( # This function explicitly must deal with broad exceptions. except Exception as exc: # defer to shared logic for handling errors - _retry_error_helper( + next_sleep = _retry_error_helper( exc, deadline, - sleep, + sleep_iter, error_list, predicate, on_error, @@ -132,9 +135,7 @@ def retry_target_stream( timeout, ) # if exception not raised, sleep before next attempt - time.sleep(sleep) - - raise ValueError("Sleep generator stopped yielding sleep values.") + time.sleep(next_sleep) class StreamingRetry(_BaseRetry): diff --git a/google/api_core/retry/retry_streaming_async.py b/google/api_core/retry/retry_streaming_async.py index 942abf5f..5e5fa240 100644 --- a/google/api_core/retry/retry_streaming_async.py +++ b/google/api_core/retry/retry_streaming_async.py @@ -109,9 +109,12 @@ async def retry_target_stream( deadline = time.monotonic() + timeout if timeout else None # keep track of retryable exceptions we encounter to pass in to exception_factory error_list: list[Exception] = [] + sleep_iter = iter(sleep_generator) target_is_generator: bool | None = None - for sleep in sleep_generator: + # continue trying until an attempt completes, or a terminal exception is raised in _retry_error_helper + # TODO: support max_attempts argument: https://github.com/googleapis/python-api-core/issues/535 + while True: # Start a new retry loop try: # Note: in the future, we can add a ResumptionStrategy object @@ -174,10 +177,10 @@ async def retry_target_stream( # This function explicitly must deal with broad exceptions. except Exception as exc: # defer to shared logic for handling errors - _retry_error_helper( + next_sleep = _retry_error_helper( exc, deadline, - sleep, + sleep_iter, error_list, predicate, on_error, @@ -185,11 +188,11 @@ async def retry_target_stream( timeout, ) # if exception not raised, sleep before next attempt - await asyncio.sleep(sleep) + await asyncio.sleep(next_sleep) + finally: if target_is_generator and target_iterator is not None: await cast(AsyncGenerator["_Y", None], target_iterator).aclose() - raise ValueError("Sleep generator stopped yielding sleep values.") class AsyncStreamingRetry(_BaseRetry): diff --git a/google/api_core/retry/retry_unary.py b/google/api_core/retry/retry_unary.py index d5dff663..6d36bc7d 100644 --- a/google/api_core/retry/retry_unary.py +++ b/google/api_core/retry/retry_unary.py @@ -138,8 +138,11 @@ def retry_target( deadline = time.monotonic() + timeout if timeout is not None else None error_list: list[Exception] = [] + sleep_iter = iter(sleep_generator) - for sleep in sleep_generator: + # continue trying until an attempt completes, or a terminal exception is raised in _retry_error_helper + # TODO: support max_attempts argument: https://github.com/googleapis/python-api-core/issues/535 + while True: try: result = target() if inspect.isawaitable(result): @@ -150,10 +153,10 @@ def retry_target( # This function explicitly must deal with broad exceptions. except Exception as exc: # defer to shared logic for handling errors - _retry_error_helper( + next_sleep = _retry_error_helper( exc, deadline, - sleep, + sleep_iter, error_list, predicate, on_error, @@ -161,9 +164,7 @@ def retry_target( timeout, ) # if exception not raised, sleep before next attempt - time.sleep(sleep) - - raise ValueError("Sleep generator stopped yielding sleep values.") + time.sleep(next_sleep) class Retry(_BaseRetry): diff --git a/google/api_core/retry/retry_unary_async.py b/google/api_core/retry/retry_unary_async.py index e76a37bb..1f72476a 100644 --- a/google/api_core/retry/retry_unary_async.py +++ b/google/api_core/retry/retry_unary_async.py @@ -149,18 +149,21 @@ async def retry_target( deadline = time.monotonic() + timeout if timeout is not None else None error_list: list[Exception] = [] + sleep_iter = iter(sleep_generator) - for sleep in sleep_generator: + # continue trying until an attempt completes, or a terminal exception is raised in _retry_error_helper + # TODO: support max_attempts argument: https://github.com/googleapis/python-api-core/issues/535 + while True: try: return await target() # pylint: disable=broad-except # This function explicitly must deal with broad exceptions. except Exception as exc: # defer to shared logic for handling errors - _retry_error_helper( + next_sleep = _retry_error_helper( exc, deadline, - sleep, + sleep_iter, error_list, predicate, on_error, @@ -168,9 +171,7 @@ async def retry_target( timeout, ) # if exception not raised, sleep before next attempt - await asyncio.sleep(sleep) - - raise ValueError("Sleep generator stopped yielding sleep values.") + await asyncio.sleep(next_sleep) class AsyncRetry(_BaseRetry): diff --git a/tests/asyncio/retry/test_retry_streaming_async.py b/tests/asyncio/retry/test_retry_streaming_async.py index d0fd799c..e44f5361 100644 --- a/tests/asyncio/retry/test_retry_streaming_async.py +++ b/tests/asyncio/retry/test_retry_streaming_async.py @@ -36,7 +36,36 @@ async def test_retry_streaming_target_bad_sleep_generator(): from google.api_core.retry.retry_streaming_async import retry_target_stream with pytest.raises(ValueError, match="Sleep generator"): - await retry_target_stream(None, None, [], None).__anext__() + await retry_target_stream(None, lambda x: True, [], None).__anext__() + + +@mock.patch("asyncio.sleep", autospec=True) +@pytest.mark.asyncio +async def test_retry_streaming_target_dynamic_backoff(sleep): + """ + sleep_generator should be iterated after on_error, to support dynamic backoff + """ + from functools import partial + from google.api_core.retry.retry_streaming_async import retry_target_stream + + sleep.side_effect = RuntimeError("stop after sleep") + # start with empty sleep generator; values are added after exception in push_sleep_value + sleep_values = [] + error_target = partial(TestAsyncStreamingRetry._generator_mock, error_on=0) + inserted_sleep = 99 + + def push_sleep_value(err): + sleep_values.append(inserted_sleep) + + with pytest.raises(RuntimeError): + await retry_target_stream( + error_target, + predicate=lambda x: True, + sleep_generator=sleep_values, + on_error=push_sleep_value, + ).__anext__() + assert sleep.call_count == 1 + sleep.assert_called_once_with(inserted_sleep) class TestAsyncStreamingRetry(Test_BaseRetry): @@ -66,8 +95,8 @@ def if_exception_type(exc): str(retry_), ) + @staticmethod async def _generator_mock( - self, num=5, error_on=None, exceptions_seen=None, @@ -87,7 +116,7 @@ async def _generator_mock( for i in range(num): if sleep_time: await asyncio.sleep(sleep_time) - if error_on and i == error_on: + if error_on is not None and i == error_on: raise ValueError("generator mock error") yield i except (Exception, BaseException, GeneratorExit) as e: diff --git a/tests/asyncio/retry/test_retry_unary_async.py b/tests/asyncio/retry/test_retry_unary_async.py index dc64299f..e7fdc963 100644 --- a/tests/asyncio/retry/test_retry_unary_async.py +++ b/tests/asyncio/retry/test_retry_unary_async.py @@ -136,9 +136,34 @@ async def test_retry_target_timeout_exceeded(monotonic, sleep, use_deadline_arg) @pytest.mark.asyncio async def test_retry_target_bad_sleep_generator(): with pytest.raises(ValueError, match="Sleep generator"): + await retry_async.retry_target(mock.sentinel.target, lambda x: True, [], None) + + +@mock.patch("asyncio.sleep", autospec=True) +@pytest.mark.asyncio +async def test_retry_target_dynamic_backoff(sleep): + """ + sleep_generator should be iterated after on_error, to support dynamic backoff + """ + sleep.side_effect = RuntimeError("stop after sleep") + # start with empty sleep generator; values are added after exception in push_sleep_value + sleep_values = [] + exception = ValueError("trigger retry") + error_target = mock.Mock(side_effect=exception) + inserted_sleep = 99 + + def push_sleep_value(err): + sleep_values.append(inserted_sleep) + + with pytest.raises(RuntimeError): await retry_async.retry_target( - mock.sentinel.target, mock.sentinel.predicate, [], None + error_target, + predicate=lambda x: True, + sleep_generator=sleep_values, + on_error=push_sleep_value, ) + assert sleep.call_count == 1 + sleep.assert_called_once_with(inserted_sleep) class TestAsyncRetry(Test_BaseRetry): diff --git a/tests/unit/retry/test_retry_streaming.py b/tests/unit/retry/test_retry_streaming.py index 0bc85d92..2499b2ae 100644 --- a/tests/unit/retry/test_retry_streaming.py +++ b/tests/unit/retry/test_retry_streaming.py @@ -33,7 +33,36 @@ def test_retry_streaming_target_bad_sleep_generator(): with pytest.raises( ValueError, match="Sleep generator stopped yielding sleep values" ): - next(retry_streaming.retry_target_stream(None, None, [], None)) + next(retry_streaming.retry_target_stream(None, lambda x: True, [], None)) + + +@mock.patch("time.sleep", autospec=True) +def test_retry_streaming_target_dynamic_backoff(sleep): + """ + sleep_generator should be iterated after on_error, to support dynamic backoff + """ + from functools import partial + + sleep.side_effect = RuntimeError("stop after sleep") + # start with empty sleep generator; values are added after exception in push_sleep_value + sleep_values = [] + error_target = partial(TestStreamingRetry._generator_mock, error_on=0) + inserted_sleep = 99 + + def push_sleep_value(err): + sleep_values.append(inserted_sleep) + + with pytest.raises(RuntimeError): + next( + retry_streaming.retry_target_stream( + error_target, + predicate=lambda x: True, + sleep_generator=sleep_values, + on_error=push_sleep_value, + ) + ) + assert sleep.call_count == 1 + sleep.assert_called_once_with(inserted_sleep) class TestStreamingRetry(Test_BaseRetry): @@ -63,8 +92,8 @@ def if_exception_type(exc): str(retry_), ) + @staticmethod def _generator_mock( - self, num=5, error_on=None, return_val=None, @@ -82,7 +111,7 @@ def _generator_mock( """ try: for i in range(num): - if error_on and i == error_on: + if error_on is not None and i == error_on: raise ValueError("generator mock error") yield i return return_val diff --git a/tests/unit/retry/test_retry_unary.py b/tests/unit/retry/test_retry_unary.py index 6851fbe4..f5bbcff7 100644 --- a/tests/unit/retry/test_retry_unary.py +++ b/tests/unit/retry/test_retry_unary.py @@ -146,7 +146,33 @@ def test_retry_target_timeout_exceeded(monotonic, sleep, use_deadline_arg): def test_retry_target_bad_sleep_generator(): with pytest.raises(ValueError, match="Sleep generator"): - retry.retry_target(mock.sentinel.target, mock.sentinel.predicate, [], None) + retry.retry_target(mock.sentinel.target, lambda x: True, [], None) + + +@mock.patch("time.sleep", autospec=True) +def test_retry_target_dynamic_backoff(sleep): + """ + sleep_generator should be iterated after on_error, to support dynamic backoff + """ + sleep.side_effect = RuntimeError("stop after sleep") + # start with empty sleep generator; values are added after exception in push_sleep_value + sleep_values = [] + exception = ValueError("trigger retry") + error_target = mock.Mock(side_effect=exception) + inserted_sleep = 99 + + def push_sleep_value(err): + sleep_values.append(inserted_sleep) + + with pytest.raises(RuntimeError): + retry.retry_target( + error_target, + predicate=lambda x: True, + sleep_generator=sleep_values, + on_error=push_sleep_value, + ) + assert sleep.call_count == 1 + sleep.assert_called_once_with(inserted_sleep) class TestRetry(Test_BaseRetry): From 1ca7973a395099403be1a99c7c4583a8f22d5d8e Mon Sep 17 00:00:00 2001 From: Anthonios Partheniou Date: Wed, 19 Mar 2025 11:40:57 -0400 Subject: [PATCH 2/6] fix: resolve issue where pre-release versions of dependencies are installed (#808) Co-authored-by: ohmayr --- pyproject.toml | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index ea3a5226..da404ab3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -58,15 +58,15 @@ Documentation = "https://googleapis.dev/python/google-api-core/latest/" Repository = "https://github.com/googleapis/python-api-core" [project.optional-dependencies] -async_rest = ["google-auth[aiohttp] >= 2.35.0, < 3.0.dev0"] +async_rest = ["google-auth[aiohttp] >= 2.35.0, < 3.0.0"] grpc = [ - "grpcio >= 1.33.2, < 2.0dev", - "grpcio >= 1.49.1, < 2.0dev; python_version >= '3.11'", - "grpcio-status >= 1.33.2, < 2.0.dev0", - "grpcio-status >= 1.49.1, < 2.0.dev0; python_version >= '3.11'", + "grpcio >= 1.33.2, < 2.0.0", + "grpcio >= 1.49.1, < 2.0.0; python_version >= '3.11'", + "grpcio-status >= 1.33.2, < 2.0.0", + "grpcio-status >= 1.49.1, < 2.0.0; python_version >= '3.11'", ] -grpcgcp = ["grpcio-gcp >= 0.2.2, < 1.0.dev0"] -grpcio-gcp = ["grpcio-gcp >= 0.2.2, < 1.0.dev0"] +grpcgcp = ["grpcio-gcp >= 0.2.2, < 1.0.0"] +grpcio-gcp = ["grpcio-gcp >= 0.2.2, < 1.0.0"] [tool.setuptools.dynamic] version = { attr = "google.api_core.version.__version__" } From 8da1fa9b160b7c9c159fecc698eede498df91f78 Mon Sep 17 00:00:00 2001 From: "gcf-owl-bot[bot]" <78513119+gcf-owl-bot[bot]@users.noreply.github.com> Date: Thu, 10 Apr 2025 17:36:27 +0000 Subject: [PATCH 3/6] chore(python): remove .gitignore from templates (#815) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore(python): remove .gitignore from templates Source-Link: https://github.com/googleapis/synthtool/commit/419d94cdddd0d859ac6743ffebd177693c8a027f Post-Processor: gcr.io/cloud-devrel-public-resources/owlbot-python:latest@sha256:a7aef70df5f13313ddc027409fc8f3151422ec2a57ac8730fce8fa75c060d5bb * 🦉 Updates from OwlBot post-processor See https://github.com/googleapis/repo-automation-bots/blob/main/packages/owl-bot/README.md * Remove replacement in owlbot.py --------- Co-authored-by: Owl Bot Co-authored-by: Anthonios Partheniou --- .github/.OwlBot.lock.yaml | 4 ++-- owlbot.py | 13 ------------- 2 files changed, 2 insertions(+), 15 deletions(-) diff --git a/.github/.OwlBot.lock.yaml b/.github/.OwlBot.lock.yaml index a6d11c01..51b21a62 100644 --- a/.github/.OwlBot.lock.yaml +++ b/.github/.OwlBot.lock.yaml @@ -13,5 +13,5 @@ # limitations under the License. docker: image: gcr.io/cloud-devrel-public-resources/owlbot-python:latest - digest: sha256:5581906b957284864632cde4e9c51d1cc66b0094990b27e689132fe5cd036046 -# created: 2025-03-04 + digest: sha256:a7aef70df5f13313ddc027409fc8f3151422ec2a57ac8730fce8fa75c060d5bb +# created: 2025-04-10T17:00:10.042601326Z diff --git a/owlbot.py b/owlbot.py index c8c76542..58bc7517 100644 --- a/owlbot.py +++ b/owlbot.py @@ -26,7 +26,6 @@ excludes = [ "noxfile.py", # pytype "setup.cfg", # pytype - ".flake8", # flake8-import-order, layout ".coveragerc", # layout "CONTRIBUTING.rst", # no systests ".github/workflows/unittest.yml", # exclude unittest gh action @@ -36,18 +35,6 @@ templated_files = common.py_library(microgenerator=True, cov_level=100) s.move(templated_files, excludes=excludes) -# Add pytype support -s.replace( - ".gitignore", - """\ -.pytest_cache -""", - """\ -.pytest_cache -.pytype -""", -) - python.configure_previous_major_version_branches() s.shell.run(["nox", "-s", "blacken"], hide_output=False) From 66d84a3a9ef0ab0c2124a1bf12cc3c45c2393e74 Mon Sep 17 00:00:00 2001 From: Daniel Sanche Date: Thu, 10 Apr 2025 16:31:57 -0700 Subject: [PATCH 4/6] test: fix BackgroundConsumer error log (#814) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fixed null pointer * added test * 🦉 Updates from OwlBot post-processor See https://github.com/googleapis/repo-automation-bots/blob/main/packages/owl-bot/README.md * removed unneeded file --------- Co-authored-by: Owl Bot --- google/api_core/bidi.py | 3 ++- tests/unit/test_bidi.py | 24 ++++++++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/google/api_core/bidi.py b/google/api_core/bidi.py index 4e800c88..b002b409 100644 --- a/google/api_core/bidi.py +++ b/google/api_core/bidi.py @@ -664,7 +664,8 @@ def _thread_main(self, ready): _LOGGER.debug("waiting for recv.") response = self._bidi_rpc.recv() _LOGGER.debug("recved response.") - self._on_response(response) + if self._on_response is not None: + self._on_response(response) except exceptions.GoogleAPICallError as exc: _LOGGER.debug( diff --git a/tests/unit/test_bidi.py b/tests/unit/test_bidi.py index cca9a214..0e7b018c 100644 --- a/tests/unit/test_bidi.py +++ b/tests/unit/test_bidi.py @@ -16,6 +16,7 @@ import logging import queue import threading +import time try: from unittest import mock @@ -894,3 +895,26 @@ def close_side_effect(): # calling stop twice should not result in an error. consumer.stop() + + def test_stop_error_logs(self, caplog): + """ + Closing the client should result in no internal error logs + + https://github.com/googleapis/python-api-core/issues/788 + """ + caplog.set_level(logging.DEBUG) + bidi_rpc = mock.create_autospec(bidi.BidiRpc, instance=True) + bidi_rpc.is_active = True + on_response = mock.Mock(spec=["__call__"]) + + consumer = bidi.BackgroundConsumer(bidi_rpc, on_response) + + consumer.start() + consumer.stop() + # let the background thread run for a while before exiting + time.sleep(0.1) + bidi_rpc.is_active = False + # running thread should not result in error logs + error_logs = [r.message for r in caplog.records if r.levelname == "ERROR"] + assert not error_logs, f"Found unexpected ERROR logs: {error_logs}" + bidi_rpc.is_active = False From 118bd96f3907234351972409834ab5309cdfcee4 Mon Sep 17 00:00:00 2001 From: Anthonios Partheniou Date: Tue, 6 May 2025 18:34:49 -0400 Subject: [PATCH 5/6] feat: Add protobuf runtime version to `x-goog-api-client` header (#812) * for testing purposes * lint --- google/api_core/client_info.py | 6 ++++++ google/api_core/gapic_v1/client_info.py | 1 + tests/asyncio/gapic/test_method_async.py | 1 + tests/unit/gapic/test_method.py | 1 + tests/unit/test_client_info.py | 10 ++++++++-- 5 files changed, 17 insertions(+), 2 deletions(-) diff --git a/google/api_core/client_info.py b/google/api_core/client_info.py index 90926beb..f0678d24 100644 --- a/google/api_core/client_info.py +++ b/google/api_core/client_info.py @@ -59,6 +59,7 @@ class ClientInfo(object): Recommended format: ``application-or-tool-ID/major.minor.version``. rest_version (Optional[str]): A string with labeled versions of the dependencies used for REST transport. + protobuf_runtime_version (Optional[str]): The protobuf runtime version. """ def __init__( @@ -70,6 +71,7 @@ def __init__( client_library_version=None, user_agent=None, rest_version=None, + protobuf_runtime_version=None, ): self.python_version = python_version self.grpc_version = grpc_version @@ -78,6 +80,7 @@ def __init__( self.client_library_version = client_library_version self.user_agent = user_agent self.rest_version = rest_version + self.protobuf_runtime_version = protobuf_runtime_version def to_user_agent(self): """Returns the user-agent string for this client info.""" @@ -105,4 +108,7 @@ def to_user_agent(self): if self.client_library_version is not None: ua += "gccl/{client_library_version} " + if self.protobuf_runtime_version is not None: + ua += "pb/{protobuf_runtime_version} " + return ua.format(**self.__dict__).strip() diff --git a/google/api_core/gapic_v1/client_info.py b/google/api_core/gapic_v1/client_info.py index 4516f339..4b3b5649 100644 --- a/google/api_core/gapic_v1/client_info.py +++ b/google/api_core/gapic_v1/client_info.py @@ -47,6 +47,7 @@ class ClientInfo(client_info.ClientInfo): Recommended format: ``application-or-tool-ID/major.minor.version``. rest_version (Optional[str]): A string with labeled versions of the dependencies used for REST transport. + protobuf_runtime_version (Optional[str]): The protobuf runtime version. """ def to_grpc_metadata(self): diff --git a/tests/asyncio/gapic/test_method_async.py b/tests/asyncio/gapic/test_method_async.py index 73f67b8d..cc4e7de8 100644 --- a/tests/asyncio/gapic/test_method_async.py +++ b/tests/asyncio/gapic/test_method_async.py @@ -81,6 +81,7 @@ async def test_wrap_method_with_custom_client_info(): api_core_version=3, gapic_version=4, client_library_version=5, + protobuf_runtime_version=6, ) fake_call = grpc_helpers_async.FakeUnaryUnaryCall() method = mock.Mock(spec=aio.UnaryUnaryMultiCallable, return_value=fake_call) diff --git a/tests/unit/gapic/test_method.py b/tests/unit/gapic/test_method.py index 87aa6390..8896429c 100644 --- a/tests/unit/gapic/test_method.py +++ b/tests/unit/gapic/test_method.py @@ -76,6 +76,7 @@ def test_wrap_method_with_custom_client_info(): api_core_version=3, gapic_version=4, client_library_version=5, + protobuf_runtime_version=6, ) method = mock.Mock(spec=["__call__"]) diff --git a/tests/unit/test_client_info.py b/tests/unit/test_client_info.py index 3361fef6..3eacabca 100644 --- a/tests/unit/test_client_info.py +++ b/tests/unit/test_client_info.py @@ -46,6 +46,7 @@ def test_constructor_options(): client_library_version="5", user_agent="6", rest_version="7", + protobuf_runtime_version="8", ) assert info.python_version == "1" @@ -55,11 +56,15 @@ def test_constructor_options(): assert info.client_library_version == "5" assert info.user_agent == "6" assert info.rest_version == "7" + assert info.protobuf_runtime_version == "8" def test_to_user_agent_minimal(): info = client_info.ClientInfo( - python_version="1", api_core_version="2", grpc_version=None + python_version="1", + api_core_version="2", + grpc_version=None, + protobuf_runtime_version=None, ) user_agent = info.to_user_agent() @@ -75,11 +80,12 @@ def test_to_user_agent_full(): gapic_version="4", client_library_version="5", user_agent="app-name/1.0", + protobuf_runtime_version="6", ) user_agent = info.to_user_agent() - assert user_agent == "app-name/1.0 gl-python/1 grpc/2 gax/3 gapic/4 gccl/5" + assert user_agent == "app-name/1.0 gl-python/1 grpc/2 gax/3 gapic/4 gccl/5 pb/6" def test_to_user_agent_rest(): From 225bf75020ed3bda5f68b2dbd99a91499dd4acc9 Mon Sep 17 00:00:00 2001 From: "release-please[bot]" <55107282+release-please[bot]@users.noreply.github.com> Date: Mon, 12 May 2025 22:09:23 +0500 Subject: [PATCH 6/6] chore(main): release 2.25.0 (#811) Co-authored-by: release-please[bot] <55107282+release-please[bot]@users.noreply.github.com> --- CHANGELOG.md | 13 +++++++++++++ google/api_core/version.py | 2 +- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e118fe46..98a6b8d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,19 @@ [1]: https://pypi.org/project/google-api-core/#history +## [2.25.0](https://github.com/googleapis/python-api-core/compare/v2.24.2...v2.25.0) (2025-05-06) + + +### Features + +* Add protobuf runtime version to `x-goog-api-client` header ([#812](https://github.com/googleapis/python-api-core/issues/812)) ([118bd96](https://github.com/googleapis/python-api-core/commit/118bd96f3907234351972409834ab5309cdfcee4)) +* Support dynamic retry backoff values ([#793](https://github.com/googleapis/python-api-core/issues/793)) ([70697a3](https://github.com/googleapis/python-api-core/commit/70697a3e39c389768e724fddacb3c9b97d609384)) + + +### Bug Fixes + +* Resolve issue where pre-release versions of dependencies are installed ([#808](https://github.com/googleapis/python-api-core/issues/808)) ([1ca7973](https://github.com/googleapis/python-api-core/commit/1ca7973a395099403be1a99c7c4583a8f22d5d8e)) + ## [2.24.2](https://github.com/googleapis/python-api-core/compare/v2.24.1...v2.24.2) (2025-03-06) diff --git a/google/api_core/version.py b/google/api_core/version.py index 04ffe6a5..f882cac3 100644 --- a/google/api_core/version.py +++ b/google/api_core/version.py @@ -12,4 +12,4 @@ # See the License for the specific language governing permissions and # limitations under the License. -__version__ = "2.24.2" +__version__ = "2.25.0"