From 70ed6bab4de2d518fa9c2a6c8094a0eadab3de1c Mon Sep 17 00:00:00 2001 From: Mattia Tommasone Date: Mon, 19 Oct 2026 15:38:32 +0200 Subject: [PATCH 1/3] Add _DEFAULT_VERSION as the default value for the version parameter in raise_formatted_for_status and raise_formatted_ads_exception Change-Id: I1f2b7b2a5654772a05a4349aa1d7177ff684d891 --- google/ads/googleads/client.py | 24 ++++++++++++------------ google/ads/googleads/errors.py | 10 ++++++++-- tests/errors_test.py | 22 ++++++++++++++++++++++ 3 files changed, 42 insertions(+), 14 deletions(-) diff --git a/google/ads/googleads/client.py b/google/ads/googleads/client.py index 10fcf5736..4638ca891 100644 --- a/google/ads/googleads/client.py +++ b/google/ads/googleads/client.py @@ -24,6 +24,18 @@ from google.protobuf.message import Message as ProtobufMessageType from proto import Message as ProtoPlusMessageType +from types import ModuleType +from typing import Any, Dict, List, Tuple, Union + +_logger = logging.getLogger(__name__) + +_SERVICE_CLIENT_TEMPLATE = "{}Client" +_ASYNC_SERVICE_CLIENT_TEMPLATE = "{}AsyncClient" + +_VALID_API_VERSIONS = ["v26", "v25", "v24", "v23"] +_MESSAGE_TYPES = ["common", "enums", "errors", "resources", "services"] +_DEFAULT_VERSION = _VALID_API_VERSIONS[0] + from google.ads.googleads import config, oauth2, util from google.ads.googleads.interceptors import ( MetadataInterceptor, @@ -37,18 +49,6 @@ AsyncUnaryStreamLoggingInterceptor, ) -from types import ModuleType -from typing import Any, Dict, List, Tuple, Union - -_logger = logging.getLogger(__name__) - -_SERVICE_CLIENT_TEMPLATE = "{}Client" -_ASYNC_SERVICE_CLIENT_TEMPLATE = "{}AsyncClient" - -_VALID_API_VERSIONS = ["v26", "v25", "v24", "v23"] -_MESSAGE_TYPES = ["common", "enums", "errors", "resources", "services"] -_DEFAULT_VERSION = _VALID_API_VERSIONS[0] - # Retrieve the version of this client library to be sent in the user-agent # information of API calls. try: diff --git a/google/ads/googleads/errors.py b/google/ads/googleads/errors.py index a720b4efb..c5c3af175 100644 --- a/google/ads/googleads/errors.py +++ b/google/ads/googleads/errors.py @@ -21,6 +21,8 @@ from google.api_core import exceptions as core_exceptions from proto import Message as ProtobufMessageType +from google.ads.googleads.client import _DEFAULT_VERSION + class GoogleAdsException(Exception): """Exception thrown in response to an API error from GoogleAds servers.""" @@ -100,7 +102,9 @@ def add_callback(self, callback: Any) -> None: pass -def raise_formatted_ads_exception(response: requests.Response, version: str): +def raise_formatted_ads_exception( + response: requests.Response, version: str = _DEFAULT_VERSION +): """Converts a RUP HTTP response into a rich GoogleAdsException. Args: @@ -137,7 +141,9 @@ def raise_formatted_ads_exception(response: requests.Response, version: str): ) -def raise_formatted_for_status(response: requests.Response, version: str): +def raise_formatted_for_status( + response: requests.Response, version: str = _DEFAULT_VERSION +): """Replacement for response.raise_for_status() that raises GoogleAdsException. Args: diff --git a/tests/errors_test.py b/tests/errors_test.py index efd1c0a2f..c7429518c 100644 --- a/tests/errors_test.py +++ b/tests/errors_test.py @@ -78,6 +78,21 @@ def test_raise_formatted_ads_exception(self): e_code = exception.failure.errors[0].error_code self.assertIsNotNone(e_code.request_error) + def test_raise_formatted_ads_exception_default_version(self): + with mock.patch( + "google.api_core.exceptions.from_http_response" + ) as mock_from_http: + mock_core_error = mock.Mock() + mock_core_error.grpc_status_code = grpc.StatusCode.NOT_FOUND + mock_core_error.message = "Core exception message" + mock_from_http.return_value = mock_core_error + + with self.assertRaises(errors.GoogleAdsException) as cm: + errors.raise_formatted_ads_exception(self.response) + + exception = cm.exception + self.assertEqual(exception.error.version, self.version) + def test_raise_formatted_for_status_when_not_ok(self): with mock.patch( "google.ads.googleads.errors.raise_formatted_ads_exception" @@ -85,6 +100,13 @@ def test_raise_formatted_for_status_when_not_ok(self): errors.raise_formatted_for_status(self.response, self.version) mock_raise.assert_called_once_with(self.response, self.version) + def test_raise_formatted_for_status_default_version(self): + with mock.patch( + "google.ads.googleads.errors.raise_formatted_ads_exception" + ) as mock_raise: + errors.raise_formatted_for_status(self.response) + mock_raise.assert_called_once_with(self.response, self.version) + def test_raise_formatted_for_status_when_ok(self): self.response.ok = True with mock.patch( From 227d873e84e8611b2b2677f95b32c8a9ee455517 Mon Sep 17 00:00:00 2001 From: Mattia Tommasone Date: Tue, 20 Oct 2026 11:26:15 +0200 Subject: [PATCH 2/3] Use the caller version instead of the latest if possible Change-Id: I968729700a08028d34b76a3dccb96b355497cd32 --- google/ads/googleads/errors.py | 47 +++++++++++++++++++++++++++++----- tests/errors_test.py | 28 ++++++++++++++++++++ 2 files changed, 69 insertions(+), 6 deletions(-) diff --git a/google/ads/googleads/errors.py b/google/ads/googleads/errors.py index c5c3af175..16ea39110 100644 --- a/google/ads/googleads/errors.py +++ b/google/ads/googleads/errors.py @@ -14,6 +14,8 @@ """Errors used by the Google Ads API library.""" import importlib +import inspect +import re from typing import Any, Optional import grpc @@ -23,6 +25,31 @@ from google.ads.googleads.client import _DEFAULT_VERSION +_VERSION_REGEX = re.compile(r"^google\.ads\.googleads\.(v\d+)\.") + + +def _get_caller_version() -> str: + """Determines the Google Ads API version from the call stack. + + Walks the call stack looking for a frame originating from a versioned + ``google.ads.googleads.`` module. Falls back to + ``_DEFAULT_VERSION`` if no versioned module is found. + + Returns: + The detected API version string (e.g., "v25") or ``_DEFAULT_VERSION``. + """ + frame = inspect.currentframe() + try: + while frame is not None: + module_name = frame.f_globals.get("__name__", "") + match = _VERSION_REGEX.match(module_name) + if match: + return match.group(1) + frame = frame.f_back + finally: + del frame + return _DEFAULT_VERSION + class GoogleAdsException(Exception): """Exception thrown in response to an API error from GoogleAds servers.""" @@ -103,21 +130,26 @@ def add_callback(self, callback: Any) -> None: def raise_formatted_ads_exception( - response: requests.Response, version: str = _DEFAULT_VERSION + response: requests.Response, version: Optional[str] = None ): """Converts a RUP HTTP response into a rich GoogleAdsException. Args: response (requests.Response): The HTTP response containing error details. - version (str): - The API version (e.g., "v23") used to import error classes. + version (Optional[str]): + The API version (e.g., "v23") used to import error classes. If not + provided, it is inferred from the calling service module or falls + back to the default API version. Raises: GoogleAdsException: A rich exception containing the mapped gRPC status, failure details, and request ID. """ + if version is None: + version = _get_caller_version() + # 1. Map HTTP status (e.g., 400) to gRPC status (e.g., INVALID_ARGUMENT) core_error = core_exceptions.from_http_response(response) grpc_code = core_error.grpc_status_code or grpc.StatusCode.INTERNAL @@ -142,19 +174,22 @@ def raise_formatted_ads_exception( def raise_formatted_for_status( - response: requests.Response, version: str = _DEFAULT_VERSION + response: requests.Response, version: Optional[str] = None ): """Replacement for response.raise_for_status() that raises GoogleAdsException. Args: response (requests.Response): The HTTP response to check for errors. - version (str): - The API version (e.g., "v23"). + version (Optional[str]): + The API version (e.g., "v23"). If not provided, it is inferred from + the calling service module or falls back to the default API version. Raises: GoogleAdsException: If the response status code indicates an error. """ if not response.ok: + if version is None: + version = _get_caller_version() raise_formatted_ads_exception(response, version) diff --git a/tests/errors_test.py b/tests/errors_test.py index c7429518c..072c7187a 100644 --- a/tests/errors_test.py +++ b/tests/errors_test.py @@ -107,6 +107,34 @@ def test_raise_formatted_for_status_default_version(self): errors.raise_formatted_for_status(self.response) mock_raise.assert_called_once_with(self.response, self.version) + def test_raise_formatted_for_status_infers_caller_version(self): + older_version = Client._VALID_API_VERSIONS[1] + resumable_upload_module = Client.import_module( + f"google.ads.googleads.{older_version}.services.services." + "you_tube_video_upload_service.transports.resumable_upload" + ) + upload = resumable_upload_module.ResumableUpload( + upload_url="https://example.com", stream=mock.Mock() + ) + + with mock.patch( + "google.api_core.exceptions.from_http_response" + ) as mock_from_http: + mock_core_error = mock.Mock() + mock_core_error.grpc_status_code = grpc.StatusCode.INVALID_ARGUMENT + mock_core_error.message = "Error from older version service" + mock_from_http.return_value = mock_core_error + + with self.assertRaises(errors.GoogleAdsException) as cm: + upload._process_initiate_response(self.response) + + exception = cm.exception + self.assertEqual(exception.error.version, older_version) + self.assertIn( + f"google.ads.googleads.{older_version}.errors", + type(exception.failure).__module__, + ) + def test_raise_formatted_for_status_when_ok(self): self.response.ok = True with mock.patch( From 35866fd53478329468fc1eab4f0018107c6586ab Mon Sep 17 00:00:00 2001 From: Mattia Tommasone Date: Tue, 20 Oct 2026 18:26:48 +0200 Subject: [PATCH 3/3] Revert import ordering, optimize _get_caller_version, guard empty-string version and remove redundant resolution Change-Id: If8be593f7b18d57ae85bcb0d5746c0824e795f42 --- google/ads/googleads/client.py | 24 +++++----- google/ads/googleads/errors.py | 20 +++++---- tests/errors_test.py | 80 ++++++++++++++++++++++++++++++---- 3 files changed, 95 insertions(+), 29 deletions(-) diff --git a/google/ads/googleads/client.py b/google/ads/googleads/client.py index 4638ca891..10fcf5736 100644 --- a/google/ads/googleads/client.py +++ b/google/ads/googleads/client.py @@ -24,18 +24,6 @@ from google.protobuf.message import Message as ProtobufMessageType from proto import Message as ProtoPlusMessageType -from types import ModuleType -from typing import Any, Dict, List, Tuple, Union - -_logger = logging.getLogger(__name__) - -_SERVICE_CLIENT_TEMPLATE = "{}Client" -_ASYNC_SERVICE_CLIENT_TEMPLATE = "{}AsyncClient" - -_VALID_API_VERSIONS = ["v26", "v25", "v24", "v23"] -_MESSAGE_TYPES = ["common", "enums", "errors", "resources", "services"] -_DEFAULT_VERSION = _VALID_API_VERSIONS[0] - from google.ads.googleads import config, oauth2, util from google.ads.googleads.interceptors import ( MetadataInterceptor, @@ -49,6 +37,18 @@ AsyncUnaryStreamLoggingInterceptor, ) +from types import ModuleType +from typing import Any, Dict, List, Tuple, Union + +_logger = logging.getLogger(__name__) + +_SERVICE_CLIENT_TEMPLATE = "{}Client" +_ASYNC_SERVICE_CLIENT_TEMPLATE = "{}AsyncClient" + +_VALID_API_VERSIONS = ["v26", "v25", "v24", "v23"] +_MESSAGE_TYPES = ["common", "enums", "errors", "resources", "services"] +_DEFAULT_VERSION = _VALID_API_VERSIONS[0] + # Retrieve the version of this client library to be sent in the user-agent # information of API calls. try: diff --git a/google/ads/googleads/errors.py b/google/ads/googleads/errors.py index 16ea39110..f541310e8 100644 --- a/google/ads/googleads/errors.py +++ b/google/ads/googleads/errors.py @@ -23,9 +23,8 @@ from google.api_core import exceptions as core_exceptions from proto import Message as ProtobufMessageType -from google.ads.googleads.client import _DEFAULT_VERSION - -_VERSION_REGEX = re.compile(r"^google\.ads\.googleads\.(v\d+)\.") +_VERSION_PREFIX = "google.ads.googleads.v" +_VERSION_REGEX = re.compile(r"^google\.ads\.googleads\.(v\d+(?:_\d+)?)(?:\.|$)") def _get_caller_version() -> str: @@ -40,14 +39,19 @@ def _get_caller_version() -> str: """ frame = inspect.currentframe() try: + if frame is not None: + frame = frame.f_back while frame is not None: module_name = frame.f_globals.get("__name__", "") - match = _VERSION_REGEX.match(module_name) - if match: - return match.group(1) + if module_name.startswith(_VERSION_PREFIX): + match = _VERSION_REGEX.match(module_name) + if match: + return match.group(1) frame = frame.f_back finally: del frame + from google.ads.googleads.client import _DEFAULT_VERSION + return _DEFAULT_VERSION @@ -147,7 +151,7 @@ def raise_formatted_ads_exception( A rich exception containing the mapped gRPC status, failure details, and request ID. """ - if version is None: + if not version: version = _get_caller_version() # 1. Map HTTP status (e.g., 400) to gRPC status (e.g., INVALID_ARGUMENT) @@ -190,6 +194,4 @@ def raise_formatted_for_status( If the response status code indicates an error. """ if not response.ok: - if version is None: - version = _get_caller_version() raise_formatted_ads_exception(response, version) diff --git a/tests/errors_test.py b/tests/errors_test.py index 072c7187a..63b52089c 100644 --- a/tests/errors_test.py +++ b/tests/errors_test.py @@ -93,6 +93,50 @@ def test_raise_formatted_ads_exception_default_version(self): exception = cm.exception self.assertEqual(exception.error.version, self.version) + def test_raise_formatted_ads_exception_empty_version(self): + with mock.patch( + "google.api_core.exceptions.from_http_response" + ) as mock_from_http: + mock_core_error = mock.Mock() + mock_core_error.grpc_status_code = grpc.StatusCode.NOT_FOUND + mock_core_error.message = "Core exception message" + mock_from_http.return_value = mock_core_error + + with self.assertRaises(errors.GoogleAdsException) as cm: + errors.raise_formatted_ads_exception(self.response, "") + + exception = cm.exception + self.assertEqual(exception.error.version, self.version) + + def test_raise_formatted_ads_exception_infers_caller_version(self): + older_version = Client._VALID_API_VERSIONS[1] + caller_globals = { + "__name__": f"google.ads.googleads.{older_version}.services.test", + "errors": errors, + } + exec( + "def call(r): errors.raise_formatted_ads_exception(r)", + caller_globals, + ) + + with mock.patch( + "google.api_core.exceptions.from_http_response" + ) as mock_from_http: + mock_core_error = mock.Mock() + mock_core_error.grpc_status_code = grpc.StatusCode.INVALID_ARGUMENT + mock_core_error.message = "Error from older version service" + mock_from_http.return_value = mock_core_error + + with self.assertRaises(errors.GoogleAdsException) as cm: + caller_globals["call"](self.response) + + exception = cm.exception + self.assertEqual(exception.error.version, older_version) + self.assertIn( + f"google.ads.googleads.{older_version}.errors", + type(exception.failure).__module__, + ) + def test_raise_formatted_for_status_when_not_ok(self): with mock.patch( "google.ads.googleads.errors.raise_formatted_ads_exception" @@ -105,16 +149,17 @@ def test_raise_formatted_for_status_default_version(self): "google.ads.googleads.errors.raise_formatted_ads_exception" ) as mock_raise: errors.raise_formatted_for_status(self.response) - mock_raise.assert_called_once_with(self.response, self.version) + mock_raise.assert_called_once_with(self.response, None) def test_raise_formatted_for_status_infers_caller_version(self): older_version = Client._VALID_API_VERSIONS[1] - resumable_upload_module = Client.import_module( - f"google.ads.googleads.{older_version}.services.services." - "you_tube_video_upload_service.transports.resumable_upload" - ) - upload = resumable_upload_module.ResumableUpload( - upload_url="https://example.com", stream=mock.Mock() + caller_globals = { + "__name__": f"google.ads.googleads.{older_version}.services.test", + "errors": errors, + } + exec( + "def call(r): errors.raise_formatted_for_status(r)", + caller_globals, ) with mock.patch( @@ -126,7 +171,7 @@ def test_raise_formatted_for_status_infers_caller_version(self): mock_from_http.return_value = mock_core_error with self.assertRaises(errors.GoogleAdsException) as cm: - upload._process_initiate_response(self.response) + caller_globals["call"](self.response) exception = cm.exception self.assertEqual(exception.error.version, older_version) @@ -135,6 +180,25 @@ def test_raise_formatted_for_status_infers_caller_version(self): type(exception.failure).__module__, ) + def test_get_caller_version_when_currentframe_is_none(self): + with mock.patch("inspect.currentframe", return_value=None): + self.assertEqual(errors._get_caller_version(), self.version) + + def test_get_caller_version_point_release_and_root_module(self): + for module_name, expected_version in [ + ("google.ads.googleads.v26_1.services.test", "v26_1"), + ("google.ads.googleads.v25", "v25"), + ]: + caller_globals = { + "__name__": module_name, + "errors": errors, + } + exec( + "def call(): return errors._get_caller_version()", + caller_globals, + ) + self.assertEqual(caller_globals["call"](), expected_version) + def test_raise_formatted_for_status_when_ok(self): self.response.ok = True with mock.patch(