diff --git a/packages/gapic-generator/gapic/templates/%namespace/%name_%version/%sub/_compat.py.j2 b/packages/gapic-generator/gapic/templates/%namespace/%name_%version/%sub/_compat.py.j2 index 39f188d6b403..c0ca7c50e49e 100644 --- a/packages/gapic-generator/gapic/templates/%namespace/%name_%version/%sub/_compat.py.j2 +++ b/packages/gapic-generator/gapic/templates/%namespace/%name_%version/%sub/_compat.py.j2 @@ -99,6 +99,15 @@ ASYNC_WRAP_METHOD_SUPPORTS_KIND = WRAP_METHOD_SUPPORTS_TRACING or hasattr( gapic_v1.method_async, "_DEFAULT_ASYNC_TRANSPORT_KIND" ) +# The `default_host` parameter in gapic_v1.method.wrap_method, +# gapic_v1.method_async.wrap_method, _observability.get_otel_interceptor, and +# _observability.apply_otel_async_channel_interceptors allows transports to pass +# their resolved default host without mutating caller-owned client_options. +SUPPORTS_DEFAULT_HOST = bool( + getattr(gapic_v1.method.wrap_method, "__kwdefaults__", None) + and "default_host" in gapic_v1.method.wrap_method.__kwdefaults__ +) + try: from google.api_core import grpc_helpers except ImportError: # pragma: NO COVER diff --git a/packages/gapic-generator/gapic/templates/%namespace/%name_%version/%sub/services/%service/transports/base.py.j2 b/packages/gapic-generator/gapic/templates/%namespace/%name_%version/%sub/services/%service/transports/base.py.j2 index 9c93aaea9c1d..a6262af2dd91 100644 --- a/packages/gapic-generator/gapic/templates/%namespace/%name_%version/%sub/services/%service/transports/base.py.j2 +++ b/packages/gapic-generator/gapic/templates/%namespace/%name_%version/%sub/services/%service/transports/base.py.j2 @@ -9,6 +9,7 @@ from typing import {% if service.any_extended_operations_methods %}Any, {% endif from {{package_path}} import gapic_version as package_version from {{package_path}}._compat import ( ASYNC_WRAP_METHOD_SUPPORTS_KIND as _ASYNC_WRAP_METHOD_SUPPORTS_KIND, + SUPPORTS_DEFAULT_HOST as _SUPPORTS_DEFAULT_HOST, WRAP_METHOD_SUPPORTS_TRACING as _WRAP_METHOD_SUPPORTS_TRACING, ) _ASYNC_WRAP_METHOD_SUPPORTS_TRACING = _WRAP_METHOD_SUPPORTS_TRACING @@ -162,7 +163,13 @@ class {{ service.name }}Transport(abc.ABC): host += ':443' self._host = host + if isinstance(client_options, dict): + client_options = client_options_lib.from_dict(client_options) + self._client_options = client_options + self._default_host: Optional[str] = ( + None if self._ignore_credentials else self._host + ) self._wrapped_methods: Dict[Callable, Callable] = {} @property @@ -181,6 +188,8 @@ class {{ service.name }}Transport(abc.ABC): """ if _WRAP_METHOD_SUPPORTS_TRACING: kwargs["client_options"] = self._client_options + if _SUPPORTS_DEFAULT_HOST: + kwargs["default_host"] = self._default_host try: kind = self.kind except NotImplementedError: @@ -193,7 +202,7 @@ class {{ service.name }}Transport(abc.ABC): # - Era 2 (>= 2.29.0, < 2.36.0): `kind` is supported (prevents REST from # falling back to gRPC wrapping), but tracing args are not. # (These fallbacks can be removed once google-api-core < 2.36.0 is no longer supported.) - for k in ["client_options", "method_name", "is_streaming"]: + for k in ["client_options", "method_name", "is_streaming", "default_host"]: kwargs.pop(k, None) try: @@ -220,6 +229,8 @@ class {{ service.name }}Transport(abc.ABC): """ if _ASYNC_WRAP_METHOD_SUPPORTS_TRACING: kwargs["client_options"] = self._client_options + if _SUPPORTS_DEFAULT_HOST: + kwargs["default_host"] = self._default_host try: kind = self.kind except NotImplementedError: @@ -232,7 +243,7 @@ class {{ service.name }}Transport(abc.ABC): # - Era 2 (>= 2.29.0, < 2.36.0): `kind` is supported (prevents REST from # falling back to gRPC wrapping), but tracing args are not. # (These fallbacks can be removed once google-api-core < 2.36.0 is no longer supported.) - for k in ["client_options", "method_name", "is_streaming"]: + for k in ["client_options", "method_name", "is_streaming", "default_host"]: kwargs.pop(k, None) try: diff --git a/packages/gapic-generator/gapic/templates/%namespace/%name_%version/%sub/services/%service/transports/grpc.py.j2 b/packages/gapic-generator/gapic/templates/%namespace/%name_%version/%sub/services/%service/transports/grpc.py.j2 index c6ea02ef3317..c424ff95dcb8 100644 --- a/packages/gapic-generator/gapic/templates/%namespace/%name_%version/%sub/services/%service/transports/grpc.py.j2 +++ b/packages/gapic-generator/gapic/templates/%namespace/%name_%version/%sub/services/%service/transports/grpc.py.j2 @@ -12,7 +12,11 @@ from typing import Callable, Dict, Optional, Sequence, Tuple, Union from typing import TYPE_CHECKING {% set package_path = api.naming.module_namespace|join('.') + "." + api.naming.versioned_module_name %} -from {{package_path}}._compat import _observability, apply_channel_interceptors +from {{package_path}}._compat import ( + _observability, + SUPPORTS_DEFAULT_HOST as _SUPPORTS_DEFAULT_HOST, + apply_channel_interceptors, +) from google.api_core import client_options as client_options_lib {% if service.has_resumable_upload_methods %} @@ -287,9 +291,12 @@ class {{ service.name }}GrpcTransport({{ service.name }}Transport): ) channel_interceptors = list(interceptors) if interceptors else [] + _otel_interceptor_kwargs = ( + {"default_host": self._default_host} if _SUPPORTS_DEFAULT_HOST else {} + ) if ( _observability is not None - and (otel_interceptor := _observability.get_otel_interceptor(self._client_options)) is not None + and (otel_interceptor := _observability.get_otel_interceptor(self._client_options, **_otel_interceptor_kwargs)) is not None and otel_interceptor not in channel_interceptors and not any(getattr(i, "_is_otel_interceptor", None) is True for i in channel_interceptors) ): diff --git a/packages/gapic-generator/gapic/templates/%namespace/%name_%version/%sub/services/%service/transports/grpc_asyncio.py.j2 b/packages/gapic-generator/gapic/templates/%namespace/%name_%version/%sub/services/%service/transports/grpc_asyncio.py.j2 index 7e35880bfd7e..1e10b95e5ddf 100644 --- a/packages/gapic-generator/gapic/templates/%namespace/%name_%version/%sub/services/%service/transports/grpc_asyncio.py.j2 +++ b/packages/gapic-generator/gapic/templates/%namespace/%name_%version/%sub/services/%service/transports/grpc_asyncio.py.j2 @@ -13,7 +13,11 @@ from typing import {% if service.has_resumable_upload_methods %}Any, {% endif %} from typing import TYPE_CHECKING {% set package_path = api.naming.module_namespace|join('.') + "." + api.naming.versioned_module_name %} -from {{package_path}}._compat import _observability, apply_async_channel_interceptors +from {{package_path}}._compat import ( + _observability, + SUPPORTS_DEFAULT_HOST as _SUPPORTS_DEFAULT_HOST, + apply_async_channel_interceptors, +) from google.api_core import client_options as client_options_lib from google.api_core import gapic_v1 @@ -349,8 +353,11 @@ class {{ service.grpc_asyncio_transport_name }}({{ service.name }}Transport): # NOTE: Coverage tool ignores async interceptors in environments running # legacy google-api-core (< 2.36.0) where OpenTelemetry is unavailable. # TODO (Issue #18657): Remove once lowest constraints require google-api-core >= v2.43.0. + _otel_interceptor_kwargs = ( + {"default_host": self._default_host} if _SUPPORTS_DEFAULT_HOST else {} + ) # pragma: NO COVER self._grpc_channel = _observability.apply_otel_async_channel_interceptors( - self._grpc_channel, self._client_options + self._grpc_channel, self._client_options, **_otel_interceptor_kwargs ) # pragma: NO COVER self._logged_channel = self._grpc_channel diff --git a/packages/gapic-generator/gapic/templates/tests/unit/gapic/%name_%version/%sub/test_%service.py.j2 b/packages/gapic-generator/gapic/templates/tests/unit/gapic/%name_%version/%sub/test_%service.py.j2 index 136319c7ced0..4ecebc020c67 100644 --- a/packages/gapic-generator/gapic/templates/tests/unit/gapic/%name_%version/%sub/test_%service.py.j2 +++ b/packages/gapic-generator/gapic/templates/tests/unit/gapic/%name_%version/%sub/test_%service.py.j2 @@ -996,7 +996,14 @@ def test_{{ service.name|snake_case }}_grpc_transport_otel_channel_interceptor() client_options=options, ) - mock_get_interceptor.assert_called_once_with(options) + expected_kwargs = ( + {"default_host": transport._default_host} + if transports.grpc._SUPPORTS_DEFAULT_HOST + else {} + ) + mock_get_interceptor.assert_called_once_with( + transport._client_options, **expected_kwargs + ) mock_apply_interceptors.assert_called_once_with( mock_channel, [mock_otel_interceptor] ) @@ -1070,7 +1077,14 @@ def test_{{ service.name|snake_case }}_grpc_asyncio_transport_otel_channel_inter client_options=options, ) - mock_apply_async_interceptors.assert_called_once_with(mock_channel, options) + expected_kwargs = ( + {"default_host": transport._default_host} + if transports.grpc_asyncio._SUPPORTS_DEFAULT_HOST + else {} + ) + mock_apply_async_interceptors.assert_called_once_with( + mock_channel, transport._client_options, **expected_kwargs + ) assert mock_create_channel.call_count == 1 assert transport.grpc_channel == mock_channel @@ -2477,7 +2491,8 @@ def test_api_key_credentials(client_class, transport_class): def test_{{ service.name|snake_case }}_base_transport_wrap_method_modern_api_core(monkeypatch): monkeypatch.setattr(transports.base, "_WRAP_METHOD_SUPPORTS_TRACING", True) - # 1. Concrete transport: passes client_options=None and kind="{{ opts.transport[0] }}" + monkeypatch.setattr(transports.base, "_SUPPORTS_DEFAULT_HOST", True) + # 1. Concrete transport: passes client_options=None, default_host, and kind="{{ opts.transport[0] }}" with mock.patch.object(transports.base.gapic_v1.method, "wrap_method") as mock_wrap: transport = transports.{{ service.name }}{{ opts.transport[0].capitalize() }}Transport( credentials=ga_credentials.AnonymousCredentials() @@ -2486,8 +2501,10 @@ def test_{{ service.name|snake_case }}_base_transport_wrap_method_modern_api_cor transport._wrap_method(func) assert mock_wrap.call_args.kwargs["kind"] == "{{ opts.transport[0] }}" assert "client_options" in mock_wrap.call_args.kwargs + assert mock_wrap.call_args.kwargs["default_host"] == transport._default_host - # 2. Base transport: kind raises NotImplementedError and is cleanly omitted + # 2. Base transport with _SUPPORTS_DEFAULT_HOST=False: kind raises NotImplementedError and default_host is omitted + monkeypatch.setattr(transports.base, "_SUPPORTS_DEFAULT_HOST", False) with mock.patch.object(transports.base.gapic_v1.method, "wrap_method") as mock_wrap: base_transport = transports.{{ service.name }}Transport( credentials=ga_credentials.AnonymousCredentials() @@ -2496,6 +2513,84 @@ def test_{{ service.name|snake_case }}_base_transport_wrap_method_modern_api_cor base_transport._wrap_method(func) assert "kind" not in mock_wrap.call_args.kwargs assert "client_options" in mock_wrap.call_args.kwargs + assert "default_host" not in mock_wrap.call_args.kwargs + + +def test_{{ service.name|snake_case }}_base_transport_client_options_none(): + transport = transports.{{ service.name }}Transport( + host="default.example.com:443", + credentials=ga_credentials.AnonymousCredentials(), + client_options=None, + ) + assert transport._client_options is None + assert transport._default_host == "default.example.com:443" + + +@pytest.mark.parametrize( + "initial_endpoint", + [ + pytest.param(None, id="unset_api_endpoint"), + pytest.param("custom.example.com:443", id="explicit_api_endpoint"), + ], +) +def test_{{ service.name|snake_case }}_base_transport_client_options_object( + initial_endpoint, +): + options = client_options.ClientOptions(api_endpoint=initial_endpoint) + transport = transports.{{ service.name }}Transport( + host="default.example.com:443", + credentials=ga_credentials.AnonymousCredentials(), + client_options=options, + ) + assert transport._client_options is options + assert transport._client_options.api_endpoint == initial_endpoint + assert transport._default_host == "default.example.com:443" + + +@pytest.mark.parametrize( + "initial_options,expected_endpoint", + [ + pytest.param( + {}, + None, + id="unset_api_endpoint", + ), + pytest.param( + {"api_endpoint": "custom.example.com:443"}, + "custom.example.com:443", + id="explicit_api_endpoint", + ), + ], +) +def test_{{ service.name|snake_case }}_base_transport_client_options_dict( + initial_options, expected_endpoint +): + options = dict(initial_options) + transport = transports.{{ service.name }}Transport( + host="default.example.com:443", + credentials=ga_credentials.AnonymousCredentials(), + client_options=options, + ) + assert isinstance(transport._client_options, client_options.ClientOptions) + assert transport._client_options.api_endpoint == expected_endpoint + assert transport._default_host == "default.example.com:443" + assert options == initial_options + + +def test_{{ service.name|snake_case }}_base_transport_default_host_ignored_when_ignore_credentials(): + class _CustomChannelTransport(transports.{{ service.name }}Transport): + def __init__(self, **kwargs): + self._ignore_credentials = True + super().__init__(**kwargs) + + options = client_options.ClientOptions() + transport = _CustomChannelTransport( + host="default.example.com:443", + client_options=options, + ) + assert transport._client_options is options + assert transport._client_options.api_endpoint is None + assert transport._default_host is None def test_{{ service.name|snake_case }}_base_transport_wrap_method_older_api_core_fallbacks(monkeypatch): @@ -2575,7 +2670,8 @@ def test_{{ service.name|snake_case }}_base_transport_wrap_method_older_api_core {% if 'grpc' in opts.transport %} def test_{{ service.name|snake_case }}_base_transport_wrap_async_method_modern_api_core(monkeypatch): monkeypatch.setattr(transports.base, "_ASYNC_WRAP_METHOD_SUPPORTS_TRACING", True) - # 1. Concrete transport: passes client_options=None and kind="grpc_asyncio" + monkeypatch.setattr(transports.base, "_SUPPORTS_DEFAULT_HOST", True) + # 1. Concrete transport: passes client_options=None, default_host, and kind="grpc_asyncio" with mock.patch.object(transports.base.gapic_v1.method_async, "wrap_method") as mock_wrap: transport = transports.{{ service.grpc_asyncio_transport_name }}( credentials=ga_credentials.AnonymousCredentials() @@ -2584,8 +2680,10 @@ def test_{{ service.name|snake_case }}_base_transport_wrap_async_method_modern_a transport._wrap_async_method(func) assert mock_wrap.call_args.kwargs["kind"] == "grpc_asyncio" assert "client_options" in mock_wrap.call_args.kwargs + assert mock_wrap.call_args.kwargs["default_host"] == transport._default_host - # 2. Base transport: kind raises NotImplementedError and is cleanly omitted + # 2. Base transport with _SUPPORTS_DEFAULT_HOST=False: kind raises NotImplementedError and default_host is omitted + monkeypatch.setattr(transports.base, "_SUPPORTS_DEFAULT_HOST", False) with mock.patch.object(transports.base.gapic_v1.method_async, "wrap_method") as mock_wrap: base_transport = transports.{{ service.name }}Transport( credentials=ga_credentials.AnonymousCredentials() @@ -2594,6 +2692,7 @@ def test_{{ service.name|snake_case }}_base_transport_wrap_async_method_modern_a base_transport._wrap_async_method(func) assert "kind" not in mock_wrap.call_args.kwargs assert "client_options" in mock_wrap.call_args.kwargs + assert "default_host" not in mock_wrap.call_args.kwargs def test_{{ service.name|snake_case }}_base_transport_wrap_async_method_older_api_core_fallbacks(monkeypatch): diff --git a/packages/gapic-generator/tests/integration/goldens/asset/google/cloud/asset_v1/_compat.py b/packages/gapic-generator/tests/integration/goldens/asset/google/cloud/asset_v1/_compat.py index a4347392b042..86424516e0a2 100755 --- a/packages/gapic-generator/tests/integration/goldens/asset/google/cloud/asset_v1/_compat.py +++ b/packages/gapic-generator/tests/integration/goldens/asset/google/cloud/asset_v1/_compat.py @@ -90,6 +90,15 @@ def trace_http_request(*args: Any, **kwargs: Any) -> _FallbackTraceContext: # t gapic_v1.method_async, "_DEFAULT_ASYNC_TRANSPORT_KIND" ) +# The `default_host` parameter in gapic_v1.method.wrap_method, +# gapic_v1.method_async.wrap_method, _observability.get_otel_interceptor, and +# _observability.apply_otel_async_channel_interceptors allows transports to pass +# their resolved default host without mutating caller-owned client_options. +SUPPORTS_DEFAULT_HOST = bool( + getattr(gapic_v1.method.wrap_method, "__kwdefaults__", None) + and "default_host" in gapic_v1.method.wrap_method.__kwdefaults__ +) + try: from google.api_core import grpc_helpers except ImportError: # pragma: NO COVER diff --git a/packages/gapic-generator/tests/integration/goldens/asset/google/cloud/asset_v1/services/asset_service/transports/base.py b/packages/gapic-generator/tests/integration/goldens/asset/google/cloud/asset_v1/services/asset_service/transports/base.py index 515c173224af..cf166ebf3b0d 100755 --- a/packages/gapic-generator/tests/integration/goldens/asset/google/cloud/asset_v1/services/asset_service/transports/base.py +++ b/packages/gapic-generator/tests/integration/goldens/asset/google/cloud/asset_v1/services/asset_service/transports/base.py @@ -19,6 +19,7 @@ from google.cloud.asset_v1 import gapic_version as package_version from google.cloud.asset_v1._compat import ( ASYNC_WRAP_METHOD_SUPPORTS_KIND as _ASYNC_WRAP_METHOD_SUPPORTS_KIND, + SUPPORTS_DEFAULT_HOST as _SUPPORTS_DEFAULT_HOST, WRAP_METHOD_SUPPORTS_TRACING as _WRAP_METHOD_SUPPORTS_TRACING, ) _ASYNC_WRAP_METHOD_SUPPORTS_TRACING = _WRAP_METHOD_SUPPORTS_TRACING @@ -133,7 +134,13 @@ def __init__( host += ':443' self._host = host + if isinstance(client_options, dict): + client_options = client_options_lib.from_dict(client_options) + self._client_options = client_options + self._default_host: Optional[str] = ( + None if self._ignore_credentials else self._host + ) self._wrapped_methods: Dict[Callable, Callable] = {} @property @@ -152,6 +159,8 @@ def _wrap_method(self, func, *args, **kwargs): """ if _WRAP_METHOD_SUPPORTS_TRACING: kwargs["client_options"] = self._client_options + if _SUPPORTS_DEFAULT_HOST: + kwargs["default_host"] = self._default_host try: kind = self.kind except NotImplementedError: @@ -164,7 +173,7 @@ def _wrap_method(self, func, *args, **kwargs): # - Era 2 (>= 2.29.0, < 2.36.0): `kind` is supported (prevents REST from # falling back to gRPC wrapping), but tracing args are not. # (These fallbacks can be removed once google-api-core < 2.36.0 is no longer supported.) - for k in ["client_options", "method_name", "is_streaming"]: + for k in ["client_options", "method_name", "is_streaming", "default_host"]: kwargs.pop(k, None) try: @@ -191,6 +200,8 @@ def _wrap_async_method(self, func, *args, **kwargs): """ if _ASYNC_WRAP_METHOD_SUPPORTS_TRACING: kwargs["client_options"] = self._client_options + if _SUPPORTS_DEFAULT_HOST: + kwargs["default_host"] = self._default_host try: kind = self.kind except NotImplementedError: @@ -203,7 +214,7 @@ def _wrap_async_method(self, func, *args, **kwargs): # - Era 2 (>= 2.29.0, < 2.36.0): `kind` is supported (prevents REST from # falling back to gRPC wrapping), but tracing args are not. # (These fallbacks can be removed once google-api-core < 2.36.0 is no longer supported.) - for k in ["client_options", "method_name", "is_streaming"]: + for k in ["client_options", "method_name", "is_streaming", "default_host"]: kwargs.pop(k, None) try: diff --git a/packages/gapic-generator/tests/integration/goldens/asset/google/cloud/asset_v1/services/asset_service/transports/grpc.py b/packages/gapic-generator/tests/integration/goldens/asset/google/cloud/asset_v1/services/asset_service/transports/grpc.py index 53964d2306d1..f2a7e6d3af1c 100755 --- a/packages/gapic-generator/tests/integration/goldens/asset/google/cloud/asset_v1/services/asset_service/transports/grpc.py +++ b/packages/gapic-generator/tests/integration/goldens/asset/google/cloud/asset_v1/services/asset_service/transports/grpc.py @@ -20,7 +20,11 @@ from typing import Callable, Dict, Optional, Sequence, Tuple, Union from typing import TYPE_CHECKING -from google.cloud.asset_v1._compat import _observability, apply_channel_interceptors +from google.cloud.asset_v1._compat import ( + _observability, + SUPPORTS_DEFAULT_HOST as _SUPPORTS_DEFAULT_HOST, + apply_channel_interceptors, +) from google.api_core import client_options as client_options_lib from google.api_core import grpc_helpers @@ -277,9 +281,12 @@ def __init__(self, *, ) channel_interceptors = list(interceptors) if interceptors else [] + _otel_interceptor_kwargs = ( + {"default_host": self._default_host} if _SUPPORTS_DEFAULT_HOST else {} + ) if ( _observability is not None - and (otel_interceptor := _observability.get_otel_interceptor(self._client_options)) is not None + and (otel_interceptor := _observability.get_otel_interceptor(self._client_options, **_otel_interceptor_kwargs)) is not None and otel_interceptor not in channel_interceptors and not any(getattr(i, "_is_otel_interceptor", None) is True for i in channel_interceptors) ): diff --git a/packages/gapic-generator/tests/integration/goldens/asset/google/cloud/asset_v1/services/asset_service/transports/grpc_asyncio.py b/packages/gapic-generator/tests/integration/goldens/asset/google/cloud/asset_v1/services/asset_service/transports/grpc_asyncio.py index 2d4465adcf4a..c74319862264 100755 --- a/packages/gapic-generator/tests/integration/goldens/asset/google/cloud/asset_v1/services/asset_service/transports/grpc_asyncio.py +++ b/packages/gapic-generator/tests/integration/goldens/asset/google/cloud/asset_v1/services/asset_service/transports/grpc_asyncio.py @@ -20,7 +20,11 @@ from typing import Awaitable, Callable, Dict, Optional, Sequence, Tuple, Union from typing import TYPE_CHECKING -from google.cloud.asset_v1._compat import _observability, apply_async_channel_interceptors +from google.cloud.asset_v1._compat import ( + _observability, + SUPPORTS_DEFAULT_HOST as _SUPPORTS_DEFAULT_HOST, + apply_async_channel_interceptors, +) from google.api_core import client_options as client_options_lib from google.api_core import gapic_v1 @@ -335,8 +339,11 @@ def __init__(self, *, # NOTE: Coverage tool ignores async interceptors in environments running # legacy google-api-core (< 2.36.0) where OpenTelemetry is unavailable. # TODO (Issue #18657): Remove once lowest constraints require google-api-core >= v2.43.0. + _otel_interceptor_kwargs = ( + {"default_host": self._default_host} if _SUPPORTS_DEFAULT_HOST else {} + ) # pragma: NO COVER self._grpc_channel = _observability.apply_otel_async_channel_interceptors( - self._grpc_channel, self._client_options + self._grpc_channel, self._client_options, **_otel_interceptor_kwargs ) # pragma: NO COVER self._logged_channel = self._grpc_channel diff --git a/packages/gapic-generator/tests/integration/goldens/asset/tests/unit/gapic/asset_v1/test_asset_service.py b/packages/gapic-generator/tests/integration/goldens/asset/tests/unit/gapic/asset_v1/test_asset_service.py index 17fc4c8a5c07..39d5b547d031 100755 --- a/packages/gapic-generator/tests/integration/goldens/asset/tests/unit/gapic/asset_v1/test_asset_service.py +++ b/packages/gapic-generator/tests/integration/goldens/asset/tests/unit/gapic/asset_v1/test_asset_service.py @@ -851,7 +851,14 @@ def test_asset_service_grpc_transport_otel_channel_interceptor(): client_options=options, ) - mock_get_interceptor.assert_called_once_with(options) + expected_kwargs = ( + {"default_host": transport._default_host} + if transports.grpc._SUPPORTS_DEFAULT_HOST + else {} + ) + mock_get_interceptor.assert_called_once_with( + transport._client_options, **expected_kwargs + ) mock_apply_interceptors.assert_called_once_with( mock_channel, [mock_otel_interceptor] ) @@ -925,7 +932,14 @@ def test_asset_service_grpc_asyncio_transport_otel_channel_interceptor(): client_options=options, ) - mock_apply_async_interceptors.assert_called_once_with(mock_channel, options) + expected_kwargs = ( + {"default_host": transport._default_host} + if transports.grpc_asyncio._SUPPORTS_DEFAULT_HOST + else {} + ) + mock_apply_async_interceptors.assert_called_once_with( + mock_channel, transport._client_options, **expected_kwargs + ) assert mock_create_channel.call_count == 1 assert transport.grpc_channel == mock_channel @@ -18412,7 +18426,8 @@ def test_api_key_credentials(client_class, transport_class): def test_asset_service_base_transport_wrap_method_modern_api_core(monkeypatch): monkeypatch.setattr(transports.base, "_WRAP_METHOD_SUPPORTS_TRACING", True) - # 1. Concrete transport: passes client_options=None and kind="grpc" + monkeypatch.setattr(transports.base, "_SUPPORTS_DEFAULT_HOST", True) + # 1. Concrete transport: passes client_options=None, default_host, and kind="grpc" with mock.patch.object(transports.base.gapic_v1.method, "wrap_method") as mock_wrap: transport = transports.AssetServiceGrpcTransport( credentials=ga_credentials.AnonymousCredentials() @@ -18421,8 +18436,10 @@ def test_asset_service_base_transport_wrap_method_modern_api_core(monkeypatch): transport._wrap_method(func) assert mock_wrap.call_args.kwargs["kind"] == "grpc" assert "client_options" in mock_wrap.call_args.kwargs + assert mock_wrap.call_args.kwargs["default_host"] == transport._default_host - # 2. Base transport: kind raises NotImplementedError and is cleanly omitted + # 2. Base transport with _SUPPORTS_DEFAULT_HOST=False: kind raises NotImplementedError and default_host is omitted + monkeypatch.setattr(transports.base, "_SUPPORTS_DEFAULT_HOST", False) with mock.patch.object(transports.base.gapic_v1.method, "wrap_method") as mock_wrap: base_transport = transports.AssetServiceTransport( credentials=ga_credentials.AnonymousCredentials() @@ -18431,6 +18448,84 @@ def test_asset_service_base_transport_wrap_method_modern_api_core(monkeypatch): base_transport._wrap_method(func) assert "kind" not in mock_wrap.call_args.kwargs assert "client_options" in mock_wrap.call_args.kwargs + assert "default_host" not in mock_wrap.call_args.kwargs + + +def test_asset_service_base_transport_client_options_none(): + transport = transports.AssetServiceTransport( + host="default.example.com:443", + credentials=ga_credentials.AnonymousCredentials(), + client_options=None, + ) + assert transport._client_options is None + assert transport._default_host == "default.example.com:443" + + +@pytest.mark.parametrize( + "initial_endpoint", + [ + pytest.param(None, id="unset_api_endpoint"), + pytest.param("custom.example.com:443", id="explicit_api_endpoint"), + ], +) +def test_asset_service_base_transport_client_options_object( + initial_endpoint, +): + options = client_options.ClientOptions(api_endpoint=initial_endpoint) + transport = transports.AssetServiceTransport( + host="default.example.com:443", + credentials=ga_credentials.AnonymousCredentials(), + client_options=options, + ) + assert transport._client_options is options + assert transport._client_options.api_endpoint == initial_endpoint + assert transport._default_host == "default.example.com:443" + + +@pytest.mark.parametrize( + "initial_options,expected_endpoint", + [ + pytest.param( + {}, + None, + id="unset_api_endpoint", + ), + pytest.param( + {"api_endpoint": "custom.example.com:443"}, + "custom.example.com:443", + id="explicit_api_endpoint", + ), + ], +) +def test_asset_service_base_transport_client_options_dict( + initial_options, expected_endpoint +): + options = dict(initial_options) + transport = transports.AssetServiceTransport( + host="default.example.com:443", + credentials=ga_credentials.AnonymousCredentials(), + client_options=options, + ) + assert isinstance(transport._client_options, client_options.ClientOptions) + assert transport._client_options.api_endpoint == expected_endpoint + assert transport._default_host == "default.example.com:443" + assert options == initial_options + + +def test_asset_service_base_transport_default_host_ignored_when_ignore_credentials(): + class _CustomChannelTransport(transports.AssetServiceTransport): + def __init__(self, **kwargs): + self._ignore_credentials = True + super().__init__(**kwargs) + + options = client_options.ClientOptions() + transport = _CustomChannelTransport( + host="default.example.com:443", + client_options=options, + ) + assert transport._client_options is options + assert transport._client_options.api_endpoint is None + assert transport._default_host is None def test_asset_service_base_transport_wrap_method_older_api_core_fallbacks(monkeypatch): @@ -18509,7 +18604,8 @@ def test_asset_service_base_transport_wrap_method_older_api_core_fallbacks(monke def test_asset_service_base_transport_wrap_async_method_modern_api_core(monkeypatch): monkeypatch.setattr(transports.base, "_ASYNC_WRAP_METHOD_SUPPORTS_TRACING", True) - # 1. Concrete transport: passes client_options=None and kind="grpc_asyncio" + monkeypatch.setattr(transports.base, "_SUPPORTS_DEFAULT_HOST", True) + # 1. Concrete transport: passes client_options=None, default_host, and kind="grpc_asyncio" with mock.patch.object(transports.base.gapic_v1.method_async, "wrap_method") as mock_wrap: transport = transports.AssetServiceGrpcAsyncIOTransport( credentials=ga_credentials.AnonymousCredentials() @@ -18518,8 +18614,10 @@ def test_asset_service_base_transport_wrap_async_method_modern_api_core(monkeypa transport._wrap_async_method(func) assert mock_wrap.call_args.kwargs["kind"] == "grpc_asyncio" assert "client_options" in mock_wrap.call_args.kwargs + assert mock_wrap.call_args.kwargs["default_host"] == transport._default_host - # 2. Base transport: kind raises NotImplementedError and is cleanly omitted + # 2. Base transport with _SUPPORTS_DEFAULT_HOST=False: kind raises NotImplementedError and default_host is omitted + monkeypatch.setattr(transports.base, "_SUPPORTS_DEFAULT_HOST", False) with mock.patch.object(transports.base.gapic_v1.method_async, "wrap_method") as mock_wrap: base_transport = transports.AssetServiceTransport( credentials=ga_credentials.AnonymousCredentials() @@ -18528,6 +18626,7 @@ def test_asset_service_base_transport_wrap_async_method_modern_api_core(monkeypa base_transport._wrap_async_method(func) assert "kind" not in mock_wrap.call_args.kwargs assert "client_options" in mock_wrap.call_args.kwargs + assert "default_host" not in mock_wrap.call_args.kwargs def test_asset_service_base_transport_wrap_async_method_older_api_core_fallbacks(monkeypatch): diff --git a/packages/gapic-generator/tests/integration/goldens/credentials/google/iam/credentials_v1/_compat.py b/packages/gapic-generator/tests/integration/goldens/credentials/google/iam/credentials_v1/_compat.py index a4347392b042..86424516e0a2 100755 --- a/packages/gapic-generator/tests/integration/goldens/credentials/google/iam/credentials_v1/_compat.py +++ b/packages/gapic-generator/tests/integration/goldens/credentials/google/iam/credentials_v1/_compat.py @@ -90,6 +90,15 @@ def trace_http_request(*args: Any, **kwargs: Any) -> _FallbackTraceContext: # t gapic_v1.method_async, "_DEFAULT_ASYNC_TRANSPORT_KIND" ) +# The `default_host` parameter in gapic_v1.method.wrap_method, +# gapic_v1.method_async.wrap_method, _observability.get_otel_interceptor, and +# _observability.apply_otel_async_channel_interceptors allows transports to pass +# their resolved default host without mutating caller-owned client_options. +SUPPORTS_DEFAULT_HOST = bool( + getattr(gapic_v1.method.wrap_method, "__kwdefaults__", None) + and "default_host" in gapic_v1.method.wrap_method.__kwdefaults__ +) + try: from google.api_core import grpc_helpers except ImportError: # pragma: NO COVER diff --git a/packages/gapic-generator/tests/integration/goldens/credentials/google/iam/credentials_v1/services/iam_credentials/transports/base.py b/packages/gapic-generator/tests/integration/goldens/credentials/google/iam/credentials_v1/services/iam_credentials/transports/base.py index 9ad2c974a10c..e06d2e33f112 100755 --- a/packages/gapic-generator/tests/integration/goldens/credentials/google/iam/credentials_v1/services/iam_credentials/transports/base.py +++ b/packages/gapic-generator/tests/integration/goldens/credentials/google/iam/credentials_v1/services/iam_credentials/transports/base.py @@ -19,6 +19,7 @@ from google.iam.credentials_v1 import gapic_version as package_version from google.iam.credentials_v1._compat import ( ASYNC_WRAP_METHOD_SUPPORTS_KIND as _ASYNC_WRAP_METHOD_SUPPORTS_KIND, + SUPPORTS_DEFAULT_HOST as _SUPPORTS_DEFAULT_HOST, WRAP_METHOD_SUPPORTS_TRACING as _WRAP_METHOD_SUPPORTS_TRACING, ) _ASYNC_WRAP_METHOD_SUPPORTS_TRACING = _WRAP_METHOD_SUPPORTS_TRACING @@ -130,7 +131,13 @@ def __init__( host += ':443' self._host = host + if isinstance(client_options, dict): + client_options = client_options_lib.from_dict(client_options) + self._client_options = client_options + self._default_host: Optional[str] = ( + None if self._ignore_credentials else self._host + ) self._wrapped_methods: Dict[Callable, Callable] = {} @property @@ -149,6 +156,8 @@ def _wrap_method(self, func, *args, **kwargs): """ if _WRAP_METHOD_SUPPORTS_TRACING: kwargs["client_options"] = self._client_options + if _SUPPORTS_DEFAULT_HOST: + kwargs["default_host"] = self._default_host try: kind = self.kind except NotImplementedError: @@ -161,7 +170,7 @@ def _wrap_method(self, func, *args, **kwargs): # - Era 2 (>= 2.29.0, < 2.36.0): `kind` is supported (prevents REST from # falling back to gRPC wrapping), but tracing args are not. # (These fallbacks can be removed once google-api-core < 2.36.0 is no longer supported.) - for k in ["client_options", "method_name", "is_streaming"]: + for k in ["client_options", "method_name", "is_streaming", "default_host"]: kwargs.pop(k, None) try: @@ -188,6 +197,8 @@ def _wrap_async_method(self, func, *args, **kwargs): """ if _ASYNC_WRAP_METHOD_SUPPORTS_TRACING: kwargs["client_options"] = self._client_options + if _SUPPORTS_DEFAULT_HOST: + kwargs["default_host"] = self._default_host try: kind = self.kind except NotImplementedError: @@ -200,7 +211,7 @@ def _wrap_async_method(self, func, *args, **kwargs): # - Era 2 (>= 2.29.0, < 2.36.0): `kind` is supported (prevents REST from # falling back to gRPC wrapping), but tracing args are not. # (These fallbacks can be removed once google-api-core < 2.36.0 is no longer supported.) - for k in ["client_options", "method_name", "is_streaming"]: + for k in ["client_options", "method_name", "is_streaming", "default_host"]: kwargs.pop(k, None) try: diff --git a/packages/gapic-generator/tests/integration/goldens/credentials/google/iam/credentials_v1/services/iam_credentials/transports/grpc.py b/packages/gapic-generator/tests/integration/goldens/credentials/google/iam/credentials_v1/services/iam_credentials/transports/grpc.py index 31fbf17d91cb..df22772304fd 100755 --- a/packages/gapic-generator/tests/integration/goldens/credentials/google/iam/credentials_v1/services/iam_credentials/transports/grpc.py +++ b/packages/gapic-generator/tests/integration/goldens/credentials/google/iam/credentials_v1/services/iam_credentials/transports/grpc.py @@ -20,7 +20,11 @@ from typing import Callable, Dict, Optional, Sequence, Tuple, Union from typing import TYPE_CHECKING -from google.iam.credentials_v1._compat import _observability, apply_channel_interceptors +from google.iam.credentials_v1._compat import ( + _observability, + SUPPORTS_DEFAULT_HOST as _SUPPORTS_DEFAULT_HOST, + apply_channel_interceptors, +) from google.api_core import client_options as client_options_lib from google.api_core import grpc_helpers @@ -282,9 +286,12 @@ def __init__(self, *, ) channel_interceptors = list(interceptors) if interceptors else [] + _otel_interceptor_kwargs = ( + {"default_host": self._default_host} if _SUPPORTS_DEFAULT_HOST else {} + ) if ( _observability is not None - and (otel_interceptor := _observability.get_otel_interceptor(self._client_options)) is not None + and (otel_interceptor := _observability.get_otel_interceptor(self._client_options, **_otel_interceptor_kwargs)) is not None and otel_interceptor not in channel_interceptors and not any(getattr(i, "_is_otel_interceptor", None) is True for i in channel_interceptors) ): diff --git a/packages/gapic-generator/tests/integration/goldens/credentials/google/iam/credentials_v1/services/iam_credentials/transports/grpc_asyncio.py b/packages/gapic-generator/tests/integration/goldens/credentials/google/iam/credentials_v1/services/iam_credentials/transports/grpc_asyncio.py index 2465db4b3328..45d77f8dacf1 100755 --- a/packages/gapic-generator/tests/integration/goldens/credentials/google/iam/credentials_v1/services/iam_credentials/transports/grpc_asyncio.py +++ b/packages/gapic-generator/tests/integration/goldens/credentials/google/iam/credentials_v1/services/iam_credentials/transports/grpc_asyncio.py @@ -20,7 +20,11 @@ from typing import Awaitable, Callable, Dict, Optional, Sequence, Tuple, Union from typing import TYPE_CHECKING -from google.iam.credentials_v1._compat import _observability, apply_async_channel_interceptors +from google.iam.credentials_v1._compat import ( + _observability, + SUPPORTS_DEFAULT_HOST as _SUPPORTS_DEFAULT_HOST, + apply_async_channel_interceptors, +) from google.api_core import client_options as client_options_lib from google.api_core import gapic_v1 @@ -340,8 +344,11 @@ def __init__(self, *, # NOTE: Coverage tool ignores async interceptors in environments running # legacy google-api-core (< 2.36.0) where OpenTelemetry is unavailable. # TODO (Issue #18657): Remove once lowest constraints require google-api-core >= v2.43.0. + _otel_interceptor_kwargs = ( + {"default_host": self._default_host} if _SUPPORTS_DEFAULT_HOST else {} + ) # pragma: NO COVER self._grpc_channel = _observability.apply_otel_async_channel_interceptors( - self._grpc_channel, self._client_options + self._grpc_channel, self._client_options, **_otel_interceptor_kwargs ) # pragma: NO COVER self._logged_channel = self._grpc_channel diff --git a/packages/gapic-generator/tests/integration/goldens/credentials/tests/unit/gapic/credentials_v1/test_iam_credentials.py b/packages/gapic-generator/tests/integration/goldens/credentials/tests/unit/gapic/credentials_v1/test_iam_credentials.py index f39f23ababfa..2ea9c767bb69 100755 --- a/packages/gapic-generator/tests/integration/goldens/credentials/tests/unit/gapic/credentials_v1/test_iam_credentials.py +++ b/packages/gapic-generator/tests/integration/goldens/credentials/tests/unit/gapic/credentials_v1/test_iam_credentials.py @@ -841,7 +841,14 @@ def test_iam_credentials_grpc_transport_otel_channel_interceptor(): client_options=options, ) - mock_get_interceptor.assert_called_once_with(options) + expected_kwargs = ( + {"default_host": transport._default_host} + if transports.grpc._SUPPORTS_DEFAULT_HOST + else {} + ) + mock_get_interceptor.assert_called_once_with( + transport._client_options, **expected_kwargs + ) mock_apply_interceptors.assert_called_once_with( mock_channel, [mock_otel_interceptor] ) @@ -915,7 +922,14 @@ def test_iam_credentials_grpc_asyncio_transport_otel_channel_interceptor(): client_options=options, ) - mock_apply_async_interceptors.assert_called_once_with(mock_channel, options) + expected_kwargs = ( + {"default_host": transport._default_host} + if transports.grpc_asyncio._SUPPORTS_DEFAULT_HOST + else {} + ) + mock_apply_async_interceptors.assert_called_once_with( + mock_channel, transport._client_options, **expected_kwargs + ) assert mock_create_channel.call_count == 1 assert transport.grpc_channel == mock_channel @@ -4458,7 +4472,8 @@ def test_api_key_credentials(client_class, transport_class): def test_iam_credentials_base_transport_wrap_method_modern_api_core(monkeypatch): monkeypatch.setattr(transports.base, "_WRAP_METHOD_SUPPORTS_TRACING", True) - # 1. Concrete transport: passes client_options=None and kind="grpc" + monkeypatch.setattr(transports.base, "_SUPPORTS_DEFAULT_HOST", True) + # 1. Concrete transport: passes client_options=None, default_host, and kind="grpc" with mock.patch.object(transports.base.gapic_v1.method, "wrap_method") as mock_wrap: transport = transports.IAMCredentialsGrpcTransport( credentials=ga_credentials.AnonymousCredentials() @@ -4467,8 +4482,10 @@ def test_iam_credentials_base_transport_wrap_method_modern_api_core(monkeypatch) transport._wrap_method(func) assert mock_wrap.call_args.kwargs["kind"] == "grpc" assert "client_options" in mock_wrap.call_args.kwargs + assert mock_wrap.call_args.kwargs["default_host"] == transport._default_host - # 2. Base transport: kind raises NotImplementedError and is cleanly omitted + # 2. Base transport with _SUPPORTS_DEFAULT_HOST=False: kind raises NotImplementedError and default_host is omitted + monkeypatch.setattr(transports.base, "_SUPPORTS_DEFAULT_HOST", False) with mock.patch.object(transports.base.gapic_v1.method, "wrap_method") as mock_wrap: base_transport = transports.IAMCredentialsTransport( credentials=ga_credentials.AnonymousCredentials() @@ -4477,6 +4494,84 @@ def test_iam_credentials_base_transport_wrap_method_modern_api_core(monkeypatch) base_transport._wrap_method(func) assert "kind" not in mock_wrap.call_args.kwargs assert "client_options" in mock_wrap.call_args.kwargs + assert "default_host" not in mock_wrap.call_args.kwargs + + +def test_iam_credentials_base_transport_client_options_none(): + transport = transports.IAMCredentialsTransport( + host="default.example.com:443", + credentials=ga_credentials.AnonymousCredentials(), + client_options=None, + ) + assert transport._client_options is None + assert transport._default_host == "default.example.com:443" + + +@pytest.mark.parametrize( + "initial_endpoint", + [ + pytest.param(None, id="unset_api_endpoint"), + pytest.param("custom.example.com:443", id="explicit_api_endpoint"), + ], +) +def test_iam_credentials_base_transport_client_options_object( + initial_endpoint, +): + options = client_options.ClientOptions(api_endpoint=initial_endpoint) + transport = transports.IAMCredentialsTransport( + host="default.example.com:443", + credentials=ga_credentials.AnonymousCredentials(), + client_options=options, + ) + assert transport._client_options is options + assert transport._client_options.api_endpoint == initial_endpoint + assert transport._default_host == "default.example.com:443" + + +@pytest.mark.parametrize( + "initial_options,expected_endpoint", + [ + pytest.param( + {}, + None, + id="unset_api_endpoint", + ), + pytest.param( + {"api_endpoint": "custom.example.com:443"}, + "custom.example.com:443", + id="explicit_api_endpoint", + ), + ], +) +def test_iam_credentials_base_transport_client_options_dict( + initial_options, expected_endpoint +): + options = dict(initial_options) + transport = transports.IAMCredentialsTransport( + host="default.example.com:443", + credentials=ga_credentials.AnonymousCredentials(), + client_options=options, + ) + assert isinstance(transport._client_options, client_options.ClientOptions) + assert transport._client_options.api_endpoint == expected_endpoint + assert transport._default_host == "default.example.com:443" + assert options == initial_options + + +def test_iam_credentials_base_transport_default_host_ignored_when_ignore_credentials(): + class _CustomChannelTransport(transports.IAMCredentialsTransport): + def __init__(self, **kwargs): + self._ignore_credentials = True + super().__init__(**kwargs) + + options = client_options.ClientOptions() + transport = _CustomChannelTransport( + host="default.example.com:443", + client_options=options, + ) + assert transport._client_options is options + assert transport._client_options.api_endpoint is None + assert transport._default_host is None def test_iam_credentials_base_transport_wrap_method_older_api_core_fallbacks(monkeypatch): @@ -4555,7 +4650,8 @@ def test_iam_credentials_base_transport_wrap_method_older_api_core_fallbacks(mon def test_iam_credentials_base_transport_wrap_async_method_modern_api_core(monkeypatch): monkeypatch.setattr(transports.base, "_ASYNC_WRAP_METHOD_SUPPORTS_TRACING", True) - # 1. Concrete transport: passes client_options=None and kind="grpc_asyncio" + monkeypatch.setattr(transports.base, "_SUPPORTS_DEFAULT_HOST", True) + # 1. Concrete transport: passes client_options=None, default_host, and kind="grpc_asyncio" with mock.patch.object(transports.base.gapic_v1.method_async, "wrap_method") as mock_wrap: transport = transports.IAMCredentialsGrpcAsyncIOTransport( credentials=ga_credentials.AnonymousCredentials() @@ -4564,8 +4660,10 @@ def test_iam_credentials_base_transport_wrap_async_method_modern_api_core(monkey transport._wrap_async_method(func) assert mock_wrap.call_args.kwargs["kind"] == "grpc_asyncio" assert "client_options" in mock_wrap.call_args.kwargs + assert mock_wrap.call_args.kwargs["default_host"] == transport._default_host - # 2. Base transport: kind raises NotImplementedError and is cleanly omitted + # 2. Base transport with _SUPPORTS_DEFAULT_HOST=False: kind raises NotImplementedError and default_host is omitted + monkeypatch.setattr(transports.base, "_SUPPORTS_DEFAULT_HOST", False) with mock.patch.object(transports.base.gapic_v1.method_async, "wrap_method") as mock_wrap: base_transport = transports.IAMCredentialsTransport( credentials=ga_credentials.AnonymousCredentials() @@ -4574,6 +4672,7 @@ def test_iam_credentials_base_transport_wrap_async_method_modern_api_core(monkey base_transport._wrap_async_method(func) assert "kind" not in mock_wrap.call_args.kwargs assert "client_options" in mock_wrap.call_args.kwargs + assert "default_host" not in mock_wrap.call_args.kwargs def test_iam_credentials_base_transport_wrap_async_method_older_api_core_fallbacks(monkeypatch): diff --git a/packages/gapic-generator/tests/integration/goldens/eventarc/google/cloud/eventarc_v1/_compat.py b/packages/gapic-generator/tests/integration/goldens/eventarc/google/cloud/eventarc_v1/_compat.py index a4347392b042..86424516e0a2 100755 --- a/packages/gapic-generator/tests/integration/goldens/eventarc/google/cloud/eventarc_v1/_compat.py +++ b/packages/gapic-generator/tests/integration/goldens/eventarc/google/cloud/eventarc_v1/_compat.py @@ -90,6 +90,15 @@ def trace_http_request(*args: Any, **kwargs: Any) -> _FallbackTraceContext: # t gapic_v1.method_async, "_DEFAULT_ASYNC_TRANSPORT_KIND" ) +# The `default_host` parameter in gapic_v1.method.wrap_method, +# gapic_v1.method_async.wrap_method, _observability.get_otel_interceptor, and +# _observability.apply_otel_async_channel_interceptors allows transports to pass +# their resolved default host without mutating caller-owned client_options. +SUPPORTS_DEFAULT_HOST = bool( + getattr(gapic_v1.method.wrap_method, "__kwdefaults__", None) + and "default_host" in gapic_v1.method.wrap_method.__kwdefaults__ +) + try: from google.api_core import grpc_helpers except ImportError: # pragma: NO COVER diff --git a/packages/gapic-generator/tests/integration/goldens/eventarc/google/cloud/eventarc_v1/services/eventarc/transports/base.py b/packages/gapic-generator/tests/integration/goldens/eventarc/google/cloud/eventarc_v1/services/eventarc/transports/base.py index 963fc7ddb134..dedfc6e240f6 100755 --- a/packages/gapic-generator/tests/integration/goldens/eventarc/google/cloud/eventarc_v1/services/eventarc/transports/base.py +++ b/packages/gapic-generator/tests/integration/goldens/eventarc/google/cloud/eventarc_v1/services/eventarc/transports/base.py @@ -19,6 +19,7 @@ from google.cloud.eventarc_v1 import gapic_version as package_version from google.cloud.eventarc_v1._compat import ( ASYNC_WRAP_METHOD_SUPPORTS_KIND as _ASYNC_WRAP_METHOD_SUPPORTS_KIND, + SUPPORTS_DEFAULT_HOST as _SUPPORTS_DEFAULT_HOST, WRAP_METHOD_SUPPORTS_TRACING as _WRAP_METHOD_SUPPORTS_TRACING, ) _ASYNC_WRAP_METHOD_SUPPORTS_TRACING = _WRAP_METHOD_SUPPORTS_TRACING @@ -145,7 +146,13 @@ def __init__( host += ':443' self._host = host + if isinstance(client_options, dict): + client_options = client_options_lib.from_dict(client_options) + self._client_options = client_options + self._default_host: Optional[str] = ( + None if self._ignore_credentials else self._host + ) self._wrapped_methods: Dict[Callable, Callable] = {} @property @@ -164,6 +171,8 @@ def _wrap_method(self, func, *args, **kwargs): """ if _WRAP_METHOD_SUPPORTS_TRACING: kwargs["client_options"] = self._client_options + if _SUPPORTS_DEFAULT_HOST: + kwargs["default_host"] = self._default_host try: kind = self.kind except NotImplementedError: @@ -176,7 +185,7 @@ def _wrap_method(self, func, *args, **kwargs): # - Era 2 (>= 2.29.0, < 2.36.0): `kind` is supported (prevents REST from # falling back to gRPC wrapping), but tracing args are not. # (These fallbacks can be removed once google-api-core < 2.36.0 is no longer supported.) - for k in ["client_options", "method_name", "is_streaming"]: + for k in ["client_options", "method_name", "is_streaming", "default_host"]: kwargs.pop(k, None) try: @@ -203,6 +212,8 @@ def _wrap_async_method(self, func, *args, **kwargs): """ if _ASYNC_WRAP_METHOD_SUPPORTS_TRACING: kwargs["client_options"] = self._client_options + if _SUPPORTS_DEFAULT_HOST: + kwargs["default_host"] = self._default_host try: kind = self.kind except NotImplementedError: @@ -215,7 +226,7 @@ def _wrap_async_method(self, func, *args, **kwargs): # - Era 2 (>= 2.29.0, < 2.36.0): `kind` is supported (prevents REST from # falling back to gRPC wrapping), but tracing args are not. # (These fallbacks can be removed once google-api-core < 2.36.0 is no longer supported.) - for k in ["client_options", "method_name", "is_streaming"]: + for k in ["client_options", "method_name", "is_streaming", "default_host"]: kwargs.pop(k, None) try: diff --git a/packages/gapic-generator/tests/integration/goldens/eventarc/google/cloud/eventarc_v1/services/eventarc/transports/grpc.py b/packages/gapic-generator/tests/integration/goldens/eventarc/google/cloud/eventarc_v1/services/eventarc/transports/grpc.py index f4cf2bfdbaff..1a4d51772a0b 100755 --- a/packages/gapic-generator/tests/integration/goldens/eventarc/google/cloud/eventarc_v1/services/eventarc/transports/grpc.py +++ b/packages/gapic-generator/tests/integration/goldens/eventarc/google/cloud/eventarc_v1/services/eventarc/transports/grpc.py @@ -20,7 +20,11 @@ from typing import Callable, Dict, Optional, Sequence, Tuple, Union from typing import TYPE_CHECKING -from google.cloud.eventarc_v1._compat import _observability, apply_channel_interceptors +from google.cloud.eventarc_v1._compat import ( + _observability, + SUPPORTS_DEFAULT_HOST as _SUPPORTS_DEFAULT_HOST, + apply_channel_interceptors, +) from google.api_core import client_options as client_options_lib from google.api_core import grpc_helpers @@ -291,9 +295,12 @@ def __init__(self, *, ) channel_interceptors = list(interceptors) if interceptors else [] + _otel_interceptor_kwargs = ( + {"default_host": self._default_host} if _SUPPORTS_DEFAULT_HOST else {} + ) if ( _observability is not None - and (otel_interceptor := _observability.get_otel_interceptor(self._client_options)) is not None + and (otel_interceptor := _observability.get_otel_interceptor(self._client_options, **_otel_interceptor_kwargs)) is not None and otel_interceptor not in channel_interceptors and not any(getattr(i, "_is_otel_interceptor", None) is True for i in channel_interceptors) ): diff --git a/packages/gapic-generator/tests/integration/goldens/eventarc/google/cloud/eventarc_v1/services/eventarc/transports/grpc_asyncio.py b/packages/gapic-generator/tests/integration/goldens/eventarc/google/cloud/eventarc_v1/services/eventarc/transports/grpc_asyncio.py index 1656b15c6881..6fb79c5eec5a 100755 --- a/packages/gapic-generator/tests/integration/goldens/eventarc/google/cloud/eventarc_v1/services/eventarc/transports/grpc_asyncio.py +++ b/packages/gapic-generator/tests/integration/goldens/eventarc/google/cloud/eventarc_v1/services/eventarc/transports/grpc_asyncio.py @@ -20,7 +20,11 @@ from typing import Awaitable, Callable, Dict, Optional, Sequence, Tuple, Union from typing import TYPE_CHECKING -from google.cloud.eventarc_v1._compat import _observability, apply_async_channel_interceptors +from google.cloud.eventarc_v1._compat import ( + _observability, + SUPPORTS_DEFAULT_HOST as _SUPPORTS_DEFAULT_HOST, + apply_async_channel_interceptors, +) from google.api_core import client_options as client_options_lib from google.api_core import gapic_v1 @@ -349,8 +353,11 @@ def __init__(self, *, # NOTE: Coverage tool ignores async interceptors in environments running # legacy google-api-core (< 2.36.0) where OpenTelemetry is unavailable. # TODO (Issue #18657): Remove once lowest constraints require google-api-core >= v2.43.0. + _otel_interceptor_kwargs = ( + {"default_host": self._default_host} if _SUPPORTS_DEFAULT_HOST else {} + ) # pragma: NO COVER self._grpc_channel = _observability.apply_otel_async_channel_interceptors( - self._grpc_channel, self._client_options + self._grpc_channel, self._client_options, **_otel_interceptor_kwargs ) # pragma: NO COVER self._logged_channel = self._grpc_channel diff --git a/packages/gapic-generator/tests/integration/goldens/eventarc/tests/unit/gapic/eventarc_v1/test_eventarc.py b/packages/gapic-generator/tests/integration/goldens/eventarc/tests/unit/gapic/eventarc_v1/test_eventarc.py index b5e055c25ec9..e217e6371716 100755 --- a/packages/gapic-generator/tests/integration/goldens/eventarc/tests/unit/gapic/eventarc_v1/test_eventarc.py +++ b/packages/gapic-generator/tests/integration/goldens/eventarc/tests/unit/gapic/eventarc_v1/test_eventarc.py @@ -872,7 +872,14 @@ def test_eventarc_grpc_transport_otel_channel_interceptor(): client_options=options, ) - mock_get_interceptor.assert_called_once_with(options) + expected_kwargs = ( + {"default_host": transport._default_host} + if transports.grpc._SUPPORTS_DEFAULT_HOST + else {} + ) + mock_get_interceptor.assert_called_once_with( + transport._client_options, **expected_kwargs + ) mock_apply_interceptors.assert_called_once_with( mock_channel, [mock_otel_interceptor] ) @@ -946,7 +953,14 @@ def test_eventarc_grpc_asyncio_transport_otel_channel_interceptor(): client_options=options, ) - mock_apply_async_interceptors.assert_called_once_with(mock_channel, options) + expected_kwargs = ( + {"default_host": transport._default_host} + if transports.grpc_asyncio._SUPPORTS_DEFAULT_HOST + else {} + ) + mock_apply_async_interceptors.assert_called_once_with( + mock_channel, transport._client_options, **expected_kwargs + ) assert mock_create_channel.call_count == 1 assert transport.grpc_channel == mock_channel @@ -33515,7 +33529,8 @@ def test_api_key_credentials(client_class, transport_class): def test_eventarc_base_transport_wrap_method_modern_api_core(monkeypatch): monkeypatch.setattr(transports.base, "_WRAP_METHOD_SUPPORTS_TRACING", True) - # 1. Concrete transport: passes client_options=None and kind="grpc" + monkeypatch.setattr(transports.base, "_SUPPORTS_DEFAULT_HOST", True) + # 1. Concrete transport: passes client_options=None, default_host, and kind="grpc" with mock.patch.object(transports.base.gapic_v1.method, "wrap_method") as mock_wrap: transport = transports.EventarcGrpcTransport( credentials=ga_credentials.AnonymousCredentials() @@ -33524,8 +33539,10 @@ def test_eventarc_base_transport_wrap_method_modern_api_core(monkeypatch): transport._wrap_method(func) assert mock_wrap.call_args.kwargs["kind"] == "grpc" assert "client_options" in mock_wrap.call_args.kwargs + assert mock_wrap.call_args.kwargs["default_host"] == transport._default_host - # 2. Base transport: kind raises NotImplementedError and is cleanly omitted + # 2. Base transport with _SUPPORTS_DEFAULT_HOST=False: kind raises NotImplementedError and default_host is omitted + monkeypatch.setattr(transports.base, "_SUPPORTS_DEFAULT_HOST", False) with mock.patch.object(transports.base.gapic_v1.method, "wrap_method") as mock_wrap: base_transport = transports.EventarcTransport( credentials=ga_credentials.AnonymousCredentials() @@ -33534,6 +33551,84 @@ def test_eventarc_base_transport_wrap_method_modern_api_core(monkeypatch): base_transport._wrap_method(func) assert "kind" not in mock_wrap.call_args.kwargs assert "client_options" in mock_wrap.call_args.kwargs + assert "default_host" not in mock_wrap.call_args.kwargs + + +def test_eventarc_base_transport_client_options_none(): + transport = transports.EventarcTransport( + host="default.example.com:443", + credentials=ga_credentials.AnonymousCredentials(), + client_options=None, + ) + assert transport._client_options is None + assert transport._default_host == "default.example.com:443" + + +@pytest.mark.parametrize( + "initial_endpoint", + [ + pytest.param(None, id="unset_api_endpoint"), + pytest.param("custom.example.com:443", id="explicit_api_endpoint"), + ], +) +def test_eventarc_base_transport_client_options_object( + initial_endpoint, +): + options = client_options.ClientOptions(api_endpoint=initial_endpoint) + transport = transports.EventarcTransport( + host="default.example.com:443", + credentials=ga_credentials.AnonymousCredentials(), + client_options=options, + ) + assert transport._client_options is options + assert transport._client_options.api_endpoint == initial_endpoint + assert transport._default_host == "default.example.com:443" + + +@pytest.mark.parametrize( + "initial_options,expected_endpoint", + [ + pytest.param( + {}, + None, + id="unset_api_endpoint", + ), + pytest.param( + {"api_endpoint": "custom.example.com:443"}, + "custom.example.com:443", + id="explicit_api_endpoint", + ), + ], +) +def test_eventarc_base_transport_client_options_dict( + initial_options, expected_endpoint +): + options = dict(initial_options) + transport = transports.EventarcTransport( + host="default.example.com:443", + credentials=ga_credentials.AnonymousCredentials(), + client_options=options, + ) + assert isinstance(transport._client_options, client_options.ClientOptions) + assert transport._client_options.api_endpoint == expected_endpoint + assert transport._default_host == "default.example.com:443" + assert options == initial_options + + +def test_eventarc_base_transport_default_host_ignored_when_ignore_credentials(): + class _CustomChannelTransport(transports.EventarcTransport): + def __init__(self, **kwargs): + self._ignore_credentials = True + super().__init__(**kwargs) + + options = client_options.ClientOptions() + transport = _CustomChannelTransport( + host="default.example.com:443", + client_options=options, + ) + assert transport._client_options is options + assert transport._client_options.api_endpoint is None + assert transport._default_host is None def test_eventarc_base_transport_wrap_method_older_api_core_fallbacks(monkeypatch): @@ -33612,7 +33707,8 @@ def test_eventarc_base_transport_wrap_method_older_api_core_fallbacks(monkeypatc def test_eventarc_base_transport_wrap_async_method_modern_api_core(monkeypatch): monkeypatch.setattr(transports.base, "_ASYNC_WRAP_METHOD_SUPPORTS_TRACING", True) - # 1. Concrete transport: passes client_options=None and kind="grpc_asyncio" + monkeypatch.setattr(transports.base, "_SUPPORTS_DEFAULT_HOST", True) + # 1. Concrete transport: passes client_options=None, default_host, and kind="grpc_asyncio" with mock.patch.object(transports.base.gapic_v1.method_async, "wrap_method") as mock_wrap: transport = transports.EventarcGrpcAsyncIOTransport( credentials=ga_credentials.AnonymousCredentials() @@ -33621,8 +33717,10 @@ def test_eventarc_base_transport_wrap_async_method_modern_api_core(monkeypatch): transport._wrap_async_method(func) assert mock_wrap.call_args.kwargs["kind"] == "grpc_asyncio" assert "client_options" in mock_wrap.call_args.kwargs + assert mock_wrap.call_args.kwargs["default_host"] == transport._default_host - # 2. Base transport: kind raises NotImplementedError and is cleanly omitted + # 2. Base transport with _SUPPORTS_DEFAULT_HOST=False: kind raises NotImplementedError and default_host is omitted + monkeypatch.setattr(transports.base, "_SUPPORTS_DEFAULT_HOST", False) with mock.patch.object(transports.base.gapic_v1.method_async, "wrap_method") as mock_wrap: base_transport = transports.EventarcTransport( credentials=ga_credentials.AnonymousCredentials() @@ -33631,6 +33729,7 @@ def test_eventarc_base_transport_wrap_async_method_modern_api_core(monkeypatch): base_transport._wrap_async_method(func) assert "kind" not in mock_wrap.call_args.kwargs assert "client_options" in mock_wrap.call_args.kwargs + assert "default_host" not in mock_wrap.call_args.kwargs def test_eventarc_base_transport_wrap_async_method_older_api_core_fallbacks(monkeypatch): diff --git a/packages/gapic-generator/tests/integration/goldens/logging/google/cloud/logging_v2/_compat.py b/packages/gapic-generator/tests/integration/goldens/logging/google/cloud/logging_v2/_compat.py index a4347392b042..86424516e0a2 100755 --- a/packages/gapic-generator/tests/integration/goldens/logging/google/cloud/logging_v2/_compat.py +++ b/packages/gapic-generator/tests/integration/goldens/logging/google/cloud/logging_v2/_compat.py @@ -90,6 +90,15 @@ def trace_http_request(*args: Any, **kwargs: Any) -> _FallbackTraceContext: # t gapic_v1.method_async, "_DEFAULT_ASYNC_TRANSPORT_KIND" ) +# The `default_host` parameter in gapic_v1.method.wrap_method, +# gapic_v1.method_async.wrap_method, _observability.get_otel_interceptor, and +# _observability.apply_otel_async_channel_interceptors allows transports to pass +# their resolved default host without mutating caller-owned client_options. +SUPPORTS_DEFAULT_HOST = bool( + getattr(gapic_v1.method.wrap_method, "__kwdefaults__", None) + and "default_host" in gapic_v1.method.wrap_method.__kwdefaults__ +) + try: from google.api_core import grpc_helpers except ImportError: # pragma: NO COVER diff --git a/packages/gapic-generator/tests/integration/goldens/logging/google/cloud/logging_v2/services/config_service_v2/transports/base.py b/packages/gapic-generator/tests/integration/goldens/logging/google/cloud/logging_v2/services/config_service_v2/transports/base.py index 4a5dbf0b6ca1..d7d41e6e5bd8 100755 --- a/packages/gapic-generator/tests/integration/goldens/logging/google/cloud/logging_v2/services/config_service_v2/transports/base.py +++ b/packages/gapic-generator/tests/integration/goldens/logging/google/cloud/logging_v2/services/config_service_v2/transports/base.py @@ -19,6 +19,7 @@ from google.cloud.logging_v2 import gapic_version as package_version from google.cloud.logging_v2._compat import ( ASYNC_WRAP_METHOD_SUPPORTS_KIND as _ASYNC_WRAP_METHOD_SUPPORTS_KIND, + SUPPORTS_DEFAULT_HOST as _SUPPORTS_DEFAULT_HOST, WRAP_METHOD_SUPPORTS_TRACING as _WRAP_METHOD_SUPPORTS_TRACING, ) _ASYNC_WRAP_METHOD_SUPPORTS_TRACING = _WRAP_METHOD_SUPPORTS_TRACING @@ -136,7 +137,13 @@ def __init__( host += ':443' self._host = host + if isinstance(client_options, dict): + client_options = client_options_lib.from_dict(client_options) + self._client_options = client_options + self._default_host: Optional[str] = ( + None if self._ignore_credentials else self._host + ) self._wrapped_methods: Dict[Callable, Callable] = {} @property @@ -155,6 +162,8 @@ def _wrap_method(self, func, *args, **kwargs): """ if _WRAP_METHOD_SUPPORTS_TRACING: kwargs["client_options"] = self._client_options + if _SUPPORTS_DEFAULT_HOST: + kwargs["default_host"] = self._default_host try: kind = self.kind except NotImplementedError: @@ -167,7 +176,7 @@ def _wrap_method(self, func, *args, **kwargs): # - Era 2 (>= 2.29.0, < 2.36.0): `kind` is supported (prevents REST from # falling back to gRPC wrapping), but tracing args are not. # (These fallbacks can be removed once google-api-core < 2.36.0 is no longer supported.) - for k in ["client_options", "method_name", "is_streaming"]: + for k in ["client_options", "method_name", "is_streaming", "default_host"]: kwargs.pop(k, None) try: @@ -194,6 +203,8 @@ def _wrap_async_method(self, func, *args, **kwargs): """ if _ASYNC_WRAP_METHOD_SUPPORTS_TRACING: kwargs["client_options"] = self._client_options + if _SUPPORTS_DEFAULT_HOST: + kwargs["default_host"] = self._default_host try: kind = self.kind except NotImplementedError: @@ -206,7 +217,7 @@ def _wrap_async_method(self, func, *args, **kwargs): # - Era 2 (>= 2.29.0, < 2.36.0): `kind` is supported (prevents REST from # falling back to gRPC wrapping), but tracing args are not. # (These fallbacks can be removed once google-api-core < 2.36.0 is no longer supported.) - for k in ["client_options", "method_name", "is_streaming"]: + for k in ["client_options", "method_name", "is_streaming", "default_host"]: kwargs.pop(k, None) try: diff --git a/packages/gapic-generator/tests/integration/goldens/logging/google/cloud/logging_v2/services/config_service_v2/transports/grpc.py b/packages/gapic-generator/tests/integration/goldens/logging/google/cloud/logging_v2/services/config_service_v2/transports/grpc.py index 543032611b9f..2ee41c76666c 100755 --- a/packages/gapic-generator/tests/integration/goldens/logging/google/cloud/logging_v2/services/config_service_v2/transports/grpc.py +++ b/packages/gapic-generator/tests/integration/goldens/logging/google/cloud/logging_v2/services/config_service_v2/transports/grpc.py @@ -20,7 +20,11 @@ from typing import Callable, Dict, Optional, Sequence, Tuple, Union from typing import TYPE_CHECKING -from google.cloud.logging_v2._compat import _observability, apply_channel_interceptors +from google.cloud.logging_v2._compat import ( + _observability, + SUPPORTS_DEFAULT_HOST as _SUPPORTS_DEFAULT_HOST, + apply_channel_interceptors, +) from google.api_core import client_options as client_options_lib from google.api_core import grpc_helpers @@ -277,9 +281,12 @@ def __init__(self, *, ) channel_interceptors = list(interceptors) if interceptors else [] + _otel_interceptor_kwargs = ( + {"default_host": self._default_host} if _SUPPORTS_DEFAULT_HOST else {} + ) if ( _observability is not None - and (otel_interceptor := _observability.get_otel_interceptor(self._client_options)) is not None + and (otel_interceptor := _observability.get_otel_interceptor(self._client_options, **_otel_interceptor_kwargs)) is not None and otel_interceptor not in channel_interceptors and not any(getattr(i, "_is_otel_interceptor", None) is True for i in channel_interceptors) ): diff --git a/packages/gapic-generator/tests/integration/goldens/logging/google/cloud/logging_v2/services/config_service_v2/transports/grpc_asyncio.py b/packages/gapic-generator/tests/integration/goldens/logging/google/cloud/logging_v2/services/config_service_v2/transports/grpc_asyncio.py index c03327f2585e..c972054052bd 100755 --- a/packages/gapic-generator/tests/integration/goldens/logging/google/cloud/logging_v2/services/config_service_v2/transports/grpc_asyncio.py +++ b/packages/gapic-generator/tests/integration/goldens/logging/google/cloud/logging_v2/services/config_service_v2/transports/grpc_asyncio.py @@ -20,7 +20,11 @@ from typing import Awaitable, Callable, Dict, Optional, Sequence, Tuple, Union from typing import TYPE_CHECKING -from google.cloud.logging_v2._compat import _observability, apply_async_channel_interceptors +from google.cloud.logging_v2._compat import ( + _observability, + SUPPORTS_DEFAULT_HOST as _SUPPORTS_DEFAULT_HOST, + apply_async_channel_interceptors, +) from google.api_core import client_options as client_options_lib from google.api_core import gapic_v1 @@ -335,8 +339,11 @@ def __init__(self, *, # NOTE: Coverage tool ignores async interceptors in environments running # legacy google-api-core (< 2.36.0) where OpenTelemetry is unavailable. # TODO (Issue #18657): Remove once lowest constraints require google-api-core >= v2.43.0. + _otel_interceptor_kwargs = ( + {"default_host": self._default_host} if _SUPPORTS_DEFAULT_HOST else {} + ) # pragma: NO COVER self._grpc_channel = _observability.apply_otel_async_channel_interceptors( - self._grpc_channel, self._client_options + self._grpc_channel, self._client_options, **_otel_interceptor_kwargs ) # pragma: NO COVER self._logged_channel = self._grpc_channel diff --git a/packages/gapic-generator/tests/integration/goldens/logging/google/cloud/logging_v2/services/logging_service_v2/transports/base.py b/packages/gapic-generator/tests/integration/goldens/logging/google/cloud/logging_v2/services/logging_service_v2/transports/base.py index 4f1882897e01..8d9f8d8ad672 100755 --- a/packages/gapic-generator/tests/integration/goldens/logging/google/cloud/logging_v2/services/logging_service_v2/transports/base.py +++ b/packages/gapic-generator/tests/integration/goldens/logging/google/cloud/logging_v2/services/logging_service_v2/transports/base.py @@ -19,6 +19,7 @@ from google.cloud.logging_v2 import gapic_version as package_version from google.cloud.logging_v2._compat import ( ASYNC_WRAP_METHOD_SUPPORTS_KIND as _ASYNC_WRAP_METHOD_SUPPORTS_KIND, + SUPPORTS_DEFAULT_HOST as _SUPPORTS_DEFAULT_HOST, WRAP_METHOD_SUPPORTS_TRACING as _WRAP_METHOD_SUPPORTS_TRACING, ) _ASYNC_WRAP_METHOD_SUPPORTS_TRACING = _WRAP_METHOD_SUPPORTS_TRACING @@ -136,7 +137,13 @@ def __init__( host += ':443' self._host = host + if isinstance(client_options, dict): + client_options = client_options_lib.from_dict(client_options) + self._client_options = client_options + self._default_host: Optional[str] = ( + None if self._ignore_credentials else self._host + ) self._wrapped_methods: Dict[Callable, Callable] = {} @property @@ -155,6 +162,8 @@ def _wrap_method(self, func, *args, **kwargs): """ if _WRAP_METHOD_SUPPORTS_TRACING: kwargs["client_options"] = self._client_options + if _SUPPORTS_DEFAULT_HOST: + kwargs["default_host"] = self._default_host try: kind = self.kind except NotImplementedError: @@ -167,7 +176,7 @@ def _wrap_method(self, func, *args, **kwargs): # - Era 2 (>= 2.29.0, < 2.36.0): `kind` is supported (prevents REST from # falling back to gRPC wrapping), but tracing args are not. # (These fallbacks can be removed once google-api-core < 2.36.0 is no longer supported.) - for k in ["client_options", "method_name", "is_streaming"]: + for k in ["client_options", "method_name", "is_streaming", "default_host"]: kwargs.pop(k, None) try: @@ -194,6 +203,8 @@ def _wrap_async_method(self, func, *args, **kwargs): """ if _ASYNC_WRAP_METHOD_SUPPORTS_TRACING: kwargs["client_options"] = self._client_options + if _SUPPORTS_DEFAULT_HOST: + kwargs["default_host"] = self._default_host try: kind = self.kind except NotImplementedError: @@ -206,7 +217,7 @@ def _wrap_async_method(self, func, *args, **kwargs): # - Era 2 (>= 2.29.0, < 2.36.0): `kind` is supported (prevents REST from # falling back to gRPC wrapping), but tracing args are not. # (These fallbacks can be removed once google-api-core < 2.36.0 is no longer supported.) - for k in ["client_options", "method_name", "is_streaming"]: + for k in ["client_options", "method_name", "is_streaming", "default_host"]: kwargs.pop(k, None) try: diff --git a/packages/gapic-generator/tests/integration/goldens/logging/google/cloud/logging_v2/services/logging_service_v2/transports/grpc.py b/packages/gapic-generator/tests/integration/goldens/logging/google/cloud/logging_v2/services/logging_service_v2/transports/grpc.py index b4508ffcde56..b36b20e3d16a 100755 --- a/packages/gapic-generator/tests/integration/goldens/logging/google/cloud/logging_v2/services/logging_service_v2/transports/grpc.py +++ b/packages/gapic-generator/tests/integration/goldens/logging/google/cloud/logging_v2/services/logging_service_v2/transports/grpc.py @@ -20,7 +20,11 @@ from typing import Callable, Dict, Optional, Sequence, Tuple, Union from typing import TYPE_CHECKING -from google.cloud.logging_v2._compat import _observability, apply_channel_interceptors +from google.cloud.logging_v2._compat import ( + _observability, + SUPPORTS_DEFAULT_HOST as _SUPPORTS_DEFAULT_HOST, + apply_channel_interceptors, +) from google.api_core import client_options as client_options_lib from google.api_core import grpc_helpers @@ -275,9 +279,12 @@ def __init__(self, *, ) channel_interceptors = list(interceptors) if interceptors else [] + _otel_interceptor_kwargs = ( + {"default_host": self._default_host} if _SUPPORTS_DEFAULT_HOST else {} + ) if ( _observability is not None - and (otel_interceptor := _observability.get_otel_interceptor(self._client_options)) is not None + and (otel_interceptor := _observability.get_otel_interceptor(self._client_options, **_otel_interceptor_kwargs)) is not None and otel_interceptor not in channel_interceptors and not any(getattr(i, "_is_otel_interceptor", None) is True for i in channel_interceptors) ): diff --git a/packages/gapic-generator/tests/integration/goldens/logging/google/cloud/logging_v2/services/logging_service_v2/transports/grpc_asyncio.py b/packages/gapic-generator/tests/integration/goldens/logging/google/cloud/logging_v2/services/logging_service_v2/transports/grpc_asyncio.py index 26b8c349c478..071fe3196e25 100755 --- a/packages/gapic-generator/tests/integration/goldens/logging/google/cloud/logging_v2/services/logging_service_v2/transports/grpc_asyncio.py +++ b/packages/gapic-generator/tests/integration/goldens/logging/google/cloud/logging_v2/services/logging_service_v2/transports/grpc_asyncio.py @@ -20,7 +20,11 @@ from typing import Awaitable, Callable, Dict, Optional, Sequence, Tuple, Union from typing import TYPE_CHECKING -from google.cloud.logging_v2._compat import _observability, apply_async_channel_interceptors +from google.cloud.logging_v2._compat import ( + _observability, + SUPPORTS_DEFAULT_HOST as _SUPPORTS_DEFAULT_HOST, + apply_async_channel_interceptors, +) from google.api_core import client_options as client_options_lib from google.api_core import gapic_v1 @@ -333,8 +337,11 @@ def __init__(self, *, # NOTE: Coverage tool ignores async interceptors in environments running # legacy google-api-core (< 2.36.0) where OpenTelemetry is unavailable. # TODO (Issue #18657): Remove once lowest constraints require google-api-core >= v2.43.0. + _otel_interceptor_kwargs = ( + {"default_host": self._default_host} if _SUPPORTS_DEFAULT_HOST else {} + ) # pragma: NO COVER self._grpc_channel = _observability.apply_otel_async_channel_interceptors( - self._grpc_channel, self._client_options + self._grpc_channel, self._client_options, **_otel_interceptor_kwargs ) # pragma: NO COVER self._logged_channel = self._grpc_channel diff --git a/packages/gapic-generator/tests/integration/goldens/logging/google/cloud/logging_v2/services/metrics_service_v2/transports/base.py b/packages/gapic-generator/tests/integration/goldens/logging/google/cloud/logging_v2/services/metrics_service_v2/transports/base.py index 98e6b8826af6..6175a2cb2a90 100755 --- a/packages/gapic-generator/tests/integration/goldens/logging/google/cloud/logging_v2/services/metrics_service_v2/transports/base.py +++ b/packages/gapic-generator/tests/integration/goldens/logging/google/cloud/logging_v2/services/metrics_service_v2/transports/base.py @@ -19,6 +19,7 @@ from google.cloud.logging_v2 import gapic_version as package_version from google.cloud.logging_v2._compat import ( ASYNC_WRAP_METHOD_SUPPORTS_KIND as _ASYNC_WRAP_METHOD_SUPPORTS_KIND, + SUPPORTS_DEFAULT_HOST as _SUPPORTS_DEFAULT_HOST, WRAP_METHOD_SUPPORTS_TRACING as _WRAP_METHOD_SUPPORTS_TRACING, ) _ASYNC_WRAP_METHOD_SUPPORTS_TRACING = _WRAP_METHOD_SUPPORTS_TRACING @@ -136,7 +137,13 @@ def __init__( host += ':443' self._host = host + if isinstance(client_options, dict): + client_options = client_options_lib.from_dict(client_options) + self._client_options = client_options + self._default_host: Optional[str] = ( + None if self._ignore_credentials else self._host + ) self._wrapped_methods: Dict[Callable, Callable] = {} @property @@ -155,6 +162,8 @@ def _wrap_method(self, func, *args, **kwargs): """ if _WRAP_METHOD_SUPPORTS_TRACING: kwargs["client_options"] = self._client_options + if _SUPPORTS_DEFAULT_HOST: + kwargs["default_host"] = self._default_host try: kind = self.kind except NotImplementedError: @@ -167,7 +176,7 @@ def _wrap_method(self, func, *args, **kwargs): # - Era 2 (>= 2.29.0, < 2.36.0): `kind` is supported (prevents REST from # falling back to gRPC wrapping), but tracing args are not. # (These fallbacks can be removed once google-api-core < 2.36.0 is no longer supported.) - for k in ["client_options", "method_name", "is_streaming"]: + for k in ["client_options", "method_name", "is_streaming", "default_host"]: kwargs.pop(k, None) try: @@ -194,6 +203,8 @@ def _wrap_async_method(self, func, *args, **kwargs): """ if _ASYNC_WRAP_METHOD_SUPPORTS_TRACING: kwargs["client_options"] = self._client_options + if _SUPPORTS_DEFAULT_HOST: + kwargs["default_host"] = self._default_host try: kind = self.kind except NotImplementedError: @@ -206,7 +217,7 @@ def _wrap_async_method(self, func, *args, **kwargs): # - Era 2 (>= 2.29.0, < 2.36.0): `kind` is supported (prevents REST from # falling back to gRPC wrapping), but tracing args are not. # (These fallbacks can be removed once google-api-core < 2.36.0 is no longer supported.) - for k in ["client_options", "method_name", "is_streaming"]: + for k in ["client_options", "method_name", "is_streaming", "default_host"]: kwargs.pop(k, None) try: diff --git a/packages/gapic-generator/tests/integration/goldens/logging/google/cloud/logging_v2/services/metrics_service_v2/transports/grpc.py b/packages/gapic-generator/tests/integration/goldens/logging/google/cloud/logging_v2/services/metrics_service_v2/transports/grpc.py index f72fc2e40ee4..351af44bd1a2 100755 --- a/packages/gapic-generator/tests/integration/goldens/logging/google/cloud/logging_v2/services/metrics_service_v2/transports/grpc.py +++ b/packages/gapic-generator/tests/integration/goldens/logging/google/cloud/logging_v2/services/metrics_service_v2/transports/grpc.py @@ -20,7 +20,11 @@ from typing import Callable, Dict, Optional, Sequence, Tuple, Union from typing import TYPE_CHECKING -from google.cloud.logging_v2._compat import _observability, apply_channel_interceptors +from google.cloud.logging_v2._compat import ( + _observability, + SUPPORTS_DEFAULT_HOST as _SUPPORTS_DEFAULT_HOST, + apply_channel_interceptors, +) from google.api_core import client_options as client_options_lib from google.api_core import grpc_helpers @@ -275,9 +279,12 @@ def __init__(self, *, ) channel_interceptors = list(interceptors) if interceptors else [] + _otel_interceptor_kwargs = ( + {"default_host": self._default_host} if _SUPPORTS_DEFAULT_HOST else {} + ) if ( _observability is not None - and (otel_interceptor := _observability.get_otel_interceptor(self._client_options)) is not None + and (otel_interceptor := _observability.get_otel_interceptor(self._client_options, **_otel_interceptor_kwargs)) is not None and otel_interceptor not in channel_interceptors and not any(getattr(i, "_is_otel_interceptor", None) is True for i in channel_interceptors) ): diff --git a/packages/gapic-generator/tests/integration/goldens/logging/google/cloud/logging_v2/services/metrics_service_v2/transports/grpc_asyncio.py b/packages/gapic-generator/tests/integration/goldens/logging/google/cloud/logging_v2/services/metrics_service_v2/transports/grpc_asyncio.py index 54ba2dbe7b1d..beaf9710314f 100755 --- a/packages/gapic-generator/tests/integration/goldens/logging/google/cloud/logging_v2/services/metrics_service_v2/transports/grpc_asyncio.py +++ b/packages/gapic-generator/tests/integration/goldens/logging/google/cloud/logging_v2/services/metrics_service_v2/transports/grpc_asyncio.py @@ -20,7 +20,11 @@ from typing import Awaitable, Callable, Dict, Optional, Sequence, Tuple, Union from typing import TYPE_CHECKING -from google.cloud.logging_v2._compat import _observability, apply_async_channel_interceptors +from google.cloud.logging_v2._compat import ( + _observability, + SUPPORTS_DEFAULT_HOST as _SUPPORTS_DEFAULT_HOST, + apply_async_channel_interceptors, +) from google.api_core import client_options as client_options_lib from google.api_core import gapic_v1 @@ -333,8 +337,11 @@ def __init__(self, *, # NOTE: Coverage tool ignores async interceptors in environments running # legacy google-api-core (< 2.36.0) where OpenTelemetry is unavailable. # TODO (Issue #18657): Remove once lowest constraints require google-api-core >= v2.43.0. + _otel_interceptor_kwargs = ( + {"default_host": self._default_host} if _SUPPORTS_DEFAULT_HOST else {} + ) # pragma: NO COVER self._grpc_channel = _observability.apply_otel_async_channel_interceptors( - self._grpc_channel, self._client_options + self._grpc_channel, self._client_options, **_otel_interceptor_kwargs ) # pragma: NO COVER self._logged_channel = self._grpc_channel diff --git a/packages/gapic-generator/tests/integration/goldens/logging/tests/unit/gapic/logging_v2/test_config_service_v2.py b/packages/gapic-generator/tests/integration/goldens/logging/tests/unit/gapic/logging_v2/test_config_service_v2.py index acea90dd0a47..af85f1467d8a 100755 --- a/packages/gapic-generator/tests/integration/goldens/logging/tests/unit/gapic/logging_v2/test_config_service_v2.py +++ b/packages/gapic-generator/tests/integration/goldens/logging/tests/unit/gapic/logging_v2/test_config_service_v2.py @@ -827,7 +827,14 @@ def test_config_service_v2_grpc_transport_otel_channel_interceptor(): client_options=options, ) - mock_get_interceptor.assert_called_once_with(options) + expected_kwargs = ( + {"default_host": transport._default_host} + if transports.grpc._SUPPORTS_DEFAULT_HOST + else {} + ) + mock_get_interceptor.assert_called_once_with( + transport._client_options, **expected_kwargs + ) mock_apply_interceptors.assert_called_once_with( mock_channel, [mock_otel_interceptor] ) @@ -901,7 +908,14 @@ def test_config_service_v2_grpc_asyncio_transport_otel_channel_interceptor(): client_options=options, ) - mock_apply_async_interceptors.assert_called_once_with(mock_channel, options) + expected_kwargs = ( + {"default_host": transport._default_host} + if transports.grpc_asyncio._SUPPORTS_DEFAULT_HOST + else {} + ) + mock_apply_async_interceptors.assert_called_once_with( + mock_channel, transport._client_options, **expected_kwargs + ) assert mock_create_channel.call_count == 1 assert transport.grpc_channel == mock_channel @@ -14009,7 +14023,8 @@ def test_api_key_credentials(client_class, transport_class): def test_config_service_v2_base_transport_wrap_method_modern_api_core(monkeypatch): monkeypatch.setattr(transports.base, "_WRAP_METHOD_SUPPORTS_TRACING", True) - # 1. Concrete transport: passes client_options=None and kind="grpc" + monkeypatch.setattr(transports.base, "_SUPPORTS_DEFAULT_HOST", True) + # 1. Concrete transport: passes client_options=None, default_host, and kind="grpc" with mock.patch.object(transports.base.gapic_v1.method, "wrap_method") as mock_wrap: transport = transports.ConfigServiceV2GrpcTransport( credentials=ga_credentials.AnonymousCredentials() @@ -14018,8 +14033,10 @@ def test_config_service_v2_base_transport_wrap_method_modern_api_core(monkeypatc transport._wrap_method(func) assert mock_wrap.call_args.kwargs["kind"] == "grpc" assert "client_options" in mock_wrap.call_args.kwargs + assert mock_wrap.call_args.kwargs["default_host"] == transport._default_host - # 2. Base transport: kind raises NotImplementedError and is cleanly omitted + # 2. Base transport with _SUPPORTS_DEFAULT_HOST=False: kind raises NotImplementedError and default_host is omitted + monkeypatch.setattr(transports.base, "_SUPPORTS_DEFAULT_HOST", False) with mock.patch.object(transports.base.gapic_v1.method, "wrap_method") as mock_wrap: base_transport = transports.ConfigServiceV2Transport( credentials=ga_credentials.AnonymousCredentials() @@ -14028,6 +14045,84 @@ def test_config_service_v2_base_transport_wrap_method_modern_api_core(monkeypatc base_transport._wrap_method(func) assert "kind" not in mock_wrap.call_args.kwargs assert "client_options" in mock_wrap.call_args.kwargs + assert "default_host" not in mock_wrap.call_args.kwargs + + +def test_config_service_v2_base_transport_client_options_none(): + transport = transports.ConfigServiceV2Transport( + host="default.example.com:443", + credentials=ga_credentials.AnonymousCredentials(), + client_options=None, + ) + assert transport._client_options is None + assert transport._default_host == "default.example.com:443" + + +@pytest.mark.parametrize( + "initial_endpoint", + [ + pytest.param(None, id="unset_api_endpoint"), + pytest.param("custom.example.com:443", id="explicit_api_endpoint"), + ], +) +def test_config_service_v2_base_transport_client_options_object( + initial_endpoint, +): + options = client_options.ClientOptions(api_endpoint=initial_endpoint) + transport = transports.ConfigServiceV2Transport( + host="default.example.com:443", + credentials=ga_credentials.AnonymousCredentials(), + client_options=options, + ) + assert transport._client_options is options + assert transport._client_options.api_endpoint == initial_endpoint + assert transport._default_host == "default.example.com:443" + + +@pytest.mark.parametrize( + "initial_options,expected_endpoint", + [ + pytest.param( + {}, + None, + id="unset_api_endpoint", + ), + pytest.param( + {"api_endpoint": "custom.example.com:443"}, + "custom.example.com:443", + id="explicit_api_endpoint", + ), + ], +) +def test_config_service_v2_base_transport_client_options_dict( + initial_options, expected_endpoint +): + options = dict(initial_options) + transport = transports.ConfigServiceV2Transport( + host="default.example.com:443", + credentials=ga_credentials.AnonymousCredentials(), + client_options=options, + ) + assert isinstance(transport._client_options, client_options.ClientOptions) + assert transport._client_options.api_endpoint == expected_endpoint + assert transport._default_host == "default.example.com:443" + assert options == initial_options + + +def test_config_service_v2_base_transport_default_host_ignored_when_ignore_credentials(): + class _CustomChannelTransport(transports.ConfigServiceV2Transport): + def __init__(self, **kwargs): + self._ignore_credentials = True + super().__init__(**kwargs) + + options = client_options.ClientOptions() + transport = _CustomChannelTransport( + host="default.example.com:443", + client_options=options, + ) + assert transport._client_options is options + assert transport._client_options.api_endpoint is None + assert transport._default_host is None def test_config_service_v2_base_transport_wrap_method_older_api_core_fallbacks(monkeypatch): @@ -14106,7 +14201,8 @@ def test_config_service_v2_base_transport_wrap_method_older_api_core_fallbacks(m def test_config_service_v2_base_transport_wrap_async_method_modern_api_core(monkeypatch): monkeypatch.setattr(transports.base, "_ASYNC_WRAP_METHOD_SUPPORTS_TRACING", True) - # 1. Concrete transport: passes client_options=None and kind="grpc_asyncio" + monkeypatch.setattr(transports.base, "_SUPPORTS_DEFAULT_HOST", True) + # 1. Concrete transport: passes client_options=None, default_host, and kind="grpc_asyncio" with mock.patch.object(transports.base.gapic_v1.method_async, "wrap_method") as mock_wrap: transport = transports.ConfigServiceV2GrpcAsyncIOTransport( credentials=ga_credentials.AnonymousCredentials() @@ -14115,8 +14211,10 @@ def test_config_service_v2_base_transport_wrap_async_method_modern_api_core(monk transport._wrap_async_method(func) assert mock_wrap.call_args.kwargs["kind"] == "grpc_asyncio" assert "client_options" in mock_wrap.call_args.kwargs + assert mock_wrap.call_args.kwargs["default_host"] == transport._default_host - # 2. Base transport: kind raises NotImplementedError and is cleanly omitted + # 2. Base transport with _SUPPORTS_DEFAULT_HOST=False: kind raises NotImplementedError and default_host is omitted + monkeypatch.setattr(transports.base, "_SUPPORTS_DEFAULT_HOST", False) with mock.patch.object(transports.base.gapic_v1.method_async, "wrap_method") as mock_wrap: base_transport = transports.ConfigServiceV2Transport( credentials=ga_credentials.AnonymousCredentials() @@ -14125,6 +14223,7 @@ def test_config_service_v2_base_transport_wrap_async_method_modern_api_core(monk base_transport._wrap_async_method(func) assert "kind" not in mock_wrap.call_args.kwargs assert "client_options" in mock_wrap.call_args.kwargs + assert "default_host" not in mock_wrap.call_args.kwargs def test_config_service_v2_base_transport_wrap_async_method_older_api_core_fallbacks(monkeypatch): diff --git a/packages/gapic-generator/tests/integration/goldens/logging/tests/unit/gapic/logging_v2/test_logging_service_v2.py b/packages/gapic-generator/tests/integration/goldens/logging/tests/unit/gapic/logging_v2/test_logging_service_v2.py index 433ff946bd8b..40bf25c8e02a 100755 --- a/packages/gapic-generator/tests/integration/goldens/logging/tests/unit/gapic/logging_v2/test_logging_service_v2.py +++ b/packages/gapic-generator/tests/integration/goldens/logging/tests/unit/gapic/logging_v2/test_logging_service_v2.py @@ -828,7 +828,14 @@ def test_logging_service_v2_grpc_transport_otel_channel_interceptor(): client_options=options, ) - mock_get_interceptor.assert_called_once_with(options) + expected_kwargs = ( + {"default_host": transport._default_host} + if transports.grpc._SUPPORTS_DEFAULT_HOST + else {} + ) + mock_get_interceptor.assert_called_once_with( + transport._client_options, **expected_kwargs + ) mock_apply_interceptors.assert_called_once_with( mock_channel, [mock_otel_interceptor] ) @@ -902,7 +909,14 @@ def test_logging_service_v2_grpc_asyncio_transport_otel_channel_interceptor(): client_options=options, ) - mock_apply_async_interceptors.assert_called_once_with(mock_channel, options) + expected_kwargs = ( + {"default_host": transport._default_host} + if transports.grpc_asyncio._SUPPORTS_DEFAULT_HOST + else {} + ) + mock_apply_async_interceptors.assert_called_once_with( + mock_channel, transport._client_options, **expected_kwargs + ) assert mock_create_channel.call_count == 1 assert transport.grpc_channel == mock_channel @@ -4449,7 +4463,8 @@ def test_api_key_credentials(client_class, transport_class): def test_logging_service_v2_base_transport_wrap_method_modern_api_core(monkeypatch): monkeypatch.setattr(transports.base, "_WRAP_METHOD_SUPPORTS_TRACING", True) - # 1. Concrete transport: passes client_options=None and kind="grpc" + monkeypatch.setattr(transports.base, "_SUPPORTS_DEFAULT_HOST", True) + # 1. Concrete transport: passes client_options=None, default_host, and kind="grpc" with mock.patch.object(transports.base.gapic_v1.method, "wrap_method") as mock_wrap: transport = transports.LoggingServiceV2GrpcTransport( credentials=ga_credentials.AnonymousCredentials() @@ -4458,8 +4473,10 @@ def test_logging_service_v2_base_transport_wrap_method_modern_api_core(monkeypat transport._wrap_method(func) assert mock_wrap.call_args.kwargs["kind"] == "grpc" assert "client_options" in mock_wrap.call_args.kwargs + assert mock_wrap.call_args.kwargs["default_host"] == transport._default_host - # 2. Base transport: kind raises NotImplementedError and is cleanly omitted + # 2. Base transport with _SUPPORTS_DEFAULT_HOST=False: kind raises NotImplementedError and default_host is omitted + monkeypatch.setattr(transports.base, "_SUPPORTS_DEFAULT_HOST", False) with mock.patch.object(transports.base.gapic_v1.method, "wrap_method") as mock_wrap: base_transport = transports.LoggingServiceV2Transport( credentials=ga_credentials.AnonymousCredentials() @@ -4468,6 +4485,84 @@ def test_logging_service_v2_base_transport_wrap_method_modern_api_core(monkeypat base_transport._wrap_method(func) assert "kind" not in mock_wrap.call_args.kwargs assert "client_options" in mock_wrap.call_args.kwargs + assert "default_host" not in mock_wrap.call_args.kwargs + + +def test_logging_service_v2_base_transport_client_options_none(): + transport = transports.LoggingServiceV2Transport( + host="default.example.com:443", + credentials=ga_credentials.AnonymousCredentials(), + client_options=None, + ) + assert transport._client_options is None + assert transport._default_host == "default.example.com:443" + + +@pytest.mark.parametrize( + "initial_endpoint", + [ + pytest.param(None, id="unset_api_endpoint"), + pytest.param("custom.example.com:443", id="explicit_api_endpoint"), + ], +) +def test_logging_service_v2_base_transport_client_options_object( + initial_endpoint, +): + options = client_options.ClientOptions(api_endpoint=initial_endpoint) + transport = transports.LoggingServiceV2Transport( + host="default.example.com:443", + credentials=ga_credentials.AnonymousCredentials(), + client_options=options, + ) + assert transport._client_options is options + assert transport._client_options.api_endpoint == initial_endpoint + assert transport._default_host == "default.example.com:443" + + +@pytest.mark.parametrize( + "initial_options,expected_endpoint", + [ + pytest.param( + {}, + None, + id="unset_api_endpoint", + ), + pytest.param( + {"api_endpoint": "custom.example.com:443"}, + "custom.example.com:443", + id="explicit_api_endpoint", + ), + ], +) +def test_logging_service_v2_base_transport_client_options_dict( + initial_options, expected_endpoint +): + options = dict(initial_options) + transport = transports.LoggingServiceV2Transport( + host="default.example.com:443", + credentials=ga_credentials.AnonymousCredentials(), + client_options=options, + ) + assert isinstance(transport._client_options, client_options.ClientOptions) + assert transport._client_options.api_endpoint == expected_endpoint + assert transport._default_host == "default.example.com:443" + assert options == initial_options + + +def test_logging_service_v2_base_transport_default_host_ignored_when_ignore_credentials(): + class _CustomChannelTransport(transports.LoggingServiceV2Transport): + def __init__(self, **kwargs): + self._ignore_credentials = True + super().__init__(**kwargs) + + options = client_options.ClientOptions() + transport = _CustomChannelTransport( + host="default.example.com:443", + client_options=options, + ) + assert transport._client_options is options + assert transport._client_options.api_endpoint is None + assert transport._default_host is None def test_logging_service_v2_base_transport_wrap_method_older_api_core_fallbacks(monkeypatch): @@ -4546,7 +4641,8 @@ def test_logging_service_v2_base_transport_wrap_method_older_api_core_fallbacks( def test_logging_service_v2_base_transport_wrap_async_method_modern_api_core(monkeypatch): monkeypatch.setattr(transports.base, "_ASYNC_WRAP_METHOD_SUPPORTS_TRACING", True) - # 1. Concrete transport: passes client_options=None and kind="grpc_asyncio" + monkeypatch.setattr(transports.base, "_SUPPORTS_DEFAULT_HOST", True) + # 1. Concrete transport: passes client_options=None, default_host, and kind="grpc_asyncio" with mock.patch.object(transports.base.gapic_v1.method_async, "wrap_method") as mock_wrap: transport = transports.LoggingServiceV2GrpcAsyncIOTransport( credentials=ga_credentials.AnonymousCredentials() @@ -4555,8 +4651,10 @@ def test_logging_service_v2_base_transport_wrap_async_method_modern_api_core(mon transport._wrap_async_method(func) assert mock_wrap.call_args.kwargs["kind"] == "grpc_asyncio" assert "client_options" in mock_wrap.call_args.kwargs + assert mock_wrap.call_args.kwargs["default_host"] == transport._default_host - # 2. Base transport: kind raises NotImplementedError and is cleanly omitted + # 2. Base transport with _SUPPORTS_DEFAULT_HOST=False: kind raises NotImplementedError and default_host is omitted + monkeypatch.setattr(transports.base, "_SUPPORTS_DEFAULT_HOST", False) with mock.patch.object(transports.base.gapic_v1.method_async, "wrap_method") as mock_wrap: base_transport = transports.LoggingServiceV2Transport( credentials=ga_credentials.AnonymousCredentials() @@ -4565,6 +4663,7 @@ def test_logging_service_v2_base_transport_wrap_async_method_modern_api_core(mon base_transport._wrap_async_method(func) assert "kind" not in mock_wrap.call_args.kwargs assert "client_options" in mock_wrap.call_args.kwargs + assert "default_host" not in mock_wrap.call_args.kwargs def test_logging_service_v2_base_transport_wrap_async_method_older_api_core_fallbacks(monkeypatch): diff --git a/packages/gapic-generator/tests/integration/goldens/logging/tests/unit/gapic/logging_v2/test_metrics_service_v2.py b/packages/gapic-generator/tests/integration/goldens/logging/tests/unit/gapic/logging_v2/test_metrics_service_v2.py index c3809e05816a..e8faf4d347c1 100755 --- a/packages/gapic-generator/tests/integration/goldens/logging/tests/unit/gapic/logging_v2/test_metrics_service_v2.py +++ b/packages/gapic-generator/tests/integration/goldens/logging/tests/unit/gapic/logging_v2/test_metrics_service_v2.py @@ -826,7 +826,14 @@ def test_metrics_service_v2_grpc_transport_otel_channel_interceptor(): client_options=options, ) - mock_get_interceptor.assert_called_once_with(options) + expected_kwargs = ( + {"default_host": transport._default_host} + if transports.grpc._SUPPORTS_DEFAULT_HOST + else {} + ) + mock_get_interceptor.assert_called_once_with( + transport._client_options, **expected_kwargs + ) mock_apply_interceptors.assert_called_once_with( mock_channel, [mock_otel_interceptor] ) @@ -900,7 +907,14 @@ def test_metrics_service_v2_grpc_asyncio_transport_otel_channel_interceptor(): client_options=options, ) - mock_apply_async_interceptors.assert_called_once_with(mock_channel, options) + expected_kwargs = ( + {"default_host": transport._default_host} + if transports.grpc_asyncio._SUPPORTS_DEFAULT_HOST + else {} + ) + mock_apply_async_interceptors.assert_called_once_with( + mock_channel, transport._client_options, **expected_kwargs + ) assert mock_create_channel.call_count == 1 assert transport.grpc_channel == mock_channel @@ -4249,7 +4263,8 @@ def test_api_key_credentials(client_class, transport_class): def test_metrics_service_v2_base_transport_wrap_method_modern_api_core(monkeypatch): monkeypatch.setattr(transports.base, "_WRAP_METHOD_SUPPORTS_TRACING", True) - # 1. Concrete transport: passes client_options=None and kind="grpc" + monkeypatch.setattr(transports.base, "_SUPPORTS_DEFAULT_HOST", True) + # 1. Concrete transport: passes client_options=None, default_host, and kind="grpc" with mock.patch.object(transports.base.gapic_v1.method, "wrap_method") as mock_wrap: transport = transports.MetricsServiceV2GrpcTransport( credentials=ga_credentials.AnonymousCredentials() @@ -4258,8 +4273,10 @@ def test_metrics_service_v2_base_transport_wrap_method_modern_api_core(monkeypat transport._wrap_method(func) assert mock_wrap.call_args.kwargs["kind"] == "grpc" assert "client_options" in mock_wrap.call_args.kwargs + assert mock_wrap.call_args.kwargs["default_host"] == transport._default_host - # 2. Base transport: kind raises NotImplementedError and is cleanly omitted + # 2. Base transport with _SUPPORTS_DEFAULT_HOST=False: kind raises NotImplementedError and default_host is omitted + monkeypatch.setattr(transports.base, "_SUPPORTS_DEFAULT_HOST", False) with mock.patch.object(transports.base.gapic_v1.method, "wrap_method") as mock_wrap: base_transport = transports.MetricsServiceV2Transport( credentials=ga_credentials.AnonymousCredentials() @@ -4268,6 +4285,84 @@ def test_metrics_service_v2_base_transport_wrap_method_modern_api_core(monkeypat base_transport._wrap_method(func) assert "kind" not in mock_wrap.call_args.kwargs assert "client_options" in mock_wrap.call_args.kwargs + assert "default_host" not in mock_wrap.call_args.kwargs + + +def test_metrics_service_v2_base_transport_client_options_none(): + transport = transports.MetricsServiceV2Transport( + host="default.example.com:443", + credentials=ga_credentials.AnonymousCredentials(), + client_options=None, + ) + assert transport._client_options is None + assert transport._default_host == "default.example.com:443" + + +@pytest.mark.parametrize( + "initial_endpoint", + [ + pytest.param(None, id="unset_api_endpoint"), + pytest.param("custom.example.com:443", id="explicit_api_endpoint"), + ], +) +def test_metrics_service_v2_base_transport_client_options_object( + initial_endpoint, +): + options = client_options.ClientOptions(api_endpoint=initial_endpoint) + transport = transports.MetricsServiceV2Transport( + host="default.example.com:443", + credentials=ga_credentials.AnonymousCredentials(), + client_options=options, + ) + assert transport._client_options is options + assert transport._client_options.api_endpoint == initial_endpoint + assert transport._default_host == "default.example.com:443" + + +@pytest.mark.parametrize( + "initial_options,expected_endpoint", + [ + pytest.param( + {}, + None, + id="unset_api_endpoint", + ), + pytest.param( + {"api_endpoint": "custom.example.com:443"}, + "custom.example.com:443", + id="explicit_api_endpoint", + ), + ], +) +def test_metrics_service_v2_base_transport_client_options_dict( + initial_options, expected_endpoint +): + options = dict(initial_options) + transport = transports.MetricsServiceV2Transport( + host="default.example.com:443", + credentials=ga_credentials.AnonymousCredentials(), + client_options=options, + ) + assert isinstance(transport._client_options, client_options.ClientOptions) + assert transport._client_options.api_endpoint == expected_endpoint + assert transport._default_host == "default.example.com:443" + assert options == initial_options + + +def test_metrics_service_v2_base_transport_default_host_ignored_when_ignore_credentials(): + class _CustomChannelTransport(transports.MetricsServiceV2Transport): + def __init__(self, **kwargs): + self._ignore_credentials = True + super().__init__(**kwargs) + + options = client_options.ClientOptions() + transport = _CustomChannelTransport( + host="default.example.com:443", + client_options=options, + ) + assert transport._client_options is options + assert transport._client_options.api_endpoint is None + assert transport._default_host is None def test_metrics_service_v2_base_transport_wrap_method_older_api_core_fallbacks(monkeypatch): @@ -4346,7 +4441,8 @@ def test_metrics_service_v2_base_transport_wrap_method_older_api_core_fallbacks( def test_metrics_service_v2_base_transport_wrap_async_method_modern_api_core(monkeypatch): monkeypatch.setattr(transports.base, "_ASYNC_WRAP_METHOD_SUPPORTS_TRACING", True) - # 1. Concrete transport: passes client_options=None and kind="grpc_asyncio" + monkeypatch.setattr(transports.base, "_SUPPORTS_DEFAULT_HOST", True) + # 1. Concrete transport: passes client_options=None, default_host, and kind="grpc_asyncio" with mock.patch.object(transports.base.gapic_v1.method_async, "wrap_method") as mock_wrap: transport = transports.MetricsServiceV2GrpcAsyncIOTransport( credentials=ga_credentials.AnonymousCredentials() @@ -4355,8 +4451,10 @@ def test_metrics_service_v2_base_transport_wrap_async_method_modern_api_core(mon transport._wrap_async_method(func) assert mock_wrap.call_args.kwargs["kind"] == "grpc_asyncio" assert "client_options" in mock_wrap.call_args.kwargs + assert mock_wrap.call_args.kwargs["default_host"] == transport._default_host - # 2. Base transport: kind raises NotImplementedError and is cleanly omitted + # 2. Base transport with _SUPPORTS_DEFAULT_HOST=False: kind raises NotImplementedError and default_host is omitted + monkeypatch.setattr(transports.base, "_SUPPORTS_DEFAULT_HOST", False) with mock.patch.object(transports.base.gapic_v1.method_async, "wrap_method") as mock_wrap: base_transport = transports.MetricsServiceV2Transport( credentials=ga_credentials.AnonymousCredentials() @@ -4365,6 +4463,7 @@ def test_metrics_service_v2_base_transport_wrap_async_method_modern_api_core(mon base_transport._wrap_async_method(func) assert "kind" not in mock_wrap.call_args.kwargs assert "client_options" in mock_wrap.call_args.kwargs + assert "default_host" not in mock_wrap.call_args.kwargs def test_metrics_service_v2_base_transport_wrap_async_method_older_api_core_fallbacks(monkeypatch): diff --git a/packages/gapic-generator/tests/integration/goldens/logging_internal/google/cloud/logging_v2/_compat.py b/packages/gapic-generator/tests/integration/goldens/logging_internal/google/cloud/logging_v2/_compat.py index a4347392b042..86424516e0a2 100755 --- a/packages/gapic-generator/tests/integration/goldens/logging_internal/google/cloud/logging_v2/_compat.py +++ b/packages/gapic-generator/tests/integration/goldens/logging_internal/google/cloud/logging_v2/_compat.py @@ -90,6 +90,15 @@ def trace_http_request(*args: Any, **kwargs: Any) -> _FallbackTraceContext: # t gapic_v1.method_async, "_DEFAULT_ASYNC_TRANSPORT_KIND" ) +# The `default_host` parameter in gapic_v1.method.wrap_method, +# gapic_v1.method_async.wrap_method, _observability.get_otel_interceptor, and +# _observability.apply_otel_async_channel_interceptors allows transports to pass +# their resolved default host without mutating caller-owned client_options. +SUPPORTS_DEFAULT_HOST = bool( + getattr(gapic_v1.method.wrap_method, "__kwdefaults__", None) + and "default_host" in gapic_v1.method.wrap_method.__kwdefaults__ +) + try: from google.api_core import grpc_helpers except ImportError: # pragma: NO COVER diff --git a/packages/gapic-generator/tests/integration/goldens/logging_internal/google/cloud/logging_v2/services/config_service_v2/transports/base.py b/packages/gapic-generator/tests/integration/goldens/logging_internal/google/cloud/logging_v2/services/config_service_v2/transports/base.py index 4a5dbf0b6ca1..d7d41e6e5bd8 100755 --- a/packages/gapic-generator/tests/integration/goldens/logging_internal/google/cloud/logging_v2/services/config_service_v2/transports/base.py +++ b/packages/gapic-generator/tests/integration/goldens/logging_internal/google/cloud/logging_v2/services/config_service_v2/transports/base.py @@ -19,6 +19,7 @@ from google.cloud.logging_v2 import gapic_version as package_version from google.cloud.logging_v2._compat import ( ASYNC_WRAP_METHOD_SUPPORTS_KIND as _ASYNC_WRAP_METHOD_SUPPORTS_KIND, + SUPPORTS_DEFAULT_HOST as _SUPPORTS_DEFAULT_HOST, WRAP_METHOD_SUPPORTS_TRACING as _WRAP_METHOD_SUPPORTS_TRACING, ) _ASYNC_WRAP_METHOD_SUPPORTS_TRACING = _WRAP_METHOD_SUPPORTS_TRACING @@ -136,7 +137,13 @@ def __init__( host += ':443' self._host = host + if isinstance(client_options, dict): + client_options = client_options_lib.from_dict(client_options) + self._client_options = client_options + self._default_host: Optional[str] = ( + None if self._ignore_credentials else self._host + ) self._wrapped_methods: Dict[Callable, Callable] = {} @property @@ -155,6 +162,8 @@ def _wrap_method(self, func, *args, **kwargs): """ if _WRAP_METHOD_SUPPORTS_TRACING: kwargs["client_options"] = self._client_options + if _SUPPORTS_DEFAULT_HOST: + kwargs["default_host"] = self._default_host try: kind = self.kind except NotImplementedError: @@ -167,7 +176,7 @@ def _wrap_method(self, func, *args, **kwargs): # - Era 2 (>= 2.29.0, < 2.36.0): `kind` is supported (prevents REST from # falling back to gRPC wrapping), but tracing args are not. # (These fallbacks can be removed once google-api-core < 2.36.0 is no longer supported.) - for k in ["client_options", "method_name", "is_streaming"]: + for k in ["client_options", "method_name", "is_streaming", "default_host"]: kwargs.pop(k, None) try: @@ -194,6 +203,8 @@ def _wrap_async_method(self, func, *args, **kwargs): """ if _ASYNC_WRAP_METHOD_SUPPORTS_TRACING: kwargs["client_options"] = self._client_options + if _SUPPORTS_DEFAULT_HOST: + kwargs["default_host"] = self._default_host try: kind = self.kind except NotImplementedError: @@ -206,7 +217,7 @@ def _wrap_async_method(self, func, *args, **kwargs): # - Era 2 (>= 2.29.0, < 2.36.0): `kind` is supported (prevents REST from # falling back to gRPC wrapping), but tracing args are not. # (These fallbacks can be removed once google-api-core < 2.36.0 is no longer supported.) - for k in ["client_options", "method_name", "is_streaming"]: + for k in ["client_options", "method_name", "is_streaming", "default_host"]: kwargs.pop(k, None) try: diff --git a/packages/gapic-generator/tests/integration/goldens/logging_internal/google/cloud/logging_v2/services/config_service_v2/transports/grpc.py b/packages/gapic-generator/tests/integration/goldens/logging_internal/google/cloud/logging_v2/services/config_service_v2/transports/grpc.py index 543032611b9f..2ee41c76666c 100755 --- a/packages/gapic-generator/tests/integration/goldens/logging_internal/google/cloud/logging_v2/services/config_service_v2/transports/grpc.py +++ b/packages/gapic-generator/tests/integration/goldens/logging_internal/google/cloud/logging_v2/services/config_service_v2/transports/grpc.py @@ -20,7 +20,11 @@ from typing import Callable, Dict, Optional, Sequence, Tuple, Union from typing import TYPE_CHECKING -from google.cloud.logging_v2._compat import _observability, apply_channel_interceptors +from google.cloud.logging_v2._compat import ( + _observability, + SUPPORTS_DEFAULT_HOST as _SUPPORTS_DEFAULT_HOST, + apply_channel_interceptors, +) from google.api_core import client_options as client_options_lib from google.api_core import grpc_helpers @@ -277,9 +281,12 @@ def __init__(self, *, ) channel_interceptors = list(interceptors) if interceptors else [] + _otel_interceptor_kwargs = ( + {"default_host": self._default_host} if _SUPPORTS_DEFAULT_HOST else {} + ) if ( _observability is not None - and (otel_interceptor := _observability.get_otel_interceptor(self._client_options)) is not None + and (otel_interceptor := _observability.get_otel_interceptor(self._client_options, **_otel_interceptor_kwargs)) is not None and otel_interceptor not in channel_interceptors and not any(getattr(i, "_is_otel_interceptor", None) is True for i in channel_interceptors) ): diff --git a/packages/gapic-generator/tests/integration/goldens/logging_internal/google/cloud/logging_v2/services/config_service_v2/transports/grpc_asyncio.py b/packages/gapic-generator/tests/integration/goldens/logging_internal/google/cloud/logging_v2/services/config_service_v2/transports/grpc_asyncio.py index c03327f2585e..c972054052bd 100755 --- a/packages/gapic-generator/tests/integration/goldens/logging_internal/google/cloud/logging_v2/services/config_service_v2/transports/grpc_asyncio.py +++ b/packages/gapic-generator/tests/integration/goldens/logging_internal/google/cloud/logging_v2/services/config_service_v2/transports/grpc_asyncio.py @@ -20,7 +20,11 @@ from typing import Awaitable, Callable, Dict, Optional, Sequence, Tuple, Union from typing import TYPE_CHECKING -from google.cloud.logging_v2._compat import _observability, apply_async_channel_interceptors +from google.cloud.logging_v2._compat import ( + _observability, + SUPPORTS_DEFAULT_HOST as _SUPPORTS_DEFAULT_HOST, + apply_async_channel_interceptors, +) from google.api_core import client_options as client_options_lib from google.api_core import gapic_v1 @@ -335,8 +339,11 @@ def __init__(self, *, # NOTE: Coverage tool ignores async interceptors in environments running # legacy google-api-core (< 2.36.0) where OpenTelemetry is unavailable. # TODO (Issue #18657): Remove once lowest constraints require google-api-core >= v2.43.0. + _otel_interceptor_kwargs = ( + {"default_host": self._default_host} if _SUPPORTS_DEFAULT_HOST else {} + ) # pragma: NO COVER self._grpc_channel = _observability.apply_otel_async_channel_interceptors( - self._grpc_channel, self._client_options + self._grpc_channel, self._client_options, **_otel_interceptor_kwargs ) # pragma: NO COVER self._logged_channel = self._grpc_channel diff --git a/packages/gapic-generator/tests/integration/goldens/logging_internal/google/cloud/logging_v2/services/logging_service_v2/transports/base.py b/packages/gapic-generator/tests/integration/goldens/logging_internal/google/cloud/logging_v2/services/logging_service_v2/transports/base.py index 4f1882897e01..8d9f8d8ad672 100755 --- a/packages/gapic-generator/tests/integration/goldens/logging_internal/google/cloud/logging_v2/services/logging_service_v2/transports/base.py +++ b/packages/gapic-generator/tests/integration/goldens/logging_internal/google/cloud/logging_v2/services/logging_service_v2/transports/base.py @@ -19,6 +19,7 @@ from google.cloud.logging_v2 import gapic_version as package_version from google.cloud.logging_v2._compat import ( ASYNC_WRAP_METHOD_SUPPORTS_KIND as _ASYNC_WRAP_METHOD_SUPPORTS_KIND, + SUPPORTS_DEFAULT_HOST as _SUPPORTS_DEFAULT_HOST, WRAP_METHOD_SUPPORTS_TRACING as _WRAP_METHOD_SUPPORTS_TRACING, ) _ASYNC_WRAP_METHOD_SUPPORTS_TRACING = _WRAP_METHOD_SUPPORTS_TRACING @@ -136,7 +137,13 @@ def __init__( host += ':443' self._host = host + if isinstance(client_options, dict): + client_options = client_options_lib.from_dict(client_options) + self._client_options = client_options + self._default_host: Optional[str] = ( + None if self._ignore_credentials else self._host + ) self._wrapped_methods: Dict[Callable, Callable] = {} @property @@ -155,6 +162,8 @@ def _wrap_method(self, func, *args, **kwargs): """ if _WRAP_METHOD_SUPPORTS_TRACING: kwargs["client_options"] = self._client_options + if _SUPPORTS_DEFAULT_HOST: + kwargs["default_host"] = self._default_host try: kind = self.kind except NotImplementedError: @@ -167,7 +176,7 @@ def _wrap_method(self, func, *args, **kwargs): # - Era 2 (>= 2.29.0, < 2.36.0): `kind` is supported (prevents REST from # falling back to gRPC wrapping), but tracing args are not. # (These fallbacks can be removed once google-api-core < 2.36.0 is no longer supported.) - for k in ["client_options", "method_name", "is_streaming"]: + for k in ["client_options", "method_name", "is_streaming", "default_host"]: kwargs.pop(k, None) try: @@ -194,6 +203,8 @@ def _wrap_async_method(self, func, *args, **kwargs): """ if _ASYNC_WRAP_METHOD_SUPPORTS_TRACING: kwargs["client_options"] = self._client_options + if _SUPPORTS_DEFAULT_HOST: + kwargs["default_host"] = self._default_host try: kind = self.kind except NotImplementedError: @@ -206,7 +217,7 @@ def _wrap_async_method(self, func, *args, **kwargs): # - Era 2 (>= 2.29.0, < 2.36.0): `kind` is supported (prevents REST from # falling back to gRPC wrapping), but tracing args are not. # (These fallbacks can be removed once google-api-core < 2.36.0 is no longer supported.) - for k in ["client_options", "method_name", "is_streaming"]: + for k in ["client_options", "method_name", "is_streaming", "default_host"]: kwargs.pop(k, None) try: diff --git a/packages/gapic-generator/tests/integration/goldens/logging_internal/google/cloud/logging_v2/services/logging_service_v2/transports/grpc.py b/packages/gapic-generator/tests/integration/goldens/logging_internal/google/cloud/logging_v2/services/logging_service_v2/transports/grpc.py index b4508ffcde56..b36b20e3d16a 100755 --- a/packages/gapic-generator/tests/integration/goldens/logging_internal/google/cloud/logging_v2/services/logging_service_v2/transports/grpc.py +++ b/packages/gapic-generator/tests/integration/goldens/logging_internal/google/cloud/logging_v2/services/logging_service_v2/transports/grpc.py @@ -20,7 +20,11 @@ from typing import Callable, Dict, Optional, Sequence, Tuple, Union from typing import TYPE_CHECKING -from google.cloud.logging_v2._compat import _observability, apply_channel_interceptors +from google.cloud.logging_v2._compat import ( + _observability, + SUPPORTS_DEFAULT_HOST as _SUPPORTS_DEFAULT_HOST, + apply_channel_interceptors, +) from google.api_core import client_options as client_options_lib from google.api_core import grpc_helpers @@ -275,9 +279,12 @@ def __init__(self, *, ) channel_interceptors = list(interceptors) if interceptors else [] + _otel_interceptor_kwargs = ( + {"default_host": self._default_host} if _SUPPORTS_DEFAULT_HOST else {} + ) if ( _observability is not None - and (otel_interceptor := _observability.get_otel_interceptor(self._client_options)) is not None + and (otel_interceptor := _observability.get_otel_interceptor(self._client_options, **_otel_interceptor_kwargs)) is not None and otel_interceptor not in channel_interceptors and not any(getattr(i, "_is_otel_interceptor", None) is True for i in channel_interceptors) ): diff --git a/packages/gapic-generator/tests/integration/goldens/logging_internal/google/cloud/logging_v2/services/logging_service_v2/transports/grpc_asyncio.py b/packages/gapic-generator/tests/integration/goldens/logging_internal/google/cloud/logging_v2/services/logging_service_v2/transports/grpc_asyncio.py index 26b8c349c478..071fe3196e25 100755 --- a/packages/gapic-generator/tests/integration/goldens/logging_internal/google/cloud/logging_v2/services/logging_service_v2/transports/grpc_asyncio.py +++ b/packages/gapic-generator/tests/integration/goldens/logging_internal/google/cloud/logging_v2/services/logging_service_v2/transports/grpc_asyncio.py @@ -20,7 +20,11 @@ from typing import Awaitable, Callable, Dict, Optional, Sequence, Tuple, Union from typing import TYPE_CHECKING -from google.cloud.logging_v2._compat import _observability, apply_async_channel_interceptors +from google.cloud.logging_v2._compat import ( + _observability, + SUPPORTS_DEFAULT_HOST as _SUPPORTS_DEFAULT_HOST, + apply_async_channel_interceptors, +) from google.api_core import client_options as client_options_lib from google.api_core import gapic_v1 @@ -333,8 +337,11 @@ def __init__(self, *, # NOTE: Coverage tool ignores async interceptors in environments running # legacy google-api-core (< 2.36.0) where OpenTelemetry is unavailable. # TODO (Issue #18657): Remove once lowest constraints require google-api-core >= v2.43.0. + _otel_interceptor_kwargs = ( + {"default_host": self._default_host} if _SUPPORTS_DEFAULT_HOST else {} + ) # pragma: NO COVER self._grpc_channel = _observability.apply_otel_async_channel_interceptors( - self._grpc_channel, self._client_options + self._grpc_channel, self._client_options, **_otel_interceptor_kwargs ) # pragma: NO COVER self._logged_channel = self._grpc_channel diff --git a/packages/gapic-generator/tests/integration/goldens/logging_internal/google/cloud/logging_v2/services/metrics_service_v2/transports/base.py b/packages/gapic-generator/tests/integration/goldens/logging_internal/google/cloud/logging_v2/services/metrics_service_v2/transports/base.py index 98e6b8826af6..6175a2cb2a90 100755 --- a/packages/gapic-generator/tests/integration/goldens/logging_internal/google/cloud/logging_v2/services/metrics_service_v2/transports/base.py +++ b/packages/gapic-generator/tests/integration/goldens/logging_internal/google/cloud/logging_v2/services/metrics_service_v2/transports/base.py @@ -19,6 +19,7 @@ from google.cloud.logging_v2 import gapic_version as package_version from google.cloud.logging_v2._compat import ( ASYNC_WRAP_METHOD_SUPPORTS_KIND as _ASYNC_WRAP_METHOD_SUPPORTS_KIND, + SUPPORTS_DEFAULT_HOST as _SUPPORTS_DEFAULT_HOST, WRAP_METHOD_SUPPORTS_TRACING as _WRAP_METHOD_SUPPORTS_TRACING, ) _ASYNC_WRAP_METHOD_SUPPORTS_TRACING = _WRAP_METHOD_SUPPORTS_TRACING @@ -136,7 +137,13 @@ def __init__( host += ':443' self._host = host + if isinstance(client_options, dict): + client_options = client_options_lib.from_dict(client_options) + self._client_options = client_options + self._default_host: Optional[str] = ( + None if self._ignore_credentials else self._host + ) self._wrapped_methods: Dict[Callable, Callable] = {} @property @@ -155,6 +162,8 @@ def _wrap_method(self, func, *args, **kwargs): """ if _WRAP_METHOD_SUPPORTS_TRACING: kwargs["client_options"] = self._client_options + if _SUPPORTS_DEFAULT_HOST: + kwargs["default_host"] = self._default_host try: kind = self.kind except NotImplementedError: @@ -167,7 +176,7 @@ def _wrap_method(self, func, *args, **kwargs): # - Era 2 (>= 2.29.0, < 2.36.0): `kind` is supported (prevents REST from # falling back to gRPC wrapping), but tracing args are not. # (These fallbacks can be removed once google-api-core < 2.36.0 is no longer supported.) - for k in ["client_options", "method_name", "is_streaming"]: + for k in ["client_options", "method_name", "is_streaming", "default_host"]: kwargs.pop(k, None) try: @@ -194,6 +203,8 @@ def _wrap_async_method(self, func, *args, **kwargs): """ if _ASYNC_WRAP_METHOD_SUPPORTS_TRACING: kwargs["client_options"] = self._client_options + if _SUPPORTS_DEFAULT_HOST: + kwargs["default_host"] = self._default_host try: kind = self.kind except NotImplementedError: @@ -206,7 +217,7 @@ def _wrap_async_method(self, func, *args, **kwargs): # - Era 2 (>= 2.29.0, < 2.36.0): `kind` is supported (prevents REST from # falling back to gRPC wrapping), but tracing args are not. # (These fallbacks can be removed once google-api-core < 2.36.0 is no longer supported.) - for k in ["client_options", "method_name", "is_streaming"]: + for k in ["client_options", "method_name", "is_streaming", "default_host"]: kwargs.pop(k, None) try: diff --git a/packages/gapic-generator/tests/integration/goldens/logging_internal/google/cloud/logging_v2/services/metrics_service_v2/transports/grpc.py b/packages/gapic-generator/tests/integration/goldens/logging_internal/google/cloud/logging_v2/services/metrics_service_v2/transports/grpc.py index f72fc2e40ee4..351af44bd1a2 100755 --- a/packages/gapic-generator/tests/integration/goldens/logging_internal/google/cloud/logging_v2/services/metrics_service_v2/transports/grpc.py +++ b/packages/gapic-generator/tests/integration/goldens/logging_internal/google/cloud/logging_v2/services/metrics_service_v2/transports/grpc.py @@ -20,7 +20,11 @@ from typing import Callable, Dict, Optional, Sequence, Tuple, Union from typing import TYPE_CHECKING -from google.cloud.logging_v2._compat import _observability, apply_channel_interceptors +from google.cloud.logging_v2._compat import ( + _observability, + SUPPORTS_DEFAULT_HOST as _SUPPORTS_DEFAULT_HOST, + apply_channel_interceptors, +) from google.api_core import client_options as client_options_lib from google.api_core import grpc_helpers @@ -275,9 +279,12 @@ def __init__(self, *, ) channel_interceptors = list(interceptors) if interceptors else [] + _otel_interceptor_kwargs = ( + {"default_host": self._default_host} if _SUPPORTS_DEFAULT_HOST else {} + ) if ( _observability is not None - and (otel_interceptor := _observability.get_otel_interceptor(self._client_options)) is not None + and (otel_interceptor := _observability.get_otel_interceptor(self._client_options, **_otel_interceptor_kwargs)) is not None and otel_interceptor not in channel_interceptors and not any(getattr(i, "_is_otel_interceptor", None) is True for i in channel_interceptors) ): diff --git a/packages/gapic-generator/tests/integration/goldens/logging_internal/google/cloud/logging_v2/services/metrics_service_v2/transports/grpc_asyncio.py b/packages/gapic-generator/tests/integration/goldens/logging_internal/google/cloud/logging_v2/services/metrics_service_v2/transports/grpc_asyncio.py index 54ba2dbe7b1d..beaf9710314f 100755 --- a/packages/gapic-generator/tests/integration/goldens/logging_internal/google/cloud/logging_v2/services/metrics_service_v2/transports/grpc_asyncio.py +++ b/packages/gapic-generator/tests/integration/goldens/logging_internal/google/cloud/logging_v2/services/metrics_service_v2/transports/grpc_asyncio.py @@ -20,7 +20,11 @@ from typing import Awaitable, Callable, Dict, Optional, Sequence, Tuple, Union from typing import TYPE_CHECKING -from google.cloud.logging_v2._compat import _observability, apply_async_channel_interceptors +from google.cloud.logging_v2._compat import ( + _observability, + SUPPORTS_DEFAULT_HOST as _SUPPORTS_DEFAULT_HOST, + apply_async_channel_interceptors, +) from google.api_core import client_options as client_options_lib from google.api_core import gapic_v1 @@ -333,8 +337,11 @@ def __init__(self, *, # NOTE: Coverage tool ignores async interceptors in environments running # legacy google-api-core (< 2.36.0) where OpenTelemetry is unavailable. # TODO (Issue #18657): Remove once lowest constraints require google-api-core >= v2.43.0. + _otel_interceptor_kwargs = ( + {"default_host": self._default_host} if _SUPPORTS_DEFAULT_HOST else {} + ) # pragma: NO COVER self._grpc_channel = _observability.apply_otel_async_channel_interceptors( - self._grpc_channel, self._client_options + self._grpc_channel, self._client_options, **_otel_interceptor_kwargs ) # pragma: NO COVER self._logged_channel = self._grpc_channel diff --git a/packages/gapic-generator/tests/integration/goldens/logging_internal/tests/unit/gapic/logging_v2/test_config_service_v2.py b/packages/gapic-generator/tests/integration/goldens/logging_internal/tests/unit/gapic/logging_v2/test_config_service_v2.py index 5d7d971258ae..826e2a864cc6 100755 --- a/packages/gapic-generator/tests/integration/goldens/logging_internal/tests/unit/gapic/logging_v2/test_config_service_v2.py +++ b/packages/gapic-generator/tests/integration/goldens/logging_internal/tests/unit/gapic/logging_v2/test_config_service_v2.py @@ -827,7 +827,14 @@ def test_config_service_v2_grpc_transport_otel_channel_interceptor(): client_options=options, ) - mock_get_interceptor.assert_called_once_with(options) + expected_kwargs = ( + {"default_host": transport._default_host} + if transports.grpc._SUPPORTS_DEFAULT_HOST + else {} + ) + mock_get_interceptor.assert_called_once_with( + transport._client_options, **expected_kwargs + ) mock_apply_interceptors.assert_called_once_with( mock_channel, [mock_otel_interceptor] ) @@ -901,7 +908,14 @@ def test_config_service_v2_grpc_asyncio_transport_otel_channel_interceptor(): client_options=options, ) - mock_apply_async_interceptors.assert_called_once_with(mock_channel, options) + expected_kwargs = ( + {"default_host": transport._default_host} + if transports.grpc_asyncio._SUPPORTS_DEFAULT_HOST + else {} + ) + mock_apply_async_interceptors.assert_called_once_with( + mock_channel, transport._client_options, **expected_kwargs + ) assert mock_create_channel.call_count == 1 assert transport.grpc_channel == mock_channel @@ -14009,7 +14023,8 @@ def test_api_key_credentials(client_class, transport_class): def test_config_service_v2_base_transport_wrap_method_modern_api_core(monkeypatch): monkeypatch.setattr(transports.base, "_WRAP_METHOD_SUPPORTS_TRACING", True) - # 1. Concrete transport: passes client_options=None and kind="grpc" + monkeypatch.setattr(transports.base, "_SUPPORTS_DEFAULT_HOST", True) + # 1. Concrete transport: passes client_options=None, default_host, and kind="grpc" with mock.patch.object(transports.base.gapic_v1.method, "wrap_method") as mock_wrap: transport = transports.ConfigServiceV2GrpcTransport( credentials=ga_credentials.AnonymousCredentials() @@ -14018,8 +14033,10 @@ def test_config_service_v2_base_transport_wrap_method_modern_api_core(monkeypatc transport._wrap_method(func) assert mock_wrap.call_args.kwargs["kind"] == "grpc" assert "client_options" in mock_wrap.call_args.kwargs + assert mock_wrap.call_args.kwargs["default_host"] == transport._default_host - # 2. Base transport: kind raises NotImplementedError and is cleanly omitted + # 2. Base transport with _SUPPORTS_DEFAULT_HOST=False: kind raises NotImplementedError and default_host is omitted + monkeypatch.setattr(transports.base, "_SUPPORTS_DEFAULT_HOST", False) with mock.patch.object(transports.base.gapic_v1.method, "wrap_method") as mock_wrap: base_transport = transports.ConfigServiceV2Transport( credentials=ga_credentials.AnonymousCredentials() @@ -14028,6 +14045,84 @@ def test_config_service_v2_base_transport_wrap_method_modern_api_core(monkeypatc base_transport._wrap_method(func) assert "kind" not in mock_wrap.call_args.kwargs assert "client_options" in mock_wrap.call_args.kwargs + assert "default_host" not in mock_wrap.call_args.kwargs + + +def test_config_service_v2_base_transport_client_options_none(): + transport = transports.ConfigServiceV2Transport( + host="default.example.com:443", + credentials=ga_credentials.AnonymousCredentials(), + client_options=None, + ) + assert transport._client_options is None + assert transport._default_host == "default.example.com:443" + + +@pytest.mark.parametrize( + "initial_endpoint", + [ + pytest.param(None, id="unset_api_endpoint"), + pytest.param("custom.example.com:443", id="explicit_api_endpoint"), + ], +) +def test_config_service_v2_base_transport_client_options_object( + initial_endpoint, +): + options = client_options.ClientOptions(api_endpoint=initial_endpoint) + transport = transports.ConfigServiceV2Transport( + host="default.example.com:443", + credentials=ga_credentials.AnonymousCredentials(), + client_options=options, + ) + assert transport._client_options is options + assert transport._client_options.api_endpoint == initial_endpoint + assert transport._default_host == "default.example.com:443" + + +@pytest.mark.parametrize( + "initial_options,expected_endpoint", + [ + pytest.param( + {}, + None, + id="unset_api_endpoint", + ), + pytest.param( + {"api_endpoint": "custom.example.com:443"}, + "custom.example.com:443", + id="explicit_api_endpoint", + ), + ], +) +def test_config_service_v2_base_transport_client_options_dict( + initial_options, expected_endpoint +): + options = dict(initial_options) + transport = transports.ConfigServiceV2Transport( + host="default.example.com:443", + credentials=ga_credentials.AnonymousCredentials(), + client_options=options, + ) + assert isinstance(transport._client_options, client_options.ClientOptions) + assert transport._client_options.api_endpoint == expected_endpoint + assert transport._default_host == "default.example.com:443" + assert options == initial_options + + +def test_config_service_v2_base_transport_default_host_ignored_when_ignore_credentials(): + class _CustomChannelTransport(transports.ConfigServiceV2Transport): + def __init__(self, **kwargs): + self._ignore_credentials = True + super().__init__(**kwargs) + + options = client_options.ClientOptions() + transport = _CustomChannelTransport( + host="default.example.com:443", + client_options=options, + ) + assert transport._client_options is options + assert transport._client_options.api_endpoint is None + assert transport._default_host is None def test_config_service_v2_base_transport_wrap_method_older_api_core_fallbacks(monkeypatch): @@ -14106,7 +14201,8 @@ def test_config_service_v2_base_transport_wrap_method_older_api_core_fallbacks(m def test_config_service_v2_base_transport_wrap_async_method_modern_api_core(monkeypatch): monkeypatch.setattr(transports.base, "_ASYNC_WRAP_METHOD_SUPPORTS_TRACING", True) - # 1. Concrete transport: passes client_options=None and kind="grpc_asyncio" + monkeypatch.setattr(transports.base, "_SUPPORTS_DEFAULT_HOST", True) + # 1. Concrete transport: passes client_options=None, default_host, and kind="grpc_asyncio" with mock.patch.object(transports.base.gapic_v1.method_async, "wrap_method") as mock_wrap: transport = transports.ConfigServiceV2GrpcAsyncIOTransport( credentials=ga_credentials.AnonymousCredentials() @@ -14115,8 +14211,10 @@ def test_config_service_v2_base_transport_wrap_async_method_modern_api_core(monk transport._wrap_async_method(func) assert mock_wrap.call_args.kwargs["kind"] == "grpc_asyncio" assert "client_options" in mock_wrap.call_args.kwargs + assert mock_wrap.call_args.kwargs["default_host"] == transport._default_host - # 2. Base transport: kind raises NotImplementedError and is cleanly omitted + # 2. Base transport with _SUPPORTS_DEFAULT_HOST=False: kind raises NotImplementedError and default_host is omitted + monkeypatch.setattr(transports.base, "_SUPPORTS_DEFAULT_HOST", False) with mock.patch.object(transports.base.gapic_v1.method_async, "wrap_method") as mock_wrap: base_transport = transports.ConfigServiceV2Transport( credentials=ga_credentials.AnonymousCredentials() @@ -14125,6 +14223,7 @@ def test_config_service_v2_base_transport_wrap_async_method_modern_api_core(monk base_transport._wrap_async_method(func) assert "kind" not in mock_wrap.call_args.kwargs assert "client_options" in mock_wrap.call_args.kwargs + assert "default_host" not in mock_wrap.call_args.kwargs def test_config_service_v2_base_transport_wrap_async_method_older_api_core_fallbacks(monkeypatch): diff --git a/packages/gapic-generator/tests/integration/goldens/logging_internal/tests/unit/gapic/logging_v2/test_logging_service_v2.py b/packages/gapic-generator/tests/integration/goldens/logging_internal/tests/unit/gapic/logging_v2/test_logging_service_v2.py index 433ff946bd8b..40bf25c8e02a 100755 --- a/packages/gapic-generator/tests/integration/goldens/logging_internal/tests/unit/gapic/logging_v2/test_logging_service_v2.py +++ b/packages/gapic-generator/tests/integration/goldens/logging_internal/tests/unit/gapic/logging_v2/test_logging_service_v2.py @@ -828,7 +828,14 @@ def test_logging_service_v2_grpc_transport_otel_channel_interceptor(): client_options=options, ) - mock_get_interceptor.assert_called_once_with(options) + expected_kwargs = ( + {"default_host": transport._default_host} + if transports.grpc._SUPPORTS_DEFAULT_HOST + else {} + ) + mock_get_interceptor.assert_called_once_with( + transport._client_options, **expected_kwargs + ) mock_apply_interceptors.assert_called_once_with( mock_channel, [mock_otel_interceptor] ) @@ -902,7 +909,14 @@ def test_logging_service_v2_grpc_asyncio_transport_otel_channel_interceptor(): client_options=options, ) - mock_apply_async_interceptors.assert_called_once_with(mock_channel, options) + expected_kwargs = ( + {"default_host": transport._default_host} + if transports.grpc_asyncio._SUPPORTS_DEFAULT_HOST + else {} + ) + mock_apply_async_interceptors.assert_called_once_with( + mock_channel, transport._client_options, **expected_kwargs + ) assert mock_create_channel.call_count == 1 assert transport.grpc_channel == mock_channel @@ -4449,7 +4463,8 @@ def test_api_key_credentials(client_class, transport_class): def test_logging_service_v2_base_transport_wrap_method_modern_api_core(monkeypatch): monkeypatch.setattr(transports.base, "_WRAP_METHOD_SUPPORTS_TRACING", True) - # 1. Concrete transport: passes client_options=None and kind="grpc" + monkeypatch.setattr(transports.base, "_SUPPORTS_DEFAULT_HOST", True) + # 1. Concrete transport: passes client_options=None, default_host, and kind="grpc" with mock.patch.object(transports.base.gapic_v1.method, "wrap_method") as mock_wrap: transport = transports.LoggingServiceV2GrpcTransport( credentials=ga_credentials.AnonymousCredentials() @@ -4458,8 +4473,10 @@ def test_logging_service_v2_base_transport_wrap_method_modern_api_core(monkeypat transport._wrap_method(func) assert mock_wrap.call_args.kwargs["kind"] == "grpc" assert "client_options" in mock_wrap.call_args.kwargs + assert mock_wrap.call_args.kwargs["default_host"] == transport._default_host - # 2. Base transport: kind raises NotImplementedError and is cleanly omitted + # 2. Base transport with _SUPPORTS_DEFAULT_HOST=False: kind raises NotImplementedError and default_host is omitted + monkeypatch.setattr(transports.base, "_SUPPORTS_DEFAULT_HOST", False) with mock.patch.object(transports.base.gapic_v1.method, "wrap_method") as mock_wrap: base_transport = transports.LoggingServiceV2Transport( credentials=ga_credentials.AnonymousCredentials() @@ -4468,6 +4485,84 @@ def test_logging_service_v2_base_transport_wrap_method_modern_api_core(monkeypat base_transport._wrap_method(func) assert "kind" not in mock_wrap.call_args.kwargs assert "client_options" in mock_wrap.call_args.kwargs + assert "default_host" not in mock_wrap.call_args.kwargs + + +def test_logging_service_v2_base_transport_client_options_none(): + transport = transports.LoggingServiceV2Transport( + host="default.example.com:443", + credentials=ga_credentials.AnonymousCredentials(), + client_options=None, + ) + assert transport._client_options is None + assert transport._default_host == "default.example.com:443" + + +@pytest.mark.parametrize( + "initial_endpoint", + [ + pytest.param(None, id="unset_api_endpoint"), + pytest.param("custom.example.com:443", id="explicit_api_endpoint"), + ], +) +def test_logging_service_v2_base_transport_client_options_object( + initial_endpoint, +): + options = client_options.ClientOptions(api_endpoint=initial_endpoint) + transport = transports.LoggingServiceV2Transport( + host="default.example.com:443", + credentials=ga_credentials.AnonymousCredentials(), + client_options=options, + ) + assert transport._client_options is options + assert transport._client_options.api_endpoint == initial_endpoint + assert transport._default_host == "default.example.com:443" + + +@pytest.mark.parametrize( + "initial_options,expected_endpoint", + [ + pytest.param( + {}, + None, + id="unset_api_endpoint", + ), + pytest.param( + {"api_endpoint": "custom.example.com:443"}, + "custom.example.com:443", + id="explicit_api_endpoint", + ), + ], +) +def test_logging_service_v2_base_transport_client_options_dict( + initial_options, expected_endpoint +): + options = dict(initial_options) + transport = transports.LoggingServiceV2Transport( + host="default.example.com:443", + credentials=ga_credentials.AnonymousCredentials(), + client_options=options, + ) + assert isinstance(transport._client_options, client_options.ClientOptions) + assert transport._client_options.api_endpoint == expected_endpoint + assert transport._default_host == "default.example.com:443" + assert options == initial_options + + +def test_logging_service_v2_base_transport_default_host_ignored_when_ignore_credentials(): + class _CustomChannelTransport(transports.LoggingServiceV2Transport): + def __init__(self, **kwargs): + self._ignore_credentials = True + super().__init__(**kwargs) + + options = client_options.ClientOptions() + transport = _CustomChannelTransport( + host="default.example.com:443", + client_options=options, + ) + assert transport._client_options is options + assert transport._client_options.api_endpoint is None + assert transport._default_host is None def test_logging_service_v2_base_transport_wrap_method_older_api_core_fallbacks(monkeypatch): @@ -4546,7 +4641,8 @@ def test_logging_service_v2_base_transport_wrap_method_older_api_core_fallbacks( def test_logging_service_v2_base_transport_wrap_async_method_modern_api_core(monkeypatch): monkeypatch.setattr(transports.base, "_ASYNC_WRAP_METHOD_SUPPORTS_TRACING", True) - # 1. Concrete transport: passes client_options=None and kind="grpc_asyncio" + monkeypatch.setattr(transports.base, "_SUPPORTS_DEFAULT_HOST", True) + # 1. Concrete transport: passes client_options=None, default_host, and kind="grpc_asyncio" with mock.patch.object(transports.base.gapic_v1.method_async, "wrap_method") as mock_wrap: transport = transports.LoggingServiceV2GrpcAsyncIOTransport( credentials=ga_credentials.AnonymousCredentials() @@ -4555,8 +4651,10 @@ def test_logging_service_v2_base_transport_wrap_async_method_modern_api_core(mon transport._wrap_async_method(func) assert mock_wrap.call_args.kwargs["kind"] == "grpc_asyncio" assert "client_options" in mock_wrap.call_args.kwargs + assert mock_wrap.call_args.kwargs["default_host"] == transport._default_host - # 2. Base transport: kind raises NotImplementedError and is cleanly omitted + # 2. Base transport with _SUPPORTS_DEFAULT_HOST=False: kind raises NotImplementedError and default_host is omitted + monkeypatch.setattr(transports.base, "_SUPPORTS_DEFAULT_HOST", False) with mock.patch.object(transports.base.gapic_v1.method_async, "wrap_method") as mock_wrap: base_transport = transports.LoggingServiceV2Transport( credentials=ga_credentials.AnonymousCredentials() @@ -4565,6 +4663,7 @@ def test_logging_service_v2_base_transport_wrap_async_method_modern_api_core(mon base_transport._wrap_async_method(func) assert "kind" not in mock_wrap.call_args.kwargs assert "client_options" in mock_wrap.call_args.kwargs + assert "default_host" not in mock_wrap.call_args.kwargs def test_logging_service_v2_base_transport_wrap_async_method_older_api_core_fallbacks(monkeypatch): diff --git a/packages/gapic-generator/tests/integration/goldens/logging_internal/tests/unit/gapic/logging_v2/test_metrics_service_v2.py b/packages/gapic-generator/tests/integration/goldens/logging_internal/tests/unit/gapic/logging_v2/test_metrics_service_v2.py index 5fc12c22406b..e6bf6d1fd8a4 100755 --- a/packages/gapic-generator/tests/integration/goldens/logging_internal/tests/unit/gapic/logging_v2/test_metrics_service_v2.py +++ b/packages/gapic-generator/tests/integration/goldens/logging_internal/tests/unit/gapic/logging_v2/test_metrics_service_v2.py @@ -826,7 +826,14 @@ def test_metrics_service_v2_grpc_transport_otel_channel_interceptor(): client_options=options, ) - mock_get_interceptor.assert_called_once_with(options) + expected_kwargs = ( + {"default_host": transport._default_host} + if transports.grpc._SUPPORTS_DEFAULT_HOST + else {} + ) + mock_get_interceptor.assert_called_once_with( + transport._client_options, **expected_kwargs + ) mock_apply_interceptors.assert_called_once_with( mock_channel, [mock_otel_interceptor] ) @@ -900,7 +907,14 @@ def test_metrics_service_v2_grpc_asyncio_transport_otel_channel_interceptor(): client_options=options, ) - mock_apply_async_interceptors.assert_called_once_with(mock_channel, options) + expected_kwargs = ( + {"default_host": transport._default_host} + if transports.grpc_asyncio._SUPPORTS_DEFAULT_HOST + else {} + ) + mock_apply_async_interceptors.assert_called_once_with( + mock_channel, transport._client_options, **expected_kwargs + ) assert mock_create_channel.call_count == 1 assert transport.grpc_channel == mock_channel @@ -4249,7 +4263,8 @@ def test_api_key_credentials(client_class, transport_class): def test_metrics_service_v2_base_transport_wrap_method_modern_api_core(monkeypatch): monkeypatch.setattr(transports.base, "_WRAP_METHOD_SUPPORTS_TRACING", True) - # 1. Concrete transport: passes client_options=None and kind="grpc" + monkeypatch.setattr(transports.base, "_SUPPORTS_DEFAULT_HOST", True) + # 1. Concrete transport: passes client_options=None, default_host, and kind="grpc" with mock.patch.object(transports.base.gapic_v1.method, "wrap_method") as mock_wrap: transport = transports.MetricsServiceV2GrpcTransport( credentials=ga_credentials.AnonymousCredentials() @@ -4258,8 +4273,10 @@ def test_metrics_service_v2_base_transport_wrap_method_modern_api_core(monkeypat transport._wrap_method(func) assert mock_wrap.call_args.kwargs["kind"] == "grpc" assert "client_options" in mock_wrap.call_args.kwargs + assert mock_wrap.call_args.kwargs["default_host"] == transport._default_host - # 2. Base transport: kind raises NotImplementedError and is cleanly omitted + # 2. Base transport with _SUPPORTS_DEFAULT_HOST=False: kind raises NotImplementedError and default_host is omitted + monkeypatch.setattr(transports.base, "_SUPPORTS_DEFAULT_HOST", False) with mock.patch.object(transports.base.gapic_v1.method, "wrap_method") as mock_wrap: base_transport = transports.MetricsServiceV2Transport( credentials=ga_credentials.AnonymousCredentials() @@ -4268,6 +4285,84 @@ def test_metrics_service_v2_base_transport_wrap_method_modern_api_core(monkeypat base_transport._wrap_method(func) assert "kind" not in mock_wrap.call_args.kwargs assert "client_options" in mock_wrap.call_args.kwargs + assert "default_host" not in mock_wrap.call_args.kwargs + + +def test_metrics_service_v2_base_transport_client_options_none(): + transport = transports.MetricsServiceV2Transport( + host="default.example.com:443", + credentials=ga_credentials.AnonymousCredentials(), + client_options=None, + ) + assert transport._client_options is None + assert transport._default_host == "default.example.com:443" + + +@pytest.mark.parametrize( + "initial_endpoint", + [ + pytest.param(None, id="unset_api_endpoint"), + pytest.param("custom.example.com:443", id="explicit_api_endpoint"), + ], +) +def test_metrics_service_v2_base_transport_client_options_object( + initial_endpoint, +): + options = client_options.ClientOptions(api_endpoint=initial_endpoint) + transport = transports.MetricsServiceV2Transport( + host="default.example.com:443", + credentials=ga_credentials.AnonymousCredentials(), + client_options=options, + ) + assert transport._client_options is options + assert transport._client_options.api_endpoint == initial_endpoint + assert transport._default_host == "default.example.com:443" + + +@pytest.mark.parametrize( + "initial_options,expected_endpoint", + [ + pytest.param( + {}, + None, + id="unset_api_endpoint", + ), + pytest.param( + {"api_endpoint": "custom.example.com:443"}, + "custom.example.com:443", + id="explicit_api_endpoint", + ), + ], +) +def test_metrics_service_v2_base_transport_client_options_dict( + initial_options, expected_endpoint +): + options = dict(initial_options) + transport = transports.MetricsServiceV2Transport( + host="default.example.com:443", + credentials=ga_credentials.AnonymousCredentials(), + client_options=options, + ) + assert isinstance(transport._client_options, client_options.ClientOptions) + assert transport._client_options.api_endpoint == expected_endpoint + assert transport._default_host == "default.example.com:443" + assert options == initial_options + + +def test_metrics_service_v2_base_transport_default_host_ignored_when_ignore_credentials(): + class _CustomChannelTransport(transports.MetricsServiceV2Transport): + def __init__(self, **kwargs): + self._ignore_credentials = True + super().__init__(**kwargs) + + options = client_options.ClientOptions() + transport = _CustomChannelTransport( + host="default.example.com:443", + client_options=options, + ) + assert transport._client_options is options + assert transport._client_options.api_endpoint is None + assert transport._default_host is None def test_metrics_service_v2_base_transport_wrap_method_older_api_core_fallbacks(monkeypatch): @@ -4346,7 +4441,8 @@ def test_metrics_service_v2_base_transport_wrap_method_older_api_core_fallbacks( def test_metrics_service_v2_base_transport_wrap_async_method_modern_api_core(monkeypatch): monkeypatch.setattr(transports.base, "_ASYNC_WRAP_METHOD_SUPPORTS_TRACING", True) - # 1. Concrete transport: passes client_options=None and kind="grpc_asyncio" + monkeypatch.setattr(transports.base, "_SUPPORTS_DEFAULT_HOST", True) + # 1. Concrete transport: passes client_options=None, default_host, and kind="grpc_asyncio" with mock.patch.object(transports.base.gapic_v1.method_async, "wrap_method") as mock_wrap: transport = transports.MetricsServiceV2GrpcAsyncIOTransport( credentials=ga_credentials.AnonymousCredentials() @@ -4355,8 +4451,10 @@ def test_metrics_service_v2_base_transport_wrap_async_method_modern_api_core(mon transport._wrap_async_method(func) assert mock_wrap.call_args.kwargs["kind"] == "grpc_asyncio" assert "client_options" in mock_wrap.call_args.kwargs + assert mock_wrap.call_args.kwargs["default_host"] == transport._default_host - # 2. Base transport: kind raises NotImplementedError and is cleanly omitted + # 2. Base transport with _SUPPORTS_DEFAULT_HOST=False: kind raises NotImplementedError and default_host is omitted + monkeypatch.setattr(transports.base, "_SUPPORTS_DEFAULT_HOST", False) with mock.patch.object(transports.base.gapic_v1.method_async, "wrap_method") as mock_wrap: base_transport = transports.MetricsServiceV2Transport( credentials=ga_credentials.AnonymousCredentials() @@ -4365,6 +4463,7 @@ def test_metrics_service_v2_base_transport_wrap_async_method_modern_api_core(mon base_transport._wrap_async_method(func) assert "kind" not in mock_wrap.call_args.kwargs assert "client_options" in mock_wrap.call_args.kwargs + assert "default_host" not in mock_wrap.call_args.kwargs def test_metrics_service_v2_base_transport_wrap_async_method_older_api_core_fallbacks(monkeypatch): diff --git a/packages/gapic-generator/tests/integration/goldens/redis/google/cloud/redis_v1/_compat.py b/packages/gapic-generator/tests/integration/goldens/redis/google/cloud/redis_v1/_compat.py index a4347392b042..86424516e0a2 100755 --- a/packages/gapic-generator/tests/integration/goldens/redis/google/cloud/redis_v1/_compat.py +++ b/packages/gapic-generator/tests/integration/goldens/redis/google/cloud/redis_v1/_compat.py @@ -90,6 +90,15 @@ def trace_http_request(*args: Any, **kwargs: Any) -> _FallbackTraceContext: # t gapic_v1.method_async, "_DEFAULT_ASYNC_TRANSPORT_KIND" ) +# The `default_host` parameter in gapic_v1.method.wrap_method, +# gapic_v1.method_async.wrap_method, _observability.get_otel_interceptor, and +# _observability.apply_otel_async_channel_interceptors allows transports to pass +# their resolved default host without mutating caller-owned client_options. +SUPPORTS_DEFAULT_HOST = bool( + getattr(gapic_v1.method.wrap_method, "__kwdefaults__", None) + and "default_host" in gapic_v1.method.wrap_method.__kwdefaults__ +) + try: from google.api_core import grpc_helpers except ImportError: # pragma: NO COVER diff --git a/packages/gapic-generator/tests/integration/goldens/redis/google/cloud/redis_v1/services/cloud_redis/transports/base.py b/packages/gapic-generator/tests/integration/goldens/redis/google/cloud/redis_v1/services/cloud_redis/transports/base.py index 7164fc0bfe5f..0a476e2d3674 100755 --- a/packages/gapic-generator/tests/integration/goldens/redis/google/cloud/redis_v1/services/cloud_redis/transports/base.py +++ b/packages/gapic-generator/tests/integration/goldens/redis/google/cloud/redis_v1/services/cloud_redis/transports/base.py @@ -19,6 +19,7 @@ from google.cloud.redis_v1 import gapic_version as package_version from google.cloud.redis_v1._compat import ( ASYNC_WRAP_METHOD_SUPPORTS_KIND as _ASYNC_WRAP_METHOD_SUPPORTS_KIND, + SUPPORTS_DEFAULT_HOST as _SUPPORTS_DEFAULT_HOST, WRAP_METHOD_SUPPORTS_TRACING as _WRAP_METHOD_SUPPORTS_TRACING, ) _ASYNC_WRAP_METHOD_SUPPORTS_TRACING = _WRAP_METHOD_SUPPORTS_TRACING @@ -133,7 +134,13 @@ def __init__( host += ':443' self._host = host + if isinstance(client_options, dict): + client_options = client_options_lib.from_dict(client_options) + self._client_options = client_options + self._default_host: Optional[str] = ( + None if self._ignore_credentials else self._host + ) self._wrapped_methods: Dict[Callable, Callable] = {} @property @@ -152,6 +159,8 @@ def _wrap_method(self, func, *args, **kwargs): """ if _WRAP_METHOD_SUPPORTS_TRACING: kwargs["client_options"] = self._client_options + if _SUPPORTS_DEFAULT_HOST: + kwargs["default_host"] = self._default_host try: kind = self.kind except NotImplementedError: @@ -164,7 +173,7 @@ def _wrap_method(self, func, *args, **kwargs): # - Era 2 (>= 2.29.0, < 2.36.0): `kind` is supported (prevents REST from # falling back to gRPC wrapping), but tracing args are not. # (These fallbacks can be removed once google-api-core < 2.36.0 is no longer supported.) - for k in ["client_options", "method_name", "is_streaming"]: + for k in ["client_options", "method_name", "is_streaming", "default_host"]: kwargs.pop(k, None) try: @@ -191,6 +200,8 @@ def _wrap_async_method(self, func, *args, **kwargs): """ if _ASYNC_WRAP_METHOD_SUPPORTS_TRACING: kwargs["client_options"] = self._client_options + if _SUPPORTS_DEFAULT_HOST: + kwargs["default_host"] = self._default_host try: kind = self.kind except NotImplementedError: @@ -203,7 +214,7 @@ def _wrap_async_method(self, func, *args, **kwargs): # - Era 2 (>= 2.29.0, < 2.36.0): `kind` is supported (prevents REST from # falling back to gRPC wrapping), but tracing args are not. # (These fallbacks can be removed once google-api-core < 2.36.0 is no longer supported.) - for k in ["client_options", "method_name", "is_streaming"]: + for k in ["client_options", "method_name", "is_streaming", "default_host"]: kwargs.pop(k, None) try: diff --git a/packages/gapic-generator/tests/integration/goldens/redis/google/cloud/redis_v1/services/cloud_redis/transports/grpc.py b/packages/gapic-generator/tests/integration/goldens/redis/google/cloud/redis_v1/services/cloud_redis/transports/grpc.py index 1a36444c1eb2..219b2278c879 100755 --- a/packages/gapic-generator/tests/integration/goldens/redis/google/cloud/redis_v1/services/cloud_redis/transports/grpc.py +++ b/packages/gapic-generator/tests/integration/goldens/redis/google/cloud/redis_v1/services/cloud_redis/transports/grpc.py @@ -20,7 +20,11 @@ from typing import Callable, Dict, Optional, Sequence, Tuple, Union from typing import TYPE_CHECKING -from google.cloud.redis_v1._compat import _observability, apply_channel_interceptors +from google.cloud.redis_v1._compat import ( + _observability, + SUPPORTS_DEFAULT_HOST as _SUPPORTS_DEFAULT_HOST, + apply_channel_interceptors, +) from google.api_core import client_options as client_options_lib from google.api_core import grpc_helpers @@ -297,9 +301,12 @@ def __init__(self, *, ) channel_interceptors = list(interceptors) if interceptors else [] + _otel_interceptor_kwargs = ( + {"default_host": self._default_host} if _SUPPORTS_DEFAULT_HOST else {} + ) if ( _observability is not None - and (otel_interceptor := _observability.get_otel_interceptor(self._client_options)) is not None + and (otel_interceptor := _observability.get_otel_interceptor(self._client_options, **_otel_interceptor_kwargs)) is not None and otel_interceptor not in channel_interceptors and not any(getattr(i, "_is_otel_interceptor", None) is True for i in channel_interceptors) ): diff --git a/packages/gapic-generator/tests/integration/goldens/redis/google/cloud/redis_v1/services/cloud_redis/transports/grpc_asyncio.py b/packages/gapic-generator/tests/integration/goldens/redis/google/cloud/redis_v1/services/cloud_redis/transports/grpc_asyncio.py index 6f5ff0d28056..c876375cb223 100755 --- a/packages/gapic-generator/tests/integration/goldens/redis/google/cloud/redis_v1/services/cloud_redis/transports/grpc_asyncio.py +++ b/packages/gapic-generator/tests/integration/goldens/redis/google/cloud/redis_v1/services/cloud_redis/transports/grpc_asyncio.py @@ -20,7 +20,11 @@ from typing import Awaitable, Callable, Dict, Optional, Sequence, Tuple, Union from typing import TYPE_CHECKING -from google.cloud.redis_v1._compat import _observability, apply_async_channel_interceptors +from google.cloud.redis_v1._compat import ( + _observability, + SUPPORTS_DEFAULT_HOST as _SUPPORTS_DEFAULT_HOST, + apply_async_channel_interceptors, +) from google.api_core import client_options as client_options_lib from google.api_core import gapic_v1 @@ -355,8 +359,11 @@ def __init__(self, *, # NOTE: Coverage tool ignores async interceptors in environments running # legacy google-api-core (< 2.36.0) where OpenTelemetry is unavailable. # TODO (Issue #18657): Remove once lowest constraints require google-api-core >= v2.43.0. + _otel_interceptor_kwargs = ( + {"default_host": self._default_host} if _SUPPORTS_DEFAULT_HOST else {} + ) # pragma: NO COVER self._grpc_channel = _observability.apply_otel_async_channel_interceptors( - self._grpc_channel, self._client_options + self._grpc_channel, self._client_options, **_otel_interceptor_kwargs ) # pragma: NO COVER self._logged_channel = self._grpc_channel diff --git a/packages/gapic-generator/tests/integration/goldens/redis/tests/unit/gapic/redis_v1/test_cloud_redis.py b/packages/gapic-generator/tests/integration/goldens/redis/tests/unit/gapic/redis_v1/test_cloud_redis.py index e2215a295660..03934535dd28 100755 --- a/packages/gapic-generator/tests/integration/goldens/redis/tests/unit/gapic/redis_v1/test_cloud_redis.py +++ b/packages/gapic-generator/tests/integration/goldens/redis/tests/unit/gapic/redis_v1/test_cloud_redis.py @@ -859,7 +859,14 @@ def test_cloud_redis_grpc_transport_otel_channel_interceptor(): client_options=options, ) - mock_get_interceptor.assert_called_once_with(options) + expected_kwargs = ( + {"default_host": transport._default_host} + if transports.grpc._SUPPORTS_DEFAULT_HOST + else {} + ) + mock_get_interceptor.assert_called_once_with( + transport._client_options, **expected_kwargs + ) mock_apply_interceptors.assert_called_once_with( mock_channel, [mock_otel_interceptor] ) @@ -933,7 +940,14 @@ def test_cloud_redis_grpc_asyncio_transport_otel_channel_interceptor(): client_options=options, ) - mock_apply_async_interceptors.assert_called_once_with(mock_channel, options) + expected_kwargs = ( + {"default_host": transport._default_host} + if transports.grpc_asyncio._SUPPORTS_DEFAULT_HOST + else {} + ) + mock_apply_async_interceptors.assert_called_once_with( + mock_channel, transport._client_options, **expected_kwargs + ) assert mock_create_channel.call_count == 1 assert transport.grpc_channel == mock_channel @@ -13303,7 +13317,8 @@ def test_api_key_credentials(client_class, transport_class): def test_cloud_redis_base_transport_wrap_method_modern_api_core(monkeypatch): monkeypatch.setattr(transports.base, "_WRAP_METHOD_SUPPORTS_TRACING", True) - # 1. Concrete transport: passes client_options=None and kind="grpc" + monkeypatch.setattr(transports.base, "_SUPPORTS_DEFAULT_HOST", True) + # 1. Concrete transport: passes client_options=None, default_host, and kind="grpc" with mock.patch.object(transports.base.gapic_v1.method, "wrap_method") as mock_wrap: transport = transports.CloudRedisGrpcTransport( credentials=ga_credentials.AnonymousCredentials() @@ -13312,8 +13327,10 @@ def test_cloud_redis_base_transport_wrap_method_modern_api_core(monkeypatch): transport._wrap_method(func) assert mock_wrap.call_args.kwargs["kind"] == "grpc" assert "client_options" in mock_wrap.call_args.kwargs + assert mock_wrap.call_args.kwargs["default_host"] == transport._default_host - # 2. Base transport: kind raises NotImplementedError and is cleanly omitted + # 2. Base transport with _SUPPORTS_DEFAULT_HOST=False: kind raises NotImplementedError and default_host is omitted + monkeypatch.setattr(transports.base, "_SUPPORTS_DEFAULT_HOST", False) with mock.patch.object(transports.base.gapic_v1.method, "wrap_method") as mock_wrap: base_transport = transports.CloudRedisTransport( credentials=ga_credentials.AnonymousCredentials() @@ -13322,6 +13339,84 @@ def test_cloud_redis_base_transport_wrap_method_modern_api_core(monkeypatch): base_transport._wrap_method(func) assert "kind" not in mock_wrap.call_args.kwargs assert "client_options" in mock_wrap.call_args.kwargs + assert "default_host" not in mock_wrap.call_args.kwargs + + +def test_cloud_redis_base_transport_client_options_none(): + transport = transports.CloudRedisTransport( + host="default.example.com:443", + credentials=ga_credentials.AnonymousCredentials(), + client_options=None, + ) + assert transport._client_options is None + assert transport._default_host == "default.example.com:443" + + +@pytest.mark.parametrize( + "initial_endpoint", + [ + pytest.param(None, id="unset_api_endpoint"), + pytest.param("custom.example.com:443", id="explicit_api_endpoint"), + ], +) +def test_cloud_redis_base_transport_client_options_object( + initial_endpoint, +): + options = client_options.ClientOptions(api_endpoint=initial_endpoint) + transport = transports.CloudRedisTransport( + host="default.example.com:443", + credentials=ga_credentials.AnonymousCredentials(), + client_options=options, + ) + assert transport._client_options is options + assert transport._client_options.api_endpoint == initial_endpoint + assert transport._default_host == "default.example.com:443" + + +@pytest.mark.parametrize( + "initial_options,expected_endpoint", + [ + pytest.param( + {}, + None, + id="unset_api_endpoint", + ), + pytest.param( + {"api_endpoint": "custom.example.com:443"}, + "custom.example.com:443", + id="explicit_api_endpoint", + ), + ], +) +def test_cloud_redis_base_transport_client_options_dict( + initial_options, expected_endpoint +): + options = dict(initial_options) + transport = transports.CloudRedisTransport( + host="default.example.com:443", + credentials=ga_credentials.AnonymousCredentials(), + client_options=options, + ) + assert isinstance(transport._client_options, client_options.ClientOptions) + assert transport._client_options.api_endpoint == expected_endpoint + assert transport._default_host == "default.example.com:443" + assert options == initial_options + + +def test_cloud_redis_base_transport_default_host_ignored_when_ignore_credentials(): + class _CustomChannelTransport(transports.CloudRedisTransport): + def __init__(self, **kwargs): + self._ignore_credentials = True + super().__init__(**kwargs) + + options = client_options.ClientOptions() + transport = _CustomChannelTransport( + host="default.example.com:443", + client_options=options, + ) + assert transport._client_options is options + assert transport._client_options.api_endpoint is None + assert transport._default_host is None def test_cloud_redis_base_transport_wrap_method_older_api_core_fallbacks(monkeypatch): @@ -13400,7 +13495,8 @@ def test_cloud_redis_base_transport_wrap_method_older_api_core_fallbacks(monkeyp def test_cloud_redis_base_transport_wrap_async_method_modern_api_core(monkeypatch): monkeypatch.setattr(transports.base, "_ASYNC_WRAP_METHOD_SUPPORTS_TRACING", True) - # 1. Concrete transport: passes client_options=None and kind="grpc_asyncio" + monkeypatch.setattr(transports.base, "_SUPPORTS_DEFAULT_HOST", True) + # 1. Concrete transport: passes client_options=None, default_host, and kind="grpc_asyncio" with mock.patch.object(transports.base.gapic_v1.method_async, "wrap_method") as mock_wrap: transport = transports.CloudRedisGrpcAsyncIOTransport( credentials=ga_credentials.AnonymousCredentials() @@ -13409,8 +13505,10 @@ def test_cloud_redis_base_transport_wrap_async_method_modern_api_core(monkeypatc transport._wrap_async_method(func) assert mock_wrap.call_args.kwargs["kind"] == "grpc_asyncio" assert "client_options" in mock_wrap.call_args.kwargs + assert mock_wrap.call_args.kwargs["default_host"] == transport._default_host - # 2. Base transport: kind raises NotImplementedError and is cleanly omitted + # 2. Base transport with _SUPPORTS_DEFAULT_HOST=False: kind raises NotImplementedError and default_host is omitted + monkeypatch.setattr(transports.base, "_SUPPORTS_DEFAULT_HOST", False) with mock.patch.object(transports.base.gapic_v1.method_async, "wrap_method") as mock_wrap: base_transport = transports.CloudRedisTransport( credentials=ga_credentials.AnonymousCredentials() @@ -13419,6 +13517,7 @@ def test_cloud_redis_base_transport_wrap_async_method_modern_api_core(monkeypatc base_transport._wrap_async_method(func) assert "kind" not in mock_wrap.call_args.kwargs assert "client_options" in mock_wrap.call_args.kwargs + assert "default_host" not in mock_wrap.call_args.kwargs def test_cloud_redis_base_transport_wrap_async_method_older_api_core_fallbacks(monkeypatch): diff --git a/packages/gapic-generator/tests/integration/goldens/redis_selective/google/cloud/redis_v1/_compat.py b/packages/gapic-generator/tests/integration/goldens/redis_selective/google/cloud/redis_v1/_compat.py index a4347392b042..86424516e0a2 100755 --- a/packages/gapic-generator/tests/integration/goldens/redis_selective/google/cloud/redis_v1/_compat.py +++ b/packages/gapic-generator/tests/integration/goldens/redis_selective/google/cloud/redis_v1/_compat.py @@ -90,6 +90,15 @@ def trace_http_request(*args: Any, **kwargs: Any) -> _FallbackTraceContext: # t gapic_v1.method_async, "_DEFAULT_ASYNC_TRANSPORT_KIND" ) +# The `default_host` parameter in gapic_v1.method.wrap_method, +# gapic_v1.method_async.wrap_method, _observability.get_otel_interceptor, and +# _observability.apply_otel_async_channel_interceptors allows transports to pass +# their resolved default host without mutating caller-owned client_options. +SUPPORTS_DEFAULT_HOST = bool( + getattr(gapic_v1.method.wrap_method, "__kwdefaults__", None) + and "default_host" in gapic_v1.method.wrap_method.__kwdefaults__ +) + try: from google.api_core import grpc_helpers except ImportError: # pragma: NO COVER diff --git a/packages/gapic-generator/tests/integration/goldens/redis_selective/google/cloud/redis_v1/services/cloud_redis/transports/base.py b/packages/gapic-generator/tests/integration/goldens/redis_selective/google/cloud/redis_v1/services/cloud_redis/transports/base.py index da37256c19e4..38ceb049065e 100755 --- a/packages/gapic-generator/tests/integration/goldens/redis_selective/google/cloud/redis_v1/services/cloud_redis/transports/base.py +++ b/packages/gapic-generator/tests/integration/goldens/redis_selective/google/cloud/redis_v1/services/cloud_redis/transports/base.py @@ -19,6 +19,7 @@ from google.cloud.redis_v1 import gapic_version as package_version from google.cloud.redis_v1._compat import ( ASYNC_WRAP_METHOD_SUPPORTS_KIND as _ASYNC_WRAP_METHOD_SUPPORTS_KIND, + SUPPORTS_DEFAULT_HOST as _SUPPORTS_DEFAULT_HOST, WRAP_METHOD_SUPPORTS_TRACING as _WRAP_METHOD_SUPPORTS_TRACING, ) _ASYNC_WRAP_METHOD_SUPPORTS_TRACING = _WRAP_METHOD_SUPPORTS_TRACING @@ -133,7 +134,13 @@ def __init__( host += ':443' self._host = host + if isinstance(client_options, dict): + client_options = client_options_lib.from_dict(client_options) + self._client_options = client_options + self._default_host: Optional[str] = ( + None if self._ignore_credentials else self._host + ) self._wrapped_methods: Dict[Callable, Callable] = {} @property @@ -152,6 +159,8 @@ def _wrap_method(self, func, *args, **kwargs): """ if _WRAP_METHOD_SUPPORTS_TRACING: kwargs["client_options"] = self._client_options + if _SUPPORTS_DEFAULT_HOST: + kwargs["default_host"] = self._default_host try: kind = self.kind except NotImplementedError: @@ -164,7 +173,7 @@ def _wrap_method(self, func, *args, **kwargs): # - Era 2 (>= 2.29.0, < 2.36.0): `kind` is supported (prevents REST from # falling back to gRPC wrapping), but tracing args are not. # (These fallbacks can be removed once google-api-core < 2.36.0 is no longer supported.) - for k in ["client_options", "method_name", "is_streaming"]: + for k in ["client_options", "method_name", "is_streaming", "default_host"]: kwargs.pop(k, None) try: @@ -191,6 +200,8 @@ def _wrap_async_method(self, func, *args, **kwargs): """ if _ASYNC_WRAP_METHOD_SUPPORTS_TRACING: kwargs["client_options"] = self._client_options + if _SUPPORTS_DEFAULT_HOST: + kwargs["default_host"] = self._default_host try: kind = self.kind except NotImplementedError: @@ -203,7 +214,7 @@ def _wrap_async_method(self, func, *args, **kwargs): # - Era 2 (>= 2.29.0, < 2.36.0): `kind` is supported (prevents REST from # falling back to gRPC wrapping), but tracing args are not. # (These fallbacks can be removed once google-api-core < 2.36.0 is no longer supported.) - for k in ["client_options", "method_name", "is_streaming"]: + for k in ["client_options", "method_name", "is_streaming", "default_host"]: kwargs.pop(k, None) try: diff --git a/packages/gapic-generator/tests/integration/goldens/redis_selective/google/cloud/redis_v1/services/cloud_redis/transports/grpc.py b/packages/gapic-generator/tests/integration/goldens/redis_selective/google/cloud/redis_v1/services/cloud_redis/transports/grpc.py index 287bcfc447cb..bdbdd5811d2c 100755 --- a/packages/gapic-generator/tests/integration/goldens/redis_selective/google/cloud/redis_v1/services/cloud_redis/transports/grpc.py +++ b/packages/gapic-generator/tests/integration/goldens/redis_selective/google/cloud/redis_v1/services/cloud_redis/transports/grpc.py @@ -20,7 +20,11 @@ from typing import Callable, Dict, Optional, Sequence, Tuple, Union from typing import TYPE_CHECKING -from google.cloud.redis_v1._compat import _observability, apply_channel_interceptors +from google.cloud.redis_v1._compat import ( + _observability, + SUPPORTS_DEFAULT_HOST as _SUPPORTS_DEFAULT_HOST, + apply_channel_interceptors, +) from google.api_core import client_options as client_options_lib from google.api_core import grpc_helpers @@ -297,9 +301,12 @@ def __init__(self, *, ) channel_interceptors = list(interceptors) if interceptors else [] + _otel_interceptor_kwargs = ( + {"default_host": self._default_host} if _SUPPORTS_DEFAULT_HOST else {} + ) if ( _observability is not None - and (otel_interceptor := _observability.get_otel_interceptor(self._client_options)) is not None + and (otel_interceptor := _observability.get_otel_interceptor(self._client_options, **_otel_interceptor_kwargs)) is not None and otel_interceptor not in channel_interceptors and not any(getattr(i, "_is_otel_interceptor", None) is True for i in channel_interceptors) ): diff --git a/packages/gapic-generator/tests/integration/goldens/redis_selective/google/cloud/redis_v1/services/cloud_redis/transports/grpc_asyncio.py b/packages/gapic-generator/tests/integration/goldens/redis_selective/google/cloud/redis_v1/services/cloud_redis/transports/grpc_asyncio.py index 9a64d47d80aa..aeb6e7aca426 100755 --- a/packages/gapic-generator/tests/integration/goldens/redis_selective/google/cloud/redis_v1/services/cloud_redis/transports/grpc_asyncio.py +++ b/packages/gapic-generator/tests/integration/goldens/redis_selective/google/cloud/redis_v1/services/cloud_redis/transports/grpc_asyncio.py @@ -20,7 +20,11 @@ from typing import Awaitable, Callable, Dict, Optional, Sequence, Tuple, Union from typing import TYPE_CHECKING -from google.cloud.redis_v1._compat import _observability, apply_async_channel_interceptors +from google.cloud.redis_v1._compat import ( + _observability, + SUPPORTS_DEFAULT_HOST as _SUPPORTS_DEFAULT_HOST, + apply_async_channel_interceptors, +) from google.api_core import client_options as client_options_lib from google.api_core import gapic_v1 @@ -355,8 +359,11 @@ def __init__(self, *, # NOTE: Coverage tool ignores async interceptors in environments running # legacy google-api-core (< 2.36.0) where OpenTelemetry is unavailable. # TODO (Issue #18657): Remove once lowest constraints require google-api-core >= v2.43.0. + _otel_interceptor_kwargs = ( + {"default_host": self._default_host} if _SUPPORTS_DEFAULT_HOST else {} + ) # pragma: NO COVER self._grpc_channel = _observability.apply_otel_async_channel_interceptors( - self._grpc_channel, self._client_options + self._grpc_channel, self._client_options, **_otel_interceptor_kwargs ) # pragma: NO COVER self._logged_channel = self._grpc_channel diff --git a/packages/gapic-generator/tests/integration/goldens/redis_selective/tests/unit/gapic/redis_v1/test_cloud_redis.py b/packages/gapic-generator/tests/integration/goldens/redis_selective/tests/unit/gapic/redis_v1/test_cloud_redis.py index 7b29386993ee..fe0e495ed367 100755 --- a/packages/gapic-generator/tests/integration/goldens/redis_selective/tests/unit/gapic/redis_v1/test_cloud_redis.py +++ b/packages/gapic-generator/tests/integration/goldens/redis_selective/tests/unit/gapic/redis_v1/test_cloud_redis.py @@ -859,7 +859,14 @@ def test_cloud_redis_grpc_transport_otel_channel_interceptor(): client_options=options, ) - mock_get_interceptor.assert_called_once_with(options) + expected_kwargs = ( + {"default_host": transport._default_host} + if transports.grpc._SUPPORTS_DEFAULT_HOST + else {} + ) + mock_get_interceptor.assert_called_once_with( + transport._client_options, **expected_kwargs + ) mock_apply_interceptors.assert_called_once_with( mock_channel, [mock_otel_interceptor] ) @@ -933,7 +940,14 @@ def test_cloud_redis_grpc_asyncio_transport_otel_channel_interceptor(): client_options=options, ) - mock_apply_async_interceptors.assert_called_once_with(mock_channel, options) + expected_kwargs = ( + {"default_host": transport._default_host} + if transports.grpc_asyncio._SUPPORTS_DEFAULT_HOST + else {} + ) + mock_apply_async_interceptors.assert_called_once_with( + mock_channel, transport._client_options, **expected_kwargs + ) assert mock_create_channel.call_count == 1 assert transport.grpc_channel == mock_channel @@ -8523,7 +8537,8 @@ def test_api_key_credentials(client_class, transport_class): def test_cloud_redis_base_transport_wrap_method_modern_api_core(monkeypatch): monkeypatch.setattr(transports.base, "_WRAP_METHOD_SUPPORTS_TRACING", True) - # 1. Concrete transport: passes client_options=None and kind="grpc" + monkeypatch.setattr(transports.base, "_SUPPORTS_DEFAULT_HOST", True) + # 1. Concrete transport: passes client_options=None, default_host, and kind="grpc" with mock.patch.object(transports.base.gapic_v1.method, "wrap_method") as mock_wrap: transport = transports.CloudRedisGrpcTransport( credentials=ga_credentials.AnonymousCredentials() @@ -8532,8 +8547,10 @@ def test_cloud_redis_base_transport_wrap_method_modern_api_core(monkeypatch): transport._wrap_method(func) assert mock_wrap.call_args.kwargs["kind"] == "grpc" assert "client_options" in mock_wrap.call_args.kwargs + assert mock_wrap.call_args.kwargs["default_host"] == transport._default_host - # 2. Base transport: kind raises NotImplementedError and is cleanly omitted + # 2. Base transport with _SUPPORTS_DEFAULT_HOST=False: kind raises NotImplementedError and default_host is omitted + monkeypatch.setattr(transports.base, "_SUPPORTS_DEFAULT_HOST", False) with mock.patch.object(transports.base.gapic_v1.method, "wrap_method") as mock_wrap: base_transport = transports.CloudRedisTransport( credentials=ga_credentials.AnonymousCredentials() @@ -8542,6 +8559,84 @@ def test_cloud_redis_base_transport_wrap_method_modern_api_core(monkeypatch): base_transport._wrap_method(func) assert "kind" not in mock_wrap.call_args.kwargs assert "client_options" in mock_wrap.call_args.kwargs + assert "default_host" not in mock_wrap.call_args.kwargs + + +def test_cloud_redis_base_transport_client_options_none(): + transport = transports.CloudRedisTransport( + host="default.example.com:443", + credentials=ga_credentials.AnonymousCredentials(), + client_options=None, + ) + assert transport._client_options is None + assert transport._default_host == "default.example.com:443" + + +@pytest.mark.parametrize( + "initial_endpoint", + [ + pytest.param(None, id="unset_api_endpoint"), + pytest.param("custom.example.com:443", id="explicit_api_endpoint"), + ], +) +def test_cloud_redis_base_transport_client_options_object( + initial_endpoint, +): + options = client_options.ClientOptions(api_endpoint=initial_endpoint) + transport = transports.CloudRedisTransport( + host="default.example.com:443", + credentials=ga_credentials.AnonymousCredentials(), + client_options=options, + ) + assert transport._client_options is options + assert transport._client_options.api_endpoint == initial_endpoint + assert transport._default_host == "default.example.com:443" + + +@pytest.mark.parametrize( + "initial_options,expected_endpoint", + [ + pytest.param( + {}, + None, + id="unset_api_endpoint", + ), + pytest.param( + {"api_endpoint": "custom.example.com:443"}, + "custom.example.com:443", + id="explicit_api_endpoint", + ), + ], +) +def test_cloud_redis_base_transport_client_options_dict( + initial_options, expected_endpoint +): + options = dict(initial_options) + transport = transports.CloudRedisTransport( + host="default.example.com:443", + credentials=ga_credentials.AnonymousCredentials(), + client_options=options, + ) + assert isinstance(transport._client_options, client_options.ClientOptions) + assert transport._client_options.api_endpoint == expected_endpoint + assert transport._default_host == "default.example.com:443" + assert options == initial_options + + +def test_cloud_redis_base_transport_default_host_ignored_when_ignore_credentials(): + class _CustomChannelTransport(transports.CloudRedisTransport): + def __init__(self, **kwargs): + self._ignore_credentials = True + super().__init__(**kwargs) + + options = client_options.ClientOptions() + transport = _CustomChannelTransport( + host="default.example.com:443", + client_options=options, + ) + assert transport._client_options is options + assert transport._client_options.api_endpoint is None + assert transport._default_host is None def test_cloud_redis_base_transport_wrap_method_older_api_core_fallbacks(monkeypatch): @@ -8620,7 +8715,8 @@ def test_cloud_redis_base_transport_wrap_method_older_api_core_fallbacks(monkeyp def test_cloud_redis_base_transport_wrap_async_method_modern_api_core(monkeypatch): monkeypatch.setattr(transports.base, "_ASYNC_WRAP_METHOD_SUPPORTS_TRACING", True) - # 1. Concrete transport: passes client_options=None and kind="grpc_asyncio" + monkeypatch.setattr(transports.base, "_SUPPORTS_DEFAULT_HOST", True) + # 1. Concrete transport: passes client_options=None, default_host, and kind="grpc_asyncio" with mock.patch.object(transports.base.gapic_v1.method_async, "wrap_method") as mock_wrap: transport = transports.CloudRedisGrpcAsyncIOTransport( credentials=ga_credentials.AnonymousCredentials() @@ -8629,8 +8725,10 @@ def test_cloud_redis_base_transport_wrap_async_method_modern_api_core(monkeypatc transport._wrap_async_method(func) assert mock_wrap.call_args.kwargs["kind"] == "grpc_asyncio" assert "client_options" in mock_wrap.call_args.kwargs + assert mock_wrap.call_args.kwargs["default_host"] == transport._default_host - # 2. Base transport: kind raises NotImplementedError and is cleanly omitted + # 2. Base transport with _SUPPORTS_DEFAULT_HOST=False: kind raises NotImplementedError and default_host is omitted + monkeypatch.setattr(transports.base, "_SUPPORTS_DEFAULT_HOST", False) with mock.patch.object(transports.base.gapic_v1.method_async, "wrap_method") as mock_wrap: base_transport = transports.CloudRedisTransport( credentials=ga_credentials.AnonymousCredentials() @@ -8639,6 +8737,7 @@ def test_cloud_redis_base_transport_wrap_async_method_modern_api_core(monkeypatc base_transport._wrap_async_method(func) assert "kind" not in mock_wrap.call_args.kwargs assert "client_options" in mock_wrap.call_args.kwargs + assert "default_host" not in mock_wrap.call_args.kwargs def test_cloud_redis_base_transport_wrap_async_method_older_api_core_fallbacks(monkeypatch): diff --git a/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/_compat.py b/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/_compat.py index 31817dab22a1..893b510545de 100755 --- a/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/_compat.py +++ b/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/_compat.py @@ -95,6 +95,15 @@ def trace_http_request(*args: Any, **kwargs: Any) -> _FallbackTraceContext: # t gapic_v1.method_async, "_DEFAULT_ASYNC_TRANSPORT_KIND" ) +# The `default_host` parameter in gapic_v1.method.wrap_method, +# gapic_v1.method_async.wrap_method, _observability.get_otel_interceptor, and +# _observability.apply_otel_async_channel_interceptors allows transports to pass +# their resolved default host without mutating caller-owned client_options. +SUPPORTS_DEFAULT_HOST = bool( + getattr(gapic_v1.method.wrap_method, "__kwdefaults__", None) + and "default_host" in gapic_v1.method.wrap_method.__kwdefaults__ +) + try: from google.api_core import grpc_helpers except ImportError: # pragma: NO COVER diff --git a/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/echo/transports/base.py b/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/echo/transports/base.py index f5e34a9f9aa7..c260977381bf 100755 --- a/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/echo/transports/base.py +++ b/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/echo/transports/base.py @@ -19,6 +19,7 @@ from google.showcase_v1beta1 import gapic_version as package_version from google.showcase_v1beta1._compat import ( ASYNC_WRAP_METHOD_SUPPORTS_KIND as _ASYNC_WRAP_METHOD_SUPPORTS_KIND, + SUPPORTS_DEFAULT_HOST as _SUPPORTS_DEFAULT_HOST, WRAP_METHOD_SUPPORTS_TRACING as _WRAP_METHOD_SUPPORTS_TRACING, ) _ASYNC_WRAP_METHOD_SUPPORTS_TRACING = _WRAP_METHOD_SUPPORTS_TRACING @@ -134,7 +135,13 @@ def __init__( host += ':443' self._host = host + if isinstance(client_options, dict): + client_options = client_options_lib.from_dict(client_options) + self._client_options = client_options + self._default_host: Optional[str] = ( + None if self._ignore_credentials else self._host + ) self._wrapped_methods: Dict[Callable, Callable] = {} @property @@ -153,6 +160,8 @@ def _wrap_method(self, func, *args, **kwargs): """ if _WRAP_METHOD_SUPPORTS_TRACING: kwargs["client_options"] = self._client_options + if _SUPPORTS_DEFAULT_HOST: + kwargs["default_host"] = self._default_host try: kind = self.kind except NotImplementedError: @@ -165,7 +174,7 @@ def _wrap_method(self, func, *args, **kwargs): # - Era 2 (>= 2.29.0, < 2.36.0): `kind` is supported (prevents REST from # falling back to gRPC wrapping), but tracing args are not. # (These fallbacks can be removed once google-api-core < 2.36.0 is no longer supported.) - for k in ["client_options", "method_name", "is_streaming"]: + for k in ["client_options", "method_name", "is_streaming", "default_host"]: kwargs.pop(k, None) try: @@ -192,6 +201,8 @@ def _wrap_async_method(self, func, *args, **kwargs): """ if _ASYNC_WRAP_METHOD_SUPPORTS_TRACING: kwargs["client_options"] = self._client_options + if _SUPPORTS_DEFAULT_HOST: + kwargs["default_host"] = self._default_host try: kind = self.kind except NotImplementedError: @@ -204,7 +215,7 @@ def _wrap_async_method(self, func, *args, **kwargs): # - Era 2 (>= 2.29.0, < 2.36.0): `kind` is supported (prevents REST from # falling back to gRPC wrapping), but tracing args are not. # (These fallbacks can be removed once google-api-core < 2.36.0 is no longer supported.) - for k in ["client_options", "method_name", "is_streaming"]: + for k in ["client_options", "method_name", "is_streaming", "default_host"]: kwargs.pop(k, None) try: diff --git a/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/echo/transports/grpc.py b/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/echo/transports/grpc.py index 4d5fc8e2ebcd..e9f25c269d79 100755 --- a/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/echo/transports/grpc.py +++ b/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/echo/transports/grpc.py @@ -20,7 +20,11 @@ from typing import Callable, Dict, Optional, Sequence, Tuple, Union from typing import TYPE_CHECKING -from google.showcase_v1beta1._compat import _observability, apply_channel_interceptors +from google.showcase_v1beta1._compat import ( + _observability, + SUPPORTS_DEFAULT_HOST as _SUPPORTS_DEFAULT_HOST, + apply_channel_interceptors, +) from google.api_core import client_options as client_options_lib from google.api_core import grpc_helpers @@ -286,9 +290,12 @@ def __init__(self, *, ) channel_interceptors = list(interceptors) if interceptors else [] + _otel_interceptor_kwargs = ( + {"default_host": self._default_host} if _SUPPORTS_DEFAULT_HOST else {} + ) if ( _observability is not None - and (otel_interceptor := _observability.get_otel_interceptor(self._client_options)) is not None + and (otel_interceptor := _observability.get_otel_interceptor(self._client_options, **_otel_interceptor_kwargs)) is not None and otel_interceptor not in channel_interceptors and not any(getattr(i, "_is_otel_interceptor", None) is True for i in channel_interceptors) ): diff --git a/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/echo/transports/grpc_asyncio.py b/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/echo/transports/grpc_asyncio.py index c2dd99d6b774..fbe193800b6a 100755 --- a/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/echo/transports/grpc_asyncio.py +++ b/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/echo/transports/grpc_asyncio.py @@ -20,7 +20,11 @@ from typing import Awaitable, Callable, Dict, Optional, Sequence, Tuple, Union from typing import TYPE_CHECKING -from google.showcase_v1beta1._compat import _observability, apply_async_channel_interceptors +from google.showcase_v1beta1._compat import ( + _observability, + SUPPORTS_DEFAULT_HOST as _SUPPORTS_DEFAULT_HOST, + apply_async_channel_interceptors, +) from google.api_core import client_options as client_options_lib from google.api_core import gapic_v1 @@ -344,8 +348,11 @@ def __init__(self, *, # NOTE: Coverage tool ignores async interceptors in environments running # legacy google-api-core (< 2.36.0) where OpenTelemetry is unavailable. # TODO (Issue #18657): Remove once lowest constraints require google-api-core >= v2.43.0. + _otel_interceptor_kwargs = ( + {"default_host": self._default_host} if _SUPPORTS_DEFAULT_HOST else {} + ) # pragma: NO COVER self._grpc_channel = _observability.apply_otel_async_channel_interceptors( - self._grpc_channel, self._client_options + self._grpc_channel, self._client_options, **_otel_interceptor_kwargs ) # pragma: NO COVER self._logged_channel = self._grpc_channel diff --git a/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/identity/transports/base.py b/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/identity/transports/base.py index 8f44d529f6dd..909403ec5787 100755 --- a/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/identity/transports/base.py +++ b/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/identity/transports/base.py @@ -19,6 +19,7 @@ from google.showcase_v1beta1 import gapic_version as package_version from google.showcase_v1beta1._compat import ( ASYNC_WRAP_METHOD_SUPPORTS_KIND as _ASYNC_WRAP_METHOD_SUPPORTS_KIND, + SUPPORTS_DEFAULT_HOST as _SUPPORTS_DEFAULT_HOST, WRAP_METHOD_SUPPORTS_TRACING as _WRAP_METHOD_SUPPORTS_TRACING, ) _ASYNC_WRAP_METHOD_SUPPORTS_TRACING = _WRAP_METHOD_SUPPORTS_TRACING @@ -134,7 +135,13 @@ def __init__( host += ':443' self._host = host + if isinstance(client_options, dict): + client_options = client_options_lib.from_dict(client_options) + self._client_options = client_options + self._default_host: Optional[str] = ( + None if self._ignore_credentials else self._host + ) self._wrapped_methods: Dict[Callable, Callable] = {} @property @@ -153,6 +160,8 @@ def _wrap_method(self, func, *args, **kwargs): """ if _WRAP_METHOD_SUPPORTS_TRACING: kwargs["client_options"] = self._client_options + if _SUPPORTS_DEFAULT_HOST: + kwargs["default_host"] = self._default_host try: kind = self.kind except NotImplementedError: @@ -165,7 +174,7 @@ def _wrap_method(self, func, *args, **kwargs): # - Era 2 (>= 2.29.0, < 2.36.0): `kind` is supported (prevents REST from # falling back to gRPC wrapping), but tracing args are not. # (These fallbacks can be removed once google-api-core < 2.36.0 is no longer supported.) - for k in ["client_options", "method_name", "is_streaming"]: + for k in ["client_options", "method_name", "is_streaming", "default_host"]: kwargs.pop(k, None) try: @@ -192,6 +201,8 @@ def _wrap_async_method(self, func, *args, **kwargs): """ if _ASYNC_WRAP_METHOD_SUPPORTS_TRACING: kwargs["client_options"] = self._client_options + if _SUPPORTS_DEFAULT_HOST: + kwargs["default_host"] = self._default_host try: kind = self.kind except NotImplementedError: @@ -204,7 +215,7 @@ def _wrap_async_method(self, func, *args, **kwargs): # - Era 2 (>= 2.29.0, < 2.36.0): `kind` is supported (prevents REST from # falling back to gRPC wrapping), but tracing args are not. # (These fallbacks can be removed once google-api-core < 2.36.0 is no longer supported.) - for k in ["client_options", "method_name", "is_streaming"]: + for k in ["client_options", "method_name", "is_streaming", "default_host"]: kwargs.pop(k, None) try: diff --git a/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/identity/transports/grpc.py b/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/identity/transports/grpc.py index 3a72ba37662e..038f440f28ac 100755 --- a/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/identity/transports/grpc.py +++ b/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/identity/transports/grpc.py @@ -20,7 +20,11 @@ from typing import Callable, Dict, Optional, Sequence, Tuple, Union from typing import TYPE_CHECKING -from google.showcase_v1beta1._compat import _observability, apply_channel_interceptors +from google.showcase_v1beta1._compat import ( + _observability, + SUPPORTS_DEFAULT_HOST as _SUPPORTS_DEFAULT_HOST, + apply_channel_interceptors, +) from google.api_core import client_options as client_options_lib from google.api_core import grpc_helpers @@ -278,9 +282,12 @@ def __init__(self, *, ) channel_interceptors = list(interceptors) if interceptors else [] + _otel_interceptor_kwargs = ( + {"default_host": self._default_host} if _SUPPORTS_DEFAULT_HOST else {} + ) if ( _observability is not None - and (otel_interceptor := _observability.get_otel_interceptor(self._client_options)) is not None + and (otel_interceptor := _observability.get_otel_interceptor(self._client_options, **_otel_interceptor_kwargs)) is not None and otel_interceptor not in channel_interceptors and not any(getattr(i, "_is_otel_interceptor", None) is True for i in channel_interceptors) ): diff --git a/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/identity/transports/grpc_asyncio.py b/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/identity/transports/grpc_asyncio.py index 90527f137ad8..bbc3afe07288 100755 --- a/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/identity/transports/grpc_asyncio.py +++ b/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/identity/transports/grpc_asyncio.py @@ -20,7 +20,11 @@ from typing import Awaitable, Callable, Dict, Optional, Sequence, Tuple, Union from typing import TYPE_CHECKING -from google.showcase_v1beta1._compat import _observability, apply_async_channel_interceptors +from google.showcase_v1beta1._compat import ( + _observability, + SUPPORTS_DEFAULT_HOST as _SUPPORTS_DEFAULT_HOST, + apply_async_channel_interceptors, +) from google.api_core import client_options as client_options_lib from google.api_core import gapic_v1 @@ -336,8 +340,11 @@ def __init__(self, *, # NOTE: Coverage tool ignores async interceptors in environments running # legacy google-api-core (< 2.36.0) where OpenTelemetry is unavailable. # TODO (Issue #18657): Remove once lowest constraints require google-api-core >= v2.43.0. + _otel_interceptor_kwargs = ( + {"default_host": self._default_host} if _SUPPORTS_DEFAULT_HOST else {} + ) # pragma: NO COVER self._grpc_channel = _observability.apply_otel_async_channel_interceptors( - self._grpc_channel, self._client_options + self._grpc_channel, self._client_options, **_otel_interceptor_kwargs ) # pragma: NO COVER self._logged_channel = self._grpc_channel diff --git a/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/messaging/transports/base.py b/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/messaging/transports/base.py index c783a24ea331..7e470bc96a64 100755 --- a/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/messaging/transports/base.py +++ b/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/messaging/transports/base.py @@ -19,6 +19,7 @@ from google.showcase_v1beta1 import gapic_version as package_version from google.showcase_v1beta1._compat import ( ASYNC_WRAP_METHOD_SUPPORTS_KIND as _ASYNC_WRAP_METHOD_SUPPORTS_KIND, + SUPPORTS_DEFAULT_HOST as _SUPPORTS_DEFAULT_HOST, WRAP_METHOD_SUPPORTS_TRACING as _WRAP_METHOD_SUPPORTS_TRACING, ) _ASYNC_WRAP_METHOD_SUPPORTS_TRACING = _WRAP_METHOD_SUPPORTS_TRACING @@ -135,7 +136,13 @@ def __init__( host += ':443' self._host = host + if isinstance(client_options, dict): + client_options = client_options_lib.from_dict(client_options) + self._client_options = client_options + self._default_host: Optional[str] = ( + None if self._ignore_credentials else self._host + ) self._wrapped_methods: Dict[Callable, Callable] = {} @property @@ -154,6 +161,8 @@ def _wrap_method(self, func, *args, **kwargs): """ if _WRAP_METHOD_SUPPORTS_TRACING: kwargs["client_options"] = self._client_options + if _SUPPORTS_DEFAULT_HOST: + kwargs["default_host"] = self._default_host try: kind = self.kind except NotImplementedError: @@ -166,7 +175,7 @@ def _wrap_method(self, func, *args, **kwargs): # - Era 2 (>= 2.29.0, < 2.36.0): `kind` is supported (prevents REST from # falling back to gRPC wrapping), but tracing args are not. # (These fallbacks can be removed once google-api-core < 2.36.0 is no longer supported.) - for k in ["client_options", "method_name", "is_streaming"]: + for k in ["client_options", "method_name", "is_streaming", "default_host"]: kwargs.pop(k, None) try: @@ -193,6 +202,8 @@ def _wrap_async_method(self, func, *args, **kwargs): """ if _ASYNC_WRAP_METHOD_SUPPORTS_TRACING: kwargs["client_options"] = self._client_options + if _SUPPORTS_DEFAULT_HOST: + kwargs["default_host"] = self._default_host try: kind = self.kind except NotImplementedError: @@ -205,7 +216,7 @@ def _wrap_async_method(self, func, *args, **kwargs): # - Era 2 (>= 2.29.0, < 2.36.0): `kind` is supported (prevents REST from # falling back to gRPC wrapping), but tracing args are not. # (These fallbacks can be removed once google-api-core < 2.36.0 is no longer supported.) - for k in ["client_options", "method_name", "is_streaming"]: + for k in ["client_options", "method_name", "is_streaming", "default_host"]: kwargs.pop(k, None) try: diff --git a/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/messaging/transports/grpc.py b/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/messaging/transports/grpc.py index 4da466085716..507ea21e57c7 100755 --- a/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/messaging/transports/grpc.py +++ b/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/messaging/transports/grpc.py @@ -20,7 +20,11 @@ from typing import Callable, Dict, Optional, Sequence, Tuple, Union from typing import TYPE_CHECKING -from google.showcase_v1beta1._compat import _observability, apply_channel_interceptors +from google.showcase_v1beta1._compat import ( + _observability, + SUPPORTS_DEFAULT_HOST as _SUPPORTS_DEFAULT_HOST, + apply_channel_interceptors, +) from google.api_core import client_options as client_options_lib from google.api_core import grpc_helpers @@ -283,9 +287,12 @@ def __init__(self, *, ) channel_interceptors = list(interceptors) if interceptors else [] + _otel_interceptor_kwargs = ( + {"default_host": self._default_host} if _SUPPORTS_DEFAULT_HOST else {} + ) if ( _observability is not None - and (otel_interceptor := _observability.get_otel_interceptor(self._client_options)) is not None + and (otel_interceptor := _observability.get_otel_interceptor(self._client_options, **_otel_interceptor_kwargs)) is not None and otel_interceptor not in channel_interceptors and not any(getattr(i, "_is_otel_interceptor", None) is True for i in channel_interceptors) ): diff --git a/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/messaging/transports/grpc_asyncio.py b/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/messaging/transports/grpc_asyncio.py index 318dff105c1a..fa1acfb38691 100755 --- a/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/messaging/transports/grpc_asyncio.py +++ b/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/messaging/transports/grpc_asyncio.py @@ -20,7 +20,11 @@ from typing import Awaitable, Callable, Dict, Optional, Sequence, Tuple, Union from typing import TYPE_CHECKING -from google.showcase_v1beta1._compat import _observability, apply_async_channel_interceptors +from google.showcase_v1beta1._compat import ( + _observability, + SUPPORTS_DEFAULT_HOST as _SUPPORTS_DEFAULT_HOST, + apply_async_channel_interceptors, +) from google.api_core import client_options as client_options_lib from google.api_core import gapic_v1 @@ -341,8 +345,11 @@ def __init__(self, *, # NOTE: Coverage tool ignores async interceptors in environments running # legacy google-api-core (< 2.36.0) where OpenTelemetry is unavailable. # TODO (Issue #18657): Remove once lowest constraints require google-api-core >= v2.43.0. + _otel_interceptor_kwargs = ( + {"default_host": self._default_host} if _SUPPORTS_DEFAULT_HOST else {} + ) # pragma: NO COVER self._grpc_channel = _observability.apply_otel_async_channel_interceptors( - self._grpc_channel, self._client_options + self._grpc_channel, self._client_options, **_otel_interceptor_kwargs ) # pragma: NO COVER self._logged_channel = self._grpc_channel diff --git a/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/resumable_upload_service/transports/base.py b/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/resumable_upload_service/transports/base.py index b9ca386076b2..6fe695e0f2b7 100755 --- a/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/resumable_upload_service/transports/base.py +++ b/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/resumable_upload_service/transports/base.py @@ -19,6 +19,7 @@ from google.showcase_v1beta1 import gapic_version as package_version from google.showcase_v1beta1._compat import ( ASYNC_WRAP_METHOD_SUPPORTS_KIND as _ASYNC_WRAP_METHOD_SUPPORTS_KIND, + SUPPORTS_DEFAULT_HOST as _SUPPORTS_DEFAULT_HOST, WRAP_METHOD_SUPPORTS_TRACING as _WRAP_METHOD_SUPPORTS_TRACING, ) _ASYNC_WRAP_METHOD_SUPPORTS_TRACING = _WRAP_METHOD_SUPPORTS_TRACING @@ -135,7 +136,13 @@ def __init__( host += ':443' self._host = host + if isinstance(client_options, dict): + client_options = client_options_lib.from_dict(client_options) + self._client_options = client_options + self._default_host: Optional[str] = ( + None if self._ignore_credentials else self._host + ) self._wrapped_methods: Dict[Callable, Callable] = {} @property @@ -154,6 +161,8 @@ def _wrap_method(self, func, *args, **kwargs): """ if _WRAP_METHOD_SUPPORTS_TRACING: kwargs["client_options"] = self._client_options + if _SUPPORTS_DEFAULT_HOST: + kwargs["default_host"] = self._default_host try: kind = self.kind except NotImplementedError: @@ -166,7 +175,7 @@ def _wrap_method(self, func, *args, **kwargs): # - Era 2 (>= 2.29.0, < 2.36.0): `kind` is supported (prevents REST from # falling back to gRPC wrapping), but tracing args are not. # (These fallbacks can be removed once google-api-core < 2.36.0 is no longer supported.) - for k in ["client_options", "method_name", "is_streaming"]: + for k in ["client_options", "method_name", "is_streaming", "default_host"]: kwargs.pop(k, None) try: @@ -193,6 +202,8 @@ def _wrap_async_method(self, func, *args, **kwargs): """ if _ASYNC_WRAP_METHOD_SUPPORTS_TRACING: kwargs["client_options"] = self._client_options + if _SUPPORTS_DEFAULT_HOST: + kwargs["default_host"] = self._default_host try: kind = self.kind except NotImplementedError: @@ -205,7 +216,7 @@ def _wrap_async_method(self, func, *args, **kwargs): # - Era 2 (>= 2.29.0, < 2.36.0): `kind` is supported (prevents REST from # falling back to gRPC wrapping), but tracing args are not. # (These fallbacks can be removed once google-api-core < 2.36.0 is no longer supported.) - for k in ["client_options", "method_name", "is_streaming"]: + for k in ["client_options", "method_name", "is_streaming", "default_host"]: kwargs.pop(k, None) try: diff --git a/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/resumable_upload_service/transports/grpc.py b/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/resumable_upload_service/transports/grpc.py index 66a696e695f0..bb49692b3852 100755 --- a/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/resumable_upload_service/transports/grpc.py +++ b/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/resumable_upload_service/transports/grpc.py @@ -20,7 +20,11 @@ from typing import Callable, Dict, Optional, Sequence, Tuple, Union from typing import TYPE_CHECKING -from google.showcase_v1beta1._compat import _observability, apply_channel_interceptors +from google.showcase_v1beta1._compat import ( + _observability, + SUPPORTS_DEFAULT_HOST as _SUPPORTS_DEFAULT_HOST, + apply_channel_interceptors, +) from google.api_core import client_options as client_options_lib from google.api_core import exceptions as core_exceptions @@ -288,9 +292,12 @@ def __init__(self, *, ) channel_interceptors = list(interceptors) if interceptors else [] + _otel_interceptor_kwargs = ( + {"default_host": self._default_host} if _SUPPORTS_DEFAULT_HOST else {} + ) if ( _observability is not None - and (otel_interceptor := _observability.get_otel_interceptor(self._client_options)) is not None + and (otel_interceptor := _observability.get_otel_interceptor(self._client_options, **_otel_interceptor_kwargs)) is not None and otel_interceptor not in channel_interceptors and not any(getattr(i, "_is_otel_interceptor", None) is True for i in channel_interceptors) ): diff --git a/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/resumable_upload_service/transports/grpc_asyncio.py b/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/resumable_upload_service/transports/grpc_asyncio.py index 48a2dc9140a1..14233a480f93 100755 --- a/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/resumable_upload_service/transports/grpc_asyncio.py +++ b/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/resumable_upload_service/transports/grpc_asyncio.py @@ -20,7 +20,11 @@ from typing import Any, Awaitable, Callable, Dict, Optional, Sequence, Tuple, Union from typing import TYPE_CHECKING -from google.showcase_v1beta1._compat import _observability, apply_async_channel_interceptors +from google.showcase_v1beta1._compat import ( + _observability, + SUPPORTS_DEFAULT_HOST as _SUPPORTS_DEFAULT_HOST, + apply_async_channel_interceptors, +) from google.api_core import client_options as client_options_lib from google.api_core import gapic_v1 @@ -349,8 +353,11 @@ def __init__(self, *, # NOTE: Coverage tool ignores async interceptors in environments running # legacy google-api-core (< 2.36.0) where OpenTelemetry is unavailable. # TODO (Issue #18657): Remove once lowest constraints require google-api-core >= v2.43.0. + _otel_interceptor_kwargs = ( + {"default_host": self._default_host} if _SUPPORTS_DEFAULT_HOST else {} + ) # pragma: NO COVER self._grpc_channel = _observability.apply_otel_async_channel_interceptors( - self._grpc_channel, self._client_options + self._grpc_channel, self._client_options, **_otel_interceptor_kwargs ) # pragma: NO COVER self._logged_channel = self._grpc_channel diff --git a/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/sequence_service/transports/base.py b/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/sequence_service/transports/base.py index f6b79c09fa5f..048e5f3fd19f 100755 --- a/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/sequence_service/transports/base.py +++ b/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/sequence_service/transports/base.py @@ -19,6 +19,7 @@ from google.showcase_v1beta1 import gapic_version as package_version from google.showcase_v1beta1._compat import ( ASYNC_WRAP_METHOD_SUPPORTS_KIND as _ASYNC_WRAP_METHOD_SUPPORTS_KIND, + SUPPORTS_DEFAULT_HOST as _SUPPORTS_DEFAULT_HOST, WRAP_METHOD_SUPPORTS_TRACING as _WRAP_METHOD_SUPPORTS_TRACING, ) _ASYNC_WRAP_METHOD_SUPPORTS_TRACING = _WRAP_METHOD_SUPPORTS_TRACING @@ -135,7 +136,13 @@ def __init__( host += ':443' self._host = host + if isinstance(client_options, dict): + client_options = client_options_lib.from_dict(client_options) + self._client_options = client_options + self._default_host: Optional[str] = ( + None if self._ignore_credentials else self._host + ) self._wrapped_methods: Dict[Callable, Callable] = {} @property @@ -154,6 +161,8 @@ def _wrap_method(self, func, *args, **kwargs): """ if _WRAP_METHOD_SUPPORTS_TRACING: kwargs["client_options"] = self._client_options + if _SUPPORTS_DEFAULT_HOST: + kwargs["default_host"] = self._default_host try: kind = self.kind except NotImplementedError: @@ -166,7 +175,7 @@ def _wrap_method(self, func, *args, **kwargs): # - Era 2 (>= 2.29.0, < 2.36.0): `kind` is supported (prevents REST from # falling back to gRPC wrapping), but tracing args are not. # (These fallbacks can be removed once google-api-core < 2.36.0 is no longer supported.) - for k in ["client_options", "method_name", "is_streaming"]: + for k in ["client_options", "method_name", "is_streaming", "default_host"]: kwargs.pop(k, None) try: @@ -193,6 +202,8 @@ def _wrap_async_method(self, func, *args, **kwargs): """ if _ASYNC_WRAP_METHOD_SUPPORTS_TRACING: kwargs["client_options"] = self._client_options + if _SUPPORTS_DEFAULT_HOST: + kwargs["default_host"] = self._default_host try: kind = self.kind except NotImplementedError: @@ -205,7 +216,7 @@ def _wrap_async_method(self, func, *args, **kwargs): # - Era 2 (>= 2.29.0, < 2.36.0): `kind` is supported (prevents REST from # falling back to gRPC wrapping), but tracing args are not. # (These fallbacks can be removed once google-api-core < 2.36.0 is no longer supported.) - for k in ["client_options", "method_name", "is_streaming"]: + for k in ["client_options", "method_name", "is_streaming", "default_host"]: kwargs.pop(k, None) try: diff --git a/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/sequence_service/transports/grpc.py b/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/sequence_service/transports/grpc.py index c26a4d8ec34c..d0b4bfcdc950 100755 --- a/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/sequence_service/transports/grpc.py +++ b/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/sequence_service/transports/grpc.py @@ -20,7 +20,11 @@ from typing import Callable, Dict, Optional, Sequence, Tuple, Union from typing import TYPE_CHECKING -from google.showcase_v1beta1._compat import _observability, apply_channel_interceptors +from google.showcase_v1beta1._compat import ( + _observability, + SUPPORTS_DEFAULT_HOST as _SUPPORTS_DEFAULT_HOST, + apply_channel_interceptors, +) from google.api_core import client_options as client_options_lib from google.api_core import grpc_helpers @@ -281,9 +285,12 @@ def __init__(self, *, ) channel_interceptors = list(interceptors) if interceptors else [] + _otel_interceptor_kwargs = ( + {"default_host": self._default_host} if _SUPPORTS_DEFAULT_HOST else {} + ) if ( _observability is not None - and (otel_interceptor := _observability.get_otel_interceptor(self._client_options)) is not None + and (otel_interceptor := _observability.get_otel_interceptor(self._client_options, **_otel_interceptor_kwargs)) is not None and otel_interceptor not in channel_interceptors and not any(getattr(i, "_is_otel_interceptor", None) is True for i in channel_interceptors) ): diff --git a/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/sequence_service/transports/grpc_asyncio.py b/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/sequence_service/transports/grpc_asyncio.py index 033643cbf9bd..37cd7e01265b 100755 --- a/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/sequence_service/transports/grpc_asyncio.py +++ b/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/sequence_service/transports/grpc_asyncio.py @@ -20,7 +20,11 @@ from typing import Awaitable, Callable, Dict, Optional, Sequence, Tuple, Union from typing import TYPE_CHECKING -from google.showcase_v1beta1._compat import _observability, apply_async_channel_interceptors +from google.showcase_v1beta1._compat import ( + _observability, + SUPPORTS_DEFAULT_HOST as _SUPPORTS_DEFAULT_HOST, + apply_async_channel_interceptors, +) from google.api_core import client_options as client_options_lib from google.api_core import gapic_v1 @@ -339,8 +343,11 @@ def __init__(self, *, # NOTE: Coverage tool ignores async interceptors in environments running # legacy google-api-core (< 2.36.0) where OpenTelemetry is unavailable. # TODO (Issue #18657): Remove once lowest constraints require google-api-core >= v2.43.0. + _otel_interceptor_kwargs = ( + {"default_host": self._default_host} if _SUPPORTS_DEFAULT_HOST else {} + ) # pragma: NO COVER self._grpc_channel = _observability.apply_otel_async_channel_interceptors( - self._grpc_channel, self._client_options + self._grpc_channel, self._client_options, **_otel_interceptor_kwargs ) # pragma: NO COVER self._logged_channel = self._grpc_channel diff --git a/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/testing/transports/base.py b/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/testing/transports/base.py index f0fc08e3ef55..46122c44f84a 100755 --- a/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/testing/transports/base.py +++ b/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/testing/transports/base.py @@ -19,6 +19,7 @@ from google.showcase_v1beta1 import gapic_version as package_version from google.showcase_v1beta1._compat import ( ASYNC_WRAP_METHOD_SUPPORTS_KIND as _ASYNC_WRAP_METHOD_SUPPORTS_KIND, + SUPPORTS_DEFAULT_HOST as _SUPPORTS_DEFAULT_HOST, WRAP_METHOD_SUPPORTS_TRACING as _WRAP_METHOD_SUPPORTS_TRACING, ) _ASYNC_WRAP_METHOD_SUPPORTS_TRACING = _WRAP_METHOD_SUPPORTS_TRACING @@ -134,7 +135,13 @@ def __init__( host += ':443' self._host = host + if isinstance(client_options, dict): + client_options = client_options_lib.from_dict(client_options) + self._client_options = client_options + self._default_host: Optional[str] = ( + None if self._ignore_credentials else self._host + ) self._wrapped_methods: Dict[Callable, Callable] = {} @property @@ -153,6 +160,8 @@ def _wrap_method(self, func, *args, **kwargs): """ if _WRAP_METHOD_SUPPORTS_TRACING: kwargs["client_options"] = self._client_options + if _SUPPORTS_DEFAULT_HOST: + kwargs["default_host"] = self._default_host try: kind = self.kind except NotImplementedError: @@ -165,7 +174,7 @@ def _wrap_method(self, func, *args, **kwargs): # - Era 2 (>= 2.29.0, < 2.36.0): `kind` is supported (prevents REST from # falling back to gRPC wrapping), but tracing args are not. # (These fallbacks can be removed once google-api-core < 2.36.0 is no longer supported.) - for k in ["client_options", "method_name", "is_streaming"]: + for k in ["client_options", "method_name", "is_streaming", "default_host"]: kwargs.pop(k, None) try: @@ -192,6 +201,8 @@ def _wrap_async_method(self, func, *args, **kwargs): """ if _ASYNC_WRAP_METHOD_SUPPORTS_TRACING: kwargs["client_options"] = self._client_options + if _SUPPORTS_DEFAULT_HOST: + kwargs["default_host"] = self._default_host try: kind = self.kind except NotImplementedError: @@ -204,7 +215,7 @@ def _wrap_async_method(self, func, *args, **kwargs): # - Era 2 (>= 2.29.0, < 2.36.0): `kind` is supported (prevents REST from # falling back to gRPC wrapping), but tracing args are not. # (These fallbacks can be removed once google-api-core < 2.36.0 is no longer supported.) - for k in ["client_options", "method_name", "is_streaming"]: + for k in ["client_options", "method_name", "is_streaming", "default_host"]: kwargs.pop(k, None) try: diff --git a/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/testing/transports/grpc.py b/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/testing/transports/grpc.py index 81aa2400356c..43ae9163f120 100755 --- a/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/testing/transports/grpc.py +++ b/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/testing/transports/grpc.py @@ -20,7 +20,11 @@ from typing import Callable, Dict, Optional, Sequence, Tuple, Union from typing import TYPE_CHECKING -from google.showcase_v1beta1._compat import _observability, apply_channel_interceptors +from google.showcase_v1beta1._compat import ( + _observability, + SUPPORTS_DEFAULT_HOST as _SUPPORTS_DEFAULT_HOST, + apply_channel_interceptors, +) from google.api_core import client_options as client_options_lib from google.api_core import grpc_helpers @@ -284,9 +288,12 @@ def __init__(self, *, ) channel_interceptors = list(interceptors) if interceptors else [] + _otel_interceptor_kwargs = ( + {"default_host": self._default_host} if _SUPPORTS_DEFAULT_HOST else {} + ) if ( _observability is not None - and (otel_interceptor := _observability.get_otel_interceptor(self._client_options)) is not None + and (otel_interceptor := _observability.get_otel_interceptor(self._client_options, **_otel_interceptor_kwargs)) is not None and otel_interceptor not in channel_interceptors and not any(getattr(i, "_is_otel_interceptor", None) is True for i in channel_interceptors) ): diff --git a/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/testing/transports/grpc_asyncio.py b/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/testing/transports/grpc_asyncio.py index 0ce4f859b8df..cdc1d93787ed 100755 --- a/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/testing/transports/grpc_asyncio.py +++ b/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/testing/transports/grpc_asyncio.py @@ -20,7 +20,11 @@ from typing import Awaitable, Callable, Dict, Optional, Sequence, Tuple, Union from typing import TYPE_CHECKING -from google.showcase_v1beta1._compat import _observability, apply_async_channel_interceptors +from google.showcase_v1beta1._compat import ( + _observability, + SUPPORTS_DEFAULT_HOST as _SUPPORTS_DEFAULT_HOST, + apply_async_channel_interceptors, +) from google.api_core import client_options as client_options_lib from google.api_core import gapic_v1 @@ -342,8 +346,11 @@ def __init__(self, *, # NOTE: Coverage tool ignores async interceptors in environments running # legacy google-api-core (< 2.36.0) where OpenTelemetry is unavailable. # TODO (Issue #18657): Remove once lowest constraints require google-api-core >= v2.43.0. + _otel_interceptor_kwargs = ( + {"default_host": self._default_host} if _SUPPORTS_DEFAULT_HOST else {} + ) # pragma: NO COVER self._grpc_channel = _observability.apply_otel_async_channel_interceptors( - self._grpc_channel, self._client_options + self._grpc_channel, self._client_options, **_otel_interceptor_kwargs ) # pragma: NO COVER self._logged_channel = self._grpc_channel diff --git a/packages/gapic-generator/tests/integration/goldens/showcase/tests/unit/gapic/showcase_v1beta1/test_echo.py b/packages/gapic-generator/tests/integration/goldens/showcase/tests/unit/gapic/showcase_v1beta1/test_echo.py index 526eb660d4ff..a5f02a67c669 100755 --- a/packages/gapic-generator/tests/integration/goldens/showcase/tests/unit/gapic/showcase_v1beta1/test_echo.py +++ b/packages/gapic-generator/tests/integration/goldens/showcase/tests/unit/gapic/showcase_v1beta1/test_echo.py @@ -1148,7 +1148,14 @@ def test_echo_grpc_transport_otel_channel_interceptor(): client_options=options, ) - mock_get_interceptor.assert_called_once_with(options) + expected_kwargs = ( + {"default_host": transport._default_host} + if transports.grpc._SUPPORTS_DEFAULT_HOST + else {} + ) + mock_get_interceptor.assert_called_once_with( + transport._client_options, **expected_kwargs + ) mock_apply_interceptors.assert_called_once_with( mock_channel, [mock_otel_interceptor] ) @@ -1222,7 +1229,14 @@ def test_echo_grpc_asyncio_transport_otel_channel_interceptor(): client_options=options, ) - mock_apply_async_interceptors.assert_called_once_with(mock_channel, options) + expected_kwargs = ( + {"default_host": transport._default_host} + if transports.grpc_asyncio._SUPPORTS_DEFAULT_HOST + else {} + ) + mock_apply_async_interceptors.assert_called_once_with( + mock_channel, transport._client_options, **expected_kwargs + ) assert mock_create_channel.call_count == 1 assert transport.grpc_channel == mock_channel @@ -9782,7 +9796,8 @@ def test_api_key_credentials(client_class, transport_class): def test_echo_base_transport_wrap_method_modern_api_core(monkeypatch): monkeypatch.setattr(transports.base, "_WRAP_METHOD_SUPPORTS_TRACING", True) - # 1. Concrete transport: passes client_options=None and kind="grpc" + monkeypatch.setattr(transports.base, "_SUPPORTS_DEFAULT_HOST", True) + # 1. Concrete transport: passes client_options=None, default_host, and kind="grpc" with mock.patch.object(transports.base.gapic_v1.method, "wrap_method") as mock_wrap: transport = transports.EchoGrpcTransport( credentials=ga_credentials.AnonymousCredentials() @@ -9791,8 +9806,10 @@ def test_echo_base_transport_wrap_method_modern_api_core(monkeypatch): transport._wrap_method(func) assert mock_wrap.call_args.kwargs["kind"] == "grpc" assert "client_options" in mock_wrap.call_args.kwargs + assert mock_wrap.call_args.kwargs["default_host"] == transport._default_host - # 2. Base transport: kind raises NotImplementedError and is cleanly omitted + # 2. Base transport with _SUPPORTS_DEFAULT_HOST=False: kind raises NotImplementedError and default_host is omitted + monkeypatch.setattr(transports.base, "_SUPPORTS_DEFAULT_HOST", False) with mock.patch.object(transports.base.gapic_v1.method, "wrap_method") as mock_wrap: base_transport = transports.EchoTransport( credentials=ga_credentials.AnonymousCredentials() @@ -9801,6 +9818,84 @@ def test_echo_base_transport_wrap_method_modern_api_core(monkeypatch): base_transport._wrap_method(func) assert "kind" not in mock_wrap.call_args.kwargs assert "client_options" in mock_wrap.call_args.kwargs + assert "default_host" not in mock_wrap.call_args.kwargs + + +def test_echo_base_transport_client_options_none(): + transport = transports.EchoTransport( + host="default.example.com:443", + credentials=ga_credentials.AnonymousCredentials(), + client_options=None, + ) + assert transport._client_options is None + assert transport._default_host == "default.example.com:443" + + +@pytest.mark.parametrize( + "initial_endpoint", + [ + pytest.param(None, id="unset_api_endpoint"), + pytest.param("custom.example.com:443", id="explicit_api_endpoint"), + ], +) +def test_echo_base_transport_client_options_object( + initial_endpoint, +): + options = client_options.ClientOptions(api_endpoint=initial_endpoint) + transport = transports.EchoTransport( + host="default.example.com:443", + credentials=ga_credentials.AnonymousCredentials(), + client_options=options, + ) + assert transport._client_options is options + assert transport._client_options.api_endpoint == initial_endpoint + assert transport._default_host == "default.example.com:443" + + +@pytest.mark.parametrize( + "initial_options,expected_endpoint", + [ + pytest.param( + {}, + None, + id="unset_api_endpoint", + ), + pytest.param( + {"api_endpoint": "custom.example.com:443"}, + "custom.example.com:443", + id="explicit_api_endpoint", + ), + ], +) +def test_echo_base_transport_client_options_dict( + initial_options, expected_endpoint +): + options = dict(initial_options) + transport = transports.EchoTransport( + host="default.example.com:443", + credentials=ga_credentials.AnonymousCredentials(), + client_options=options, + ) + assert isinstance(transport._client_options, client_options.ClientOptions) + assert transport._client_options.api_endpoint == expected_endpoint + assert transport._default_host == "default.example.com:443" + assert options == initial_options + + +def test_echo_base_transport_default_host_ignored_when_ignore_credentials(): + class _CustomChannelTransport(transports.EchoTransport): + def __init__(self, **kwargs): + self._ignore_credentials = True + super().__init__(**kwargs) + + options = client_options.ClientOptions() + transport = _CustomChannelTransport( + host="default.example.com:443", + client_options=options, + ) + assert transport._client_options is options + assert transport._client_options.api_endpoint is None + assert transport._default_host is None def test_echo_base_transport_wrap_method_older_api_core_fallbacks(monkeypatch): @@ -9879,7 +9974,8 @@ def test_echo_base_transport_wrap_method_older_api_core_fallbacks(monkeypatch): def test_echo_base_transport_wrap_async_method_modern_api_core(monkeypatch): monkeypatch.setattr(transports.base, "_ASYNC_WRAP_METHOD_SUPPORTS_TRACING", True) - # 1. Concrete transport: passes client_options=None and kind="grpc_asyncio" + monkeypatch.setattr(transports.base, "_SUPPORTS_DEFAULT_HOST", True) + # 1. Concrete transport: passes client_options=None, default_host, and kind="grpc_asyncio" with mock.patch.object(transports.base.gapic_v1.method_async, "wrap_method") as mock_wrap: transport = transports.EchoGrpcAsyncIOTransport( credentials=ga_credentials.AnonymousCredentials() @@ -9888,8 +9984,10 @@ def test_echo_base_transport_wrap_async_method_modern_api_core(monkeypatch): transport._wrap_async_method(func) assert mock_wrap.call_args.kwargs["kind"] == "grpc_asyncio" assert "client_options" in mock_wrap.call_args.kwargs + assert mock_wrap.call_args.kwargs["default_host"] == transport._default_host - # 2. Base transport: kind raises NotImplementedError and is cleanly omitted + # 2. Base transport with _SUPPORTS_DEFAULT_HOST=False: kind raises NotImplementedError and default_host is omitted + monkeypatch.setattr(transports.base, "_SUPPORTS_DEFAULT_HOST", False) with mock.patch.object(transports.base.gapic_v1.method_async, "wrap_method") as mock_wrap: base_transport = transports.EchoTransport( credentials=ga_credentials.AnonymousCredentials() @@ -9898,6 +9996,7 @@ def test_echo_base_transport_wrap_async_method_modern_api_core(monkeypatch): base_transport._wrap_async_method(func) assert "kind" not in mock_wrap.call_args.kwargs assert "client_options" in mock_wrap.call_args.kwargs + assert "default_host" not in mock_wrap.call_args.kwargs def test_echo_base_transport_wrap_async_method_older_api_core_fallbacks(monkeypatch): diff --git a/packages/gapic-generator/tests/integration/goldens/showcase/tests/unit/gapic/showcase_v1beta1/test_identity.py b/packages/gapic-generator/tests/integration/goldens/showcase/tests/unit/gapic/showcase_v1beta1/test_identity.py index 28a7dd420d23..954469d383ef 100755 --- a/packages/gapic-generator/tests/integration/goldens/showcase/tests/unit/gapic/showcase_v1beta1/test_identity.py +++ b/packages/gapic-generator/tests/integration/goldens/showcase/tests/unit/gapic/showcase_v1beta1/test_identity.py @@ -849,7 +849,14 @@ def test_identity_grpc_transport_otel_channel_interceptor(): client_options=options, ) - mock_get_interceptor.assert_called_once_with(options) + expected_kwargs = ( + {"default_host": transport._default_host} + if transports.grpc._SUPPORTS_DEFAULT_HOST + else {} + ) + mock_get_interceptor.assert_called_once_with( + transport._client_options, **expected_kwargs + ) mock_apply_interceptors.assert_called_once_with( mock_channel, [mock_otel_interceptor] ) @@ -923,7 +930,14 @@ def test_identity_grpc_asyncio_transport_otel_channel_interceptor(): client_options=options, ) - mock_apply_async_interceptors.assert_called_once_with(mock_channel, options) + expected_kwargs = ( + {"default_host": transport._default_host} + if transports.grpc_asyncio._SUPPORTS_DEFAULT_HOST + else {} + ) + mock_apply_async_interceptors.assert_called_once_with( + mock_channel, transport._client_options, **expected_kwargs + ) assert mock_create_channel.call_count == 1 assert transport.grpc_channel == mock_channel @@ -6889,7 +6903,8 @@ def test_api_key_credentials(client_class, transport_class): def test_identity_base_transport_wrap_method_modern_api_core(monkeypatch): monkeypatch.setattr(transports.base, "_WRAP_METHOD_SUPPORTS_TRACING", True) - # 1. Concrete transport: passes client_options=None and kind="grpc" + monkeypatch.setattr(transports.base, "_SUPPORTS_DEFAULT_HOST", True) + # 1. Concrete transport: passes client_options=None, default_host, and kind="grpc" with mock.patch.object(transports.base.gapic_v1.method, "wrap_method") as mock_wrap: transport = transports.IdentityGrpcTransport( credentials=ga_credentials.AnonymousCredentials() @@ -6898,8 +6913,10 @@ def test_identity_base_transport_wrap_method_modern_api_core(monkeypatch): transport._wrap_method(func) assert mock_wrap.call_args.kwargs["kind"] == "grpc" assert "client_options" in mock_wrap.call_args.kwargs + assert mock_wrap.call_args.kwargs["default_host"] == transport._default_host - # 2. Base transport: kind raises NotImplementedError and is cleanly omitted + # 2. Base transport with _SUPPORTS_DEFAULT_HOST=False: kind raises NotImplementedError and default_host is omitted + monkeypatch.setattr(transports.base, "_SUPPORTS_DEFAULT_HOST", False) with mock.patch.object(transports.base.gapic_v1.method, "wrap_method") as mock_wrap: base_transport = transports.IdentityTransport( credentials=ga_credentials.AnonymousCredentials() @@ -6908,6 +6925,84 @@ def test_identity_base_transport_wrap_method_modern_api_core(monkeypatch): base_transport._wrap_method(func) assert "kind" not in mock_wrap.call_args.kwargs assert "client_options" in mock_wrap.call_args.kwargs + assert "default_host" not in mock_wrap.call_args.kwargs + + +def test_identity_base_transport_client_options_none(): + transport = transports.IdentityTransport( + host="default.example.com:443", + credentials=ga_credentials.AnonymousCredentials(), + client_options=None, + ) + assert transport._client_options is None + assert transport._default_host == "default.example.com:443" + + +@pytest.mark.parametrize( + "initial_endpoint", + [ + pytest.param(None, id="unset_api_endpoint"), + pytest.param("custom.example.com:443", id="explicit_api_endpoint"), + ], +) +def test_identity_base_transport_client_options_object( + initial_endpoint, +): + options = client_options.ClientOptions(api_endpoint=initial_endpoint) + transport = transports.IdentityTransport( + host="default.example.com:443", + credentials=ga_credentials.AnonymousCredentials(), + client_options=options, + ) + assert transport._client_options is options + assert transport._client_options.api_endpoint == initial_endpoint + assert transport._default_host == "default.example.com:443" + + +@pytest.mark.parametrize( + "initial_options,expected_endpoint", + [ + pytest.param( + {}, + None, + id="unset_api_endpoint", + ), + pytest.param( + {"api_endpoint": "custom.example.com:443"}, + "custom.example.com:443", + id="explicit_api_endpoint", + ), + ], +) +def test_identity_base_transport_client_options_dict( + initial_options, expected_endpoint +): + options = dict(initial_options) + transport = transports.IdentityTransport( + host="default.example.com:443", + credentials=ga_credentials.AnonymousCredentials(), + client_options=options, + ) + assert isinstance(transport._client_options, client_options.ClientOptions) + assert transport._client_options.api_endpoint == expected_endpoint + assert transport._default_host == "default.example.com:443" + assert options == initial_options + + +def test_identity_base_transport_default_host_ignored_when_ignore_credentials(): + class _CustomChannelTransport(transports.IdentityTransport): + def __init__(self, **kwargs): + self._ignore_credentials = True + super().__init__(**kwargs) + + options = client_options.ClientOptions() + transport = _CustomChannelTransport( + host="default.example.com:443", + client_options=options, + ) + assert transport._client_options is options + assert transport._client_options.api_endpoint is None + assert transport._default_host is None def test_identity_base_transport_wrap_method_older_api_core_fallbacks(monkeypatch): @@ -6986,7 +7081,8 @@ def test_identity_base_transport_wrap_method_older_api_core_fallbacks(monkeypatc def test_identity_base_transport_wrap_async_method_modern_api_core(monkeypatch): monkeypatch.setattr(transports.base, "_ASYNC_WRAP_METHOD_SUPPORTS_TRACING", True) - # 1. Concrete transport: passes client_options=None and kind="grpc_asyncio" + monkeypatch.setattr(transports.base, "_SUPPORTS_DEFAULT_HOST", True) + # 1. Concrete transport: passes client_options=None, default_host, and kind="grpc_asyncio" with mock.patch.object(transports.base.gapic_v1.method_async, "wrap_method") as mock_wrap: transport = transports.IdentityGrpcAsyncIOTransport( credentials=ga_credentials.AnonymousCredentials() @@ -6995,8 +7091,10 @@ def test_identity_base_transport_wrap_async_method_modern_api_core(monkeypatch): transport._wrap_async_method(func) assert mock_wrap.call_args.kwargs["kind"] == "grpc_asyncio" assert "client_options" in mock_wrap.call_args.kwargs + assert mock_wrap.call_args.kwargs["default_host"] == transport._default_host - # 2. Base transport: kind raises NotImplementedError and is cleanly omitted + # 2. Base transport with _SUPPORTS_DEFAULT_HOST=False: kind raises NotImplementedError and default_host is omitted + monkeypatch.setattr(transports.base, "_SUPPORTS_DEFAULT_HOST", False) with mock.patch.object(transports.base.gapic_v1.method_async, "wrap_method") as mock_wrap: base_transport = transports.IdentityTransport( credentials=ga_credentials.AnonymousCredentials() @@ -7005,6 +7103,7 @@ def test_identity_base_transport_wrap_async_method_modern_api_core(monkeypatch): base_transport._wrap_async_method(func) assert "kind" not in mock_wrap.call_args.kwargs assert "client_options" in mock_wrap.call_args.kwargs + assert "default_host" not in mock_wrap.call_args.kwargs def test_identity_base_transport_wrap_async_method_older_api_core_fallbacks(monkeypatch): diff --git a/packages/gapic-generator/tests/integration/goldens/showcase/tests/unit/gapic/showcase_v1beta1/test_messaging.py b/packages/gapic-generator/tests/integration/goldens/showcase/tests/unit/gapic/showcase_v1beta1/test_messaging.py index 84906c414aa2..188b3e746bf7 100755 --- a/packages/gapic-generator/tests/integration/goldens/showcase/tests/unit/gapic/showcase_v1beta1/test_messaging.py +++ b/packages/gapic-generator/tests/integration/goldens/showcase/tests/unit/gapic/showcase_v1beta1/test_messaging.py @@ -853,7 +853,14 @@ def test_messaging_grpc_transport_otel_channel_interceptor(): client_options=options, ) - mock_get_interceptor.assert_called_once_with(options) + expected_kwargs = ( + {"default_host": transport._default_host} + if transports.grpc._SUPPORTS_DEFAULT_HOST + else {} + ) + mock_get_interceptor.assert_called_once_with( + transport._client_options, **expected_kwargs + ) mock_apply_interceptors.assert_called_once_with( mock_channel, [mock_otel_interceptor] ) @@ -927,7 +934,14 @@ def test_messaging_grpc_asyncio_transport_otel_channel_interceptor(): client_options=options, ) - mock_apply_async_interceptors.assert_called_once_with(mock_channel, options) + expected_kwargs = ( + {"default_host": transport._default_host} + if transports.grpc_asyncio._SUPPORTS_DEFAULT_HOST + else {} + ) + mock_apply_async_interceptors.assert_called_once_with( + mock_channel, transport._client_options, **expected_kwargs + ) assert mock_create_channel.call_count == 1 assert transport.grpc_channel == mock_channel @@ -11789,7 +11803,8 @@ def test_api_key_credentials(client_class, transport_class): def test_messaging_base_transport_wrap_method_modern_api_core(monkeypatch): monkeypatch.setattr(transports.base, "_WRAP_METHOD_SUPPORTS_TRACING", True) - # 1. Concrete transport: passes client_options=None and kind="grpc" + monkeypatch.setattr(transports.base, "_SUPPORTS_DEFAULT_HOST", True) + # 1. Concrete transport: passes client_options=None, default_host, and kind="grpc" with mock.patch.object(transports.base.gapic_v1.method, "wrap_method") as mock_wrap: transport = transports.MessagingGrpcTransport( credentials=ga_credentials.AnonymousCredentials() @@ -11798,8 +11813,10 @@ def test_messaging_base_transport_wrap_method_modern_api_core(monkeypatch): transport._wrap_method(func) assert mock_wrap.call_args.kwargs["kind"] == "grpc" assert "client_options" in mock_wrap.call_args.kwargs + assert mock_wrap.call_args.kwargs["default_host"] == transport._default_host - # 2. Base transport: kind raises NotImplementedError and is cleanly omitted + # 2. Base transport with _SUPPORTS_DEFAULT_HOST=False: kind raises NotImplementedError and default_host is omitted + monkeypatch.setattr(transports.base, "_SUPPORTS_DEFAULT_HOST", False) with mock.patch.object(transports.base.gapic_v1.method, "wrap_method") as mock_wrap: base_transport = transports.MessagingTransport( credentials=ga_credentials.AnonymousCredentials() @@ -11808,6 +11825,84 @@ def test_messaging_base_transport_wrap_method_modern_api_core(monkeypatch): base_transport._wrap_method(func) assert "kind" not in mock_wrap.call_args.kwargs assert "client_options" in mock_wrap.call_args.kwargs + assert "default_host" not in mock_wrap.call_args.kwargs + + +def test_messaging_base_transport_client_options_none(): + transport = transports.MessagingTransport( + host="default.example.com:443", + credentials=ga_credentials.AnonymousCredentials(), + client_options=None, + ) + assert transport._client_options is None + assert transport._default_host == "default.example.com:443" + + +@pytest.mark.parametrize( + "initial_endpoint", + [ + pytest.param(None, id="unset_api_endpoint"), + pytest.param("custom.example.com:443", id="explicit_api_endpoint"), + ], +) +def test_messaging_base_transport_client_options_object( + initial_endpoint, +): + options = client_options.ClientOptions(api_endpoint=initial_endpoint) + transport = transports.MessagingTransport( + host="default.example.com:443", + credentials=ga_credentials.AnonymousCredentials(), + client_options=options, + ) + assert transport._client_options is options + assert transport._client_options.api_endpoint == initial_endpoint + assert transport._default_host == "default.example.com:443" + + +@pytest.mark.parametrize( + "initial_options,expected_endpoint", + [ + pytest.param( + {}, + None, + id="unset_api_endpoint", + ), + pytest.param( + {"api_endpoint": "custom.example.com:443"}, + "custom.example.com:443", + id="explicit_api_endpoint", + ), + ], +) +def test_messaging_base_transport_client_options_dict( + initial_options, expected_endpoint +): + options = dict(initial_options) + transport = transports.MessagingTransport( + host="default.example.com:443", + credentials=ga_credentials.AnonymousCredentials(), + client_options=options, + ) + assert isinstance(transport._client_options, client_options.ClientOptions) + assert transport._client_options.api_endpoint == expected_endpoint + assert transport._default_host == "default.example.com:443" + assert options == initial_options + + +def test_messaging_base_transport_default_host_ignored_when_ignore_credentials(): + class _CustomChannelTransport(transports.MessagingTransport): + def __init__(self, **kwargs): + self._ignore_credentials = True + super().__init__(**kwargs) + + options = client_options.ClientOptions() + transport = _CustomChannelTransport( + host="default.example.com:443", + client_options=options, + ) + assert transport._client_options is options + assert transport._client_options.api_endpoint is None + assert transport._default_host is None def test_messaging_base_transport_wrap_method_older_api_core_fallbacks(monkeypatch): @@ -11886,7 +11981,8 @@ def test_messaging_base_transport_wrap_method_older_api_core_fallbacks(monkeypat def test_messaging_base_transport_wrap_async_method_modern_api_core(monkeypatch): monkeypatch.setattr(transports.base, "_ASYNC_WRAP_METHOD_SUPPORTS_TRACING", True) - # 1. Concrete transport: passes client_options=None and kind="grpc_asyncio" + monkeypatch.setattr(transports.base, "_SUPPORTS_DEFAULT_HOST", True) + # 1. Concrete transport: passes client_options=None, default_host, and kind="grpc_asyncio" with mock.patch.object(transports.base.gapic_v1.method_async, "wrap_method") as mock_wrap: transport = transports.MessagingGrpcAsyncIOTransport( credentials=ga_credentials.AnonymousCredentials() @@ -11895,8 +11991,10 @@ def test_messaging_base_transport_wrap_async_method_modern_api_core(monkeypatch) transport._wrap_async_method(func) assert mock_wrap.call_args.kwargs["kind"] == "grpc_asyncio" assert "client_options" in mock_wrap.call_args.kwargs + assert mock_wrap.call_args.kwargs["default_host"] == transport._default_host - # 2. Base transport: kind raises NotImplementedError and is cleanly omitted + # 2. Base transport with _SUPPORTS_DEFAULT_HOST=False: kind raises NotImplementedError and default_host is omitted + monkeypatch.setattr(transports.base, "_SUPPORTS_DEFAULT_HOST", False) with mock.patch.object(transports.base.gapic_v1.method_async, "wrap_method") as mock_wrap: base_transport = transports.MessagingTransport( credentials=ga_credentials.AnonymousCredentials() @@ -11905,6 +12003,7 @@ def test_messaging_base_transport_wrap_async_method_modern_api_core(monkeypatch) base_transport._wrap_async_method(func) assert "kind" not in mock_wrap.call_args.kwargs assert "client_options" in mock_wrap.call_args.kwargs + assert "default_host" not in mock_wrap.call_args.kwargs def test_messaging_base_transport_wrap_async_method_older_api_core_fallbacks(monkeypatch): diff --git a/packages/gapic-generator/tests/integration/goldens/showcase/tests/unit/gapic/showcase_v1beta1/test_resumable_upload_service.py b/packages/gapic-generator/tests/integration/goldens/showcase/tests/unit/gapic/showcase_v1beta1/test_resumable_upload_service.py index 3d2f99ea1ef7..3e711747603f 100755 --- a/packages/gapic-generator/tests/integration/goldens/showcase/tests/unit/gapic/showcase_v1beta1/test_resumable_upload_service.py +++ b/packages/gapic-generator/tests/integration/goldens/showcase/tests/unit/gapic/showcase_v1beta1/test_resumable_upload_service.py @@ -854,7 +854,14 @@ def test_resumable_upload_service_grpc_transport_otel_channel_interceptor(): client_options=options, ) - mock_get_interceptor.assert_called_once_with(options) + expected_kwargs = ( + {"default_host": transport._default_host} + if transports.grpc._SUPPORTS_DEFAULT_HOST + else {} + ) + mock_get_interceptor.assert_called_once_with( + transport._client_options, **expected_kwargs + ) mock_apply_interceptors.assert_called_once_with( mock_channel, [mock_otel_interceptor] ) @@ -928,7 +935,14 @@ def test_resumable_upload_service_grpc_asyncio_transport_otel_channel_intercepto client_options=options, ) - mock_apply_async_interceptors.assert_called_once_with(mock_channel, options) + expected_kwargs = ( + {"default_host": transport._default_host} + if transports.grpc_asyncio._SUPPORTS_DEFAULT_HOST + else {} + ) + mock_apply_async_interceptors.assert_called_once_with( + mock_channel, transport._client_options, **expected_kwargs + ) assert mock_create_channel.call_count == 1 assert transport.grpc_channel == mock_channel @@ -4482,7 +4496,8 @@ def test_api_key_credentials(client_class, transport_class): def test_resumable_upload_service_base_transport_wrap_method_modern_api_core(monkeypatch): monkeypatch.setattr(transports.base, "_WRAP_METHOD_SUPPORTS_TRACING", True) - # 1. Concrete transport: passes client_options=None and kind="grpc" + monkeypatch.setattr(transports.base, "_SUPPORTS_DEFAULT_HOST", True) + # 1. Concrete transport: passes client_options=None, default_host, and kind="grpc" with mock.patch.object(transports.base.gapic_v1.method, "wrap_method") as mock_wrap: transport = transports.ResumableUploadServiceGrpcTransport( credentials=ga_credentials.AnonymousCredentials() @@ -4491,8 +4506,10 @@ def test_resumable_upload_service_base_transport_wrap_method_modern_api_core(mon transport._wrap_method(func) assert mock_wrap.call_args.kwargs["kind"] == "grpc" assert "client_options" in mock_wrap.call_args.kwargs + assert mock_wrap.call_args.kwargs["default_host"] == transport._default_host - # 2. Base transport: kind raises NotImplementedError and is cleanly omitted + # 2. Base transport with _SUPPORTS_DEFAULT_HOST=False: kind raises NotImplementedError and default_host is omitted + monkeypatch.setattr(transports.base, "_SUPPORTS_DEFAULT_HOST", False) with mock.patch.object(transports.base.gapic_v1.method, "wrap_method") as mock_wrap: base_transport = transports.ResumableUploadServiceTransport( credentials=ga_credentials.AnonymousCredentials() @@ -4501,6 +4518,84 @@ def test_resumable_upload_service_base_transport_wrap_method_modern_api_core(mon base_transport._wrap_method(func) assert "kind" not in mock_wrap.call_args.kwargs assert "client_options" in mock_wrap.call_args.kwargs + assert "default_host" not in mock_wrap.call_args.kwargs + + +def test_resumable_upload_service_base_transport_client_options_none(): + transport = transports.ResumableUploadServiceTransport( + host="default.example.com:443", + credentials=ga_credentials.AnonymousCredentials(), + client_options=None, + ) + assert transport._client_options is None + assert transport._default_host == "default.example.com:443" + + +@pytest.mark.parametrize( + "initial_endpoint", + [ + pytest.param(None, id="unset_api_endpoint"), + pytest.param("custom.example.com:443", id="explicit_api_endpoint"), + ], +) +def test_resumable_upload_service_base_transport_client_options_object( + initial_endpoint, +): + options = client_options.ClientOptions(api_endpoint=initial_endpoint) + transport = transports.ResumableUploadServiceTransport( + host="default.example.com:443", + credentials=ga_credentials.AnonymousCredentials(), + client_options=options, + ) + assert transport._client_options is options + assert transport._client_options.api_endpoint == initial_endpoint + assert transport._default_host == "default.example.com:443" + + +@pytest.mark.parametrize( + "initial_options,expected_endpoint", + [ + pytest.param( + {}, + None, + id="unset_api_endpoint", + ), + pytest.param( + {"api_endpoint": "custom.example.com:443"}, + "custom.example.com:443", + id="explicit_api_endpoint", + ), + ], +) +def test_resumable_upload_service_base_transport_client_options_dict( + initial_options, expected_endpoint +): + options = dict(initial_options) + transport = transports.ResumableUploadServiceTransport( + host="default.example.com:443", + credentials=ga_credentials.AnonymousCredentials(), + client_options=options, + ) + assert isinstance(transport._client_options, client_options.ClientOptions) + assert transport._client_options.api_endpoint == expected_endpoint + assert transport._default_host == "default.example.com:443" + assert options == initial_options + + +def test_resumable_upload_service_base_transport_default_host_ignored_when_ignore_credentials(): + class _CustomChannelTransport(transports.ResumableUploadServiceTransport): + def __init__(self, **kwargs): + self._ignore_credentials = True + super().__init__(**kwargs) + + options = client_options.ClientOptions() + transport = _CustomChannelTransport( + host="default.example.com:443", + client_options=options, + ) + assert transport._client_options is options + assert transport._client_options.api_endpoint is None + assert transport._default_host is None def test_resumable_upload_service_base_transport_wrap_method_older_api_core_fallbacks(monkeypatch): @@ -4579,7 +4674,8 @@ def test_resumable_upload_service_base_transport_wrap_method_older_api_core_fall def test_resumable_upload_service_base_transport_wrap_async_method_modern_api_core(monkeypatch): monkeypatch.setattr(transports.base, "_ASYNC_WRAP_METHOD_SUPPORTS_TRACING", True) - # 1. Concrete transport: passes client_options=None and kind="grpc_asyncio" + monkeypatch.setattr(transports.base, "_SUPPORTS_DEFAULT_HOST", True) + # 1. Concrete transport: passes client_options=None, default_host, and kind="grpc_asyncio" with mock.patch.object(transports.base.gapic_v1.method_async, "wrap_method") as mock_wrap: transport = transports.ResumableUploadServiceGrpcAsyncIOTransport( credentials=ga_credentials.AnonymousCredentials() @@ -4588,8 +4684,10 @@ def test_resumable_upload_service_base_transport_wrap_async_method_modern_api_co transport._wrap_async_method(func) assert mock_wrap.call_args.kwargs["kind"] == "grpc_asyncio" assert "client_options" in mock_wrap.call_args.kwargs + assert mock_wrap.call_args.kwargs["default_host"] == transport._default_host - # 2. Base transport: kind raises NotImplementedError and is cleanly omitted + # 2. Base transport with _SUPPORTS_DEFAULT_HOST=False: kind raises NotImplementedError and default_host is omitted + monkeypatch.setattr(transports.base, "_SUPPORTS_DEFAULT_HOST", False) with mock.patch.object(transports.base.gapic_v1.method_async, "wrap_method") as mock_wrap: base_transport = transports.ResumableUploadServiceTransport( credentials=ga_credentials.AnonymousCredentials() @@ -4598,6 +4696,7 @@ def test_resumable_upload_service_base_transport_wrap_async_method_modern_api_co base_transport._wrap_async_method(func) assert "kind" not in mock_wrap.call_args.kwargs assert "client_options" in mock_wrap.call_args.kwargs + assert "default_host" not in mock_wrap.call_args.kwargs def test_resumable_upload_service_base_transport_wrap_async_method_older_api_core_fallbacks(monkeypatch): diff --git a/packages/gapic-generator/tests/integration/goldens/showcase/tests/unit/gapic/showcase_v1beta1/test_sequence_service.py b/packages/gapic-generator/tests/integration/goldens/showcase/tests/unit/gapic/showcase_v1beta1/test_sequence_service.py index 444a6f91969a..46e331d3b2f7 100755 --- a/packages/gapic-generator/tests/integration/goldens/showcase/tests/unit/gapic/showcase_v1beta1/test_sequence_service.py +++ b/packages/gapic-generator/tests/integration/goldens/showcase/tests/unit/gapic/showcase_v1beta1/test_sequence_service.py @@ -850,7 +850,14 @@ def test_sequence_service_grpc_transport_otel_channel_interceptor(): client_options=options, ) - mock_get_interceptor.assert_called_once_with(options) + expected_kwargs = ( + {"default_host": transport._default_host} + if transports.grpc._SUPPORTS_DEFAULT_HOST + else {} + ) + mock_get_interceptor.assert_called_once_with( + transport._client_options, **expected_kwargs + ) mock_apply_interceptors.assert_called_once_with( mock_channel, [mock_otel_interceptor] ) @@ -924,7 +931,14 @@ def test_sequence_service_grpc_asyncio_transport_otel_channel_interceptor(): client_options=options, ) - mock_apply_async_interceptors.assert_called_once_with(mock_channel, options) + expected_kwargs = ( + {"default_host": transport._default_host} + if transports.grpc_asyncio._SUPPORTS_DEFAULT_HOST + else {} + ) + mock_apply_async_interceptors.assert_called_once_with( + mock_channel, transport._client_options, **expected_kwargs + ) assert mock_create_channel.call_count == 1 assert transport.grpc_channel == mock_channel @@ -7571,7 +7585,8 @@ def test_api_key_credentials(client_class, transport_class): def test_sequence_service_base_transport_wrap_method_modern_api_core(monkeypatch): monkeypatch.setattr(transports.base, "_WRAP_METHOD_SUPPORTS_TRACING", True) - # 1. Concrete transport: passes client_options=None and kind="grpc" + monkeypatch.setattr(transports.base, "_SUPPORTS_DEFAULT_HOST", True) + # 1. Concrete transport: passes client_options=None, default_host, and kind="grpc" with mock.patch.object(transports.base.gapic_v1.method, "wrap_method") as mock_wrap: transport = transports.SequenceServiceGrpcTransport( credentials=ga_credentials.AnonymousCredentials() @@ -7580,8 +7595,10 @@ def test_sequence_service_base_transport_wrap_method_modern_api_core(monkeypatch transport._wrap_method(func) assert mock_wrap.call_args.kwargs["kind"] == "grpc" assert "client_options" in mock_wrap.call_args.kwargs + assert mock_wrap.call_args.kwargs["default_host"] == transport._default_host - # 2. Base transport: kind raises NotImplementedError and is cleanly omitted + # 2. Base transport with _SUPPORTS_DEFAULT_HOST=False: kind raises NotImplementedError and default_host is omitted + monkeypatch.setattr(transports.base, "_SUPPORTS_DEFAULT_HOST", False) with mock.patch.object(transports.base.gapic_v1.method, "wrap_method") as mock_wrap: base_transport = transports.SequenceServiceTransport( credentials=ga_credentials.AnonymousCredentials() @@ -7590,6 +7607,84 @@ def test_sequence_service_base_transport_wrap_method_modern_api_core(monkeypatch base_transport._wrap_method(func) assert "kind" not in mock_wrap.call_args.kwargs assert "client_options" in mock_wrap.call_args.kwargs + assert "default_host" not in mock_wrap.call_args.kwargs + + +def test_sequence_service_base_transport_client_options_none(): + transport = transports.SequenceServiceTransport( + host="default.example.com:443", + credentials=ga_credentials.AnonymousCredentials(), + client_options=None, + ) + assert transport._client_options is None + assert transport._default_host == "default.example.com:443" + + +@pytest.mark.parametrize( + "initial_endpoint", + [ + pytest.param(None, id="unset_api_endpoint"), + pytest.param("custom.example.com:443", id="explicit_api_endpoint"), + ], +) +def test_sequence_service_base_transport_client_options_object( + initial_endpoint, +): + options = client_options.ClientOptions(api_endpoint=initial_endpoint) + transport = transports.SequenceServiceTransport( + host="default.example.com:443", + credentials=ga_credentials.AnonymousCredentials(), + client_options=options, + ) + assert transport._client_options is options + assert transport._client_options.api_endpoint == initial_endpoint + assert transport._default_host == "default.example.com:443" + + +@pytest.mark.parametrize( + "initial_options,expected_endpoint", + [ + pytest.param( + {}, + None, + id="unset_api_endpoint", + ), + pytest.param( + {"api_endpoint": "custom.example.com:443"}, + "custom.example.com:443", + id="explicit_api_endpoint", + ), + ], +) +def test_sequence_service_base_transport_client_options_dict( + initial_options, expected_endpoint +): + options = dict(initial_options) + transport = transports.SequenceServiceTransport( + host="default.example.com:443", + credentials=ga_credentials.AnonymousCredentials(), + client_options=options, + ) + assert isinstance(transport._client_options, client_options.ClientOptions) + assert transport._client_options.api_endpoint == expected_endpoint + assert transport._default_host == "default.example.com:443" + assert options == initial_options + + +def test_sequence_service_base_transport_default_host_ignored_when_ignore_credentials(): + class _CustomChannelTransport(transports.SequenceServiceTransport): + def __init__(self, **kwargs): + self._ignore_credentials = True + super().__init__(**kwargs) + + options = client_options.ClientOptions() + transport = _CustomChannelTransport( + host="default.example.com:443", + client_options=options, + ) + assert transport._client_options is options + assert transport._client_options.api_endpoint is None + assert transport._default_host is None def test_sequence_service_base_transport_wrap_method_older_api_core_fallbacks(monkeypatch): @@ -7668,7 +7763,8 @@ def test_sequence_service_base_transport_wrap_method_older_api_core_fallbacks(mo def test_sequence_service_base_transport_wrap_async_method_modern_api_core(monkeypatch): monkeypatch.setattr(transports.base, "_ASYNC_WRAP_METHOD_SUPPORTS_TRACING", True) - # 1. Concrete transport: passes client_options=None and kind="grpc_asyncio" + monkeypatch.setattr(transports.base, "_SUPPORTS_DEFAULT_HOST", True) + # 1. Concrete transport: passes client_options=None, default_host, and kind="grpc_asyncio" with mock.patch.object(transports.base.gapic_v1.method_async, "wrap_method") as mock_wrap: transport = transports.SequenceServiceGrpcAsyncIOTransport( credentials=ga_credentials.AnonymousCredentials() @@ -7677,8 +7773,10 @@ def test_sequence_service_base_transport_wrap_async_method_modern_api_core(monke transport._wrap_async_method(func) assert mock_wrap.call_args.kwargs["kind"] == "grpc_asyncio" assert "client_options" in mock_wrap.call_args.kwargs + assert mock_wrap.call_args.kwargs["default_host"] == transport._default_host - # 2. Base transport: kind raises NotImplementedError and is cleanly omitted + # 2. Base transport with _SUPPORTS_DEFAULT_HOST=False: kind raises NotImplementedError and default_host is omitted + monkeypatch.setattr(transports.base, "_SUPPORTS_DEFAULT_HOST", False) with mock.patch.object(transports.base.gapic_v1.method_async, "wrap_method") as mock_wrap: base_transport = transports.SequenceServiceTransport( credentials=ga_credentials.AnonymousCredentials() @@ -7687,6 +7785,7 @@ def test_sequence_service_base_transport_wrap_async_method_modern_api_core(monke base_transport._wrap_async_method(func) assert "kind" not in mock_wrap.call_args.kwargs assert "client_options" in mock_wrap.call_args.kwargs + assert "default_host" not in mock_wrap.call_args.kwargs def test_sequence_service_base_transport_wrap_async_method_older_api_core_fallbacks(monkeypatch): diff --git a/packages/gapic-generator/tests/integration/goldens/showcase/tests/unit/gapic/showcase_v1beta1/test_testing.py b/packages/gapic-generator/tests/integration/goldens/showcase/tests/unit/gapic/showcase_v1beta1/test_testing.py index dbda59a14f09..dc14d73973a1 100755 --- a/packages/gapic-generator/tests/integration/goldens/showcase/tests/unit/gapic/showcase_v1beta1/test_testing.py +++ b/packages/gapic-generator/tests/integration/goldens/showcase/tests/unit/gapic/showcase_v1beta1/test_testing.py @@ -847,7 +847,14 @@ def test_testing_grpc_transport_otel_channel_interceptor(): client_options=options, ) - mock_get_interceptor.assert_called_once_with(options) + expected_kwargs = ( + {"default_host": transport._default_host} + if transports.grpc._SUPPORTS_DEFAULT_HOST + else {} + ) + mock_get_interceptor.assert_called_once_with( + transport._client_options, **expected_kwargs + ) mock_apply_interceptors.assert_called_once_with( mock_channel, [mock_otel_interceptor] ) @@ -921,7 +928,14 @@ def test_testing_grpc_asyncio_transport_otel_channel_interceptor(): client_options=options, ) - mock_apply_async_interceptors.assert_called_once_with(mock_channel, options) + expected_kwargs = ( + {"default_host": transport._default_host} + if transports.grpc_asyncio._SUPPORTS_DEFAULT_HOST + else {} + ) + mock_apply_async_interceptors.assert_called_once_with( + mock_channel, transport._client_options, **expected_kwargs + ) assert mock_create_channel.call_count == 1 assert transport.grpc_channel == mock_channel @@ -7760,7 +7774,8 @@ def test_api_key_credentials(client_class, transport_class): def test_testing_base_transport_wrap_method_modern_api_core(monkeypatch): monkeypatch.setattr(transports.base, "_WRAP_METHOD_SUPPORTS_TRACING", True) - # 1. Concrete transport: passes client_options=None and kind="grpc" + monkeypatch.setattr(transports.base, "_SUPPORTS_DEFAULT_HOST", True) + # 1. Concrete transport: passes client_options=None, default_host, and kind="grpc" with mock.patch.object(transports.base.gapic_v1.method, "wrap_method") as mock_wrap: transport = transports.TestingGrpcTransport( credentials=ga_credentials.AnonymousCredentials() @@ -7769,8 +7784,10 @@ def test_testing_base_transport_wrap_method_modern_api_core(monkeypatch): transport._wrap_method(func) assert mock_wrap.call_args.kwargs["kind"] == "grpc" assert "client_options" in mock_wrap.call_args.kwargs + assert mock_wrap.call_args.kwargs["default_host"] == transport._default_host - # 2. Base transport: kind raises NotImplementedError and is cleanly omitted + # 2. Base transport with _SUPPORTS_DEFAULT_HOST=False: kind raises NotImplementedError and default_host is omitted + monkeypatch.setattr(transports.base, "_SUPPORTS_DEFAULT_HOST", False) with mock.patch.object(transports.base.gapic_v1.method, "wrap_method") as mock_wrap: base_transport = transports.TestingTransport( credentials=ga_credentials.AnonymousCredentials() @@ -7779,6 +7796,84 @@ def test_testing_base_transport_wrap_method_modern_api_core(monkeypatch): base_transport._wrap_method(func) assert "kind" not in mock_wrap.call_args.kwargs assert "client_options" in mock_wrap.call_args.kwargs + assert "default_host" not in mock_wrap.call_args.kwargs + + +def test_testing_base_transport_client_options_none(): + transport = transports.TestingTransport( + host="default.example.com:443", + credentials=ga_credentials.AnonymousCredentials(), + client_options=None, + ) + assert transport._client_options is None + assert transport._default_host == "default.example.com:443" + + +@pytest.mark.parametrize( + "initial_endpoint", + [ + pytest.param(None, id="unset_api_endpoint"), + pytest.param("custom.example.com:443", id="explicit_api_endpoint"), + ], +) +def test_testing_base_transport_client_options_object( + initial_endpoint, +): + options = client_options.ClientOptions(api_endpoint=initial_endpoint) + transport = transports.TestingTransport( + host="default.example.com:443", + credentials=ga_credentials.AnonymousCredentials(), + client_options=options, + ) + assert transport._client_options is options + assert transport._client_options.api_endpoint == initial_endpoint + assert transport._default_host == "default.example.com:443" + + +@pytest.mark.parametrize( + "initial_options,expected_endpoint", + [ + pytest.param( + {}, + None, + id="unset_api_endpoint", + ), + pytest.param( + {"api_endpoint": "custom.example.com:443"}, + "custom.example.com:443", + id="explicit_api_endpoint", + ), + ], +) +def test_testing_base_transport_client_options_dict( + initial_options, expected_endpoint +): + options = dict(initial_options) + transport = transports.TestingTransport( + host="default.example.com:443", + credentials=ga_credentials.AnonymousCredentials(), + client_options=options, + ) + assert isinstance(transport._client_options, client_options.ClientOptions) + assert transport._client_options.api_endpoint == expected_endpoint + assert transport._default_host == "default.example.com:443" + assert options == initial_options + + +def test_testing_base_transport_default_host_ignored_when_ignore_credentials(): + class _CustomChannelTransport(transports.TestingTransport): + def __init__(self, **kwargs): + self._ignore_credentials = True + super().__init__(**kwargs) + + options = client_options.ClientOptions() + transport = _CustomChannelTransport( + host="default.example.com:443", + client_options=options, + ) + assert transport._client_options is options + assert transport._client_options.api_endpoint is None + assert transport._default_host is None def test_testing_base_transport_wrap_method_older_api_core_fallbacks(monkeypatch): @@ -7857,7 +7952,8 @@ def test_testing_base_transport_wrap_method_older_api_core_fallbacks(monkeypatch def test_testing_base_transport_wrap_async_method_modern_api_core(monkeypatch): monkeypatch.setattr(transports.base, "_ASYNC_WRAP_METHOD_SUPPORTS_TRACING", True) - # 1. Concrete transport: passes client_options=None and kind="grpc_asyncio" + monkeypatch.setattr(transports.base, "_SUPPORTS_DEFAULT_HOST", True) + # 1. Concrete transport: passes client_options=None, default_host, and kind="grpc_asyncio" with mock.patch.object(transports.base.gapic_v1.method_async, "wrap_method") as mock_wrap: transport = transports.TestingGrpcAsyncIOTransport( credentials=ga_credentials.AnonymousCredentials() @@ -7866,8 +7962,10 @@ def test_testing_base_transport_wrap_async_method_modern_api_core(monkeypatch): transport._wrap_async_method(func) assert mock_wrap.call_args.kwargs["kind"] == "grpc_asyncio" assert "client_options" in mock_wrap.call_args.kwargs + assert mock_wrap.call_args.kwargs["default_host"] == transport._default_host - # 2. Base transport: kind raises NotImplementedError and is cleanly omitted + # 2. Base transport with _SUPPORTS_DEFAULT_HOST=False: kind raises NotImplementedError and default_host is omitted + monkeypatch.setattr(transports.base, "_SUPPORTS_DEFAULT_HOST", False) with mock.patch.object(transports.base.gapic_v1.method_async, "wrap_method") as mock_wrap: base_transport = transports.TestingTransport( credentials=ga_credentials.AnonymousCredentials() @@ -7876,6 +7974,7 @@ def test_testing_base_transport_wrap_async_method_modern_api_core(monkeypatch): base_transport._wrap_async_method(func) assert "kind" not in mock_wrap.call_args.kwargs assert "client_options" in mock_wrap.call_args.kwargs + assert "default_host" not in mock_wrap.call_args.kwargs def test_testing_base_transport_wrap_async_method_older_api_core_fallbacks(monkeypatch): diff --git a/packages/gapic-generator/tests/integration/goldens/storagebatchoperations/google/cloud/storagebatchoperations_v1/_compat.py b/packages/gapic-generator/tests/integration/goldens/storagebatchoperations/google/cloud/storagebatchoperations_v1/_compat.py index 31817dab22a1..893b510545de 100755 --- a/packages/gapic-generator/tests/integration/goldens/storagebatchoperations/google/cloud/storagebatchoperations_v1/_compat.py +++ b/packages/gapic-generator/tests/integration/goldens/storagebatchoperations/google/cloud/storagebatchoperations_v1/_compat.py @@ -95,6 +95,15 @@ def trace_http_request(*args: Any, **kwargs: Any) -> _FallbackTraceContext: # t gapic_v1.method_async, "_DEFAULT_ASYNC_TRANSPORT_KIND" ) +# The `default_host` parameter in gapic_v1.method.wrap_method, +# gapic_v1.method_async.wrap_method, _observability.get_otel_interceptor, and +# _observability.apply_otel_async_channel_interceptors allows transports to pass +# their resolved default host without mutating caller-owned client_options. +SUPPORTS_DEFAULT_HOST = bool( + getattr(gapic_v1.method.wrap_method, "__kwdefaults__", None) + and "default_host" in gapic_v1.method.wrap_method.__kwdefaults__ +) + try: from google.api_core import grpc_helpers except ImportError: # pragma: NO COVER diff --git a/packages/gapic-generator/tests/integration/goldens/storagebatchoperations/google/cloud/storagebatchoperations_v1/services/storage_batch_operations/transports/base.py b/packages/gapic-generator/tests/integration/goldens/storagebatchoperations/google/cloud/storagebatchoperations_v1/services/storage_batch_operations/transports/base.py index 5cf929454825..0e282374f4b4 100755 --- a/packages/gapic-generator/tests/integration/goldens/storagebatchoperations/google/cloud/storagebatchoperations_v1/services/storage_batch_operations/transports/base.py +++ b/packages/gapic-generator/tests/integration/goldens/storagebatchoperations/google/cloud/storagebatchoperations_v1/services/storage_batch_operations/transports/base.py @@ -19,6 +19,7 @@ from google.cloud.storagebatchoperations_v1 import gapic_version as package_version from google.cloud.storagebatchoperations_v1._compat import ( ASYNC_WRAP_METHOD_SUPPORTS_KIND as _ASYNC_WRAP_METHOD_SUPPORTS_KIND, + SUPPORTS_DEFAULT_HOST as _SUPPORTS_DEFAULT_HOST, WRAP_METHOD_SUPPORTS_TRACING as _WRAP_METHOD_SUPPORTS_TRACING, ) _ASYNC_WRAP_METHOD_SUPPORTS_TRACING = _WRAP_METHOD_SUPPORTS_TRACING @@ -135,7 +136,13 @@ def __init__( host += ':443' self._host = host + if isinstance(client_options, dict): + client_options = client_options_lib.from_dict(client_options) + self._client_options = client_options + self._default_host: Optional[str] = ( + None if self._ignore_credentials else self._host + ) self._wrapped_methods: Dict[Callable, Callable] = {} @property @@ -154,6 +161,8 @@ def _wrap_method(self, func, *args, **kwargs): """ if _WRAP_METHOD_SUPPORTS_TRACING: kwargs["client_options"] = self._client_options + if _SUPPORTS_DEFAULT_HOST: + kwargs["default_host"] = self._default_host try: kind = self.kind except NotImplementedError: @@ -166,7 +175,7 @@ def _wrap_method(self, func, *args, **kwargs): # - Era 2 (>= 2.29.0, < 2.36.0): `kind` is supported (prevents REST from # falling back to gRPC wrapping), but tracing args are not. # (These fallbacks can be removed once google-api-core < 2.36.0 is no longer supported.) - for k in ["client_options", "method_name", "is_streaming"]: + for k in ["client_options", "method_name", "is_streaming", "default_host"]: kwargs.pop(k, None) try: @@ -193,6 +202,8 @@ def _wrap_async_method(self, func, *args, **kwargs): """ if _ASYNC_WRAP_METHOD_SUPPORTS_TRACING: kwargs["client_options"] = self._client_options + if _SUPPORTS_DEFAULT_HOST: + kwargs["default_host"] = self._default_host try: kind = self.kind except NotImplementedError: @@ -205,7 +216,7 @@ def _wrap_async_method(self, func, *args, **kwargs): # - Era 2 (>= 2.29.0, < 2.36.0): `kind` is supported (prevents REST from # falling back to gRPC wrapping), but tracing args are not. # (These fallbacks can be removed once google-api-core < 2.36.0 is no longer supported.) - for k in ["client_options", "method_name", "is_streaming"]: + for k in ["client_options", "method_name", "is_streaming", "default_host"]: kwargs.pop(k, None) try: diff --git a/packages/gapic-generator/tests/integration/goldens/storagebatchoperations/google/cloud/storagebatchoperations_v1/services/storage_batch_operations/transports/grpc.py b/packages/gapic-generator/tests/integration/goldens/storagebatchoperations/google/cloud/storagebatchoperations_v1/services/storage_batch_operations/transports/grpc.py index 16792a55723a..5f56b630fa1a 100755 --- a/packages/gapic-generator/tests/integration/goldens/storagebatchoperations/google/cloud/storagebatchoperations_v1/services/storage_batch_operations/transports/grpc.py +++ b/packages/gapic-generator/tests/integration/goldens/storagebatchoperations/google/cloud/storagebatchoperations_v1/services/storage_batch_operations/transports/grpc.py @@ -20,7 +20,11 @@ from typing import Callable, Dict, Optional, Sequence, Tuple, Union from typing import TYPE_CHECKING -from google.cloud.storagebatchoperations_v1._compat import _observability, apply_channel_interceptors +from google.cloud.storagebatchoperations_v1._compat import ( + _observability, + SUPPORTS_DEFAULT_HOST as _SUPPORTS_DEFAULT_HOST, + apply_channel_interceptors, +) from google.api_core import client_options as client_options_lib from google.api_core import grpc_helpers @@ -283,9 +287,12 @@ def __init__(self, *, ) channel_interceptors = list(interceptors) if interceptors else [] + _otel_interceptor_kwargs = ( + {"default_host": self._default_host} if _SUPPORTS_DEFAULT_HOST else {} + ) if ( _observability is not None - and (otel_interceptor := _observability.get_otel_interceptor(self._client_options)) is not None + and (otel_interceptor := _observability.get_otel_interceptor(self._client_options, **_otel_interceptor_kwargs)) is not None and otel_interceptor not in channel_interceptors and not any(getattr(i, "_is_otel_interceptor", None) is True for i in channel_interceptors) ): diff --git a/packages/gapic-generator/tests/integration/goldens/storagebatchoperations/google/cloud/storagebatchoperations_v1/services/storage_batch_operations/transports/grpc_asyncio.py b/packages/gapic-generator/tests/integration/goldens/storagebatchoperations/google/cloud/storagebatchoperations_v1/services/storage_batch_operations/transports/grpc_asyncio.py index 364bc887126e..9abce3fe8581 100755 --- a/packages/gapic-generator/tests/integration/goldens/storagebatchoperations/google/cloud/storagebatchoperations_v1/services/storage_batch_operations/transports/grpc_asyncio.py +++ b/packages/gapic-generator/tests/integration/goldens/storagebatchoperations/google/cloud/storagebatchoperations_v1/services/storage_batch_operations/transports/grpc_asyncio.py @@ -20,7 +20,11 @@ from typing import Awaitable, Callable, Dict, Optional, Sequence, Tuple, Union from typing import TYPE_CHECKING -from google.cloud.storagebatchoperations_v1._compat import _observability, apply_async_channel_interceptors +from google.cloud.storagebatchoperations_v1._compat import ( + _observability, + SUPPORTS_DEFAULT_HOST as _SUPPORTS_DEFAULT_HOST, + apply_async_channel_interceptors, +) from google.api_core import client_options as client_options_lib from google.api_core import gapic_v1 @@ -341,8 +345,11 @@ def __init__(self, *, # NOTE: Coverage tool ignores async interceptors in environments running # legacy google-api-core (< 2.36.0) where OpenTelemetry is unavailable. # TODO (Issue #18657): Remove once lowest constraints require google-api-core >= v2.43.0. + _otel_interceptor_kwargs = ( + {"default_host": self._default_host} if _SUPPORTS_DEFAULT_HOST else {} + ) # pragma: NO COVER self._grpc_channel = _observability.apply_otel_async_channel_interceptors( - self._grpc_channel, self._client_options + self._grpc_channel, self._client_options, **_otel_interceptor_kwargs ) # pragma: NO COVER self._logged_channel = self._grpc_channel diff --git a/packages/gapic-generator/tests/integration/goldens/storagebatchoperations/tests/unit/gapic/storagebatchoperations_v1/test_storage_batch_operations.py b/packages/gapic-generator/tests/integration/goldens/storagebatchoperations/tests/unit/gapic/storagebatchoperations_v1/test_storage_batch_operations.py index c1f5ed51ac5c..afce4a107cc7 100755 --- a/packages/gapic-generator/tests/integration/goldens/storagebatchoperations/tests/unit/gapic/storagebatchoperations_v1/test_storage_batch_operations.py +++ b/packages/gapic-generator/tests/integration/goldens/storagebatchoperations/tests/unit/gapic/storagebatchoperations_v1/test_storage_batch_operations.py @@ -851,7 +851,14 @@ def test_storage_batch_operations_grpc_transport_otel_channel_interceptor(): client_options=options, ) - mock_get_interceptor.assert_called_once_with(options) + expected_kwargs = ( + {"default_host": transport._default_host} + if transports.grpc._SUPPORTS_DEFAULT_HOST + else {} + ) + mock_get_interceptor.assert_called_once_with( + transport._client_options, **expected_kwargs + ) mock_apply_interceptors.assert_called_once_with( mock_channel, [mock_otel_interceptor] ) @@ -925,7 +932,14 @@ def test_storage_batch_operations_grpc_asyncio_transport_otel_channel_intercepto client_options=options, ) - mock_apply_async_interceptors.assert_called_once_with(mock_channel, options) + expected_kwargs = ( + {"default_host": transport._default_host} + if transports.grpc_asyncio._SUPPORTS_DEFAULT_HOST + else {} + ) + mock_apply_async_interceptors.assert_called_once_with( + mock_channel, transport._client_options, **expected_kwargs + ) assert mock_create_channel.call_count == 1 assert transport.grpc_channel == mock_channel @@ -8459,7 +8473,8 @@ def test_api_key_credentials(client_class, transport_class): def test_storage_batch_operations_base_transport_wrap_method_modern_api_core(monkeypatch): monkeypatch.setattr(transports.base, "_WRAP_METHOD_SUPPORTS_TRACING", True) - # 1. Concrete transport: passes client_options=None and kind="grpc" + monkeypatch.setattr(transports.base, "_SUPPORTS_DEFAULT_HOST", True) + # 1. Concrete transport: passes client_options=None, default_host, and kind="grpc" with mock.patch.object(transports.base.gapic_v1.method, "wrap_method") as mock_wrap: transport = transports.StorageBatchOperationsGrpcTransport( credentials=ga_credentials.AnonymousCredentials() @@ -8468,8 +8483,10 @@ def test_storage_batch_operations_base_transport_wrap_method_modern_api_core(mon transport._wrap_method(func) assert mock_wrap.call_args.kwargs["kind"] == "grpc" assert "client_options" in mock_wrap.call_args.kwargs + assert mock_wrap.call_args.kwargs["default_host"] == transport._default_host - # 2. Base transport: kind raises NotImplementedError and is cleanly omitted + # 2. Base transport with _SUPPORTS_DEFAULT_HOST=False: kind raises NotImplementedError and default_host is omitted + monkeypatch.setattr(transports.base, "_SUPPORTS_DEFAULT_HOST", False) with mock.patch.object(transports.base.gapic_v1.method, "wrap_method") as mock_wrap: base_transport = transports.StorageBatchOperationsTransport( credentials=ga_credentials.AnonymousCredentials() @@ -8478,6 +8495,84 @@ def test_storage_batch_operations_base_transport_wrap_method_modern_api_core(mon base_transport._wrap_method(func) assert "kind" not in mock_wrap.call_args.kwargs assert "client_options" in mock_wrap.call_args.kwargs + assert "default_host" not in mock_wrap.call_args.kwargs + + +def test_storage_batch_operations_base_transport_client_options_none(): + transport = transports.StorageBatchOperationsTransport( + host="default.example.com:443", + credentials=ga_credentials.AnonymousCredentials(), + client_options=None, + ) + assert transport._client_options is None + assert transport._default_host == "default.example.com:443" + + +@pytest.mark.parametrize( + "initial_endpoint", + [ + pytest.param(None, id="unset_api_endpoint"), + pytest.param("custom.example.com:443", id="explicit_api_endpoint"), + ], +) +def test_storage_batch_operations_base_transport_client_options_object( + initial_endpoint, +): + options = client_options.ClientOptions(api_endpoint=initial_endpoint) + transport = transports.StorageBatchOperationsTransport( + host="default.example.com:443", + credentials=ga_credentials.AnonymousCredentials(), + client_options=options, + ) + assert transport._client_options is options + assert transport._client_options.api_endpoint == initial_endpoint + assert transport._default_host == "default.example.com:443" + + +@pytest.mark.parametrize( + "initial_options,expected_endpoint", + [ + pytest.param( + {}, + None, + id="unset_api_endpoint", + ), + pytest.param( + {"api_endpoint": "custom.example.com:443"}, + "custom.example.com:443", + id="explicit_api_endpoint", + ), + ], +) +def test_storage_batch_operations_base_transport_client_options_dict( + initial_options, expected_endpoint +): + options = dict(initial_options) + transport = transports.StorageBatchOperationsTransport( + host="default.example.com:443", + credentials=ga_credentials.AnonymousCredentials(), + client_options=options, + ) + assert isinstance(transport._client_options, client_options.ClientOptions) + assert transport._client_options.api_endpoint == expected_endpoint + assert transport._default_host == "default.example.com:443" + assert options == initial_options + + +def test_storage_batch_operations_base_transport_default_host_ignored_when_ignore_credentials(): + class _CustomChannelTransport(transports.StorageBatchOperationsTransport): + def __init__(self, **kwargs): + self._ignore_credentials = True + super().__init__(**kwargs) + + options = client_options.ClientOptions() + transport = _CustomChannelTransport( + host="default.example.com:443", + client_options=options, + ) + assert transport._client_options is options + assert transport._client_options.api_endpoint is None + assert transport._default_host is None def test_storage_batch_operations_base_transport_wrap_method_older_api_core_fallbacks(monkeypatch): @@ -8556,7 +8651,8 @@ def test_storage_batch_operations_base_transport_wrap_method_older_api_core_fall def test_storage_batch_operations_base_transport_wrap_async_method_modern_api_core(monkeypatch): monkeypatch.setattr(transports.base, "_ASYNC_WRAP_METHOD_SUPPORTS_TRACING", True) - # 1. Concrete transport: passes client_options=None and kind="grpc_asyncio" + monkeypatch.setattr(transports.base, "_SUPPORTS_DEFAULT_HOST", True) + # 1. Concrete transport: passes client_options=None, default_host, and kind="grpc_asyncio" with mock.patch.object(transports.base.gapic_v1.method_async, "wrap_method") as mock_wrap: transport = transports.StorageBatchOperationsGrpcAsyncIOTransport( credentials=ga_credentials.AnonymousCredentials() @@ -8565,8 +8661,10 @@ def test_storage_batch_operations_base_transport_wrap_async_method_modern_api_co transport._wrap_async_method(func) assert mock_wrap.call_args.kwargs["kind"] == "grpc_asyncio" assert "client_options" in mock_wrap.call_args.kwargs + assert mock_wrap.call_args.kwargs["default_host"] == transport._default_host - # 2. Base transport: kind raises NotImplementedError and is cleanly omitted + # 2. Base transport with _SUPPORTS_DEFAULT_HOST=False: kind raises NotImplementedError and default_host is omitted + monkeypatch.setattr(transports.base, "_SUPPORTS_DEFAULT_HOST", False) with mock.patch.object(transports.base.gapic_v1.method_async, "wrap_method") as mock_wrap: base_transport = transports.StorageBatchOperationsTransport( credentials=ga_credentials.AnonymousCredentials() @@ -8575,6 +8673,7 @@ def test_storage_batch_operations_base_transport_wrap_async_method_modern_api_co base_transport._wrap_async_method(func) assert "kind" not in mock_wrap.call_args.kwargs assert "client_options" in mock_wrap.call_args.kwargs + assert "default_host" not in mock_wrap.call_args.kwargs def test_storage_batch_operations_base_transport_wrap_async_method_older_api_core_fallbacks(monkeypatch): diff --git a/packages/gapic-generator/tests/system/telemetry_requirements_matrix.csv b/packages/gapic-generator/tests/system/telemetry_requirements_matrix.csv index 6b52380bc2c6..9aa07baf72aa 100644 --- a/packages/gapic-generator/tests/system/telemetry_requirements_matrix.csv +++ b/packages/gapic-generator/tests/system/telemetry_requirements_matrix.csv @@ -6,28 +6,28 @@ F1.4,Client Timeout (HTTP),T4,0,HTTP/REST,Client Timeout,1,CLIENT,POST /v1beta1/ F1.5a,Retry Recovery - Attempt 1 (HTTP),T4,0,HTTP/REST,Retry Recovery,2,CLIENT,POST /v1beta1/{name=sequences/*},ERROR,http,NOT SET,NOT SET,POST,503,NOT SET,googleapis.com,/v1beta1/{name=sequences/*},localhost,7469,503,Temporary glitch,NOT SET,T3.span_id,url.full F1.5b,Retry Recovery - Attempt 2 (HTTP),T4,1,HTTP/REST,Retry Recovery,2,CLIENT,POST /v1beta1/{name=sequences/*},UNSET,http,NOT SET,NOT SET,POST,200,NOT SET,googleapis.com,/v1beta1/{name=sequences/*},localhost,7469,NOT SET,NOT SET,NOT SET,T3.span_id,url.full F1.6,Tracing Off (gRPC),T4,N/A,gRPC,Tracing Off,0,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,No spans emitted -F1.7,Unary Success (gRPC),T4,0,gRPC,Happy Path,1,CLIENT,google.showcase.v1beta1.Echo/Echo,UNSET,grpc,google.showcase.v1beta1.Echo/Echo,OK,NOT SET,NOT SET,0,googleapis.com,NOT SET,NOT SET,NOT SET,NOT SET,NOT SET,NOT SET,T3.span_id,"rpc.system, rpc.service" -F1.8,Server Error (gRPC),T4,0,gRPC,Server Failure,1,CLIENT,google.showcase.v1beta1.Echo/Echo,ERROR,grpc,google.showcase.v1beta1.Echo/Echo,NOT SET,NOT SET,NOT SET,3,googleapis.com,NOT SET,NOT SET,NOT SET,NOT SET,NOT SET,NOT SET,T3.span_id,"rpc.system, rpc.service" -F1.9,Client Timeout (gRPC),T4,0,gRPC,Client Timeout,1,CLIENT,google.showcase.v1beta1.Echo/Block,ERROR,grpc,google.showcase.v1beta1.Echo/Block,NOT SET,NOT SET,NOT SET,4,googleapis.com,NOT SET,NOT SET,NOT SET,NOT SET,NOT SET,NOT SET,T3.span_id,"rpc.system, rpc.service" -F1.10a,Retry Recovery - Attempt 1 (gRPC),T4,0,gRPC,Retry Recovery,2,CLIENT,google.showcase.v1beta1.SequenceService/AttemptSequence,ERROR,grpc,google.showcase.v1beta1.SequenceService/AttemptSequence,NOT SET,NOT SET,NOT SET,14,googleapis.com,NOT SET,NOT SET,NOT SET,NOT SET,NOT SET,NOT SET,T3.span_id,"rpc.system, rpc.service" -F1.10b,Retry Recovery - Attempt 2 (gRPC),T4,1,gRPC,Retry Recovery,2,CLIENT,google.showcase.v1beta1.SequenceService/AttemptSequence,UNSET,grpc,google.showcase.v1beta1.SequenceService/AttemptSequence,OK,NOT SET,NOT SET,0,googleapis.com,NOT SET,NOT SET,NOT SET,NOT SET,NOT SET,NOT SET,T3.span_id,"rpc.system, rpc.service" +F1.7,Unary Success (gRPC),T4,0,gRPC,Happy Path,1,CLIENT,google.showcase.v1beta1.Echo/Echo,UNSET,grpc,google.showcase.v1beta1.Echo/Echo,OK,NOT SET,NOT SET,0,googleapis.com,NOT SET,localhost,7469,NOT SET,NOT SET,NOT SET,T3.span_id,"rpc.system, rpc.service" +F1.8,Server Error (gRPC),T4,0,gRPC,Server Failure,1,CLIENT,google.showcase.v1beta1.Echo/Echo,ERROR,grpc,google.showcase.v1beta1.Echo/Echo,NOT SET,NOT SET,NOT SET,3,googleapis.com,NOT SET,localhost,7469,NOT SET,NOT SET,NOT SET,T3.span_id,"rpc.system, rpc.service" +F1.9,Client Timeout (gRPC),T4,0,gRPC,Client Timeout,1,CLIENT,google.showcase.v1beta1.Echo/Block,ERROR,grpc,google.showcase.v1beta1.Echo/Block,NOT SET,NOT SET,NOT SET,4,googleapis.com,NOT SET,localhost,7469,NOT SET,NOT SET,NOT SET,T3.span_id,"rpc.system, rpc.service" +F1.10a,Retry Recovery - Attempt 1 (gRPC),T4,0,gRPC,Retry Recovery,2,CLIENT,google.showcase.v1beta1.SequenceService/AttemptSequence,ERROR,grpc,google.showcase.v1beta1.SequenceService/AttemptSequence,NOT SET,NOT SET,NOT SET,14,googleapis.com,NOT SET,localhost,7469,NOT SET,NOT SET,NOT SET,T3.span_id,"rpc.system, rpc.service" +F1.10b,Retry Recovery - Attempt 2 (gRPC),T4,1,gRPC,Retry Recovery,2,CLIENT,google.showcase.v1beta1.SequenceService/AttemptSequence,UNSET,grpc,google.showcase.v1beta1.SequenceService/AttemptSequence,OK,NOT SET,NOT SET,0,googleapis.com,NOT SET,localhost,7469,NOT SET,NOT SET,NOT SET,T3.span_id,"rpc.system, rpc.service" F2.1,Tracing Off (HTTP),T3,N/A,HTTP/REST,Tracing Off,0,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,No spans emitted -F2.2,Unary Success (HTTP),T3,0,HTTP/REST,Happy Path,1,INTERNAL,google.showcase.v1beta1.Echo/Echo,UNSET,http,google.showcase.v1beta1.Echo/Echo,OK,NOT SET,200,NOT SET,googleapis.com,/v1beta1/echo:echo,NOT SET,NOT SET,NOT SET,NOT SET,NOT SET,None (Root),Strict ceiling (no unknown attrs) -F2.3,Server Error (HTTP),T3,0,HTTP/REST,Server Failure,1,INTERNAL,google.showcase.v1beta1.Echo/Echo,ERROR,http,google.showcase.v1beta1.Echo/Echo,BAD_REQUEST,NOT SET,400,NOT SET,googleapis.com,/v1beta1/echo:echo,NOT SET,NOT SET,400,Simulated unretryable invalid argument error.,NOT SET,None (Root),"gcp.errors.domain, gcp.errors.metadata.*" -F2.4,Client Timeout (HTTP),T3,0,HTTP/REST,Client Timeout,1,INTERNAL,google.showcase.v1beta1.Echo/Block,ERROR,http,google.showcase.v1beta1.Echo/Block,ReadTimeout,NOT SET,NOT SET,NOT SET,googleapis.com,/v1beta1/echo:block,NOT SET,NOT SET,ReadTimeout,"HTTPConnectionPool(host='localhost', port=7469): Read timed out. (read timeout=0.2)",NOT SET,None (Root),gcp.errors.domain +F2.2,Unary Success (HTTP),T3,0,HTTP/REST,Happy Path,1,INTERNAL,google.showcase.v1beta1.Echo/Echo,UNSET,http,google.showcase.v1beta1.Echo/Echo,OK,NOT SET,200,NOT SET,googleapis.com,/v1beta1/echo:echo,localhost,7469,NOT SET,NOT SET,NOT SET,None (Root),Strict ceiling (no unknown attrs) +F2.3,Server Error (HTTP),T3,0,HTTP/REST,Server Failure,1,INTERNAL,google.showcase.v1beta1.Echo/Echo,ERROR,http,google.showcase.v1beta1.Echo/Echo,BAD_REQUEST,NOT SET,400,NOT SET,googleapis.com,/v1beta1/echo:echo,localhost,7469,400,Simulated unretryable invalid argument error.,NOT SET,None (Root),"gcp.errors.domain, gcp.errors.metadata.*" +F2.4,Client Timeout (HTTP),T3,0,HTTP/REST,Client Timeout,1,INTERNAL,google.showcase.v1beta1.Echo/Block,ERROR,http,google.showcase.v1beta1.Echo/Block,ReadTimeout,NOT SET,NOT SET,NOT SET,googleapis.com,/v1beta1/echo:block,localhost,7469,ReadTimeout,"HTTPConnectionPool(host='localhost', port=7469): Read timed out. (read timeout=0.2)",NOT SET,None (Root),gcp.errors.domain F2.5,Tracing Off (gRPC),T3,N/A,gRPC,Tracing Off,0,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,No spans emitted -F2.6,Unary Success (gRPC),T3,0,gRPC,Happy Path,1,INTERNAL,google.showcase.v1beta1.Echo/Echo,UNSET,grpc,google.showcase.v1beta1.Echo/Echo,OK,NOT SET,NOT SET,NOT SET,googleapis.com,NOT SET,NOT SET,NOT SET,NOT SET,NOT SET,NOT SET,None (Root),Strict ceiling (no unknown attrs) -F2.7,Server Error (gRPC),T3,0,gRPC,Server Failure,1,INTERNAL,google.showcase.v1beta1.Echo/Echo,ERROR,grpc,google.showcase.v1beta1.Echo/Echo,INVALID_ARGUMENT,NOT SET,NOT SET,NOT SET,googleapis.com,NOT SET,NOT SET,NOT SET,INVALID_ARGUMENT,Simulated unretryable invalid argument error.,NOT SET,None (Root),"gcp.errors.domain, gcp.errors.metadata.*" -F2.8,Client Timeout (gRPC),T3,0,gRPC,Client Timeout,1,INTERNAL,google.showcase.v1beta1.Echo/Block,ERROR,grpc,google.showcase.v1beta1.Echo/Block,DEADLINE_EXCEEDED,NOT SET,NOT SET,NOT SET,googleapis.com,NOT SET,NOT SET,NOT SET,DEADLINE_EXCEEDED,Deadline Exceeded,NOT SET,None (Root),gcp.errors.domain -F3.1a,Retry Succeeds - Root (HTTP),T3,0,HTTP/REST,Retry Recovery,1,INTERNAL,google.showcase.v1beta1.SequenceService/AttemptSequence,UNSET,http,google.showcase.v1beta1.SequenceService/AttemptSequence,OK,NOT SET,200,NOT SET,googleapis.com,/v1beta1/{name=sequences/*},NOT SET,NOT SET,NOT SET,NOT SET,NOT SET,None (Root),Parent aggregates OK; T4 children nested under T3 +F2.6,Unary Success (gRPC),T3,0,gRPC,Happy Path,1,INTERNAL,google.showcase.v1beta1.Echo/Echo,UNSET,grpc,google.showcase.v1beta1.Echo/Echo,OK,NOT SET,NOT SET,NOT SET,googleapis.com,NOT SET,localhost,7469,NOT SET,NOT SET,NOT SET,None (Root),Strict ceiling (no unknown attrs) +F2.7,Server Error (gRPC),T3,0,gRPC,Server Failure,1,INTERNAL,google.showcase.v1beta1.Echo/Echo,ERROR,grpc,google.showcase.v1beta1.Echo/Echo,INVALID_ARGUMENT,NOT SET,NOT SET,NOT SET,googleapis.com,NOT SET,localhost,7469,INVALID_ARGUMENT,Simulated unretryable invalid argument error.,NOT SET,None (Root),"gcp.errors.domain, gcp.errors.metadata.*" +F2.8,Client Timeout (gRPC),T3,0,gRPC,Client Timeout,1,INTERNAL,google.showcase.v1beta1.Echo/Block,ERROR,grpc,google.showcase.v1beta1.Echo/Block,DEADLINE_EXCEEDED,NOT SET,NOT SET,NOT SET,googleapis.com,NOT SET,localhost,7469,DEADLINE_EXCEEDED,Deadline Exceeded,NOT SET,None (Root),gcp.errors.domain +F3.1a,Retry Succeeds - Root (HTTP),T3,0,HTTP/REST,Retry Recovery,1,INTERNAL,google.showcase.v1beta1.SequenceService/AttemptSequence,UNSET,http,google.showcase.v1beta1.SequenceService/AttemptSequence,OK,NOT SET,200,NOT SET,googleapis.com,/v1beta1/{name=sequences/*},localhost,7469,NOT SET,NOT SET,NOT SET,None (Root),Parent aggregates OK; T4 children nested under T3 F3.1b,Retry Succeeds - Attempt 1 (HTTP),T4,0,HTTP/REST,Retry Recovery,2,CLIENT,POST /v1beta1/{name=sequences/*},ERROR,http,NOT SET,NOT SET,POST,503,NOT SET,googleapis.com,/v1beta1/{name=sequences/*},localhost,7469,503,Temporary glitch,NOT SET,T3.span_id,Parent aggregates OK; T4 children nested under T3 F3.1c,Retry Succeeds - Attempt 2 (HTTP),T4,1,HTTP/REST,Retry Recovery,2,CLIENT,POST /v1beta1/{name=sequences/*},UNSET,http,NOT SET,NOT SET,POST,200,NOT SET,googleapis.com,/v1beta1/{name=sequences/*},localhost,7469,NOT SET,NOT SET,NOT SET,T3.span_id,Parent aggregates OK; T4 children nested under T3 -F3.2a,Retries Exhausted - Root (HTTP),T3,0,HTTP/REST,Retries Exhausted,1,INTERNAL,google.showcase.v1beta1.SequenceService/AttemptSequence,ERROR,http,google.showcase.v1beta1.SequenceService/AttemptSequence,UNAVAILABLE,NOT SET,503,NOT SET,googleapis.com,/v1beta1/{name=sequences/*},NOT SET,NOT SET,503,Persistent outage,NOT SET,None (Root),Parent aggregates UNAVAILABLE; all T4 children nested under T3 +F3.2a,Retries Exhausted - Root (HTTP),T3,0,HTTP/REST,Retries Exhausted,1,INTERNAL,google.showcase.v1beta1.SequenceService/AttemptSequence,ERROR,http,google.showcase.v1beta1.SequenceService/AttemptSequence,UNAVAILABLE,NOT SET,503,NOT SET,googleapis.com,/v1beta1/{name=sequences/*},localhost,7469,503,Persistent outage,NOT SET,None (Root),Parent aggregates UNAVAILABLE; all T4 children nested under T3 F3.2b,Retries Exhausted - Attempt 1 (HTTP),T4,0,HTTP/REST,Retries Exhausted,2,CLIENT,POST /v1beta1/{name=sequences/*},ERROR,http,NOT SET,NOT SET,POST,503,NOT SET,googleapis.com,/v1beta1/{name=sequences/*},localhost,7469,503,Persistent outage,NOT SET,T3.span_id,Parent aggregates UNAVAILABLE; all T4 children nested under T3 F3.2c,Retries Exhausted - Attempt 2 (HTTP),T4,1,HTTP/REST,Retries Exhausted,2,CLIENT,POST /v1beta1/{name=sequences/*},ERROR,http,NOT SET,NOT SET,POST,503,NOT SET,googleapis.com,/v1beta1/{name=sequences/*},localhost,7469,503,Persistent outage,NOT SET,T3.span_id,Parent aggregates UNAVAILABLE; all T4 children nested under T3 -F3.3a,Retry Succeeds - Root (gRPC),T3,0,gRPC,Retry Recovery,1,INTERNAL,google.showcase.v1beta1.SequenceService/AttemptSequence,UNSET,grpc,google.showcase.v1beta1.SequenceService/AttemptSequence,OK,NOT SET,NOT SET,NOT SET,googleapis.com,NOT SET,NOT SET,NOT SET,NOT SET,NOT SET,NOT SET,None (Root),Parent aggregates OK; T4 children nested under T3 -F3.3b,Retry Succeeds - Attempt 1 (gRPC),T4,0,gRPC,Retry Recovery,2,CLIENT,google.showcase.v1beta1.SequenceService/AttemptSequence,ERROR,grpc,google.showcase.v1beta1.SequenceService/AttemptSequence,NOT SET,NOT SET,NOT SET,14,googleapis.com,NOT SET,NOT SET,NOT SET,NOT SET,NOT SET,NOT SET,T3.span_id,Parent aggregates OK; T4 children nested under T3 -F3.3c,Retry Succeeds - Attempt 2 (gRPC),T4,1,gRPC,Retry Recovery,2,CLIENT,google.showcase.v1beta1.SequenceService/AttemptSequence,UNSET,grpc,google.showcase.v1beta1.SequenceService/AttemptSequence,OK,NOT SET,NOT SET,0,googleapis.com,NOT SET,NOT SET,NOT SET,NOT SET,NOT SET,NOT SET,T3.span_id,Parent aggregates OK; T4 children nested under T3 -F3.4a,Retries Exhausted - Root (gRPC),T3,0,gRPC,Retries Exhausted,1,INTERNAL,google.showcase.v1beta1.SequenceService/AttemptSequence,ERROR,grpc,google.showcase.v1beta1.SequenceService/AttemptSequence,UNAVAILABLE,NOT SET,NOT SET,NOT SET,googleapis.com,NOT SET,NOT SET,NOT SET,UNAVAILABLE,Persistent outage,NOT SET,None (Root),Parent aggregates UNAVAILABLE; all T4 children nested under T3 -F3.4b,Retries Exhausted - Attempt 1 (gRPC),T4,0,gRPC,Retries Exhausted,2,CLIENT,google.showcase.v1beta1.SequenceService/AttemptSequence,ERROR,grpc,google.showcase.v1beta1.SequenceService/AttemptSequence,NOT SET,NOT SET,NOT SET,14,googleapis.com,NOT SET,NOT SET,NOT SET,NOT SET,NOT SET,NOT SET,T3.span_id,Parent aggregates UNAVAILABLE; all T4 children nested under T3 -F3.4c,Retries Exhausted - Attempt 2 (gRPC),T4,1,gRPC,Retries Exhausted,2,CLIENT,google.showcase.v1beta1.SequenceService/AttemptSequence,ERROR,grpc,google.showcase.v1beta1.SequenceService/AttemptSequence,NOT SET,NOT SET,NOT SET,14,googleapis.com,NOT SET,NOT SET,NOT SET,NOT SET,NOT SET,NOT SET,T3.span_id,Parent aggregates UNAVAILABLE; all T4 children nested under T3 +F3.3a,Retry Succeeds - Root (gRPC),T3,0,gRPC,Retry Recovery,1,INTERNAL,google.showcase.v1beta1.SequenceService/AttemptSequence,UNSET,grpc,google.showcase.v1beta1.SequenceService/AttemptSequence,OK,NOT SET,NOT SET,NOT SET,googleapis.com,NOT SET,localhost,7469,NOT SET,NOT SET,NOT SET,None (Root),Parent aggregates OK; T4 children nested under T3 +F3.3b,Retry Succeeds - Attempt 1 (gRPC),T4,0,gRPC,Retry Recovery,2,CLIENT,google.showcase.v1beta1.SequenceService/AttemptSequence,ERROR,grpc,google.showcase.v1beta1.SequenceService/AttemptSequence,NOT SET,NOT SET,NOT SET,14,googleapis.com,NOT SET,localhost,7469,NOT SET,NOT SET,NOT SET,T3.span_id,Parent aggregates OK; T4 children nested under T3 +F3.3c,Retry Succeeds - Attempt 2 (gRPC),T4,1,gRPC,Retry Recovery,2,CLIENT,google.showcase.v1beta1.SequenceService/AttemptSequence,UNSET,grpc,google.showcase.v1beta1.SequenceService/AttemptSequence,OK,NOT SET,NOT SET,0,googleapis.com,NOT SET,localhost,7469,NOT SET,NOT SET,NOT SET,T3.span_id,Parent aggregates OK; T4 children nested under T3 +F3.4a,Retries Exhausted - Root (gRPC),T3,0,gRPC,Retries Exhausted,1,INTERNAL,google.showcase.v1beta1.SequenceService/AttemptSequence,ERROR,grpc,google.showcase.v1beta1.SequenceService/AttemptSequence,UNAVAILABLE,NOT SET,NOT SET,NOT SET,googleapis.com,NOT SET,localhost,7469,UNAVAILABLE,Persistent outage,NOT SET,None (Root),Parent aggregates UNAVAILABLE; all T4 children nested under T3 +F3.4b,Retries Exhausted - Attempt 1 (gRPC),T4,0,gRPC,Retries Exhausted,2,CLIENT,google.showcase.v1beta1.SequenceService/AttemptSequence,ERROR,grpc,google.showcase.v1beta1.SequenceService/AttemptSequence,NOT SET,NOT SET,NOT SET,14,googleapis.com,NOT SET,localhost,7469,NOT SET,NOT SET,NOT SET,T3.span_id,Parent aggregates UNAVAILABLE; all T4 children nested under T3 +F3.4c,Retries Exhausted - Attempt 2 (gRPC),T4,1,gRPC,Retries Exhausted,2,CLIENT,google.showcase.v1beta1.SequenceService/AttemptSequence,ERROR,grpc,google.showcase.v1beta1.SequenceService/AttemptSequence,NOT SET,NOT SET,NOT SET,14,googleapis.com,NOT SET,localhost,7469,NOT SET,NOT SET,NOT SET,T3.span_id,Parent aggregates UNAVAILABLE; all T4 children nested under T3 diff --git a/packages/gapic-generator/tests/system/test_span_compliance.py b/packages/gapic-generator/tests/system/test_span_compliance.py index cc1a87a66a5a..18443ebc86c2 100644 --- a/packages/gapic-generator/tests/system/test_span_compliance.py +++ b/packages/gapic-generator/tests/system/test_span_compliance.py @@ -201,6 +201,20 @@ def span_exporter(monkeypatch): # --------------------------------------------------------------------------- +def _insecure_channel_factory(host: str, **kwargs: Any) -> Any: + """Callable channel factory for local Showcase gRPC daemon testing. + + Passing a callable factory instead of a pre-constructed ``grpc.Channel`` + instance ensures ``EchoGrpcTransport`` resolves ``self._host`` from its + ``DEFAULT_HOST`` ("localhost:7469") through the standard host-managed path + (``self._ignore_credentials = False``) while still creating an insecure + channel to the local Showcase server. + """ + import grpc + + return grpc.insecure_channel(host) + + def construct_observability_client( client_class, transport: str, @@ -227,11 +241,15 @@ def construct_observability_client( Returns: An instantiated, ready-to-call Showcase client instance. """ + channel = ( + _insecure_channel_factory if (transport == "grpc" and not use_mtls) else None + ) return conftest.construct_client( client_class, use_mtls=use_mtls, transport_name=transport, client_options=client_options, + channel=channel, ) diff --git a/packages/google-api-core/google/api_core/_observability.py b/packages/google-api-core/google/api_core/_observability.py index 38874b666b6e..34e1ca152c37 100644 --- a/packages/google-api-core/google/api_core/_observability.py +++ b/packages/google-api-core/google/api_core/_observability.py @@ -76,27 +76,32 @@ def is_otel_capabilities_enabled( def _extract_endpoint_attributes( client_options: ClientOptions | dict[str, Any] | None = None, + default_host: str | None = None, ) -> dict[str, Any]: """Extracts server.address, server.port (if non-default), and url.domain from client options if present. Args: client_options (Optional[Union[ClientOptions, dict[str, Any]]]): The client options object or dictionary. + default_host (Optional[str]): Fallback transport host when ``api_endpoint`` is not + set on ``client_options``. Returns: - dict[str, Any]: A dictionary containing url.domain and, if an api_endpoint is configured, - server.address and non-default server.port. + dict[str, Any]: A dictionary containing url.domain and, if an api_endpoint or default_host + is configured, server.address and non-default server.port. """ attrs: dict[str, Any] = {} endpoint = None universe_domain = None if isinstance(client_options, dict): - endpoint = client_options.get("api_endpoint") + endpoint = client_options.get("api_endpoint") or default_host universe_domain = client_options.get("universe_domain") elif client_options is not None: - endpoint = getattr(client_options, "api_endpoint", None) + endpoint = getattr(client_options, "api_endpoint", None) or default_host universe_domain = getattr(client_options, "universe_domain", None) + else: + endpoint = default_host attrs["url.domain"] = universe_domain or "googleapis.com" @@ -367,12 +372,15 @@ async def intercept_stream_stream( def get_otel_interceptor( client_options: ClientOptions | dict[str, Any] | None = None, + default_host: str | None = None, ) -> Callable[[grpc.Channel], grpc.Channel] | None: """Returns an interceptor callable that wraps a sync gRPC channel with OpenTelemetry tracing. Args: client_options (Optional[Union[ClientOptions, dict[str, Any]]]): The client options object or dictionary used for feature gating and extracting the tracer provider. + default_host (Optional[str]): Fallback transport host when ``api_endpoint`` is not + set on ``client_options``. Returns: Callable[[grpc.Channel], grpc.Channel] | None: An interceptor callable if OpenTelemetry @@ -383,7 +391,9 @@ def get_otel_interceptor( import opentelemetry.instrumentation.grpc as otel_grpc # type: ignore[import-not-found] - endpoint_attrs = _extract_endpoint_attributes(client_options) + endpoint_attrs = _extract_endpoint_attributes( + client_options, default_host=default_host + ) request_hook = _make_grpc_client_request_hook(endpoint_attrs) interceptor: ClientInterceptor = otel_grpc.client_interceptor( @@ -408,12 +418,15 @@ def otel_interceptor(channel: grpc.Channel) -> grpc.Channel: def get_otel_async_interceptor( client_options: ClientOptions | dict[str, Any] | None = None, + default_host: str | None = None, ) -> Sequence[grpc.aio.ClientInterceptor] | None: """Returns async gRPC client interceptors for OpenTelemetry tracing. Args: client_options (Optional[Union[ClientOptions, dict[str, Any]]]): The client options object or dictionary used for feature gating and extracting the tracer provider. + default_host (Optional[str]): Fallback transport host when ``api_endpoint`` is not + set on ``client_options``. Returns: Sequence[grpc.aio.ClientInterceptor] | None: Instantiated OpenTelemetry async @@ -425,7 +438,9 @@ def get_otel_async_interceptor( # Ignored by mypy: Optional dependency only loaded if early-return is skipped import opentelemetry.instrumentation.grpc as otel_grpc # type: ignore[import-not-found] - endpoint_attrs = _extract_endpoint_attributes(client_options) + endpoint_attrs = _extract_endpoint_attributes( + client_options, default_host=default_host + ) request_hook = _make_grpc_client_request_hook(endpoint_attrs) raw_interceptors = otel_grpc.aio_client_interceptors( @@ -454,6 +469,7 @@ def get_otel_async_interceptor( def apply_otel_async_channel_interceptors( channel: Any, client_options: ClientOptions | dict[str, Any] | Sequence[Any] | None = None, + default_host: str | None = None, ) -> Any: """Creates and attaches OpenTelemetry client interceptors to the front of an async gRPC channel. @@ -467,6 +483,8 @@ def apply_otel_async_channel_interceptors( client_options (Optional[Union[ClientOptions, dict[str, Any], Sequence[Any]]]): The client options used to resolve feature flags and tracer provider, or a sequence of pre-resolved interceptors. + default_host (Optional[str]): Fallback transport host when ``api_endpoint`` is not + set on ``client_options``. Returns: The channel with interceptors prepended to its internal lists, @@ -474,7 +492,9 @@ def apply_otel_async_channel_interceptors( """ otel_interceptors: Sequence[Any] | None if isinstance(client_options, (ClientOptions, dict)) or client_options is None: - otel_interceptors = get_otel_async_interceptor(client_options) + otel_interceptors = get_otel_async_interceptor( + client_options, default_host=default_host + ) elif isinstance(client_options, Sequence): otel_interceptors = client_options else: diff --git a/packages/google-api-core/google/api_core/gapic_v1/method.py b/packages/google-api-core/google/api_core/gapic_v1/method.py index 873e925ec1f7..c154d0173320 100644 --- a/packages/google-api-core/google/api_core/gapic_v1/method.py +++ b/packages/google-api-core/google/api_core/gapic_v1/method.py @@ -295,6 +295,8 @@ class _GapicCallable(object): Client information used for metadata headers. Defaults to None. kind (str): The transport kind for the RPC method. Defaults to "grpc". Allowed values for OpenTelemetry method tracing are "grpc" and "rest". + default_host (Optional[str]): Fallback transport host when ``api_endpoint`` + is not set on ``client_options``. Defaults to None. """ _SUPPORTED_TRACING_KINDS = (_TRANSPORT_KIND_GRPC, _TRANSPORT_KIND_REST) @@ -311,6 +313,7 @@ def __init__( is_streaming=False, client_info=None, kind=_DEFAULT_TRANSPORT_KIND, + default_host=None, ): self._target = target self._retry = retry @@ -352,7 +355,7 @@ def __init__( span_name, _, _ = _extract_rpc_identity(method_name) endpoint_attrs = _observability._extract_endpoint_attributes( - client_options + client_options, default_host=default_host ) span_attributes = { "rpc.system.name": "http" if kind.startswith("rest") else "grpc", @@ -452,6 +455,7 @@ def wrap_method( method_name=None, is_streaming=False, kind=_DEFAULT_TRANSPORT_KIND, + default_host=None, ): """Wrap an RPC method with common behavior. @@ -546,6 +550,8 @@ def get_topic(name, timeout=None): Streaming methods are currently gated and do not generate Tier 3 spans. kind (str): The transport kind for the RPC method. Defaults to "grpc". Allowed values for OpenTelemetry method tracing are "grpc" and "rest". + default_host (Optional[str]): Fallback transport host when ``api_endpoint`` + is not set on ``client_options``. Defaults to None. Returns: Callable: A new callable that takes optional ``retry``, ``timeout``, @@ -578,5 +584,6 @@ def get_topic(name, timeout=None): is_streaming=is_streaming, client_info=client_info, kind=kind, + default_host=default_host, ) ) diff --git a/packages/google-api-core/google/api_core/gapic_v1/method_async.py b/packages/google-api-core/google/api_core/gapic_v1/method_async.py index 2402f9f3ff95..aa7c79ea2e10 100644 --- a/packages/google-api-core/google/api_core/gapic_v1/method_async.py +++ b/packages/google-api-core/google/api_core/gapic_v1/method_async.py @@ -64,6 +64,7 @@ def wrap_method( client_options=None, method_name=None, is_streaming=False, + default_host=None, ): """Wrap an async RPC method with common behavior. @@ -93,6 +94,8 @@ def wrap_method( Used to identify the RPC for observability. is_streaming (bool): Whether the RPC method is streaming. Defaults to False. Streaming methods are currently gated and do not generate Tier 3 spans. + default_host (Optional[str]): Fallback transport host when ``api_endpoint`` + is not set on ``client_options``. Defaults to None. Returns: Callable: A new callable that takes optional ``retry``, ``timeout``, @@ -116,5 +119,6 @@ def wrap_method( is_streaming=is_streaming, client_info=client_info, kind=kind, + default_host=default_host, ) ) diff --git a/packages/google-api-core/tests/asyncio/gapic/test_method_async.py b/packages/google-api-core/tests/asyncio/gapic/test_method_async.py index 159eee84af8e..0108d9e8417a 100644 --- a/packages/google-api-core/tests/asyncio/gapic/test_method_async.py +++ b/packages/google-api-core/tests/asyncio/gapic/test_method_async.py @@ -469,6 +469,33 @@ async def test_wrap_method_async_otel_tracing_client_options( mock_provider.get_tracer.assert_called_once_with("google.api_core") +@pytest.mark.asyncio +async def test_wrap_method_async_otel_tracing_default_host(mock_otel): + """Proves that async wrap_method falls back to default_host when api_endpoint is unset.""" + mock_target = mock.AsyncMock(return_value="success") + + wrapped = gapic_v1.method_async.wrap_method( + mock_target, + client_options=client_options_lib.ClientOptions(), + method_name="/google.showcase.v1beta1.Echo/Echo", + default_host="localhost:7469", + ) + result = await wrapped() + + assert result == "success" + mock_otel.tracer.start_as_current_span.assert_called_once_with( + "google.showcase.v1beta1.Echo/Echo", + kind="INTERNAL", + attributes={ + "rpc.system.name": "grpc", + "rpc.method": "google.showcase.v1beta1.Echo/Echo", + "url.domain": "googleapis.com", + "server.address": "localhost", + "server.port": 7469, + }, + ) + + @pytest.mark.asyncio async def test_wrap_method_async_otel_tracing_enabled_error(mock_otel): """Proves that on async error, status code and error attributes are recorded and exception is raised.""" diff --git a/packages/google-api-core/tests/unit/gapic/test_method.py b/packages/google-api-core/tests/unit/gapic/test_method.py index a4a5f805ac91..1a2d85bc12c8 100644 --- a/packages/google-api-core/tests/unit/gapic/test_method.py +++ b/packages/google-api-core/tests/unit/gapic/test_method.py @@ -753,6 +753,33 @@ def test_wrap_method_otel_tracing_endpoint_attributes_from_client_options( ) +def test_wrap_method_otel_tracing_endpoint_attributes_from_default_host( + mock_otel, +): + """Proves that T3 spans fall back to default_host when client_options.api_endpoint is unset.""" + mock_target = mock.Mock(return_value="ok") + options = client_options_lib.ClientOptions() + wrapped = google.api_core.gapic_v1.method.wrap_method( + mock_target, + client_options=options, + method_name="/google.showcase.v1beta1.Echo/Echo", + kind="grpc", + default_host="localhost:7469", + ) + assert wrapped() == "ok" + mock_otel.tracer.start_as_current_span.assert_called_once_with( + "google.showcase.v1beta1.Echo/Echo", + kind="INTERNAL", + attributes={ + "rpc.system.name": "grpc", + "rpc.method": "google.showcase.v1beta1.Echo/Echo", + "url.domain": "googleapis.com", + "server.address": "localhost", + "server.port": 7469, + }, + ) + + def test_extract_error_attributes_standard_exception(): """Proves that _extract_error_attributes returns fallback error.type for exceptions without ErrorInfo.""" assert google.api_core.gapic_v1.method._extract_error_attributes( diff --git a/packages/google-api-core/tests/unit/test_observability.py b/packages/google-api-core/tests/unit/test_observability.py index 1f18428102b8..8dc0ab4efad9 100644 --- a/packages/google-api-core/tests/unit/test_observability.py +++ b/packages/google-api-core/tests/unit/test_observability.py @@ -379,6 +379,73 @@ def test_extract_endpoint_attributes(client_options, expected_attrs): assert _observability._extract_endpoint_attributes(client_options) == expected_attrs +@pytest.mark.parametrize( + "client_options,default_host,expected_attrs", + [ + pytest.param( + None, + "localhost:7469", + { + "server.address": "localhost", + "server.port": 7469, + "url.domain": "googleapis.com", + }, + id="none_options_uses_default_host", + ), + pytest.param( + {}, + "localhost:7469", + { + "server.address": "localhost", + "server.port": 7469, + "url.domain": "googleapis.com", + }, + id="empty_dict_uses_default_host", + ), + pytest.param( + ClientOptions(api_endpoint=None), + "localhost:7469", + { + "server.address": "localhost", + "server.port": 7469, + "url.domain": "googleapis.com", + }, + id="client_options_without_endpoint_uses_default_host", + ), + pytest.param( + ClientOptions(api_endpoint="custom.example.com:8443"), + "localhost:7469", + { + "server.address": "custom.example.com", + "server.port": 8443, + "url.domain": "googleapis.com", + }, + id="client_options_endpoint_overrides_default_host", + ), + pytest.param( + {"api_endpoint": "custom.example.com:8443"}, + "localhost:7469", + { + "server.address": "custom.example.com", + "server.port": 8443, + "url.domain": "googleapis.com", + }, + id="dict_endpoint_overrides_default_host", + ), + ], +) +def test_extract_endpoint_attributes_default_host( + client_options, default_host, expected_attrs +): + """Proves that _extract_endpoint_attributes falls back to default_host when api_endpoint is not set.""" + assert ( + _observability._extract_endpoint_attributes( + client_options, default_host=default_host + ) + == expected_attrs + ) + + def test_grpc_client_request_hook(): """Proves that _grpc_client_request_hook attaches extracted T4 attributes to recording spans, normalizes span names, sets fully qualified rpc.method, and allows legacy rpc.system to coexist.