Visitar URL original
feat(api-core): support positional *channel_args and partial applicat… · googleapis/google-cloud-python@1a04d27 · GitHub
Skip to content

Commit 1a04d27

Browse files
committed
feat(api-core): support positional *channel_args and partial application in channel factories
- Add *channel_args to create_channel_with_otel and create_async_channel_with_otel - Make client_options keyword-only to prevent argument collision with functools.partial - Add TDD unit tests with detailed docstrings for positional forwarding and partial binding
1 parent d3e6d1f commit 1a04d27

2 files changed

Lines changed: 162 additions & 2 deletions

File tree

‎packages/google-api-core/google/api_core/_observability.py‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ def _get_otel_interceptor(
8383

8484
def create_channel_with_otel(
8585
channel_factory: Callable[..., Any],
86+
*channel_args: Any,
8687
client_options: Optional[Union[ClientOptions, dict[str, Any]]] = None,
8788
**channel_kwargs: Any,
8889
) -> Any:
@@ -97,12 +98,15 @@ def create_channel_with_otel(
9798
that instantiates and returns a raw gRPC channel.
9899
client_options: The client options object or dictionary used for feature gating
99100
and extracting the tracer provider.
101+
*channel_args: Positional arguments forwarded directly to `channel_factory` (e.g. `host`).
102+
Supporting positional arguments allows this function to be easily bound via
103+
`functools.partial` in Client initialization.
100104
**channel_kwargs: Keyword arguments forwarded directly to `channel_factory`.
101105
102106
Returns:
103107
Any: The intercepted or raw gRPC channel.
104108
"""
105-
raw_channel = channel_factory(**channel_kwargs)
109+
raw_channel = channel_factory(*channel_args, **channel_kwargs)
106110
if is_otel_capabilities_enabled(client_options):
107111
import opentelemetry.instrumentation.grpc as otel_grpc # type: ignore[import-not-found]
108112

@@ -113,6 +117,7 @@ def create_channel_with_otel(
113117

114118
def create_async_channel_with_otel(
115119
channel_factory: Callable[..., Any],
120+
*channel_args: Any,
116121
client_options: Optional[Union[ClientOptions, dict[str, Any]]] = None,
117122
**channel_kwargs: Any,
118123
) -> Any:
@@ -126,6 +131,9 @@ def create_async_channel_with_otel(
126131
that instantiates and returns an async gRPC channel.
127132
client_options: The client options object or dictionary used for feature gating
128133
and extracting the tracer provider.
134+
*channel_args: Positional arguments forwarded directly to `channel_factory` (e.g. `host`).
135+
Supporting positional arguments allows this function to be easily bound via
136+
`functools.partial` in Client initialization.
129137
**channel_kwargs: Keyword arguments forwarded directly to `channel_factory`.
130138
131139
Returns:
@@ -137,4 +145,4 @@ def create_async_channel_with_otel(
137145
interceptors.append(async_interceptor)
138146
channel_kwargs["interceptors"] = interceptors
139147

140-
return channel_factory(**channel_kwargs)
148+
return channel_factory(*channel_args, **channel_kwargs)

‎packages/google-api-core/tests/unit/test_observability.py‎

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15+
import functools
1516
import sys
1617
from unittest import mock
1718

@@ -327,3 +328,154 @@ def test_create_async_channel_with_otel_omitted_interceptors(monkeypatch):
327328
target="example.com:443",
328329
interceptors=[mock_async_interceptor],
329330
)
331+
332+
333+
def test_create_channel_with_otel_positional_args(monkeypatch):
334+
"""Proves that create_channel_with_otel forwards positional arguments (*channel_args)
335+
to the underlying channel_factory callable.
336+
337+
Why this matters: Transports pass host as a positional argument
338+
(e.g., channel_init(self._host, credentials=...)), so the helper must pass
339+
positional arguments through without argument-binding errors.
340+
"""
341+
mock_raw_channel = mock.Mock(name="raw_channel")
342+
mock_channel_factory = mock.Mock(return_value=mock_raw_channel)
343+
344+
result = _observability.create_channel_with_otel(
345+
mock_channel_factory,
346+
"example.com:443", # positional host argument
347+
credentials="mock_creds",
348+
)
349+
350+
assert result is mock_raw_channel
351+
mock_channel_factory.assert_called_once_with(
352+
"example.com:443", credentials="mock_creds"
353+
)
354+
355+
356+
def test_create_channel_with_otel_partial_application(monkeypatch):
357+
"""Proves that create_channel_with_otel can be bound with functools.partial
358+
(e.g. functools.partial(create_channel_with_otel, channel_factory, client_options=options))
359+
and subsequently called by a Transport with positional (*channel_args) and keyword (**channel_kwargs) args.
360+
361+
Why this matters: This allows Client.__init__ to pass a lazy factory to
362+
Transport(channel=partial(...)) without needing to eagerly extract and duplicate
363+
credentials, scopes, and quota_project_id.
364+
"""
365+
mock_tracer_provider = object()
366+
options = ClientOptions(tracer_provider=mock_tracer_provider)
367+
368+
mock_raw_channel = mock.Mock(name="raw_channel")
369+
mock_wrapped_channel = mock.Mock(name="wrapped_channel")
370+
mock_channel_factory = mock.Mock(return_value=mock_raw_channel)
371+
372+
mock_otel = mock.Mock()
373+
mock_otel_grpc = mock_otel.instrumentation.grpc
374+
mock_interceptor = mock.Mock()
375+
376+
mock_otel_grpc.client_interceptor.return_value = mock_interceptor
377+
mock_otel_grpc.intercept_channel.return_value = mock_wrapped_channel
378+
379+
monkeypatch.setitem(sys.modules, "opentelemetry", mock_otel)
380+
monkeypatch.setitem(
381+
sys.modules, "opentelemetry.instrumentation", mock_otel.instrumentation
382+
)
383+
monkeypatch.setitem(
384+
sys.modules, "opentelemetry.instrumentation.grpc", mock_otel_grpc
385+
)
386+
387+
# 1. Client creates lazy factory using functools.partial
388+
lazy_factory = functools.partial(
389+
_observability.create_channel_with_otel,
390+
mock_channel_factory,
391+
client_options=options,
392+
)
393+
394+
# 2. Transport invokes the factory passing host positionally and credentials by keyword
395+
result = lazy_factory(
396+
"secretmanager.googleapis.com:443",
397+
credentials="mock_credentials",
398+
scopes=["https://www.googleapis.com/auth/cloud-platform"],
399+
)
400+
401+
assert result is mock_wrapped_channel
402+
mock_channel_factory.assert_called_once_with(
403+
"secretmanager.googleapis.com:443",
404+
credentials="mock_credentials",
405+
scopes=["https://www.googleapis.com/auth/cloud-platform"],
406+
)
407+
mock_otel_grpc.client_interceptor.assert_called_once_with(
408+
tracer_provider=mock_tracer_provider
409+
)
410+
mock_otel_grpc.intercept_channel.assert_called_once_with(
411+
mock_raw_channel, mock_interceptor
412+
)
413+
414+
415+
def test_create_async_channel_with_otel_positional_args(monkeypatch):
416+
"""Proves that create_async_channel_with_otel forwards positional arguments (*channel_args)
417+
to the underlying async channel_factory callable.
418+
"""
419+
mock_async_channel = mock.Mock(name="async_channel")
420+
mock_channel_factory = mock.Mock(return_value=mock_async_channel)
421+
422+
result = _observability.create_async_channel_with_otel(
423+
mock_channel_factory,
424+
"example.com:443", # positional host argument
425+
credentials="mock_creds",
426+
)
427+
428+
assert result is mock_async_channel
429+
mock_channel_factory.assert_called_once_with(
430+
"example.com:443", credentials="mock_creds"
431+
)
432+
433+
434+
def test_create_async_channel_with_otel_partial_application(monkeypatch):
435+
"""Proves that create_async_channel_with_otel can be bound with functools.partial
436+
and called by an Async Transport with positional host and keyword arguments,
437+
injecting the async OTel interceptor seamlessly into kwargs['interceptors'].
438+
"""
439+
mock_tracer_provider = object()
440+
options = ClientOptions(tracer_provider=mock_tracer_provider)
441+
442+
mock_async_channel = mock.Mock(name="async_channel")
443+
mock_channel_factory = mock.Mock(return_value=mock_async_channel)
444+
user_interceptor = mock.Mock(name="user_interceptor")
445+
mock_async_interceptor = mock.Mock(name="otel_async_interceptor")
446+
447+
mock_otel = mock.Mock()
448+
mock_otel_grpc = mock_otel.instrumentation.grpc
449+
mock_otel_grpc.aio_client_interceptor.return_value = mock_async_interceptor
450+
451+
monkeypatch.setitem(sys.modules, "opentelemetry", mock_otel)
452+
monkeypatch.setitem(
453+
sys.modules, "opentelemetry.instrumentation", mock_otel.instrumentation
454+
)
455+
monkeypatch.setitem(
456+
sys.modules, "opentelemetry.instrumentation.grpc", mock_otel_grpc
457+
)
458+
459+
# 1. Client creates lazy factory using functools.partial
460+
lazy_factory = functools.partial(
461+
_observability.create_async_channel_with_otel,
462+
mock_channel_factory,
463+
client_options=options,
464+
)
465+
466+
# 2. Transport invokes the factory passing host positionally and interceptors by keyword
467+
result = lazy_factory(
468+
"secretmanager.googleapis.com:443",
469+
credentials="mock_credentials",
470+
interceptors=[user_interceptor],
471+
)
472+
473+
assert result is mock_async_channel
474+
mock_otel_grpc.aio_client_interceptor.assert_called_once_with(
475+
tracer_provider=mock_tracer_provider
476+
)
477+
mock_channel_factory.assert_called_once_with(
478+
"secretmanager.googleapis.com:443",
479+
credentials="mock_credentials",
480+
interceptors=[user_interceptor, mock_async_interceptor],
481+
)

0 commit comments

Comments
 (0)