1# Copyright 2017 Google LLC
2#
3# Licensed under the Apache License, Version 2.0 (the "License");
4# you may not use this file except in compliance with the License.
5# You may obtain a copy of the License at
6#
7# http://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS,
11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12# See the License for the specific language governing permissions and
13# limitations under the License.
14
15"""Helpers for :mod:`grpc`."""
16
17import collections
18import functools
19import os
20import urllib.parse
21import warnings
22from typing import (
23 Callable,
24 Generic,
25 Iterator,
26 Optional,
27 Sequence,
28 TypeAlias,
29 TypeVar,
30 cast,
31 get_args,
32)
33
34import google.auth
35import google.auth.credentials
36import google.auth.transport.grpc
37import google.auth.transport.requests
38import google.protobuf
39import grpc
40
41from google.api_core import exceptions, general_helpers
42from google.api_core._feature_gating_helpers import _strtobool
43
44_DIRECT_PATH_INTERCONNECT_ENV = "GOOGLE_CLOUD_ENABLE_DIRECT_PATH_XDS_OVER_INTERCONNECT"
45
46
47def _resolve_direct_path_interconnect(
48 attempt_direct_path_xds_over_interconnect: Optional[bool] = None,
49) -> bool:
50 """Resolves whether DirectPath over Interconnect is enabled.
51
52 Args:
53 attempt_direct_path_xds_over_interconnect (Optional[bool]): Programmatic
54 override. If specified, this value takes precedence over environment
55 variables. If None, falls back to the environment variable.
56
57 Returns:
58 bool: True if DirectPath over Interconnect is enabled, False otherwise.
59
60 Raises:
61 ValueError: If the environment variable contains an invalid boolean value.
62 """
63 if attempt_direct_path_xds_over_interconnect is not None:
64 return attempt_direct_path_xds_over_interconnect
65
66 env_val = os.environ.get(_DIRECT_PATH_INTERCONNECT_ENV)
67 if env_val is not None:
68 try:
69 val = _strtobool(env_val)
70 except ValueError:
71 val = None
72 if val is None:
73 raise ValueError(
74 f"Invalid value for {_DIRECT_PATH_INTERCONNECT_ENV}: {env_val!r}"
75 )
76 return val
77
78 return False
79
80
81def _extract_target_host(target: str) -> str:
82 """Extracts the host from a target URI or address."""
83 stripped_target = target
84 for prefix in ("google-c2p:///", "dns:///", "https://", "http://"):
85 if target.startswith(prefix):
86 stripped_target = target.removeprefix(prefix)
87 break
88 try:
89 return urllib.parse.urlsplit(f"//{stripped_target}").hostname or target
90 except ValueError:
91 return target
92
93
94def _extract_direct_path_authority(target: str) -> Optional[str]:
95 """Extracts the canonical TLS/HTTP2 authority for a ``-direct.googleapis.com`` target."""
96 clean_host = _extract_target_host(target)
97 suffix = "-direct.googleapis.com"
98 if clean_host.endswith(suffix):
99 service_prefix = clean_host.removesuffix(suffix)
100 if service_prefix:
101 return f"{service_prefix}.googleapis.com"
102 return None
103
104
105# The list of gRPC Callable interfaces that return iterators.
106_STREAM_WRAP_CLASSES = (grpc.UnaryStreamMultiCallable, grpc.StreamStreamMultiCallable)
107
108# denotes the proto response type for grpc calls
109P = TypeVar("P")
110
111# Type alias representing any client-side gRPC interceptor
112ClientInterceptor: TypeAlias = (
113 grpc.UnaryUnaryClientInterceptor
114 | grpc.UnaryStreamClientInterceptor
115 | grpc.StreamUnaryClientInterceptor
116 | grpc.StreamStreamClientInterceptor
117)
118
119# Runtime tuple of gRPC client interceptor base classes for isinstance checks
120_CLIENT_INTERCEPTOR_CLASSES = get_args(ClientInterceptor)
121
122
123def _patch_callable_name(callable_):
124 """Fix-up gRPC callable attributes.
125
126 gRPC callable lack the ``__name__`` attribute which causes
127 :func:`functools.wraps` to error. This adds the attribute if needed.
128 """
129 if not hasattr(callable_, "__name__"):
130 callable_.__name__ = callable_.__class__.__name__
131
132
133def _wrap_unary_errors(callable_):
134 """Map errors for Unary-Unary and Stream-Unary gRPC callables."""
135 _patch_callable_name(callable_)
136
137 @functools.wraps(callable_)
138 def error_remapped_callable(*args, **kwargs):
139 try:
140 return callable_(*args, **kwargs)
141 except grpc.RpcError as exc:
142 raise exceptions.from_grpc_error(exc) from exc
143
144 return error_remapped_callable
145
146
147class _StreamingResponseIterator(Generic[P], grpc.Call):
148 def __init__(self, wrapped, prefetch_first_result=True):
149 self._wrapped = wrapped
150
151 # This iterator is used in a retry context, and returned outside after init.
152 # gRPC will not throw an exception until the stream is consumed, so we need
153 # to retrieve the first result, in order to fail, in order to trigger a retry.
154 try:
155 if prefetch_first_result:
156 self._stored_first_result = next(self._wrapped)
157 except TypeError:
158 # It is possible the wrapped method isn't an iterable (a grpc.Call
159 # for instance). If this happens don't store the first result.
160 pass
161 except StopIteration:
162 # ignore stop iteration at this time. This should be handled outside of retry.
163 pass
164
165 def __iter__(self) -> Iterator[P]:
166 """This iterator is also an iterable that returns itself."""
167 return self
168
169 def __next__(self) -> P:
170 """Get the next response from the stream.
171
172 Returns:
173 protobuf.Message: A single response from the stream.
174 """
175 try:
176 if hasattr(self, "_stored_first_result"):
177 result = self._stored_first_result
178 del self._stored_first_result
179 return result
180 return next(self._wrapped)
181 except grpc.RpcError as exc:
182 # If the stream has already returned data, we cannot recover here.
183 raise exceptions.from_grpc_error(exc) from exc
184
185 # grpc.Call & grpc.RpcContext interface
186
187 def add_callback(self, callback):
188 return self._wrapped.add_callback(callback)
189
190 def cancel(self):
191 return self._wrapped.cancel()
192
193 def code(self):
194 return self._wrapped.code()
195
196 def details(self):
197 return self._wrapped.details()
198
199 def initial_metadata(self):
200 return self._wrapped.initial_metadata()
201
202 def is_active(self):
203 return self._wrapped.is_active()
204
205 def time_remaining(self):
206 return self._wrapped.time_remaining()
207
208 def trailing_metadata(self):
209 return self._wrapped.trailing_metadata()
210
211
212# public type alias denoting the return type of streaming gapic calls
213GrpcStream = _StreamingResponseIterator[P]
214
215
216def _wrap_stream_errors(callable_):
217 """Wrap errors for Unary-Stream and Stream-Stream gRPC callables.
218
219 The callables that return iterators require a bit more logic to re-map
220 errors when iterating. This wraps both the initial invocation and the
221 iterator of the return value to re-map errors.
222 """
223 _patch_callable_name(callable_)
224
225 @functools.wraps(callable_)
226 def error_remapped_callable(*args, **kwargs):
227 try:
228 result = callable_(*args, **kwargs)
229 # Auto-fetching the first result causes PubSub client's streaming pull
230 # to hang when re-opening the stream, thus we need examine the hacky
231 # hidden flag to see if pre-fetching is disabled.
232 # https://github.com/googleapis/python-pubsub/issues/93#issuecomment-630762257
233 prefetch_first = getattr(callable_, "_prefetch_first_result_", True)
234 return _StreamingResponseIterator(
235 result, prefetch_first_result=prefetch_first
236 )
237 except grpc.RpcError as exc:
238 raise exceptions.from_grpc_error(exc) from exc
239
240 return error_remapped_callable
241
242
243def wrap_errors(callable_):
244 """Wrap a gRPC callable and map :class:`grpc.RpcErrors` to friendly error
245 classes.
246
247 Errors raised by the gRPC callable are mapped to the appropriate
248 :class:`google.api_core.exceptions.GoogleAPICallError` subclasses.
249 The original `grpc.RpcError` (which is usually also a `grpc.Call`) is
250 available from the ``response`` property on the mapped exception. This
251 is useful for extracting metadata from the original error.
252
253 Args:
254 callable_ (Callable): A gRPC callable.
255
256 Returns:
257 Callable: The wrapped gRPC callable.
258 """
259 if isinstance(callable_, _STREAM_WRAP_CLASSES):
260 return _wrap_stream_errors(callable_)
261 else:
262 return _wrap_unary_errors(callable_)
263
264
265def _create_composite_credentials(
266 credentials=None,
267 credentials_file=None,
268 default_scopes=None,
269 scopes=None,
270 ssl_credentials=None,
271 quota_project_id=None,
272 default_host=None,
273):
274 """Create the composite credentials for secure channels.
275
276 Args:
277 credentials (google.auth.credentials.Credentials): The credentials. If
278 not specified, then this function will attempt to ascertain the
279 credentials from the environment using :func:`google.auth.default`.
280 credentials_file (str): Deprecated. A file with credentials that can be loaded with
281 :func:`google.auth.load_credentials_from_file`. This argument is
282 mutually exclusive with credentials. This argument will be
283 removed in the next major version of `google-api-core`.
284
285 .. warning::
286 Important: If you accept a credential configuration (credential JSON/File/Stream)
287 from an external source for authentication to Google Cloud Platform, you must
288 validate it before providing it to any Google API or client library. Providing an
289 unvalidated credential configuration to Google APIs or libraries can compromise
290 the security of your systems and data. For more information, refer to
291 `Validate credential configurations from external sources`_.
292
293 .. _Validate credential configurations from external sources:
294
295 https://cloud.google.com/docs/authentication/external/externally-sourced-credentials
296 default_scopes (Sequence[str]): A optional list of scopes needed for this
297 service. These are only used when credentials are not specified and
298 are passed to :func:`google.auth.default`.
299 scopes (Sequence[str]): A optional list of scopes needed for this
300 service. These are only used when credentials are not specified and
301 are passed to :func:`google.auth.default`.
302 ssl_credentials (grpc.ChannelCredentials): Optional SSL channel
303 credentials. This can be used to specify different certificates.
304 quota_project_id (str): An optional project to use for billing and quota.
305 default_host (str): The default endpoint. e.g., "pubsub.googleapis.com".
306
307 Returns:
308 grpc.ChannelCredentials: The composed channel credentials object.
309
310 Raises:
311 google.api_core.DuplicateCredentialArgs: If both a credentials object and credentials_file are passed.
312 """
313 if credentials_file is not None:
314 warnings.warn(general_helpers._CREDENTIALS_FILE_WARNING, DeprecationWarning)
315
316 if credentials and credentials_file:
317 raise exceptions.DuplicateCredentialArgs(
318 "'credentials' and 'credentials_file' are mutually exclusive."
319 )
320
321 if credentials_file:
322 credentials, _ = google.auth.load_credentials_from_file(
323 credentials_file, scopes=scopes, default_scopes=default_scopes
324 )
325 elif credentials:
326 credentials = google.auth.credentials.with_scopes_if_required(
327 credentials, scopes=scopes, default_scopes=default_scopes
328 )
329 else:
330 credentials, _ = google.auth.default(
331 scopes=scopes, default_scopes=default_scopes
332 )
333
334 if quota_project_id and isinstance(
335 credentials, google.auth.credentials.CredentialsWithQuotaProject
336 ):
337 credentials = credentials.with_quota_project(quota_project_id)
338
339 request = google.auth.transport.requests.Request()
340
341 # Create the metadata plugin for inserting the authorization header.
342 try:
343 metadata_plugin = google.auth.transport.grpc.AuthMetadataPlugin(
344 credentials,
345 request,
346 default_host=default_host,
347 suppress_metrics_header=True,
348 )
349 except TypeError:
350 # Support older versions of google-auth that do not accept suppress_metrics_header
351 metadata_plugin = google.auth.transport.grpc.AuthMetadataPlugin(
352 credentials,
353 request,
354 default_host=default_host,
355 )
356
357 # Create a set of grpc.CallCredentials using the metadata plugin.
358 google_auth_credentials = grpc.metadata_call_credentials(metadata_plugin)
359
360 # if `ssl_credentials` is set, use `grpc.composite_channel_credentials` instead of
361 # `grpc.compute_engine_channel_credentials` as the former supports passing
362 # `ssl_credentials` via `channel_credentials` which is needed for mTLS.
363 if ssl_credentials:
364 # Combine the ssl credentials and the authorization credentials.
365 # See https://grpc.github.io/grpc/python/grpc.html#grpc.composite_channel_credentials
366 return grpc.composite_channel_credentials(
367 ssl_credentials, google_auth_credentials
368 )
369 else:
370 # Use grpc.compute_engine_channel_credentials in order to support Direct Path.
371 # See https://grpc.github.io/grpc/python/grpc.html#grpc.compute_engine_channel_credentials
372 # TODO(https://github.com/googleapis/python-api-core/issues/598):
373 # Although `grpc.compute_engine_channel_credentials` returns channel credentials
374 # outside of a Google Compute Engine environment (GCE), we should determine if
375 # there is a way to reliably detect a GCE environment so that
376 # `grpc.compute_engine_channel_credentials` is not called outside of GCE.
377 return grpc.compute_engine_channel_credentials(google_auth_credentials)
378
379
380def _setup_direct_path_and_credentials(
381 target,
382 credentials=None,
383 scopes=None,
384 ssl_credentials=None,
385 credentials_file=None,
386 quota_project_id=None,
387 default_scopes=None,
388 default_host=None,
389 attempt_direct_path: Optional[bool] = False,
390 attempt_direct_path_xds_over_interconnect: Optional[bool] = None,
391 **kwargs,
392):
393 """Configures credentials, options, and target for DirectPath and Interconnect.
394
395 Args:
396 target (str): The target service address in the format 'hostname:port'.
397 credentials (google.auth.credentials.Credentials): The credentials. If
398 not specified, then this function will attempt to ascertain the
399 credentials from the environment using :func:`google.auth.default`.
400 scopes (Sequence[str]): A optional list of scopes needed for this
401 service. These are only used when credentials are not specified and
402 are passed to :func:`google.auth.default`.
403 ssl_credentials (grpc.ChannelCredentials): Optional SSL channel
404 credentials. This can be used to specify different certificates.
405 credentials_file (str): Deprecated. A file with credentials that can be
406 loaded with :func:`google.auth.load_credentials_from_file`.
407 quota_project_id (str): An optional project to use for billing and quota.
408 default_scopes (Sequence[str]): Default scopes passed by a Google client
409 library. Use 'scopes' for user-defined scopes.
410 default_host (str): The default endpoint. e.g., "pubsub.googleapis.com".
411 attempt_direct_path (Optional[bool]): If set, Direct Path will be attempted.
412 attempt_direct_path_xds_over_interconnect (Optional[bool]): If set,
413 DirectPath over Cloud Interconnect will be attempted using standard
414 TLS credentials and ``?force-xds`` C2P target resolution. If None
415 (default), the setting is resolved from the
416 ``GOOGLE_CLOUD_ENABLE_DIRECT_PATH_XDS_OVER_INTERCONNECT``
417 environment variable.
418 kwargs: Additional key-word args passed to the channel creation function.
419
420 Returns:
421 tuple[str, grpc.ChannelCredentials, dict]: A tuple containing the resolved
422 target, composite credentials, and kwargs.
423
424 Raises:
425 google.api_core.DuplicateCredentialArgs: If both a credentials object
426 and credentials_file are passed.
427 ValueError: If `ssl_credentials` is set and `attempt_direct_path` is
428 set to `True` without `attempt_direct_path_xds_over_interconnect`.
429 """
430 if attempt_direct_path or attempt_direct_path_xds_over_interconnect is not None:
431 use_dp_interconnect = _resolve_direct_path_interconnect(
432 attempt_direct_path_xds_over_interconnect
433 )
434 else:
435 use_dp_interconnect = False
436
437 # If `ssl_credentials` is set and `attempt_direct_path` is set to `True`,
438 # raise ValueError as this is not yet supported for GCE ALTS DirectPath.
439 # See https://github.com/googleapis/python-api-core/issues/590
440 if ssl_credentials and attempt_direct_path and not use_dp_interconnect:
441 raise ValueError("Using ssl_credentials with Direct Path is not supported")
442
443 if use_dp_interconnect and ssl_credentials is None:
444 ssl_credentials = grpc.ssl_channel_credentials()
445
446 composite_credentials = _create_composite_credentials(
447 credentials=credentials,
448 credentials_file=credentials_file,
449 default_scopes=default_scopes,
450 scopes=scopes,
451 ssl_credentials=ssl_credentials,
452 quota_project_id=quota_project_id,
453 default_host=default_host,
454 )
455
456 if use_dp_interconnect:
457 authority = _extract_direct_path_authority(target)
458 if authority:
459 existing_options = tuple(kwargs.get("options") or ())
460 option_keys = {opt[0] for opt in existing_options}
461 if (
462 "grpc.ssl_target_name_override" not in option_keys
463 and "grpc.default_authority" not in option_keys
464 ):
465 kwargs["options"] = existing_options + (
466 ("grpc.default_authority", authority),
467 )
468
469 if attempt_direct_path or use_dp_interconnect:
470 target = _modify_target_for_direct_path(
471 target,
472 attempt_direct_path_xds_over_interconnect=use_dp_interconnect,
473 )
474 elif not target.startswith("google-c2p:///"):
475 clean_host = _extract_target_host(target)
476 if clean_host.endswith("-direct.googleapis.com") and len(clean_host) > len(
477 "-direct.googleapis.com"
478 ):
479 new_host = (
480 clean_host.removesuffix("-direct.googleapis.com") + ".googleapis.com"
481 )
482 target = target.replace(clean_host, new_host, 1)
483
484 return target, composite_credentials, kwargs
485
486
487def create_channel(
488 target,
489 credentials=None,
490 scopes=None,
491 ssl_credentials=None,
492 credentials_file=None,
493 quota_project_id=None,
494 default_scopes=None,
495 default_host=None,
496 compression=None,
497 attempt_direct_path: Optional[bool] = False,
498 attempt_direct_path_xds_over_interconnect: Optional[bool] = None,
499 **kwargs,
500):
501 """Create a secure channel with credentials.
502
503 Args:
504 target (str): The target service address in the format 'hostname:port'.
505 credentials (google.auth.credentials.Credentials): The credentials. If
506 not specified, then this function will attempt to ascertain the
507 credentials from the environment using :func:`google.auth.default`.
508 scopes (Sequence[str]): A optional list of scopes needed for this
509 service. These are only used when credentials are not specified and
510 are passed to :func:`google.auth.default`.
511 ssl_credentials (grpc.ChannelCredentials): Optional SSL channel
512 credentials. This can be used to specify different certificates.
513 credentials_file (str): A file with credentials that can be loaded with
514 :func:`google.auth.load_credentials_from_file`. This argument is
515 mutually exclusive with credentials.
516
517 .. warning::
518 Important: If you accept a credential configuration (credential JSON/File/Stream)
519 from an external source for authentication to Google Cloud Platform, you must
520 validate it before providing it to any Google API or client library. Providing an
521 unvalidated credential configuration to Google APIs or libraries can compromise
522 the security of your systems and data. For more information, refer to
523 `Validate credential configurations from external sources`_.
524
525 .. _Validate credential configurations from external sources:
526
527 https://cloud.google.com/docs/authentication/external/externally-sourced-credentials
528 quota_project_id (str): An optional project to use for billing and quota.
529 default_scopes (Sequence[str]): Default scopes passed by a Google client
530 library. Use 'scopes' for user-defined scopes.
531 default_host (str): The default endpoint. e.g., "pubsub.googleapis.com".
532 compression (grpc.Compression): An optional value indicating the
533 compression method to be used over the lifetime of the channel.
534 attempt_direct_path (Optional[bool]): If set, Direct Path will be attempted
535 when the request is made. Direct Path is only available within a Google
536 Compute Engine (GCE) environment and provides a proxyless connection
537 which increases the available throughput, reduces latency, and increases
538 reliability. Note:
539
540 - This argument should only be set in a GCE environment and for Services
541 that are known to support Direct Path.
542 - If this argument is set outside of GCE, then this request will fail
543 unless the back-end service happens to have configured fall-back to DNS.
544 - If the request causes a `ServiceUnavailable` response, it is recommended
545 that the client repeat the request with `attempt_direct_path` set to
546 `False` as the Service may not support Direct Path.
547 - Using `ssl_credentials` with `attempt_direct_path` set to `True` will
548 result in `ValueError` as this combination is not yet supported.
549 attempt_direct_path_xds_over_interconnect (Optional[bool]): If set,
550 DirectPath over Cloud Interconnect will be attempted using standard
551 TLS credentials and ``?force-xds`` C2P target resolution. If None
552 (default), the setting is resolved from the
553 ``GOOGLE_CLOUD_ENABLE_DIRECT_PATH_XDS_OVER_INTERCONNECT``
554 environment variable.
555
556 kwargs: Additional key-word args passed to
557 :func:`grpc.secure_channel`.
558
559 Returns:
560 grpc.Channel: The created channel.
561
562 Raises:
563 google.api_core.DuplicateCredentialArgs: If both a credentials object
564 and credentials_file are passed.
565 ValueError: If `ssl_credentials` is set and `attempt_direct_path` is
566 set to `True` without `attempt_direct_path_xds_over_interconnect`.
567 """
568
569 target, composite_credentials, kwargs = _setup_direct_path_and_credentials(
570 target=target,
571 credentials=credentials,
572 scopes=scopes,
573 ssl_credentials=ssl_credentials,
574 credentials_file=credentials_file,
575 quota_project_id=quota_project_id,
576 default_scopes=default_scopes,
577 default_host=default_host,
578 attempt_direct_path=attempt_direct_path,
579 attempt_direct_path_xds_over_interconnect=attempt_direct_path_xds_over_interconnect,
580 **kwargs,
581 )
582
583 return grpc.secure_channel(
584 target, composite_credentials, compression=compression, **kwargs
585 )
586
587
588def _modify_target_for_direct_path(
589 target: str,
590 attempt_direct_path_xds_over_interconnect: Optional[bool] = False,
591) -> str:
592 """
593 Given a target, return a modified version which is compatible with Direct Path.
594
595 Args:
596 target (str): The target service address in the format 'hostname[:port]' or
597 'dns://hostname[:port]'.
598 attempt_direct_path_xds_over_interconnect (Optional[bool]): Whether to
599 append ``?force-xds`` for DirectPath over Cloud Interconnect.
600
601 Returns:
602 target (str): The target service address which is converted into a format compatible with Direct Path.
603 If the target contains `dns:///` or does not contain `:///`, the target will be converted in
604 a format compatible with Direct Path; otherwise the original target will be returned as the
605 original target may already denote Direct Path.
606 """
607
608 # Strip standard URI scheme prefixes ("dns:///", "https://", "http://") if
609 # `attempt_direct_path` is enabled, as the Direct Path prefix `google-c2p:///`
610 # will be used instead.
611 for scheme_prefix in ("dns:///", "https://", "http://"):
612 if target.startswith(scheme_prefix):
613 target = target.removeprefix(scheme_prefix)
614 break
615
616 direct_path_separator = ":///"
617 if direct_path_separator not in target:
618 if "?" in target:
619 host_part, query_part = target.split("?", 1)
620 target_without_port = host_part.split("/")[0].split(":")[0]
621 target = (
622 f"google-c2p{direct_path_separator}{target_without_port}?{query_part}"
623 )
624 else:
625 target_without_port = target.split("/")[0].split(":")[0]
626 # Modify the target to use Direct Path by adding the `google-c2p:///` prefix
627 target = f"google-c2p{direct_path_separator}{target_without_port}"
628
629 if attempt_direct_path_xds_over_interconnect and target.startswith(
630 "google-c2p:///"
631 ):
632 if "force-xds" not in target:
633 separator = "&" if "?" in target else "?"
634 target = f"{target}{separator}force-xds"
635 return target
636
637
638def apply_channel_interceptors(
639 channel: grpc.Channel,
640 interceptors: (
641 Sequence[ClientInterceptor | Callable[[grpc.Channel], grpc.Channel]] | None
642 ) = None,
643) -> grpc.Channel:
644 """Applies client interceptors or channel-intercepting callables to a gRPC channel.
645
646 Executes in reverse order so the first interceptor in the sequence becomes the
647 outermost layer on outbound requests and the innermost layer on inbound responses,
648 aligning with the behavior of ``grpc.intercept_channel``.
649
650 Args:
651 channel (grpc.Channel): The channel to intercept.
652 interceptors (Optional[Sequence[Union[ClientInterceptor, Callable[[grpc.Channel], grpc.Channel]]]]):
653 Additional interceptors (or callables that apply interceptors) to apply to the gRPC channel.
654
655 Returns:
656 grpc.Channel: The intercepted channel, or the original channel if no
657 interceptors were provided.
658
659 Raises:
660 TypeError: If an item in ``interceptors`` is neither a gRPC ClientInterceptor
661 nor a Callable[[Channel], Channel].
662 """
663 if not interceptors:
664 return channel
665
666 modified_channel = channel
667 # Reverse the inputs to align with the behavior of grpc.create_channel(*interceptors)
668 for interceptor in reversed(list(interceptors)):
669 if isinstance(interceptor, _CLIENT_INTERCEPTOR_CLASSES):
670 modified_channel = grpc.intercept_channel(modified_channel, interceptor)
671 elif callable(interceptor):
672 interceptor_callable = cast(
673 Callable[[grpc.Channel], grpc.Channel], interceptor
674 )
675 modified_channel = interceptor_callable(modified_channel)
676 else:
677 raise TypeError(
678 f"Expected ClientInterceptor or Callable[[Channel], Channel], got {type(interceptor).__name__}"
679 )
680
681 return modified_channel
682
683
684_MethodCall = collections.namedtuple(
685 "_MethodCall", ("request", "timeout", "metadata", "credentials", "compression")
686)
687
688_ChannelRequest = collections.namedtuple("_ChannelRequest", ("method", "request"))
689
690
691class _CallableStub(object):
692 """Stub for the grpc.*MultiCallable interfaces."""
693
694 def __init__(self, method, channel):
695 self._method = method
696 self._channel = channel
697 self.response = None
698 """Union[protobuf.Message, Callable[protobuf.Message], exception]:
699 The response to give when invoking this callable. If this is a
700 callable, it will be invoked with the request protobuf. If it's an
701 exception, the exception will be raised when this is invoked.
702 """
703 self.responses = None
704 """Iterator[
705 Union[protobuf.Message, Callable[protobuf.Message], exception]]:
706 An iterator of responses. If specified, self.response will be populated
707 on each invocation by calling ``next(self.responses)``."""
708 self.requests = []
709 """List[protobuf.Message]: All requests sent to this callable."""
710 self.calls = []
711 """List[Tuple]: All invocations of this callable. Each tuple is the
712 request, timeout, metadata, compression, and credentials."""
713
714 def __call__(
715 self, request, timeout=None, metadata=None, credentials=None, compression=None
716 ):
717 self._channel.requests.append(_ChannelRequest(self._method, request))
718 self.calls.append(
719 _MethodCall(request, timeout, metadata, credentials, compression)
720 )
721 self.requests.append(request)
722
723 response = self.response
724 if self.responses is not None:
725 if response is None:
726 response = next(self.responses)
727 else:
728 raise ValueError(
729 "{method}.response and {method}.responses are mutually "
730 "exclusive.".format(method=self._method)
731 )
732
733 if callable(response):
734 return response(request)
735
736 if isinstance(response, Exception):
737 raise response
738
739 if response is not None:
740 return response
741
742 raise ValueError('Method stub for "{}" has no response.'.format(self._method))
743
744
745def _simplify_method_name(method):
746 """Simplifies a gRPC method name.
747
748 When gRPC invokes the channel to create a callable, it gives a full
749 method name like "/google.pubsub.v1.Publisher/CreateTopic". This
750 returns just the name of the method, in this case "CreateTopic".
751
752 Args:
753 method (str): The name of the method.
754
755 Returns:
756 str: The simplified name of the method.
757 """
758 return method.rsplit("/", 1).pop()
759
760
761class ChannelStub(grpc.Channel):
762 """A testing stub for the grpc.Channel interface.
763
764 This can be used to test any client that eventually uses a gRPC channel
765 to communicate. By passing in a channel stub, you can configure which
766 responses are returned and track which requests are made.
767
768 For example:
769
770 .. code-block:: python
771
772 channel_stub = grpc_helpers.ChannelStub()
773 client = FooClient(channel=channel_stub)
774
775 channel_stub.GetFoo.response = foo_pb2.Foo(name='bar')
776
777 foo = client.get_foo(labels=['baz'])
778
779 assert foo.name == 'bar'
780 assert channel_stub.GetFoo.requests[0].labels = ['baz']
781
782 Each method on the stub can be accessed and configured on the channel.
783 Here's some examples of various configurations:
784
785 .. code-block:: python
786
787 # Return a basic response:
788
789 channel_stub.GetFoo.response = foo_pb2.Foo(name='bar')
790 assert client.get_foo().name == 'bar'
791
792 # Raise an exception:
793 channel_stub.GetFoo.response = NotFound('...')
794
795 with pytest.raises(NotFound):
796 client.get_foo()
797
798 # Use a sequence of responses:
799 channel_stub.GetFoo.responses = iter([
800 foo_pb2.Foo(name='bar'),
801 foo_pb2.Foo(name='baz'),
802 ])
803
804 assert client.get_foo().name == 'bar'
805 assert client.get_foo().name == 'baz'
806
807 # Use a callable
808
809 def on_get_foo(request):
810 return foo_pb2.Foo(name='bar' + request.id)
811
812 channel_stub.GetFoo.response = on_get_foo
813
814 assert client.get_foo(id='123').name == 'bar123'
815 """
816
817 def __init__(self, responses=[]):
818 self.requests = []
819 """Sequence[Tuple[str, protobuf.Message]]: A list of all requests made
820 on this channel in order. The tuple is of method name, request
821 message."""
822 self._method_stubs = {}
823
824 def _stub_for_method(self, method):
825 method = _simplify_method_name(method)
826 self._method_stubs[method] = _CallableStub(method, self)
827 return self._method_stubs[method]
828
829 def __getattr__(self, key):
830 try:
831 return self._method_stubs[key]
832 except KeyError:
833 raise AttributeError
834
835 def unary_unary(
836 self,
837 method,
838 request_serializer=None,
839 response_deserializer=None,
840 _registered_method=False,
841 ):
842 """grpc.Channel.unary_unary implementation."""
843 return self._stub_for_method(method)
844
845 def unary_stream(
846 self,
847 method,
848 request_serializer=None,
849 response_deserializer=None,
850 _registered_method=False,
851 ):
852 """grpc.Channel.unary_stream implementation."""
853 return self._stub_for_method(method)
854
855 def stream_unary(
856 self,
857 method,
858 request_serializer=None,
859 response_deserializer=None,
860 _registered_method=False,
861 ):
862 """grpc.Channel.stream_unary implementation."""
863 return self._stub_for_method(method)
864
865 def stream_stream(
866 self,
867 method,
868 request_serializer=None,
869 response_deserializer=None,
870 _registered_method=False,
871 ):
872 """grpc.Channel.stream_stream implementation."""
873 return self._stub_for_method(method)
874
875 def subscribe(self, callback, try_to_connect=False):
876 """grpc.Channel.subscribe implementation."""
877 pass
878
879 def unsubscribe(self, callback):
880 """grpc.Channel.unsubscribe implementation."""
881 pass
882
883 def close(self):
884 """grpc.Channel.close implementation."""
885 pass