Skip to content

Commit 394451b

Browse files
committed
feat(api-core): add ChannelWrapper and apply_channel_wrappers helper
1 parent f10fd03 commit 394451b

2 files changed

Lines changed: 136 additions & 33 deletions

File tree

packages/google-api-core/google/api_core/grpc_helpers.py

Lines changed: 49 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,16 @@
1717
import collections
1818
import functools
1919
import warnings
20-
from typing import Generic, Iterator, Optional, Sequence, TypeVar, Union
20+
from typing import (
21+
Callable,
22+
Generic,
23+
Iterator,
24+
Optional,
25+
Sequence,
26+
TypeVar,
27+
Union,
28+
get_args,
29+
)
2130

2231
import google.auth
2332
import google.auth.credentials
@@ -41,6 +50,15 @@
4150
grpc.StreamStreamClientInterceptor,
4251
]
4352

53+
# Runtime tuple of gRPC client interceptor base classes for isinstance checks
54+
_CLIENT_INTERCEPTOR_CLASSES = get_args(ClientInterceptor)
55+
56+
# Type alias representing a channel-wrapping callable
57+
ChannelWrapperCallable = Callable[[grpc.Channel], grpc.Channel]
58+
59+
# Generic type alias representing any channel wrapper (interceptor or callable)
60+
ChannelWrapper = Union[ClientInterceptor, ChannelWrapperCallable]
61+
4462

4563
def _patch_callable_name(callable_):
4664
"""Fix-up gRPC callable attributes.
@@ -426,27 +444,44 @@ def _modify_target_for_direct_path(target: str) -> str:
426444
return target
427445

428446

429-
def apply_interceptors(
447+
def apply_channel_wrappers(
430448
channel: grpc.Channel,
431-
interceptors: Optional[Sequence[ClientInterceptor]] = None,
449+
wrappers: Optional[Sequence[ChannelWrapper]] = None,
432450
) -> grpc.Channel:
433-
"""Applies client interceptors to a gRPC channel.
451+
"""Applies channel wrappers (client interceptors or channel-wrapping callables) to a gRPC channel.
434452
435-
The first interceptor in the sequence is the outermost layer: it
436-
executes first on outbound requests and last on inbound responses.
453+
Executes in reverse order so the first wrapper in the sequence becomes the
454+
outermost layer on outbound requests and the innermost layer on inbound responses.
437455
438456
Args:
439-
channel (grpc.Channel): The channel to intercept.
440-
interceptors (Optional[Sequence[ClientInterceptor]]): An optional sequence
441-
of client interceptors to apply.
457+
channel (grpc.Channel): The channel to wrap.
458+
wrappers (Optional[Sequence[ChannelWrapper]]):
459+
An optional sequence of client interceptors or channel-wrapping
460+
callables to apply.
442461
443462
Returns:
444-
grpc.Channel: The intercepted channel, or the original channel if no
445-
interceptors were provided.
463+
grpc.Channel: The wrapped channel, or the original channel if no
464+
wrappers were provided.
465+
466+
Raises:
467+
TypeError: If an item in ``wrappers`` is neither a gRPC ClientInterceptor
468+
nor a Callable[[Channel], Channel].
446469
"""
447-
if interceptors:
448-
return grpc.intercept_channel(channel, *interceptors)
449-
return channel
470+
if not wrappers:
471+
return channel
472+
473+
modified_channel = channel
474+
for wrapper in reversed(list(wrappers)):
475+
if isinstance(wrapper, _CLIENT_INTERCEPTOR_CLASSES):
476+
modified_channel = grpc.intercept_channel(modified_channel, wrapper)
477+
elif callable(wrapper):
478+
modified_channel = wrapper(modified_channel)
479+
else:
480+
raise TypeError(
481+
f"Expected ChannelWrapper (ClientInterceptor or Callable[[Channel], Channel]), got {type(wrapper).__name__}"
482+
)
483+
484+
return modified_channel
450485

451486

452487
_MethodCall = collections.namedtuple(

packages/google-api-core/tests/unit/test_grpc_helpers.py

Lines changed: 87 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -933,32 +933,100 @@ def test_close(self):
933933
assert channel.close() is None
934934

935935

936-
@pytest.mark.parametrize("falsy_interceptors", [None, [], ()])
937-
def test_apply_interceptors_passthrough(falsy_interceptors):
938-
"""Verify that falsy or empty interceptor sequences return the channel unmodified."""
936+
@pytest.mark.parametrize("falsy_wrappers", [None, [], ()])
937+
def test_apply_channel_wrappers_passthrough(falsy_wrappers):
938+
"""Verify that falsy or empty wrapper sequences return the channel unmodified."""
939939
mock_base_channel = mock.Mock(name="base_channel")
940-
result = grpc_helpers.apply_interceptors(mock_base_channel, falsy_interceptors)
940+
result = grpc_helpers.apply_channel_wrappers(mock_base_channel, falsy_wrappers)
941941
assert result is mock_base_channel
942942

943943

944-
@pytest.mark.parametrize("count", [1, 2, 3])
945-
def test_apply_interceptors_wrapping(count):
946-
"""Verify that interceptors are passed to grpc.intercept_channel unpacked in a single call.
944+
def test_apply_channel_wrappers_grpc_client_interceptors():
945+
"""Verify that standard gRPC ClientInterceptor instances are applied via grpc.intercept_channel."""
946+
947+
class DummyUnaryInterceptor(grpc.UnaryUnaryClientInterceptor):
948+
def intercept_unary_unary(self, continuation, client_call_details, request):
949+
return continuation(client_call_details, request)
950+
951+
class DummyStreamInterceptor(grpc.StreamStreamClientInterceptor):
952+
def intercept_stream_stream(
953+
self, continuation, client_call_details, request_iterator
954+
):
955+
return continuation(client_call_details, request_iterator)
956+
957+
interceptor1 = DummyUnaryInterceptor()
958+
interceptor2 = DummyStreamInterceptor()
947959

948-
When given a sequence of N interceptors [i_0, i_1, ..., i_{N-1}], apply_interceptors
949-
must pass the base channel and all interceptors unpacked (*interceptors) to
950-
grpc.intercept_channel.
951-
"""
952960
mock_base_channel = mock.Mock(name="base_channel")
953-
mock_wrapped_channel = mock.Mock(name="wrapped_channel")
954-
mock_interceptors = [mock.Mock(name=f"interceptor_{i}") for i in range(count)]
961+
mock_chan_after_i2 = mock.Mock(name="chan_after_i2")
962+
mock_chan_after_i1 = mock.Mock(name="chan_after_i1")
955963

956964
with mock.patch(
957-
"grpc.intercept_channel", return_value=mock_wrapped_channel
958-
) as mock_intercept_channel:
959-
result = grpc_helpers.apply_interceptors(mock_base_channel, mock_interceptors)
965+
"grpc.intercept_channel",
966+
side_effect=[mock_chan_after_i2, mock_chan_after_i1],
967+
) as mock_intercept:
968+
result = grpc_helpers.apply_channel_wrappers(
969+
mock_base_channel, [interceptor1, interceptor2]
970+
)
960971

961-
assert result is mock_wrapped_channel
962-
mock_intercept_channel.assert_called_once_with(
963-
mock_base_channel, *mock_interceptors
972+
assert result is mock_chan_after_i1
973+
assert mock_intercept.call_count == 2
974+
# Executed in reverse order so interceptor1 is outermost
975+
mock_intercept.assert_has_calls(
976+
[
977+
mock.call(mock_base_channel, interceptor2),
978+
mock.call(mock_chan_after_i2, interceptor1),
979+
]
964980
)
981+
982+
983+
def test_apply_channel_wrappers_callables():
984+
"""Verify that channel-wrapping callables Callable[[Channel], Channel] are invoked in sequence."""
985+
mock_base_channel = mock.Mock(name="base_channel")
986+
mock_chan_1 = mock.Mock(name="chan_1")
987+
mock_chan_2 = mock.Mock(name="chan_2")
988+
989+
wrapper1 = mock.Mock(side_effect=lambda ch: mock_chan_2)
990+
wrapper2 = mock.Mock(side_effect=lambda ch: mock_chan_1)
991+
992+
result = grpc_helpers.apply_channel_wrappers(
993+
mock_base_channel, [wrapper1, wrapper2]
994+
)
995+
996+
assert result is mock_chan_2
997+
# Executed in reverse order: wrapper2 runs first on base channel, then wrapper1
998+
wrapper2.assert_called_once_with(mock_base_channel)
999+
wrapper1.assert_called_once_with(mock_chan_1)
1000+
1001+
1002+
def test_apply_channel_wrappers_interspersed():
1003+
"""Verify that a mixed sequence of gRPC interceptors and channel wrapper callables are applied."""
1004+
1005+
class DummyUnaryInterceptor(grpc.UnaryUnaryClientInterceptor):
1006+
def intercept_unary_unary(self, continuation, client_call_details, request):
1007+
return continuation(client_call_details, request)
1008+
1009+
interceptor = DummyUnaryInterceptor()
1010+
mock_base_channel = mock.Mock(name="base_channel")
1011+
mock_chan_after_wrapper = mock.Mock(name="chan_after_wrapper")
1012+
mock_chan_after_interceptor = mock.Mock(name="chan_after_interceptor")
1013+
1014+
wrapper = mock.Mock(return_value=mock_chan_after_wrapper)
1015+
1016+
with mock.patch(
1017+
"grpc.intercept_channel", return_value=mock_chan_after_interceptor
1018+
) as mock_intercept:
1019+
result = grpc_helpers.apply_channel_wrappers(
1020+
mock_base_channel, [interceptor, wrapper]
1021+
)
1022+
1023+
assert result is mock_chan_after_interceptor
1024+
wrapper.assert_called_once_with(mock_base_channel)
1025+
mock_intercept.assert_called_once_with(mock_chan_after_wrapper, interceptor)
1026+
1027+
1028+
def test_apply_channel_wrappers_invalid_type_raises():
1029+
"""Verify that passing an invalid object that is neither an interceptor nor callable raises TypeError."""
1030+
mock_base_channel = mock.Mock(name="base_channel")
1031+
with pytest.raises(TypeError, match="Expected ChannelWrapper"):
1032+
grpc_helpers.apply_channel_wrappers(mock_base_channel, [12345])

0 commit comments

Comments
 (0)