From 7605848875bd0d5efc13cc4c242738960398847b Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Mon, 22 Jun 2026 11:21:36 -0400 Subject: [PATCH 1/8] feat: Add OpenTelemetry environment variable and options configuration helpers --- .../google/api_core/observability/__init__.py | 3 + .../google/api_core/observability/options.py | 110 ++++++++++++++++++ .../tests/unit/observability/test_options.py | 70 +++++++++++ 3 files changed, 183 insertions(+) create mode 100644 packages/google-api-core/google/api_core/observability/__init__.py create mode 100644 packages/google-api-core/google/api_core/observability/options.py create mode 100644 packages/google-api-core/tests/unit/observability/test_options.py diff --git a/packages/google-api-core/google/api_core/observability/__init__.py b/packages/google-api-core/google/api_core/observability/__init__.py new file mode 100644 index 000000000000..f4144485e5f9 --- /dev/null +++ b/packages/google-api-core/google/api_core/observability/__init__.py @@ -0,0 +1,3 @@ +from .options import is_signal_enabled + +__all__ = ["is_signal_enabled"] diff --git a/packages/google-api-core/google/api_core/observability/options.py b/packages/google-api-core/google/api_core/observability/options.py new file mode 100644 index 000000000000..69aabdecfd96 --- /dev/null +++ b/packages/google-api-core/google/api_core/observability/options.py @@ -0,0 +1,110 @@ +"""Observability environment variable and client options resolution helpers.""" + +import os +import warnings +from typing import Any, Dict, List, Optional, Union + +# Allowed truthy and falsy patterns for environment variables +_TRUTHY_VALUES = ("y", "yes", "t", "true", "on", "1") +_FALSY_VALUES = ("n", "no", "f", "false", "off", "0") + + +def _strtobool(val: str) -> Optional[bool]: + """Convert a string representation of truth to a boolean.""" + clean_val = val.lower().strip() + if not clean_val: + return None + if clean_val in _TRUTHY_VALUES: + return True + if clean_val in _FALSY_VALUES: + return False + raise ValueError(f"Invalid truth value: {val!r}") + + +def _get_env_bool(name: str) -> Optional[bool]: + """Retrieve the boolean value of an environment variable.""" + val = os.getenv(name) + if val is None: + return None + try: + return _strtobool(val) + except ValueError: + return None + + +def _get_env_bool_with_dev_fallback(name: str) -> Optional[bool]: + """Retrieve the boolean value of an environment variable, checking dev/exp fallbacks first.""" + if name.startswith("GOOGLE_CLOUD_"): + exp_name = name.replace("GOOGLE_CLOUD_", "GOOGLE_CLOUD_EXPERIMENTAL_", 1) + val = _get_env_bool(exp_name) + if val is not None: + return val + return _get_env_bool(name) + + +def is_signal_enabled( + service_name: str, + signal_type: str, + client_options: Optional[Union[Dict[str, Any], Any]] = None, + default: bool = False, + legacy_vars: Optional[List[str]] = None, +) -> bool: + """Determines if a telemetry signal is enabled.""" + service_upper = service_name.upper().replace("-", "_") + signal_upper = signal_type.upper() + + # 1. Resolve Programmatic Options First + if client_options is not None: + options_dict = ( + client_options + if isinstance(client_options, dict) + else getattr(client_options, "__dict__", {}) + ) + option_key = f"enable_{signal_type.lower()}" + provider_key = f"{signal_type.rstrip('s').lower()}_provider" + + if options_dict.get(option_key) is not None: + return bool(options_dict.get(option_key)) + if options_dict.get(provider_key) is not None: + return True + + # 2. Language & Service-specific + val = _get_env_bool_with_dev_fallback( + f"GOOGLE_CLOUD_PYTHON_{service_upper}_{signal_upper}_ENABLED" + ) + if val is not None: + return val + + # 3. Language-wide Global + val = _get_env_bool_with_dev_fallback(f"GOOGLE_CLOUD_PYTHON_{signal_upper}_ENABLED") + if val is not None: + return val + + # 4. Cross-language Service-specific + val = _get_env_bool_with_dev_fallback( + f"GOOGLE_CLOUD_{service_upper}_{signal_upper}_ENABLED" + ) + if val is not None: + return val + + # 5. Cross-language Global + val = _get_env_bool_with_dev_fallback(f"GOOGLE_CLOUD_{signal_upper}_ENABLED") + if val is not None: + return val + + # 6. Legacy Variables + if legacy_vars: + for legacy_var in legacy_vars: + val = _get_env_bool(legacy_var) + if val is not None: + warnings.warn( + f"Environment variable {legacy_var!r} is deprecated and will be removed " + "in a future release. Please migrate to the standardized " + f"GOOGLE_CLOUD_PYTHON_{service_upper}_{signal_upper}_ENABLED instead.", + DeprecationWarning, + stacklevel=2, + ) + return val + + # 7. Default Fallback + return default diff --git a/packages/google-api-core/tests/unit/observability/test_options.py b/packages/google-api-core/tests/unit/observability/test_options.py new file mode 100644 index 000000000000..494d2ae8816a --- /dev/null +++ b/packages/google-api-core/tests/unit/observability/test_options.py @@ -0,0 +1,70 @@ +import pytest + +from google.api_core.observability import options + + +@pytest.mark.parametrize( + "env_vars, client_options, default_val, expected", + [ + # Default fallback tests + ({}, None, False, False), + ({}, None, True, True), + # Service-specific env var + ({"GOOGLE_CLOUD_PYTHON_TRANSLATE_TRACES_ENABLED": "true"}, None, False, True), + ({"GOOGLE_CLOUD_PYTHON_TRANSLATE_TRACES_ENABLED": "false"}, None, True, False), + # Experimental fallback + ( + {"GOOGLE_CLOUD_EXPERIMENTAL_PYTHON_TRANSLATE_TRACES_ENABLED": "true"}, + None, + False, + True, + ), + # Precedence: Service specific overrides global + ( + { + "GOOGLE_CLOUD_PYTHON_TRACES_ENABLED": "true", + "GOOGLE_CLOUD_PYTHON_TRANSLATE_TRACES_ENABLED": "false", + }, + None, + False, + False, + ), + ( + { + "GOOGLE_CLOUD_PYTHON_TRACES_ENABLED": "false", + "GOOGLE_CLOUD_PYTHON_TRANSLATE_TRACES_ENABLED": "true", + }, + None, + False, + True, + ), + # Precedence: Client options override env vars + ( + {"GOOGLE_CLOUD_PYTHON_TRANSLATE_TRACES_ENABLED": "false"}, + {"enable_traces": True}, + False, + True, + ), + ], +) +def test_is_signal_enabled( + monkeypatch, env_vars, client_options, default_val, expected +): + # Setup environment variables using pytest's monkeypatch fixture + for k, v in env_vars.items(): + monkeypatch.setenv(k, v) + + result = options.is_signal_enabled( + "translate", "traces", client_options=client_options, default=default_val + ) + assert result is expected + + +def test_legacy_var_with_warning(monkeypatch): + monkeypatch.setenv("LEGACY_TRACE_VAR", "true") + + with pytest.warns(DeprecationWarning, match="LEGACY_TRACE_VAR"): + result = options.is_signal_enabled( + "translate", "traces", legacy_vars=["LEGACY_TRACE_VAR"] + ) + assert result is True From 09e84204e9ee1c84496620a4a7a0894a120be71e Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Mon, 22 Jun 2026 14:18:20 -0400 Subject: [PATCH 2/8] feat(observability): add base OpenTelemetry span enricher interceptor --- .../google/api_core/observability/__init__.py | 8 +- .../google/api_core/observability/tracing.py | 75 ++++++++ packages/google-api-core/pyproject.toml | 1 + .../testing/constraints-3.10.txt | 1 + .../testing/constraints-async-rest-3.10.txt | 1 + .../tests/unit/observability/test_tracing.py | 160 ++++++++++++++++++ 6 files changed, 245 insertions(+), 1 deletion(-) create mode 100644 packages/google-api-core/google/api_core/observability/tracing.py create mode 100644 packages/google-api-core/tests/unit/observability/test_tracing.py diff --git a/packages/google-api-core/google/api_core/observability/__init__.py b/packages/google-api-core/google/api_core/observability/__init__.py index f4144485e5f9..2059d1f16d34 100644 --- a/packages/google-api-core/google/api_core/observability/__init__.py +++ b/packages/google-api-core/google/api_core/observability/__init__.py @@ -1,3 +1,9 @@ from .options import is_signal_enabled -__all__ = ["is_signal_enabled"] +try: + # Tell flake8 that it's okay this is unused, it's just being exposed to the package namespace. + from .tracing import OtelSpanEnricher # noqa: F401 + + __all__ = ["is_signal_enabled", "OtelSpanEnricher"] +except ImportError: + __all__ = ["is_signal_enabled"] diff --git a/packages/google-api-core/google/api_core/observability/tracing.py b/packages/google-api-core/google/api_core/observability/tracing.py new file mode 100644 index 000000000000..9f26b9e7dc62 --- /dev/null +++ b/packages/google-api-core/google/api_core/observability/tracing.py @@ -0,0 +1,75 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""OpenTelemetry Tracing Enrichment Interceptors.""" + +from typing import Any, Callable, Dict, Optional + +import grpc +from opentelemetry import trace + + +class OtelSpanEnricher(grpc.UnaryUnaryClientInterceptor): + """A gRPC client interceptor that enriches the active OpenTelemetry span. + + This interceptor relies on the standard OpenTelemetry gRPC instrumentor + to create the baseline span. It runs in the interceptor chain to inject + additional Google Cloud specific domain attributes. + """ + + def __init__( + self, + static_attributes: Optional[Dict[str, Any]] = None, + attribute_extractor: Optional[ + Callable[[Any, grpc.ClientCallDetails], Dict[str, Any]] + ] = None, + ): + """Initializes the OtelSpanEnricher. + + Args: + static_attributes: Standard static attributes to attach to every span. + E.g. {"gcp.client.repo": "googleapis/google-cloud-python"} + attribute_extractor: A callable that extracts dynamic attributes from + the request and client call details. + """ + self._static_attributes = static_attributes or {} + self._attribute_extractor = attribute_extractor + + def intercept_unary_unary( + self, + continuation: Callable[[grpc.ClientCallDetails, Any], Any], + client_call_details: grpc.ClientCallDetails, + request: Any, + ) -> Any: + span = trace.get_current_span() + + if span.is_recording(): + # Inject static attributes + for key, val in self._static_attributes.items(): + span.set_attribute(key, val) + + # Extract and inject dynamic attributes + if self._attribute_extractor: + try: + dynamic_attrs = self._attribute_extractor( + request, client_call_details + ) + for key, val in dynamic_attrs.items(): + if val is not None: + span.set_attribute(key, val) + except Exception: + # Prevent custom extractor exceptions from failing the RPC + pass + + return continuation(client_call_details, request) diff --git a/packages/google-api-core/pyproject.toml b/packages/google-api-core/pyproject.toml index 28c42be84295..64719047b689 100644 --- a/packages/google-api-core/pyproject.toml +++ b/packages/google-api-core/pyproject.toml @@ -49,6 +49,7 @@ dependencies = [ "proto-plus >= 1.25.0, < 2.0.0; python_version >= '3.13'", "google-auth >= 2.14.1, < 3.0.0", "requests >= 2.33.0, < 3.0.0", + "opentelemetry-api >= 1.1.0, < 2.0.0", ] dynamic = ["version"] diff --git a/packages/google-api-core/testing/constraints-3.10.txt b/packages/google-api-core/testing/constraints-3.10.txt index 4b3f2d263eef..72bbac9f82f5 100644 --- a/packages/google-api-core/testing/constraints-3.10.txt +++ b/packages/google-api-core/testing/constraints-3.10.txt @@ -12,3 +12,4 @@ requests==2.33.0 grpcio==1.41.0 grpcio-status==1.41.0 proto-plus==1.24.0 +opentelemetry-api==1.1.0 diff --git a/packages/google-api-core/testing/constraints-async-rest-3.10.txt b/packages/google-api-core/testing/constraints-async-rest-3.10.txt index f1b6af2fcd94..5713bdc6163b 100644 --- a/packages/google-api-core/testing/constraints-async-rest-3.10.txt +++ b/packages/google-api-core/testing/constraints-async-rest-3.10.txt @@ -13,3 +13,4 @@ grpcio==1.41.0 grpcio-status==1.41.0 proto-plus==1.24.0 aiohttp==3.13.4 +opentelemetry-api==1.1.0 diff --git a/packages/google-api-core/tests/unit/observability/test_tracing.py b/packages/google-api-core/tests/unit/observability/test_tracing.py new file mode 100644 index 000000000000..d1ba00802480 --- /dev/null +++ b/packages/google-api-core/tests/unit/observability/test_tracing.py @@ -0,0 +1,160 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from unittest.mock import MagicMock, Mock + +import pytest + +# Check if grpc is available +try: + import grpc + + has_grpc = True +except ImportError: + has_grpc = False + +# Skip all tests in this module if grpc is not installed +pytestmark = pytest.mark.skipif(not has_grpc, reason="grpc package is required") + +if has_grpc: + + class MockClientCallDetails(grpc.ClientCallDetails): + pass + +else: + # Tell mypy that we are intentionally redefining this class for the non-gRPC fallback path. + class MockClientCallDetails: # type: ignore[no-redef] + pass + + +@pytest.fixture +def mock_span(mocker): + """Mocks trace.get_current_span to return a recording span.""" + mock_span_obj = MagicMock() + mock_span_obj.is_recording.return_value = True + mocker.patch("opentelemetry.trace.get_current_span", return_value=mock_span_obj) + return mock_span_obj + + +@pytest.fixture +def mock_span_non_recording(mocker): + """Mocks trace.get_current_span to return a non-recording span.""" + mock_span_obj = MagicMock() + mock_span_obj.is_recording.return_value = False + mocker.patch("opentelemetry.trace.get_current_span", return_value=mock_span_obj) + return mock_span_obj + + +def test_enricher_non_recording_span(mock_span_non_recording): + """Verifies that non-recording spans do not have attributes set and extractor is skipped.""" + from google.api_core.observability.tracing import OtelSpanEnricher + + extractor = Mock() + enricher = OtelSpanEnricher( + static_attributes={"static.key": "static.val"}, attribute_extractor=extractor + ) + + continuation = Mock(return_value="response") + details = MockClientCallDetails() + request = "request" + + res = enricher.intercept_unary_unary(continuation, details, request) + + assert res == "response" + continuation.assert_called_once_with(details, request) + mock_span_non_recording.set_attribute.assert_not_called() + extractor.assert_not_called() + + +@pytest.mark.parametrize( + "static_attrs,request_val,extractor_return,expected_attrs", + [ + # Case 1: Only static attributes + ({"static.key": "static.val"}, "req", None, {"static.key": "static.val"}), + # Case 2: Only dynamic attributes + (None, "req", {"dynamic.key": "dynamic.val"}, {"dynamic.key": "dynamic.val"}), + # Case 3: Both static and dynamic + ( + {"static.key": "static.val"}, + "req", + {"dynamic.key": "dynamic.val"}, + {"static.key": "static.val", "dynamic.key": "dynamic.val"}, + ), + # Case 4: Dynamic extractor returns None values (should be skipped) + ( + {"static.key": "static.val"}, + "req", + {"dynamic.key": None, "other.key": "other.val"}, + {"static.key": "static.val", "other.key": "other.val"}, + ), + ], +) +def test_enricher_recording_span( + mock_span, static_attrs, request_val, extractor_return, expected_attrs +): + """Verifies static and dynamic attribute resolution on recording spans.""" + from google.api_core.observability.tracing import OtelSpanEnricher + + if extractor_return is not None: + extractor = Mock(return_value=extractor_return) + else: + extractor = None + + enricher = OtelSpanEnricher( + static_attributes=static_attrs, attribute_extractor=extractor + ) + + continuation = Mock(return_value="response") + details = MockClientCallDetails() + request = request_val + + res = enricher.intercept_unary_unary(continuation, details, request) + + assert res == "response" + continuation.assert_called_once_with(details, request) + + # Check that expected attributes were set + for key, val in expected_attrs.items(): + mock_span.set_attribute.assert_any_call(key, val) + + # Total set_attribute calls should match expected_attrs size + assert mock_span.set_attribute.call_count == len(expected_attrs) + + if extractor: + extractor.assert_called_once_with(request, details) + + +def test_enricher_extractor_exception(mock_span): + """Verifies that exceptions in attribute extraction are caught and do not fail the call.""" + from google.api_core.observability.tracing import OtelSpanEnricher + + def bad_extractor(req, details): + raise ValueError("Extraction failure") + + enricher = OtelSpanEnricher( + static_attributes={"static.key": "static.val"}, + attribute_extractor=bad_extractor, + ) + + continuation = Mock(return_value="response") + details = MockClientCallDetails() + request = "req" + + res = enricher.intercept_unary_unary(continuation, details, request) + + assert res == "response" + continuation.assert_called_once_with(details, request) + + # Static attributes should still be set before extractor failure + mock_span.set_attribute.assert_called_once_with("static.key", "static.val") From 88915d48bc8c42db85c6f40dfc1cf83a3a82c6bd Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Mon, 22 Jun 2026 15:02:55 -0400 Subject: [PATCH 3/8] test(observability): add test-only environment variable overrides and refactor tests --- .../google/api_core/observability/__init__.py | 19 +++- .../google/api_core/observability/options.py | 23 +++++ .../tests/unit/observability/test_options.py | 94 ++++++++++++++++--- 3 files changed, 118 insertions(+), 18 deletions(-) diff --git a/packages/google-api-core/google/api_core/observability/__init__.py b/packages/google-api-core/google/api_core/observability/__init__.py index 2059d1f16d34..46f4d5b4a0dc 100644 --- a/packages/google-api-core/google/api_core/observability/__init__.py +++ b/packages/google-api-core/google/api_core/observability/__init__.py @@ -1,9 +1,22 @@ -from .options import is_signal_enabled +from .options import ( + clear_test_env_overrides, + is_signal_enabled, + set_test_env_override, +) try: # Tell flake8 that it's okay this is unused, it's just being exposed to the package namespace. from .tracing import OtelSpanEnricher # noqa: F401 - __all__ = ["is_signal_enabled", "OtelSpanEnricher"] + __all__ = [ + "is_signal_enabled", + "set_test_env_override", + "clear_test_env_overrides", + "OtelSpanEnricher", + ] except ImportError: - __all__ = ["is_signal_enabled"] + __all__ = [ + "is_signal_enabled", + "set_test_env_override", + "clear_test_env_overrides", + ] diff --git a/packages/google-api-core/google/api_core/observability/options.py b/packages/google-api-core/google/api_core/observability/options.py index 69aabdecfd96..c8c0890bf05d 100644 --- a/packages/google-api-core/google/api_core/observability/options.py +++ b/packages/google-api-core/google/api_core/observability/options.py @@ -21,8 +21,31 @@ def _strtobool(val: str) -> Optional[bool]: raise ValueError(f"Invalid truth value: {val!r}") +_TEST_ENV_OVERRIDES: Dict[str, bool] = {} + + +def set_test_env_override(name: str, value: Optional[bool]) -> None: + """Sets a test-only override for a specific environment variable. + + This is intended ONLY for unit/integration testing to prevent mutating + os.environ. + """ + if value is None: + _TEST_ENV_OVERRIDES.pop(name, None) + else: + _TEST_ENV_OVERRIDES[name] = value + + +def clear_test_env_overrides() -> None: + """Clears all test-only overrides.""" + _TEST_ENV_OVERRIDES.clear() + + def _get_env_bool(name: str) -> Optional[bool]: """Retrieve the boolean value of an environment variable.""" + if name in _TEST_ENV_OVERRIDES: + return _TEST_ENV_OVERRIDES[name] + val = os.getenv(name) if val is None: return None diff --git a/packages/google-api-core/tests/unit/observability/test_options.py b/packages/google-api-core/tests/unit/observability/test_options.py index 494d2ae8816a..740f7278eda1 100644 --- a/packages/google-api-core/tests/unit/observability/test_options.py +++ b/packages/google-api-core/tests/unit/observability/test_options.py @@ -1,6 +1,72 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + import pytest from google.api_core.observability import options +from google.api_core.observability.options import ( + _get_env_bool, + _strtobool, + clear_test_env_overrides, + set_test_env_override, +) + + +@pytest.fixture(autouse=True) +def clean_overrides(): + yield + clear_test_env_overrides() + + +@pytest.mark.parametrize( + "value,expected", + [ + ("y", True), + ("yes", True), + ("t", True), + ("true", True), + ("on", True), + ("1", True), + ("n", False), + ("no", False), + ("f", False), + ("false", False), + ("off", False), + ("0", False), + (" True ", True), + (" FALSE ", False), + ("", None), + ], +) +def test_strtobool(value, expected): + assert _strtobool(value) is expected + + +def test_strtobool_invalid(): + with pytest.raises(ValueError): + _strtobool("invalid") + + +def test_get_env_bool(monkeypatch): + monkeypatch.setenv("TEST_VAR", "true") + assert _get_env_bool("TEST_VAR") is True + + monkeypatch.setenv("TEST_VAR", "invalid") + assert _get_env_bool("TEST_VAR") is None + + monkeypatch.delenv("TEST_VAR", raising=False) + assert _get_env_bool("TEST_VAR") is None @pytest.mark.parametrize( @@ -10,11 +76,11 @@ ({}, None, False, False), ({}, None, True, True), # Service-specific env var - ({"GOOGLE_CLOUD_PYTHON_TRANSLATE_TRACES_ENABLED": "true"}, None, False, True), - ({"GOOGLE_CLOUD_PYTHON_TRANSLATE_TRACES_ENABLED": "false"}, None, True, False), + ({"GOOGLE_CLOUD_PYTHON_TRANSLATE_TRACES_ENABLED": True}, None, False, True), + ({"GOOGLE_CLOUD_PYTHON_TRANSLATE_TRACES_ENABLED": False}, None, True, False), # Experimental fallback ( - {"GOOGLE_CLOUD_EXPERIMENTAL_PYTHON_TRANSLATE_TRACES_ENABLED": "true"}, + {"GOOGLE_CLOUD_EXPERIMENTAL_PYTHON_TRANSLATE_TRACES_ENABLED": True}, None, False, True, @@ -22,8 +88,8 @@ # Precedence: Service specific overrides global ( { - "GOOGLE_CLOUD_PYTHON_TRACES_ENABLED": "true", - "GOOGLE_CLOUD_PYTHON_TRANSLATE_TRACES_ENABLED": "false", + "GOOGLE_CLOUD_PYTHON_TRACES_ENABLED": True, + "GOOGLE_CLOUD_PYTHON_TRANSLATE_TRACES_ENABLED": False, }, None, False, @@ -31,8 +97,8 @@ ), ( { - "GOOGLE_CLOUD_PYTHON_TRACES_ENABLED": "false", - "GOOGLE_CLOUD_PYTHON_TRANSLATE_TRACES_ENABLED": "true", + "GOOGLE_CLOUD_PYTHON_TRACES_ENABLED": False, + "GOOGLE_CLOUD_PYTHON_TRANSLATE_TRACES_ENABLED": True, }, None, False, @@ -40,19 +106,17 @@ ), # Precedence: Client options override env vars ( - {"GOOGLE_CLOUD_PYTHON_TRANSLATE_TRACES_ENABLED": "false"}, + {"GOOGLE_CLOUD_PYTHON_TRANSLATE_TRACES_ENABLED": False}, {"enable_traces": True}, False, True, ), ], ) -def test_is_signal_enabled( - monkeypatch, env_vars, client_options, default_val, expected -): - # Setup environment variables using pytest's monkeypatch fixture +def test_is_signal_enabled(env_vars, client_options, default_val, expected): + # Setup environment variables using our test overrides for k, v in env_vars.items(): - monkeypatch.setenv(k, v) + set_test_env_override(k, v) result = options.is_signal_enabled( "translate", "traces", client_options=client_options, default=default_val @@ -60,8 +124,8 @@ def test_is_signal_enabled( assert result is expected -def test_legacy_var_with_warning(monkeypatch): - monkeypatch.setenv("LEGACY_TRACE_VAR", "true") +def test_legacy_var_with_warning(): + set_test_env_override("LEGACY_TRACE_VAR", True) with pytest.warns(DeprecationWarning, match="LEGACY_TRACE_VAR"): result = options.is_signal_enabled( From 18785441fba49fd5cf9bfc6cadd70cd02ae0290b Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Wed, 24 Jun 2026 09:23:31 -0400 Subject: [PATCH 4/8] feat(observability): simplify options resolver to tracing-only --- .../google/api_core/observability/options.py | 73 +++++++------------ packages/google-api-core/pyproject.toml | 4 +- .../testing/constraints-3.10.txt | 2 +- .../testing/constraints-async-rest-3.10.txt | 2 +- .../tests/unit/observability/test_options.py | 64 ++++++++-------- 5 files changed, 63 insertions(+), 82 deletions(-) diff --git a/packages/google-api-core/google/api_core/observability/options.py b/packages/google-api-core/google/api_core/observability/options.py index c8c0890bf05d..b4141424b12b 100644 --- a/packages/google-api-core/google/api_core/observability/options.py +++ b/packages/google-api-core/google/api_core/observability/options.py @@ -1,8 +1,7 @@ """Observability environment variable and client options resolution helpers.""" import os -import warnings -from typing import Any, Dict, List, Optional, Union +from typing import Any, Dict, Optional, Union # Allowed truthy and falsy patterns for environment variables _TRUTHY_VALUES = ("y", "yes", "t", "true", "on", "1") @@ -66,15 +65,30 @@ def _get_env_bool_with_dev_fallback(name: str) -> Optional[bool]: def is_signal_enabled( - service_name: str, signal_type: str, client_options: Optional[Union[Dict[str, Any], Any]] = None, default: bool = False, - legacy_vars: Optional[List[str]] = None, ) -> bool: - """Determines if a telemetry signal is enabled.""" - service_upper = service_name.upper().replace("-", "_") - signal_upper = signal_type.upper() + """Determines if a telemetry signal is enabled. + + Resolves settings in the following order of precedence: + 1. Programmatic overrides in client_options (checks tracer_provider) + 2. Language-wide Environment Variable: GOOGLE_CLOUD_PYTHON_TRACING_ENABLED + (natively checks for an EXPERIMENTAL prefix variant first) + 3. Default fallback + + Args: + signal_type: The signal type: must be 'tracing'. + client_options: A dictionary or object representing client configuration. + default: Fallback boolean if no options or env variables match. + + Returns: + bool: True if the signal is resolved to enabled, False otherwise. + """ + if signal_type != "tracing": + raise ValueError( + f"Invalid signal_type: {signal_type!r}. Only 'tracing' is supported." + ) # 1. Resolve Programmatic Options First if client_options is not None: @@ -83,51 +97,14 @@ def is_signal_enabled( if isinstance(client_options, dict) else getattr(client_options, "__dict__", {}) ) - option_key = f"enable_{signal_type.lower()}" - provider_key = f"{signal_type.rstrip('s').lower()}_provider" - if options_dict.get(option_key) is not None: - return bool(options_dict.get(option_key)) - if options_dict.get(provider_key) is not None: + if options_dict.get("tracer_provider") is not None: return True - # 2. Language & Service-specific - val = _get_env_bool_with_dev_fallback( - f"GOOGLE_CLOUD_PYTHON_{service_upper}_{signal_upper}_ENABLED" - ) - if val is not None: - return val - - # 3. Language-wide Global - val = _get_env_bool_with_dev_fallback(f"GOOGLE_CLOUD_PYTHON_{signal_upper}_ENABLED") - if val is not None: - return val - - # 4. Cross-language Service-specific - val = _get_env_bool_with_dev_fallback( - f"GOOGLE_CLOUD_{service_upper}_{signal_upper}_ENABLED" - ) - if val is not None: - return val - - # 5. Cross-language Global - val = _get_env_bool_with_dev_fallback(f"GOOGLE_CLOUD_{signal_upper}_ENABLED") + # 2. Check Language-Wide Environment Variable + val = _get_env_bool_with_dev_fallback("GOOGLE_CLOUD_PYTHON_TRACING_ENABLED") if val is not None: return val - # 6. Legacy Variables - if legacy_vars: - for legacy_var in legacy_vars: - val = _get_env_bool(legacy_var) - if val is not None: - warnings.warn( - f"Environment variable {legacy_var!r} is deprecated and will be removed " - "in a future release. Please migrate to the standardized " - f"GOOGLE_CLOUD_PYTHON_{service_upper}_{signal_upper}_ENABLED instead.", - DeprecationWarning, - stacklevel=2, - ) - return val - - # 7. Default Fallback + # 3. Default Fallback return default diff --git a/packages/google-api-core/pyproject.toml b/packages/google-api-core/pyproject.toml index 64719047b689..2490acb60697 100644 --- a/packages/google-api-core/pyproject.toml +++ b/packages/google-api-core/pyproject.toml @@ -49,7 +49,7 @@ dependencies = [ "proto-plus >= 1.25.0, < 2.0.0; python_version >= '3.13'", "google-auth >= 2.14.1, < 3.0.0", "requests >= 2.33.0, < 3.0.0", - "opentelemetry-api >= 1.1.0, < 2.0.0", + "opentelemetry-api >= 1.27.0, < 2.0.0", ] dynamic = ["version"] @@ -95,4 +95,6 @@ filterwarnings = [ "ignore:.*custom tp_new.*in Python 3.14:DeprecationWarning", # Remove once https://github.com/grpc/grpc/issues/35086 is fixed (and version newer than 1.60.0 is published) "ignore:There is no current event loop:DeprecationWarning", + # Ignore external OpenTelemetry/importlib.metadata SelectableGroups warning + "ignore:.*SelectableGroups dict interface is deprecated:DeprecationWarning", ] diff --git a/packages/google-api-core/testing/constraints-3.10.txt b/packages/google-api-core/testing/constraints-3.10.txt index 72bbac9f82f5..0627965b8545 100644 --- a/packages/google-api-core/testing/constraints-3.10.txt +++ b/packages/google-api-core/testing/constraints-3.10.txt @@ -12,4 +12,4 @@ requests==2.33.0 grpcio==1.41.0 grpcio-status==1.41.0 proto-plus==1.24.0 -opentelemetry-api==1.1.0 +opentelemetry-api==1.27.0 diff --git a/packages/google-api-core/testing/constraints-async-rest-3.10.txt b/packages/google-api-core/testing/constraints-async-rest-3.10.txt index 5713bdc6163b..bbb332b89bca 100644 --- a/packages/google-api-core/testing/constraints-async-rest-3.10.txt +++ b/packages/google-api-core/testing/constraints-async-rest-3.10.txt @@ -13,4 +13,4 @@ grpcio==1.41.0 grpcio-status==1.41.0 proto-plus==1.24.0 aiohttp==3.13.4 -opentelemetry-api==1.1.0 +opentelemetry-api==1.27.0 diff --git a/packages/google-api-core/tests/unit/observability/test_options.py b/packages/google-api-core/tests/unit/observability/test_options.py index 740f7278eda1..44e952af366d 100644 --- a/packages/google-api-core/tests/unit/observability/test_options.py +++ b/packages/google-api-core/tests/unit/observability/test_options.py @@ -70,65 +70,67 @@ def test_get_env_bool(monkeypatch): @pytest.mark.parametrize( - "env_vars, client_options, default_val, expected", + "signal_type, env_vars, client_options, default_val, expected", [ # Default fallback tests - ({}, None, False, False), - ({}, None, True, True), - # Service-specific env var - ({"GOOGLE_CLOUD_PYTHON_TRANSLATE_TRACES_ENABLED": True}, None, False, True), - ({"GOOGLE_CLOUD_PYTHON_TRANSLATE_TRACES_ENABLED": False}, None, True, False), + ("tracing", {}, None, False, False), + ("tracing", {}, None, True, True), + # Global env var + ("tracing", {"GOOGLE_CLOUD_PYTHON_TRACING_ENABLED": True}, None, False, True), + ("tracing", {"GOOGLE_CLOUD_PYTHON_TRACING_ENABLED": False}, None, True, False), # Experimental fallback ( - {"GOOGLE_CLOUD_EXPERIMENTAL_PYTHON_TRANSLATE_TRACES_ENABLED": True}, + "tracing", + {"GOOGLE_CLOUD_EXPERIMENTAL_PYTHON_TRACING_ENABLED": True}, None, False, True, ), - # Precedence: Service specific overrides global ( - { - "GOOGLE_CLOUD_PYTHON_TRACES_ENABLED": True, - "GOOGLE_CLOUD_PYTHON_TRANSLATE_TRACES_ENABLED": False, - }, + "tracing", + {"GOOGLE_CLOUD_EXPERIMENTAL_PYTHON_TRACING_ENABLED": False}, None, - False, + True, False, ), + # Implicit opt-in with provider ( - { - "GOOGLE_CLOUD_PYTHON_TRACES_ENABLED": False, - "GOOGLE_CLOUD_PYTHON_TRANSLATE_TRACES_ENABLED": True, - }, - None, + "tracing", + {"GOOGLE_CLOUD_PYTHON_TRACING_ENABLED": False}, + {"tracer_provider": object()}, False, True, ), - # Precedence: Client options override env vars + # Programmatic boolean flags are NOT supported (should default/fallback) ( - {"GOOGLE_CLOUD_PYTHON_TRANSLATE_TRACES_ENABLED": False}, + "tracing", + {"GOOGLE_CLOUD_PYTHON_TRACING_ENABLED": False}, {"enable_traces": True}, False, - True, + False, + ), + ( + "tracing", + {"GOOGLE_CLOUD_PYTHON_TRACING_ENABLED": False}, + {"enable_tracing": True}, + False, + False, ), ], ) -def test_is_signal_enabled(env_vars, client_options, default_val, expected): +def test_is_signal_enabled( + signal_type, env_vars, client_options, default_val, expected +): # Setup environment variables using our test overrides for k, v in env_vars.items(): set_test_env_override(k, v) result = options.is_signal_enabled( - "translate", "traces", client_options=client_options, default=default_val + signal_type, client_options=client_options, default=default_val ) assert result is expected -def test_legacy_var_with_warning(): - set_test_env_override("LEGACY_TRACE_VAR", True) - - with pytest.warns(DeprecationWarning, match="LEGACY_TRACE_VAR"): - result = options.is_signal_enabled( - "translate", "traces", legacy_vars=["LEGACY_TRACE_VAR"] - ) - assert result is True +def test_is_signal_enabled_invalid_signal(): + with pytest.raises(ValueError, match="Only 'tracing' is supported"): + options.is_signal_enabled("traces") From ebbe3c671ba91eeb107e0c71d43c0fa2fe570817 Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Wed, 24 Jun 2026 10:36:23 -0400 Subject: [PATCH 5/8] feat(observability): remove OtelSpanEnricher from current PR --- .../google/api_core/observability/__init__.py | 21 +-- .../google/api_core/observability/tracing.py | 75 -------- .../tests/unit/observability/test_tracing.py | 160 ------------------ 3 files changed, 5 insertions(+), 251 deletions(-) delete mode 100644 packages/google-api-core/google/api_core/observability/tracing.py delete mode 100644 packages/google-api-core/tests/unit/observability/test_tracing.py diff --git a/packages/google-api-core/google/api_core/observability/__init__.py b/packages/google-api-core/google/api_core/observability/__init__.py index 46f4d5b4a0dc..51330334b981 100644 --- a/packages/google-api-core/google/api_core/observability/__init__.py +++ b/packages/google-api-core/google/api_core/observability/__init__.py @@ -4,19 +4,8 @@ set_test_env_override, ) -try: - # Tell flake8 that it's okay this is unused, it's just being exposed to the package namespace. - from .tracing import OtelSpanEnricher # noqa: F401 - - __all__ = [ - "is_signal_enabled", - "set_test_env_override", - "clear_test_env_overrides", - "OtelSpanEnricher", - ] -except ImportError: - __all__ = [ - "is_signal_enabled", - "set_test_env_override", - "clear_test_env_overrides", - ] +__all__ = [ + "is_signal_enabled", + "set_test_env_override", + "clear_test_env_overrides", +] diff --git a/packages/google-api-core/google/api_core/observability/tracing.py b/packages/google-api-core/google/api_core/observability/tracing.py deleted file mode 100644 index 9f26b9e7dc62..000000000000 --- a/packages/google-api-core/google/api_core/observability/tracing.py +++ /dev/null @@ -1,75 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""OpenTelemetry Tracing Enrichment Interceptors.""" - -from typing import Any, Callable, Dict, Optional - -import grpc -from opentelemetry import trace - - -class OtelSpanEnricher(grpc.UnaryUnaryClientInterceptor): - """A gRPC client interceptor that enriches the active OpenTelemetry span. - - This interceptor relies on the standard OpenTelemetry gRPC instrumentor - to create the baseline span. It runs in the interceptor chain to inject - additional Google Cloud specific domain attributes. - """ - - def __init__( - self, - static_attributes: Optional[Dict[str, Any]] = None, - attribute_extractor: Optional[ - Callable[[Any, grpc.ClientCallDetails], Dict[str, Any]] - ] = None, - ): - """Initializes the OtelSpanEnricher. - - Args: - static_attributes: Standard static attributes to attach to every span. - E.g. {"gcp.client.repo": "googleapis/google-cloud-python"} - attribute_extractor: A callable that extracts dynamic attributes from - the request and client call details. - """ - self._static_attributes = static_attributes or {} - self._attribute_extractor = attribute_extractor - - def intercept_unary_unary( - self, - continuation: Callable[[grpc.ClientCallDetails, Any], Any], - client_call_details: grpc.ClientCallDetails, - request: Any, - ) -> Any: - span = trace.get_current_span() - - if span.is_recording(): - # Inject static attributes - for key, val in self._static_attributes.items(): - span.set_attribute(key, val) - - # Extract and inject dynamic attributes - if self._attribute_extractor: - try: - dynamic_attrs = self._attribute_extractor( - request, client_call_details - ) - for key, val in dynamic_attrs.items(): - if val is not None: - span.set_attribute(key, val) - except Exception: - # Prevent custom extractor exceptions from failing the RPC - pass - - return continuation(client_call_details, request) diff --git a/packages/google-api-core/tests/unit/observability/test_tracing.py b/packages/google-api-core/tests/unit/observability/test_tracing.py deleted file mode 100644 index d1ba00802480..000000000000 --- a/packages/google-api-core/tests/unit/observability/test_tracing.py +++ /dev/null @@ -1,160 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -from unittest.mock import MagicMock, Mock - -import pytest - -# Check if grpc is available -try: - import grpc - - has_grpc = True -except ImportError: - has_grpc = False - -# Skip all tests in this module if grpc is not installed -pytestmark = pytest.mark.skipif(not has_grpc, reason="grpc package is required") - -if has_grpc: - - class MockClientCallDetails(grpc.ClientCallDetails): - pass - -else: - # Tell mypy that we are intentionally redefining this class for the non-gRPC fallback path. - class MockClientCallDetails: # type: ignore[no-redef] - pass - - -@pytest.fixture -def mock_span(mocker): - """Mocks trace.get_current_span to return a recording span.""" - mock_span_obj = MagicMock() - mock_span_obj.is_recording.return_value = True - mocker.patch("opentelemetry.trace.get_current_span", return_value=mock_span_obj) - return mock_span_obj - - -@pytest.fixture -def mock_span_non_recording(mocker): - """Mocks trace.get_current_span to return a non-recording span.""" - mock_span_obj = MagicMock() - mock_span_obj.is_recording.return_value = False - mocker.patch("opentelemetry.trace.get_current_span", return_value=mock_span_obj) - return mock_span_obj - - -def test_enricher_non_recording_span(mock_span_non_recording): - """Verifies that non-recording spans do not have attributes set and extractor is skipped.""" - from google.api_core.observability.tracing import OtelSpanEnricher - - extractor = Mock() - enricher = OtelSpanEnricher( - static_attributes={"static.key": "static.val"}, attribute_extractor=extractor - ) - - continuation = Mock(return_value="response") - details = MockClientCallDetails() - request = "request" - - res = enricher.intercept_unary_unary(continuation, details, request) - - assert res == "response" - continuation.assert_called_once_with(details, request) - mock_span_non_recording.set_attribute.assert_not_called() - extractor.assert_not_called() - - -@pytest.mark.parametrize( - "static_attrs,request_val,extractor_return,expected_attrs", - [ - # Case 1: Only static attributes - ({"static.key": "static.val"}, "req", None, {"static.key": "static.val"}), - # Case 2: Only dynamic attributes - (None, "req", {"dynamic.key": "dynamic.val"}, {"dynamic.key": "dynamic.val"}), - # Case 3: Both static and dynamic - ( - {"static.key": "static.val"}, - "req", - {"dynamic.key": "dynamic.val"}, - {"static.key": "static.val", "dynamic.key": "dynamic.val"}, - ), - # Case 4: Dynamic extractor returns None values (should be skipped) - ( - {"static.key": "static.val"}, - "req", - {"dynamic.key": None, "other.key": "other.val"}, - {"static.key": "static.val", "other.key": "other.val"}, - ), - ], -) -def test_enricher_recording_span( - mock_span, static_attrs, request_val, extractor_return, expected_attrs -): - """Verifies static and dynamic attribute resolution on recording spans.""" - from google.api_core.observability.tracing import OtelSpanEnricher - - if extractor_return is not None: - extractor = Mock(return_value=extractor_return) - else: - extractor = None - - enricher = OtelSpanEnricher( - static_attributes=static_attrs, attribute_extractor=extractor - ) - - continuation = Mock(return_value="response") - details = MockClientCallDetails() - request = request_val - - res = enricher.intercept_unary_unary(continuation, details, request) - - assert res == "response" - continuation.assert_called_once_with(details, request) - - # Check that expected attributes were set - for key, val in expected_attrs.items(): - mock_span.set_attribute.assert_any_call(key, val) - - # Total set_attribute calls should match expected_attrs size - assert mock_span.set_attribute.call_count == len(expected_attrs) - - if extractor: - extractor.assert_called_once_with(request, details) - - -def test_enricher_extractor_exception(mock_span): - """Verifies that exceptions in attribute extraction are caught and do not fail the call.""" - from google.api_core.observability.tracing import OtelSpanEnricher - - def bad_extractor(req, details): - raise ValueError("Extraction failure") - - enricher = OtelSpanEnricher( - static_attributes={"static.key": "static.val"}, - attribute_extractor=bad_extractor, - ) - - continuation = Mock(return_value="response") - details = MockClientCallDetails() - request = "req" - - res = enricher.intercept_unary_unary(continuation, details, request) - - assert res == "response" - continuation.assert_called_once_with(details, request) - - # Static attributes should still be set before extractor failure - mock_span.set_attribute.assert_called_once_with("static.key", "static.val") From 6720f2bc89ca173fb9b788baf2266cff8e625eaf Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Wed, 24 Jun 2026 16:41:56 -0400 Subject: [PATCH 6/8] test(o11y): add coverage for environment overrides and fallback --- .../tests/unit/observability/test_options.py | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/packages/google-api-core/tests/unit/observability/test_options.py b/packages/google-api-core/tests/unit/observability/test_options.py index 44e952af366d..b95b40de1304 100644 --- a/packages/google-api-core/tests/unit/observability/test_options.py +++ b/packages/google-api-core/tests/unit/observability/test_options.py @@ -69,6 +69,30 @@ def test_get_env_bool(monkeypatch): assert _get_env_bool("TEST_VAR") is None +def test_set_test_env_override_clear_specific(): + """Verify that setting an override to None clears that specific override. + + This is important to ensure tests can reset individual environment overrides + without affecting other overrides that might be set for other tests running + concurrently or subsequently. + """ + set_test_env_override("TEST_CLEAR", True) + assert _get_env_bool("TEST_CLEAR") is True + set_test_env_override("TEST_CLEAR", None) + assert _get_env_bool("TEST_CLEAR") is None + + +def test_get_env_bool_with_dev_fallback_other_prefix(monkeypatch): + """Verify that environment variables without the 'GOOGLE_CLOUD_' prefix fall back directly. + + This is important to ensure that generic or non-GCP environment variables + are handled correctly by the fallback logic without triggering GCP-specific + replacement logic. + """ + monkeypatch.setenv("OTHER_PREFIX_VAR", "true") + assert options._get_env_bool_with_dev_fallback("OTHER_PREFIX_VAR") is True + + @pytest.mark.parametrize( "signal_type, env_vars, client_options, default_val, expected", [ From 4ad77ff801694c8472a2c39aeb6274f9425b73e4 Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Wed, 24 Jun 2026 17:48:00 -0400 Subject: [PATCH 7/8] test(o11y): improve test isolation in environment overrides --- .../tests/unit/observability/test_options.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/packages/google-api-core/tests/unit/observability/test_options.py b/packages/google-api-core/tests/unit/observability/test_options.py index b95b40de1304..f2196063f91b 100644 --- a/packages/google-api-core/tests/unit/observability/test_options.py +++ b/packages/google-api-core/tests/unit/observability/test_options.py @@ -76,10 +76,17 @@ def test_set_test_env_override_clear_specific(): without affecting other overrides that might be set for other tests running concurrently or subsequently. """ - set_test_env_override("TEST_CLEAR", True) - assert _get_env_bool("TEST_CLEAR") is True - set_test_env_override("TEST_CLEAR", None) - assert _get_env_bool("TEST_CLEAR") is None + set_test_env_override("TEST_A", True) + set_test_env_override("TEST_B", True) + assert _get_env_bool("TEST_A") is True + assert _get_env_bool("TEST_B") is True + + # Clear only TEST_A + set_test_env_override("TEST_A", None) + + # Verify TEST_A is cleared but TEST_B remains + assert _get_env_bool("TEST_A") is None + assert _get_env_bool("TEST_B") is True def test_get_env_bool_with_dev_fallback_other_prefix(monkeypatch): From 53085c166af00d2e2b84559e1915a678c82397aa Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Wed, 24 Jun 2026 17:56:54 -0400 Subject: [PATCH 8/8] test: fix dead assert in retry streaming tests causing coverage issues The assert statement was incorrectly indented inside the pytest.raises context manager, causing it to never execute. Dedented the assert to ensure exception messages are actually verified, which also resolves coverage gaps. --- .../tests/asyncio/retry/test_retry_streaming_async.py | 2 +- .../google-api-core/tests/unit/retry/test_retry_streaming.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/google-api-core/tests/asyncio/retry/test_retry_streaming_async.py b/packages/google-api-core/tests/asyncio/retry/test_retry_streaming_async.py index e44f5361e112..f2473849eaad 100644 --- a/packages/google-api-core/tests/asyncio/retry/test_retry_streaming_async.py +++ b/packages/google-api-core/tests/asyncio/retry/test_retry_streaming_async.py @@ -293,7 +293,7 @@ async def test___call___generator_send_retry(self, sleep): generator = await retry_(self._generator_mock)(error_on=3) with pytest.raises(TypeError) as exc_info: await generator.asend("cannot send to fresh generator") - assert exc_info.match("can't send non-None value") + assert exc_info.match("can't send non-None value") await generator.aclose() # error thrown on 3 diff --git a/packages/google-api-core/tests/unit/retry/test_retry_streaming.py b/packages/google-api-core/tests/unit/retry/test_retry_streaming.py index 2499b2ae9512..1fb3a80c1a3a 100644 --- a/packages/google-api-core/tests/unit/retry/test_retry_streaming.py +++ b/packages/google-api-core/tests/unit/retry/test_retry_streaming.py @@ -263,7 +263,7 @@ def test___call___with_generator_send_retry(self, sleep): with pytest.raises(TypeError) as exc_info: # calling first send with non-None input should raise a TypeError result.send("can not send to fresh generator") - assert exc_info.match("can't send non-None value") + assert exc_info.match("can't send non-None value") # initiate iteration with None result = retry_(self._generator_mock)(error_on=3) assert result.send(None) == 0