diff --git a/packages/gapic-generator/gapic/generator/generator.py b/packages/gapic-generator/gapic/generator/generator.py index 50a0bc649dcd..76bd9ed4ccee 100644 --- a/packages/gapic-generator/gapic/generator/generator.py +++ b/packages/gapic-generator/gapic/generator/generator.py @@ -342,7 +342,9 @@ def _render_template( ) or ( "transport" in template_name - and not self._is_desired_transport(template_name, opts) + and not self._is_desired_transport( + template_name, opts, service=service + ) ) or # TODO(https://github.com/googleapis/gapic-generator-python/issues/2121): Remove this condition when async rest is GA. @@ -358,8 +360,16 @@ def _render_template( and not api_schema.all_library_settings[ api_schema.naming.proto_package ].python_settings.experimental_features.rest_async_io_enabled + and not ( + "grpc" in opts.transport + and service.has_resumable_upload_methods + ) + ) + or ( + "rest_base" in template_name + and "rest" not in opts.transport + and not service.has_resumable_upload_methods ) - or ("rest_base" in template_name and "rest" not in opts.transport) ): continue @@ -386,9 +396,20 @@ def _render_template( ) return answer - def _is_desired_transport(self, template_name: str, opts: Options) -> bool: + def _is_desired_transport( + self, + template_name: str, + opts: Options, + service: Optional[Any] = None, + ) -> bool: """Returns true if template name contains a desired transport""" desired_transports = ["__init__", "base", "README"] + opts.transport + if ( + service is not None + and service.has_resumable_upload_methods + and "rest" not in desired_transports + ): + desired_transports.append("rest") return any(transport in template_name for transport in desired_transports) def _get_file( diff --git a/packages/gapic-generator/gapic/schema/wrappers.py b/packages/gapic-generator/gapic/schema/wrappers.py index a26f40ff4c45..778800b076a5 100644 --- a/packages/gapic-generator/gapic/schema/wrappers.py +++ b/packages/gapic-generator/gapic/schema/wrappers.py @@ -1640,6 +1640,28 @@ def _client_output(self, enable_asyncio: bool): ) ) + # If this method is a resumable upload, return a PythonType instance + # representing the resumable upload session (while self.output remains + # the underlying protobuf response message for final deserialization). + if self.is_resumable_upload: + return PythonType( + meta=metadata.Metadata( + address=metadata.Address( + name=( + "AsyncResumableUploadSession" + if enable_asyncio + else "ResumableUploadSession" + ), + module="resumable_transfer", + package=("google", "api_core"), + collisions=self.input.ident.collisions, + ), + documentation=utils.doc( + "An object representing a resumable upload session." + ), + ), + ) + # Return the usual output. return self.output @@ -1943,6 +1965,11 @@ def _ref_types(self, recursive: bool) -> Sequence[Union[MessageType, EnumType]]: if self.paged_result_field and self.paged_result_field.message: answer.append(self.paged_result_field.message) + # If this method is a resumable upload, client_output is ResumableUploadSession, + # so explicitly include self.output to ensure the underlying response message is imported. + if self.is_resumable_upload: + answer.append(self.output) + # Done; return the answer. return tuple(answer) diff --git a/packages/gapic-generator/gapic/templates/%namespace/%name_%version/%sub/services/%service/_client_macros.j2 b/packages/gapic-generator/gapic/templates/%namespace/%name_%version/%sub/services/%service/_client_macros.j2 index 9029ac8eca23..08da6aaa052b 100644 --- a/packages/gapic-generator/gapic/templates/%namespace/%name_%version/%sub/services/%service/_client_macros.j2 +++ b/packages/gapic-generator/gapic/templates/%namespace/%name_%version/%sub/services/%service/_client_macros.j2 @@ -28,6 +28,9 @@ requests: Optional[Iterator[{{ method.input.ident }}]] = None, *, {% endif %} + {% if method.is_resumable_upload %} + config: Optional[ResumableUploadConfig] = None, + {% endif %} retry: OptionalRetry = gapic_v1.method.DEFAULT, timeout: Union[float, object] = gapic_v1.method.DEFAULT, {{ shared_macros.client_method_metadata_argument()|indent(8) }} = {{ shared_macros.client_method_metadata_default_value() }}, @@ -65,6 +68,10 @@ The request object iterator.{{ " " }} {{- method.input.meta.doc|rst(width=72, indent=16, nl=False) }} {% endif %} + {% if method.is_resumable_upload %} + config (Optional[google.api_core.resumable_transfer.ResumableUploadConfig]): + Optional configuration for the resumable upload session. + {% endif %} retry (google.api_core.retry.Retry): Designation of what errors, if any, should be retried. timeout (float): The timeout for this request. @@ -161,6 +168,10 @@ retry=retry, timeout=timeout, metadata=metadata, + {% if method.is_resumable_upload %} + config=config, + start_retry=retry, + {% endif %} ) {% if method.lro %} diff --git a/packages/gapic-generator/gapic/templates/%namespace/%name_%version/%sub/services/%service/_shared_macros.j2 b/packages/gapic-generator/gapic/templates/%namespace/%name_%version/%sub/services/%service/_shared_macros.j2 index e39425bb8117..e6ce18bfb94c 100644 --- a/packages/gapic-generator/gapic/templates/%namespace/%name_%version/%sub/services/%service/_shared_macros.j2 +++ b/packages/gapic-generator/gapic/templates/%namespace/%name_%version/%sub/services/%service/_shared_macros.j2 @@ -132,13 +132,13 @@ from google.longrunning import operations_pb2 # type: ignore {% endif %}{# import_ns.has_operations_mixin #} {% endmacro %} -{% macro http_options_method(rules) %} +{% macro http_options_method(rules, is_resumable_upload=False, resumable_upload_prefix="resumable/upload") %} @staticmethod def _get_http_options(): http_options: List[Dict[str, str]] = [ {%- for rule in rules %}{ 'method': '{{ rule.method }}', - 'uri': '{{ rule.uri }}', + 'uri': '{% if is_resumable_upload %}/{{ resumable_upload_prefix }}{% endif %}{{ rule.uri }}', {% if rule.body %} 'body': '{{ rule.body }}', {% endif %}{# rule.body #} @@ -263,6 +263,68 @@ def _get_http_options(): {% endmacro %} +{# rest_resumable_upload_call_method_common includes the common code for a rest + resumable upload __call__ method to be re-used for sync and async REST + __call__ implementation. + + Args: + method: The method. + service: The service. + is_async (bool): Used to determine the code path i.e. whether for sync or async call. + rest_numeric_enums (bool): Used to determine whether to encode enums as numbers. #} +{% macro rest_resumable_upload_call_method_common(method, service, is_async=False, rest_numeric_enums=False) %} +{% set service_name = service.name %} +{% set method_name = method.name %} +{% set body_spec = method.http_options[0].body %} +{% set await_prefix = "await " if is_async else "" %} +{% set client_output_ident = method.client_output_async.ident if is_async else method.client_output.ident %} +{% set retry_class = "retries.AsyncRetry" if is_async else "retries.Retry" %} +http_options = _Base{{ service_name }}RestTransport._Base{{ method_name }}._get_http_options() +request, metadata = {{ await_prefix }}self._interceptor.pre_{{ method_name|snake_case }}(request, metadata) +transcoded_request, body, query_params = transcode_request( + http_options, + request, + required_fields_default_values=getattr( + _Base{{ service_name }}RestTransport._Base{{ method_name }}, + "_Base{{ method_name }}__REQUIRED_FIELDS_DEFAULT_VALUES", + None, + ), + rest_numeric_enums={{ rest_numeric_enums }}, +) + +uri = transcoded_request["uri"] +params = rest_helpers.flatten_query_params(query_params, strict=True) +query_string = f"?{urllib.parse.urlencode(params)}" if params else "" +upload_url = f"{self._host}{uri}{query_string}" +headers: Dict[str, Any] = {**dict(metadata), **dict((config.headers or {}) if config else {})} +headers["Content-Type"] = "application/json" +if config is None: + config = resumable_transfer.ResumableUploadConfig(headers=headers) +else: + config = dataclasses.replace(config, headers=headers) + +session_kwargs: Dict[str, Any] = ( + {"start_timeout": timeout} + if isinstance(timeout, (int, float)) + else {} +) +# ``start_retry`` is used instead of ``retry`` because ``_GapicCallable`` +# consumes the ``retry`` argument before invoking the transport callable +# and only forwards extra keyword arguments such as ``start_retry``. +return {{ client_output_ident }}( + upload_url=upload_url, + config=config, + transport=self._session, + response_type={{ method.output.ident }}, + start_retry=start_retry if isinstance(start_retry, {{ retry_class }}) else None, + {% if body_spec %} + request_body=body, + {% endif %} + **session_kwargs, +) +{%- endmacro %} + + {% macro unary_request_interceptor_common(service) %} logging_enabled = CLIENT_LOGGING_SUPPORTED and _LOGGER.isEnabledFor(std_logging.DEBUG) if logging_enabled: # pragma: NO COVER @@ -299,11 +361,12 @@ def _get_http_options(): {%- endmacro %} -{% macro prep_wrapped_messages_async_method(api, service) %} +{% macro prep_wrapped_messages_async_method(api, service, is_rest_asyncio=False) %} +{% set rest_async_io_enabled = api.all_library_settings[api.naming.proto_package].python_settings.experimental_features.rest_async_io_enabled %} def _prep_wrapped_messages(self, client_info): """ Precompute the wrapped methods, overriding the base class method to use async wrappers.""" self._wrapped_methods = { - {% for method in service.methods.values() %} + {% for method in service.methods.values() if not is_rest_asyncio or rest_async_io_enabled or method.is_resumable_upload %} self.{{ method.transport_safe_name|snake_case }}: self._wrap_method( self.{{ method.transport_safe_name|snake_case }}, {% if method.retry %} @@ -329,6 +392,7 @@ def _prep_wrapped_messages(self, client_info): client_info=client_info, ), {% endfor %}{# service.methods.values() #} + {% if not is_rest_asyncio or rest_async_io_enabled %} {% for method_name in api.mixin_api_methods.keys() %} {# TODO(https://github.com/googleapis/gapic-generator-python/issues/2197): Use `transport_safe_name` similar # to what we do for non-mixin methods above. @@ -339,6 +403,7 @@ def _prep_wrapped_messages(self, client_info): client_info=client_info, ), {% endfor %}{# method_name in api.mixin_api_methods.keys() #} + {% endif %} } {% endmacro %} @@ -362,6 +427,7 @@ def _wrap_method(self, func, *args, **kwargs): # synchronous and asynchronous rest transports #} {% macro create_interceptor_class(api, service, method, is_async=False) %} +{% set rest_async_io_enabled = api.all_library_settings[api.naming.proto_package].python_settings.experimental_features.rest_async_io_enabled %} {% set async_prefix = "async " if is_async else "" %} {% set async_method_name_prefix = "Async" if is_async else "" %} {% set async_docstring = "Asynchronous " if is_async else "" %} @@ -382,12 +448,12 @@ class {{ async_method_name_prefix }}{{ service.name }}RestInterceptor: .. code-block:: python class MyCustom{{ service.name }}Interceptor({{ service.name }}RestInterceptor): - {% for _, method in service.methods|dictsort if not method.client_streaming %} + {% for _, method in service.methods|dictsort if not method.client_streaming and (not is_async or rest_async_io_enabled or method.is_resumable_upload) %} {{ async_prefix }}def pre_{{ method.name|snake_case }}(self, request, metadata): logging.log(f"Received request: {request}") return request, metadata - {% if not method.void %} + {% if not method.void and not method.is_resumable_upload %} {{ async_prefix }}def post_{{ method.name|snake_case }}(self, response): logging.log(f"Received response: {response}") return response @@ -400,7 +466,7 @@ class {{ async_method_name_prefix }}{{ service.name }}RestInterceptor: """ - {% for method in service.methods.values()|sort(attribute="name") if not method.client_streaming and method.http_options %} + {% for method in service.methods.values()|sort(attribute="name") if not method.client_streaming and method.http_options and (not is_async or rest_async_io_enabled or method.is_resumable_upload) %} {# TODO(https://github.com/googleapis/gapic-generator-python/issues/2147): Remove the condition below once async rest transport supports the guarded methods. #} {{ async_prefix }}def pre_{{ method.name|snake_case }}(self, request: {{method.input.ident}}, {{ client_method_metadata_argument() }}) -> Tuple[{{method.input.ident}}, {{ client_method_metadata_type() }}]: """Pre-rpc interceptor for {{ method.name|snake_case }} @@ -410,7 +476,7 @@ class {{ async_method_name_prefix }}{{ service.name }}RestInterceptor: """ return request, metadata - {% if not method.void %} + {% if not method.void and not method.is_resumable_upload %} {% if not method.server_streaming %} {{ async_prefix }}def post_{{ method.name|snake_case }}(self, response: {{method.output.ident}}) -> {{method.output.ident}}: {% else %} @@ -450,6 +516,7 @@ class {{ async_method_name_prefix }}{{ service.name }}RestInterceptor: {% endif %}{# not method.void #} {% endfor %} + {% if not is_async or rest_async_io_enabled %} {% for name, signature in api.mixin_api_signatures.items() %} {{ async_prefix }}def pre_{{ name|snake_case }}( self, request: {{signature.request_type}}, {{ client_method_metadata_argument() }} @@ -473,6 +540,7 @@ class {{ async_method_name_prefix }}{{ service.name }}RestInterceptor: return response {% endfor %} + {% endif %} {% endmacro %} {% macro generate_mixin_call_method(service, api, name, sig, is_async) %} diff --git a/packages/gapic-generator/gapic/templates/%namespace/%name_%version/%sub/services/%service/async_client.py.j2 b/packages/gapic-generator/gapic/templates/%namespace/%name_%version/%sub/services/%service/async_client.py.j2 index a21ba6030a3d..8d96ab289388 100644 --- a/packages/gapic-generator/gapic/templates/%namespace/%name_%version/%sub/services/%service/async_client.py.j2 +++ b/packages/gapic-generator/gapic/templates/%namespace/%name_%version/%sub/services/%service/async_client.py.j2 @@ -21,6 +21,9 @@ from {{package_path}} import gapic_version as package_version from google.api_core.client_options import ClientOptions from google.api_core import exceptions as core_exceptions from google.api_core import gapic_v1 +{% if service.has_resumable_upload_methods %} +from google.api_core.resumable_transfer import ResumableUploadConfig +{% endif %} {% if has_auto_populated_fields %} from {{package_path}}._compat import setup_request_id {% endif %} @@ -289,6 +292,9 @@ class {{ service.async_client_name }}: requests: Optional[AsyncIterator[{{ method.input.ident }}]] = None, *, {% endif %} + {% if method.is_resumable_upload %} + config: Optional[ResumableUploadConfig] = None, + {% endif %} retry: OptionalRetry = gapic_v1.method.DEFAULT, timeout: Union[float, object] = gapic_v1.method.DEFAULT, {{ shared_macros.client_method_metadata_argument()|indent(8) }} = {{ shared_macros.client_method_metadata_default_value() }}, @@ -324,6 +330,10 @@ class {{ service.async_client_name }}: The request object AsyncIterator.{{ " " }} {{- method.input.meta.doc|rst(width=72, indent=16, nl=False) }} {% endif %} + {% if method.is_resumable_upload %} + config (Optional[google.api_core.resumable_transfer.ResumableUploadConfig]): + Optional configuration for the resumable upload session. + {% endif %} retry (google.api_core.retry_async.AsyncRetry): Designation of what errors, if any, should be retried. timeout (float): The timeout for this request. @@ -414,6 +424,10 @@ class {{ service.async_client_name }}: retry=retry, timeout=timeout, metadata=metadata, + {% if method.is_resumable_upload %} + config=config, + start_retry=retry, + {% endif %} ) {% if method.lro %} diff --git a/packages/gapic-generator/gapic/templates/%namespace/%name_%version/%sub/services/%service/client.py.j2 b/packages/gapic-generator/gapic/templates/%namespace/%name_%version/%sub/services/%service/client.py.j2 index 01407a160d99..423c8d61a2fa 100644 --- a/packages/gapic-generator/gapic/templates/%namespace/%name_%version/%sub/services/%service/client.py.j2 +++ b/packages/gapic-generator/gapic/templates/%namespace/%name_%version/%sub/services/%service/client.py.j2 @@ -30,6 +30,9 @@ from google.api_core import exceptions as core_exceptions from google.api_core import extended_operation {% endif %} from google.api_core import gapic_v1 +{% if service.has_resumable_upload_methods %} +from google.api_core.resumable_transfer import ResumableUploadConfig +{% endif %} from {{package_path}}._compat import get_universe_domain, get_api_endpoint, get_default_mtls_endpoint, should_use_client_cert, read_environment_variables {% if has_auto_populated_fields %} from {{package_path}}._compat import setup_request_id @@ -82,14 +85,12 @@ from .transports.grpc_asyncio import {{ service.grpc_asyncio_transport_name }} from .transports.rest import {{ service.name }}RestTransport {# TODO(https://github.com/googleapis/gapic-generator-python/issues/2121): Remove this condition when async rest is GA. #} {% if rest_async_io_enabled %} -ASYNC_REST_EXCEPTION = None try: from .transports.rest_asyncio import Async{{ service.name }}RestTransport - HAS_ASYNC_REST_DEPENDENCIES = True + HAS_ASYNC_REST_DEPENDENCIES = True # pragma: NO COVER {# NOTE: `pragma: NO COVER` is needed since the coverage for presubmits isn't combined. #} -except ImportError as e: # pragma: NO COVER +except ImportError: # pragma: NO COVER HAS_ASYNC_REST_DEPENDENCIES = False - ASYNC_REST_EXCEPTION = e {% endif %}{# if rest_async_io_enabled #} {% endif %} @@ -133,7 +134,9 @@ class {{ service.client_name }}Meta(type): {% if rest_async_io_enabled %} {# NOTE: `pragma: NO COVER` is needed since the coverage for presubmits isn't combined. #} if label == "rest_asyncio" and not HAS_ASYNC_REST_DEPENDENCIES: # pragma: NO COVER - raise ASYNC_REST_EXCEPTION + raise ImportError( + "`rest_asyncio` transport requires the library to be installed with the `async_rest` extra. Install the library with the `async_rest` extra using `pip install {{ api.naming.warehouse_package_name }}[async_rest]`" + ) {% endif %} if label: return cls._transport_registry[label] @@ -494,7 +497,7 @@ class {{ service.client_name }}(metaclass={{ service.client_name }}Meta): else cast(Callable[..., {{ service.name }}Transport], transport) ) - if "rest_asyncio" in str(transport_init): + if "rest_asyncio" in str(transport_init): # pragma: NO COVER {# TODO(https://github.com/googleapis/gapic-generator-python/issues/2136): Support the following parameters in async rest: #} unsupported_params = { "google.api_core.client_options.ClientOptions.credentials_file": self._client_options.credentials_file, diff --git a/packages/gapic-generator/gapic/templates/%namespace/%name_%version/%sub/services/%service/transports/__init__.py.j2 b/packages/gapic-generator/gapic/templates/%namespace/%name_%version/%sub/services/%service/transports/__init__.py.j2 index 9745b08d789c..5bba915d0d2b 100644 --- a/packages/gapic-generator/gapic/templates/%namespace/%name_%version/%sub/services/%service/transports/__init__.py.j2 +++ b/packages/gapic-generator/gapic/templates/%namespace/%name_%version/%sub/services/%service/transports/__init__.py.j2 @@ -19,9 +19,9 @@ from .rest import {{ service.name }}RestInterceptor ASYNC_REST_CLASSES: Tuple[str, ...] try: from .rest_asyncio import Async{{ service.name }}RestTransport - from .rest_asyncio import Async{{ service.name }}RestInterceptor - ASYNC_REST_CLASSES = ('Async{{ service.name }}RestTransport', 'Async{{ service.name }}RestInterceptor') - HAS_REST_ASYNC = True + from .rest_asyncio import Async{{ service.name }}RestInterceptor # pragma: NO COVER + ASYNC_REST_CLASSES = ('Async{{ service.name }}RestTransport', 'Async{{ service.name }}RestInterceptor') # pragma: NO COVER + HAS_REST_ASYNC = True # pragma: NO COVER except ImportError: # pragma: NO COVER ASYNC_REST_CLASSES = () HAS_REST_ASYNC = False diff --git a/packages/gapic-generator/gapic/templates/%namespace/%name_%version/%sub/services/%service/transports/base.py.j2 b/packages/gapic-generator/gapic/templates/%namespace/%name_%version/%sub/services/%service/transports/base.py.j2 index f0cf1178da69..a19c8a242463 100644 --- a/packages/gapic-generator/gapic/templates/%namespace/%name_%version/%sub/services/%service/transports/base.py.j2 +++ b/packages/gapic-generator/gapic/templates/%namespace/%name_%version/%sub/services/%service/transports/base.py.j2 @@ -16,6 +16,9 @@ from google.api_core import retry as retries {% if service.has_lro %} from google.api_core import operations_v1 {% endif %} +{% if service.has_resumable_upload_methods %} +from google.api_core import resumable_transfer +{% endif %} from google.auth import credentials as ga_credentials # type: ignore from google.oauth2 import service_account # type: ignore import google.protobuf @@ -139,6 +142,9 @@ class {{ service.name }}Transport(abc.ABC): # Save the credentials. self._credentials = credentials + {% if service.has_resumable_upload_methods %} + self._client_info = client_info + {% endif %} # Save the hostname. Default to port 443 (HTTPS) if none is specified. if ':' not in host: @@ -210,10 +216,18 @@ class {{ service.name }}Transport(abc.ABC): @property def {{ method.transport_safe_name|snake_case }}(self) -> Callable[ [{{ method.input.ident }}], + {% if method.is_resumable_upload %} + Union[ + {{ method.client_output.ident }}, + {{ method.client_output_async.ident }}, + Awaitable[{{ method.client_output_async.ident }}] + ]]: + {% else %} Union[ {{ method.output.ident }}, Awaitable[{{ method.output.ident }}] ]]: + {% endif %} raise NotImplementedError() {% endfor %} diff --git a/packages/gapic-generator/gapic/templates/%namespace/%name_%version/%sub/services/%service/transports/grpc.py.j2 b/packages/gapic-generator/gapic/templates/%namespace/%name_%version/%sub/services/%service/transports/grpc.py.j2 index e906c9d9ea71..7edaf533e71e 100644 --- a/packages/gapic-generator/gapic/templates/%namespace/%name_%version/%sub/services/%service/transports/grpc.py.j2 +++ b/packages/gapic-generator/gapic/templates/%namespace/%name_%version/%sub/services/%service/transports/grpc.py.j2 @@ -10,6 +10,10 @@ import pickle import warnings from typing import Callable, Dict, Optional, Sequence, Tuple, Union +{% if service.has_resumable_upload_methods %} +from google.api_core import exceptions as core_exceptions +from google.api_core import resumable_transfer +{% endif %} from google.api_core import grpc_helpers {% if service.has_lro %} from google.api_core import operations_v1 @@ -49,6 +53,9 @@ from google.longrunning import operations_pb2 # type: ignore {% endif %} {% endfilter %} from .base import {{ service.name }}Transport, DEFAULT_CLIENT_INFO +{% if service.has_resumable_upload_methods %} +from .rest import {{ service.name }}RestTransport +{% endif %} try: from google.api_core import client_logging # type: ignore @@ -186,6 +193,15 @@ class {{ service.name }}GrpcTransport({{ service.name }}Transport): {% if service.has_lro %} self._operations_client: Optional[operations_v1.OperationsClient] = None {% endif %} + {% if service.has_resumable_upload_methods %} + # Resumable upload RPCs operate over HTTP/REST rather than gRPC. + # Since gRPC is the default transport for GAPIC clients and services + # mix standard gRPC RPCs with resumable upload RPCs on the same client, + # gRPC transports delegate resumable upload calls to an internal REST + # transport instance. + self._rest_transport: Optional[{{ service.name }}RestTransport] = None + self._client_cert_source_for_mtls = client_cert_source_for_mtls + {% endif %} if api_mtls_endpoint: warnings.warn("api_mtls_endpoint is deprecated", DeprecationWarning) @@ -334,7 +350,11 @@ class {{ service.name }}GrpcTransport({{ service.name }}Transport): @property def {{ method.transport_safe_name|snake_case }}(self) -> Callable[ [{{ method.input.ident }}], + {% if method.is_resumable_upload %} + {{ method.client_output.ident }}]: + {% else %} {{ method.output.ident }}]: + {% endif %} r"""Return a callable for the{{ ' ' }} {{- (method.name|snake_case).replace('_',' ')|wrap( width=70, offset=40, indent=8) -}} @@ -344,7 +364,7 @@ class {{ service.name }}GrpcTransport({{ service.name }}Transport): Returns: Callable[[~.{{ method.input.name }}], - ~.{{ method.output.name }}]: + ~.{% if method.is_resumable_upload %}{{ method.client_output.name }}{% else %}{{ method.output.name }}{% endif %}]: A function that, when called, will call the underlying RPC on the server. """ @@ -353,11 +373,40 @@ class {{ service.name }}GrpcTransport({{ service.name }}Transport): # gRPC handles serialization and deserialization, so we just need # to pass in the functions for each. if '{{ method.transport_safe_name|snake_case }}' not in self._stubs: + {% if method.is_resumable_upload %} + if not self._credentials: + class _ErrorStub: + def __init__(self, error: Exception): + self._error = error + + def __call__(self, *args, **kwargs): + raise self._error + + self._stubs['{{ method.transport_safe_name|snake_case }}'] = _ErrorStub( + core_exceptions.GoogleAPICallError( + "Resumable upload methods operate over REST and cannot be invoked when the transport is initialized with a pre-constructed gRPC channel. Please supply credentials directly instead of a gRPC channel to use resumable upload functionality." + ) + ) + else: + transport = self + class _RestStub: + def __call__(self, *args, **kwargs): + if transport._rest_transport is None: + transport._rest_transport = {{ service.name }}RestTransport( + host=transport._host, + credentials=transport._credentials, + client_info=transport._client_info, + client_cert_source_for_mtls=transport._client_cert_source_for_mtls, + ) + return transport._rest_transport.{{ method.transport_safe_name|snake_case }}(*args, **kwargs) + self._stubs['{{ method.transport_safe_name|snake_case }}'] = _RestStub() + {% else %} self._stubs['{{ method.transport_safe_name|snake_case }}'] = self._logged_channel.{{ method.grpc_stub_type }}( '/{{ '.'.join(method.meta.address.package) }}.{{ service.name }}/{{ method.name }}', request_serializer={{ method.input.ident }}.{% if method.input.ident.python_import.module.endswith('_pb2') %}SerializeToString{% else %}serialize{% endif %}, response_deserializer={{ method.output.ident }}.{% if method.output.ident.python_import.module.endswith('_pb2') %}FromString{% else %}deserialize{% endif %}, ) + {% endif %} return self._stubs['{{ method.transport_safe_name|snake_case }}'] {% endfor %} @@ -445,6 +494,10 @@ class {{ service.name }}GrpcTransport({{ service.name }}Transport): def close(self): self._logged_channel.close() + {% if service.has_resumable_upload_methods %} + if self._rest_transport is not None: + self._rest_transport.close() + {% endif %} {% include '%namespace/%name_%version/%sub/services/%service/transports/_mixins.py.j2' %} diff --git a/packages/gapic-generator/gapic/templates/%namespace/%name_%version/%sub/services/%service/transports/grpc_asyncio.py.j2 b/packages/gapic-generator/gapic/templates/%namespace/%name_%version/%sub/services/%service/transports/grpc_asyncio.py.j2 index 7b8a885d227c..e597d1b55de4 100644 --- a/packages/gapic-generator/gapic/templates/%namespace/%name_%version/%sub/services/%service/transports/grpc_asyncio.py.j2 +++ b/packages/gapic-generator/gapic/templates/%namespace/%name_%version/%sub/services/%service/transports/grpc_asyncio.py.j2 @@ -10,7 +10,7 @@ import json import pickle import logging as std_logging import warnings -from typing import Awaitable, Callable, Dict, Optional, Sequence, Tuple, Union +from typing import {% if service.has_resumable_upload_methods %}Any, {% endif %}Awaitable, Callable, Dict, Optional, Sequence, Tuple, Union from google.api_core import gapic_v1 from google.api_core import grpc_helpers_async @@ -19,6 +19,9 @@ from google.api_core import retry_async as retries {% if service.has_lro %} from google.api_core import operations_v1 {% endif %} +{% if service.has_resumable_upload_methods %} +from google.api_core import resumable_transfer +{% endif %} from google.auth import credentials as ga_credentials # type: ignore from google.auth.transport.grpc import SslCredentials # type: ignore from google.protobuf.json_format import MessageToJson @@ -54,6 +57,13 @@ from google.longrunning import operations_pb2 # type: ignore {% endfilter %} from .base import {{ service.name }}Transport, DEFAULT_CLIENT_INFO from .grpc import {{ service.name }}GrpcTransport +{% if service.has_resumable_upload_methods %} +try: + from .rest_asyncio import Async{{ service.name }}RestTransport + HAS_ASYNC_REST = True # pragma: NO COVER +except ImportError: # pragma: NO COVER + HAS_ASYNC_REST = False +{% endif %} try: from google.api_core import client_logging # type: ignore @@ -235,6 +245,15 @@ class {{ service.grpc_asyncio_transport_name }}({{ service.name }}Transport): {% if service.has_lro %} self._operations_client: Optional[operations_v1.OperationsAsyncClient] = None {% endif %} + {% if service.has_resumable_upload_methods %} + # Resumable upload RPCs operate over HTTP/REST rather than gRPC. + # Since gRPC is the default transport for GAPIC clients and services + # mix standard gRPC RPCs with resumable upload RPCs on the same client, + # gRPC transports delegate resumable upload calls to an internal REST + # transport instance. + self._rest_transport: Optional[Any] = None + self._client_cert_source_for_mtls = client_cert_source_for_mtls + {% endif %} if api_mtls_endpoint: warnings.warn("api_mtls_endpoint is deprecated", DeprecationWarning) @@ -339,7 +358,7 @@ class {{ service.grpc_asyncio_transport_name }}({{ service.name }}Transport): @property def {{ method.transport_safe_name|snake_case }}(self) -> Callable[ [{{ method.input.ident }}], - Awaitable[{{ method.output.ident }}]]: + Awaitable[{% if method.is_resumable_upload %}{{ method.client_output_async.ident }}{% else %}{{ method.output.ident }}{% endif %}]]: r"""Return a callable for the{{ ' ' }} {{- (method.name|snake_case).replace('_',' ')|wrap( width=70, offset=40, indent=8) -}} @@ -349,7 +368,7 @@ class {{ service.grpc_asyncio_transport_name }}({{ service.name }}Transport): Returns: Callable[[~.{{ method.input.name }}], - Awaitable[~.{{ method.output.name }}]]: + Awaitable[~.{% if method.is_resumable_upload %}{{ method.client_output_async.name }}{% else %}{{ method.output.name }}{% endif %}]]: A function that, when called, will call the underlying RPC on the server. """ @@ -358,11 +377,55 @@ class {{ service.grpc_asyncio_transport_name }}({{ service.name }}Transport): # gRPC handles serialization and deserialization, so we just need # to pass in the functions for each. if '{{ method.transport_safe_name|snake_case }}' not in self._stubs: + {% if method.is_resumable_upload %} + class _ErrorStub: + def __init__(self, error: Exception): + self._error = error + + def __call__(self, *args, **kwargs): + async def _raise(): + raise self._error + return _raise() + + if not self._credentials: + self._stubs['{{ method.transport_safe_name|snake_case }}'] = _ErrorStub( + core_exceptions.GoogleAPICallError( + "Resumable upload methods operate over REST and cannot be invoked when the transport is initialized with a pre-constructed gRPC channel. Please supply credentials directly instead of a gRPC channel to use resumable upload functionality." + ) + ) + elif self._client_cert_source_for_mtls: + self._stubs['{{ method.transport_safe_name|snake_case }}'] = _ErrorStub( + core_exceptions.AsyncRestUnsupportedParameterError( + "Mutual TLS (client_cert_source_for_mtls) is not currently supported for async resumable upload methods." + ) + ) + elif HAS_ASYNC_REST: + transport = self + class _AsyncRestStub: + def __call__(self, *args, **kwargs): + if transport._rest_transport is None: + transport._rest_transport = Async{{ service.name }}RestTransport( + host=transport._host, + credentials=transport._credentials, + client_info=transport._client_info, + ) + return transport._rest_transport.{{ method.transport_safe_name|snake_case }}(*args, **kwargs) + self._stubs['{{ method.transport_safe_name|snake_case }}'] = _AsyncRestStub() + else: + self._stubs['{{ method.transport_safe_name|snake_case }}'] = _ErrorStub( + NotImplementedError( + "Async REST transport is required for async resumable upload methods. " + "Install the library with the `async_rest` extra using " + "`pip install {{ api.naming.warehouse_package_name }}[async_rest]`." + ) + ) + {% else %} self._stubs['{{ method.transport_safe_name|snake_case }}'] = self._logged_channel.{{ method.grpc_stub_type }}( '/{{ '.'.join(method.meta.address.package) }}.{{ service.name }}/{{ method.name }}', request_serializer={{ method.input.ident }}.{% if method.input.ident.python_import.module.endswith('_pb2') %}SerializeToString{% else %}serialize{% endif %}, response_deserializer={{ method.output.ident }}.{% if method.output.ident.python_import.module.endswith('_pb2') %}FromString{% else %}deserialize{% endif %}, ) + {% endif %} return self._stubs['{{ method.transport_safe_name|snake_case }}'] {% endfor %} @@ -453,8 +516,15 @@ class {{ service.grpc_asyncio_transport_name }}({{ service.name }}Transport): {{ shared_macros.wrap_async_method_macro()|indent(4) }} + {% if service.has_resumable_upload_methods %} + async def close(self): + await self._logged_channel.close() + if self._rest_transport is not None: + await self._rest_transport.close() + {% else %} def close(self): return self._logged_channel.close() + {% endif %} @property def kind(self) -> str: diff --git a/packages/gapic-generator/gapic/templates/%namespace/%name_%version/%sub/services/%service/transports/rest.py.j2 b/packages/gapic-generator/gapic/templates/%namespace/%name_%version/%sub/services/%service/transports/rest.py.j2 index 1bc499c068ee..3f4f3f4797b3 100644 --- a/packages/gapic-generator/gapic/templates/%namespace/%name_%version/%sub/services/%service/transports/rest.py.j2 +++ b/packages/gapic-generator/gapic/templates/%namespace/%name_%version/%sub/services/%service/transports/rest.py.j2 @@ -13,6 +13,10 @@ from google.api_core import retry as retries from google.api_core import rest_helpers from google.api_core import rest_streaming from google.api_core import gapic_v1 +{% if service.has_resumable_upload_methods %} +import urllib.parse +from google.api_core import resumable_transfer +{% endif %} {% set package_path = api.naming.module_namespace|join('.') + "." + api.naming.versioned_module_name %} from {{package_path}}._compat import transcode_request import google.protobuf @@ -213,18 +217,23 @@ class {{service.name}}RestTransport(_Base{{ service.name }}RestTransport): class {{ method.name|make_private }}(_Base{{ service.name }}RestTransport._Base{{method.name}}, {{service.name}}RestStub): def __hash__(self): return hash("{{service.name}}RestTransport.{{method.name}}") - {% if method.http_options and not method.client_streaming %} + {% if method.http_options and not method.client_streaming and not method.is_resumable_upload %} {% set body_spec = method.http_options[0].body %} {{ shared_macros.response_method(body_spec, is_async=False, is_streaming_method=method.server_streaming)|indent(8) }} - {% endif %}{# method.http_options and not method.client_streaming #} + {% endif %}{# method.http_options and not method.client_streaming and not method.is_resumable_upload #} def __call__(self, request: {{method.input.ident}}, *, + {% if method.is_resumable_upload %} + config: Optional[resumable_transfer.ResumableUploadConfig]=None, + start_retry: OptionalRetry=gapic_v1.method.DEFAULT, + {% else %} retry: OptionalRetry=gapic_v1.method.DEFAULT, + {% endif %} timeout: Optional[float]=None, {{ shared_macros.client_method_metadata_argument()|indent(8) }}={{ shared_macros.client_method_metadata_default_value() }}, - ){% if not method.void %} -> {% if not method.server_streaming %}{{method.output.ident}}{% else %}rest_streaming.ResponseIterator{% endif %}{% endif %}: + ){% if not method.void %} -> {% if method.is_resumable_upload %}{{method.client_output.ident}}{% elif not method.server_streaming %}{{method.output.ident}}{% else %}rest_streaming.ResponseIterator{% endif %}{% endif %}: {% if method.http_options and not method.client_streaming %} r"""Call the {{- ' ' -}} {{ (method.name|snake_case).replace('_',' ')|wrap( @@ -235,17 +244,32 @@ class {{service.name}}RestTransport(_Base{{ service.name }}RestTransport): request (~.{{ method.input.ident }}): The request object.{{ ' ' }} {{- method.input.meta.doc|rst(width=72, indent=16, nl=False) }} + {% if method.is_resumable_upload %} + config (Optional[google.api_core.resumable_transfer.ResumableUploadConfig]): + Optional configuration for the resumable upload session. + start_retry (google.api_core.retry.Retry): Designation of what errors, if any, + should be retried when initiating the resumable upload session. + {% else %} retry (google.api_core.retry.Retry): Designation of what errors, if any, should be retried. + {% endif %} timeout (float): The timeout for this request. {{ shared_macros.client_method_metadata_argument_doc()|indent(8) }} {% if not method.void %} Returns: + {% if method.is_resumable_upload %} + ~.{{ method.client_output.ident }}: + {{ method.client_output.meta.doc|rst(width=72, indent=16) }} + {% else %} ~.{{ method.output.ident }}: {{ method.output.meta.doc|rst(width=72, indent=16) }} + {% endif %} {% endif %} """ + {% if method.is_resumable_upload %} + {{ shared_macros.rest_resumable_upload_call_method_common(method, service, False, opts.rest_numeric_enums)|indent(12) }} + {% else %} {{ shared_macros.rest_call_method_common(body_spec, method.name, service, False, method.input.ident.is_proto_plus_type, opts.rest_numeric_enums)|indent(8) }} @@ -304,6 +328,7 @@ class {{service.name}}RestTransport(_Base{{ service.name }}RestTransport): return resp {% endif %}{# method.void #} + {% endif %}{# method.is_resumable_upload #} {% else %}{# method.http_options and not method.client_streaming #} raise NotImplementedError( "Method {{ method.name }} is not available over REST transport" @@ -315,7 +340,11 @@ class {{service.name}}RestTransport(_Base{{ service.name }}RestTransport): @property def {{method.transport_safe_name|snake_case}}(self) -> Callable[ [{{method.input.ident}}], - {{method.output.ident}}]: + {% if method.is_resumable_upload %} + {{ method.client_output.ident }}]: + {% else %} + {{ method.output.ident }}]: + {% endif %} # The return type is fine, but mypy isn't sophisticated enough to determine what's going on here. # In C++ this would require a dynamic_cast return self._{{method.name}}(self._session, self._host, self._interceptor) # type: ignore diff --git a/packages/gapic-generator/gapic/templates/%namespace/%name_%version/%sub/services/%service/transports/rest_asyncio.py.j2 b/packages/gapic-generator/gapic/templates/%namespace/%name_%version/%sub/services/%service/transports/rest_asyncio.py.j2 index 0f79d6e1ffef..8ff5ece64292 100644 --- a/packages/gapic-generator/gapic/templates/%namespace/%name_%version/%sub/services/%service/transports/rest_asyncio.py.j2 +++ b/packages/gapic-generator/gapic/templates/%namespace/%name_%version/%sub/services/%service/transports/rest_asyncio.py.j2 @@ -17,10 +17,15 @@ try: except ImportError as e: # pragma: NO COVER raise ImportError("`rest_asyncio` transport requires the library to be installed with the `async_rest` extra. Install the library with the `async_rest` extra using `pip install {{ api.naming.warehouse_package_name }}[async_rest]`") from e +from google.auth import credentials as ga_credentials # type: ignore from google.auth.aio import credentials as ga_credentials_async # type: ignore from google.api_core import exceptions as core_exceptions from google.api_core import gapic_v1 +{% if service.has_resumable_upload_methods %} +import urllib.parse +from google.api_core import resumable_transfer +{% endif %} {% if service.has_lro %} from google.api_core import operations_v1 {% endif %}{# service.has_lro #} @@ -107,7 +112,7 @@ class Async{{service.name}}RestTransport(_Base{{ service.name }}RestTransport): def __init__(self, *, host: str{% if service.host %} = '{{ service.host }}'{% endif %}, - credentials: Optional[ga_credentials_async.Credentials] = None, + credentials: Optional[Union[ga_credentials.Credentials, ga_credentials_async.Credentials]] = None, client_info: gapic_v1.client_info.ClientInfo = DEFAULT_CLIENT_INFO, url_scheme: str = 'https', interceptor: Optional[Async{{ service.name }}RestInterceptor] = None, @@ -123,7 +128,7 @@ class Async{{service.name}}RestTransport(_Base{{ service.name }}RestTransport): Args: host ({% if service.host %}Optional[str]{% else %}str{% endif %}): {{ ' ' }}The hostname to connect to {% if service.host %}(default: '{{ service.host }}'){% endif %}. - credentials (Optional[google.auth.aio.credentials.Credentials]): The + credentials (Optional[Union[google.auth.credentials.Credentials, google.auth.aio.credentials.Credentials]]): The authorization credentials to attach to requests. These credentials identify the application to the service; if none are specified, the client will attempt to ascertain the @@ -156,33 +161,38 @@ class Async{{service.name}}RestTransport(_Base{{ service.name }}RestTransport): self._interceptor = interceptor or Async{{ service.name }}RestInterceptor() self._wrap_with_kind = True self._prep_wrapped_messages(client_info) - {% if service.has_lro %} + {% if service.has_lro and rest_async_io_enabled %} self._operations_client: Optional[operations_v1.AsyncOperationsRestClient] = None {% endif %} - {{ shared_macros.prep_wrapped_messages_async_method(api, service)|indent(4) }} + {{ shared_macros.prep_wrapped_messages_async_method(api, service, is_rest_asyncio=True)|indent(4) }} {{ shared_macros.wrap_async_method_macro()|indent(4) }} - {% for method in service.methods.values()|sort(attribute="name") %} + {% for method in service.methods.values()|sort(attribute="name") if rest_async_io_enabled or method.is_resumable_upload %} class {{ method.name|make_private }}(_Base{{ service.name }}RestTransport._Base{{method.name}}, Async{{service.name}}RestStub): def __hash__(self): return hash("Async{{service.name}}RestTransport.{{method.name}}") {# TODO(https://github.com/googleapis/gapic-generator-python/issues/2169): Implement client streaming method. #} - {% if method.http_options and not method.client_streaming %} + {% if method.http_options and not method.client_streaming and not method.is_resumable_upload %} {% set body_spec = method.http_options[0].body %} {{ shared_macros.response_method(body_spec, is_async=True, is_streaming_method=None)|indent(8) }} - {% endif %}{# method.http_options and not method.client_streaming and not method.paged_result_field #} + {% endif %}{# method.http_options and not method.client_streaming and not method.is_resumable_upload #} async def __call__(self, request: {{method.input.ident}}, *, + {% if method.is_resumable_upload %} + config: Optional[resumable_transfer.ResumableUploadConfig]=None, + start_retry: OptionalRetry=gapic_v1.method.DEFAULT, + {% else %} retry: OptionalRetry=gapic_v1.method.DEFAULT, + {% endif %} timeout: Optional[float]=None, {{ shared_macros.client_method_metadata_argument()|indent(12) }}={{ shared_macros.client_method_metadata_default_value() }}, {# TODO(https://github.com/googleapis/gapic-generator-python/issues/2169): Update return type for client streaming method. #} - ){% if not method.void %} -> {% if not method.server_streaming %}{{method.output.ident}}{% else %}rest_streaming_async.AsyncResponseIterator{% endif %}{% endif %}: + ){% if not method.void %} -> {% if method.is_resumable_upload %}{{method.client_output_async.ident}}{% elif not method.server_streaming %}{{method.output.ident}}{% else %}rest_streaming_async.AsyncResponseIterator{% endif %}{% endif %}: {# TODO(https://github.com/googleapis/gapic-generator-python/issues/2169): Implement client streaming method. #} {% if method.http_options and not method.client_streaming %} r"""Call the {{- ' ' -}} @@ -194,17 +204,32 @@ class Async{{service.name}}RestTransport(_Base{{ service.name }}RestTransport): request (~.{{ method.input.ident }}): The request object.{{ ' ' }} {{- method.input.meta.doc|rst(width=72, indent=16, nl=False) }} + {% if method.is_resumable_upload %} + config (Optional[google.api_core.resumable_transfer.ResumableUploadConfig]): + Optional configuration for the resumable upload session. + start_retry (google.api_core.retry_async.AsyncRetry): Designation of what errors, if any, + should be retried when initiating the resumable upload session. + {% else %} retry (google.api_core.retry_async.AsyncRetry): Designation of what errors, if any, should be retried. + {% endif %} timeout (float): The timeout for this request. {{ shared_macros.client_method_metadata_argument_doc()|indent(8) }} {% if not method.void %} Returns: + {% if method.is_resumable_upload %} + ~.{{ method.client_output_async.ident }}: + {{ method.client_output_async.meta.doc|rst(width=72, indent=16) }} + {% else %} ~.{{ method.output.ident }}: {{ method.output.meta.doc|rst(width=72, indent=16) }} + {% endif %} {% endif %} """ + {% if method.is_resumable_upload %} + {{ shared_macros.rest_resumable_upload_call_method_common(method, service, True, opts.rest_numeric_enums)|indent(12) }} + {% else %} {{ shared_macros.rest_call_method_common(body_spec, method.name, service, True, method.input.ident.is_proto_plus_type, opts.rest_numeric_enums)|indent(8) }} @@ -260,6 +285,7 @@ class Async{{service.name}}RestTransport(_Base{{ service.name }}RestTransport): return resp {% endif %}{# method.void #} + {% endif %}{# method.is_resumable_upload #} {% else %} raise NotImplementedError( @@ -269,7 +295,7 @@ class Async{{service.name}}RestTransport(_Base{{ service.name }}RestTransport): {% endfor %} - {% if service.has_lro %} + {% if service.has_lro and rest_async_io_enabled %} @property def operations_client(self) -> AsyncOperationsRestClient: @@ -316,18 +342,24 @@ class Async{{service.name}}RestTransport(_Base{{ service.name }}RestTransport): return self._operations_client {% endif %} - {% for method in service.methods.values()|sort(attribute="name") %} + {% for method in service.methods.values()|sort(attribute="name") if rest_async_io_enabled or method.is_resumable_upload %} {# TODO(https://github.com/googleapis/gapic-generator-python/issues/2154): Remove `type: ignore`. #} @property def {{method.transport_safe_name|snake_case}}(self) -> Callable[ [{{method.input.ident}}], + {% if method.is_resumable_upload %} + {{method.client_output_async.ident}}]: + {% else %} {{method.output.ident}}]: + {% endif %} return self._{{method.name}}(self._session, self._host, self._interceptor) # type: ignore {% endfor %} + {% if rest_async_io_enabled %} {% for name, sig in api.mixin_api_signatures.items() %} {{ shared_macros.generate_mixin_call_method(service, api, name, sig, is_async=True) | indent(4) }} {% endfor %} + {% endif %} @property def kind(self) -> str: diff --git a/packages/gapic-generator/gapic/templates/%namespace/%name_%version/%sub/services/%service/transports/rest_base.py.j2 b/packages/gapic-generator/gapic/templates/%namespace/%name_%version/%sub/services/%service/transports/rest_base.py.j2 index 08887c16eb51..005bdceaeff1 100644 --- a/packages/gapic-generator/gapic/templates/%namespace/%name_%version/%sub/services/%service/transports/rest_base.py.j2 +++ b/packages/gapic-generator/gapic/templates/%namespace/%name_%version/%sub/services/%service/transports/rest_base.py.j2 @@ -120,7 +120,7 @@ class _Base{{ service.name }}RestTransport({{service.name}}Transport): {% set method_http_options = method.http_options %} - {{ shared_macros.http_options_method(method_http_options)|indent(8) }} + {{ shared_macros.http_options_method(method_http_options, method.is_resumable_upload, method.resumable_upload_prefix)|indent(8) }} {% endif %}{# method.http_options and not method.client_streaming #} {% endfor %} diff --git a/packages/gapic-generator/gapic/templates/setup.py.j2 b/packages/gapic-generator/gapic/templates/setup.py.j2 index 610fdc35dc3f..8e0ccbcfdb8d 100644 --- a/packages/gapic-generator/gapic/templates/setup.py.j2 +++ b/packages/gapic-generator/gapic/templates/setup.py.j2 @@ -1,5 +1,5 @@ {# TODO(https://github.com/googleapis/gapic-generator-python/issues/2121): Remove the following variable (and the condition later in this file) for async rest transport once support for it is GA. #} -{% set rest_async_io_enabled = api.all_library_settings[api.naming.proto_package].python_settings.experimental_features.rest_async_io_enabled %} +{% set rest_async_io_enabled = api.all_library_settings[api.naming.proto_package].python_settings.experimental_features.rest_async_io_enabled or api.has_resumable_upload_methods %} {% set unversioned_package_disabled = api.all_library_settings[api.naming.proto_package].python_settings.experimental_features.unversioned_package_disabled %} {% extends '_base.py.j2' %} {% from '_pypi_packages.j2' import pypi_packages %} @@ -36,7 +36,11 @@ else: release_status = "Development Status :: 5 - Production/Stable" dependencies = [ + {% if api.has_resumable_upload_methods %} + "google-api-core[grpc] >= 2.41.0, <3.0.0", + {% else %} "google-api-core[grpc] >= 2.28.0, <3.0.0", + {% endif %} # Exclude incompatible versions of `google-auth` # See https://github.com/googleapis/google-cloud-python/issues/12364 "google-auth >= 2.14.1, <3.0.0,!=2.24.0,!=2.25.0", @@ -57,7 +61,8 @@ dependencies = [ extras = { {% if rest_async_io_enabled %} "async_rest": [ - "google-auth[aiohttp] >= 2.35.0, <3.0.0" + "google-auth[aiohttp] >= 2.60.0, <3.0.0", + "google-api-core >= 2.41.0, <3.0.0", ], {% endif %} } diff --git a/packages/gapic-generator/gapic/templates/testing/constraints-3.10-async-rest.txt.j2 b/packages/gapic-generator/gapic/templates/testing/constraints-3.10-async-rest.txt.j2 index 04e798f94d9a..54179f0b0cba 100644 --- a/packages/gapic-generator/gapic/templates/testing/constraints-3.10-async-rest.txt.j2 +++ b/packages/gapic-generator/gapic/templates/testing/constraints-3.10-async-rest.txt.j2 @@ -1,4 +1,4 @@ -{% set rest_async_io_enabled = api.all_library_settings[api.naming.proto_package].python_settings.experimental_features.rest_async_io_enabled %} +{% set rest_async_io_enabled = api.all_library_settings[api.naming.proto_package].python_settings.experimental_features.rest_async_io_enabled or api.has_resumable_upload_methods %} {% if rest_async_io_enabled %} # -*- coding: utf-8 -*- {% from '_pypi_packages.j2' import pypi_packages %} @@ -8,8 +8,13 @@ # pinning their versions to their lower bounds. # For example, if setup.py has "google-cloud-foo >= 1.14.0, < 2.0.0", # then this file should have google-cloud-foo==1.14.0 +{% if api.has_resumable_upload_methods %} +google-api-core==2.41.0 +{% else %} google-api-core==2.28.0 -google-auth==2.35.0 +{% endif %} +google-auth==2.60.0 +aiohttp==3.8.0 grpcio==1.59.0 proto-plus==1.26.1 protobuf==6.33.5 diff --git a/packages/gapic-generator/gapic/templates/testing/constraints-3.10.txt.j2 b/packages/gapic-generator/gapic/templates/testing/constraints-3.10.txt.j2 index f8d078bfabb9..85af16c110fd 100644 --- a/packages/gapic-generator/gapic/templates/testing/constraints-3.10.txt.j2 +++ b/packages/gapic-generator/gapic/templates/testing/constraints-3.10.txt.j2 @@ -5,7 +5,11 @@ # pinning their versions to their lower bounds. # For example, if setup.py has "google-cloud-foo >= 1.14.0, < 2.0.0", # then this file should have google-cloud-foo==1.14.0 +{% if api.has_resumable_upload_methods %} +google-api-core==2.41.0 +{% else %} google-api-core==2.28.0 +{% endif %} google-auth==2.14.1 grpcio==1.59.0 proto-plus==1.26.1 diff --git a/packages/gapic-generator/gapic/templates/tests/unit/gapic/%name_%version/%sub/test_%service.py.j2 b/packages/gapic-generator/gapic/templates/tests/unit/gapic/%name_%version/%sub/test_%service.py.j2 index d6880c73605a..8240485ce167 100644 --- a/packages/gapic-generator/gapic/templates/tests/unit/gapic/%name_%version/%sub/test_%service.py.j2 +++ b/packages/gapic-generator/gapic/templates/tests/unit/gapic/%name_%version/%sub/test_%service.py.j2 @@ -16,7 +16,7 @@ from unittest.mock import AsyncMock import grpc from grpc.experimental import aio -{% if "rest" in opts.transport %} +{% if "rest" in opts.transport or service.has_resumable_upload_methods %} from collections.abc import Iterable, AsyncIterable from google.protobuf import json_format {% endif %} @@ -27,8 +27,7 @@ from collections.abc import Sequence, Mapping from google.api_core import api_core_version from proto.marshal.rules.dates import DurationRule, TimestampRule from proto.marshal.rules import wrappers -{% if 'rest' in opts.transport %} -{% if rest_async_io_enabled %} +{% if ('rest' in opts.transport and rest_async_io_enabled) or ('grpc' in opts.transport and service.has_resumable_upload_methods) %} try: import aiohttp # type: ignore from google.auth.aio.transport.sessions import AsyncAuthorizedSession @@ -37,7 +36,8 @@ try: {# NOTE: `pragma: NO COVER` is needed since the coverage for presubmits isn't combined. #} except ImportError: # pragma: NO COVER HAS_ASYNC_REST_EXTRA = False -{% endif %}{# if rest_async_io_enabled #} +{% endif %} +{% if 'rest' in opts.transport %} from requests import Response from requests import Request, PreparedRequest from requests.sessions import Session @@ -1522,6 +1522,523 @@ def test_{{ service.name|snake_case }}_grpc_asyncio_transport_channel(): assert transport._ssl_channel_credentials == None +{% if service.has_resumable_upload_methods and 'grpc' in opts.transport %} +{% for method in service.methods.values() if method.is_resumable_upload %} +def test_{{ service.name|snake_case }}_{{ method.name|snake_case }}_grpc_channel_without_credentials_error(): + channel = grpc.secure_channel('http://localhost/', grpc.local_channel_credentials()) + transport = transports.{{ service.name }}GrpcTransport( + host="localhost:7469", + channel=channel, + ) + with pytest.raises(core_exceptions.GoogleAPICallError) as exc_info: + transport.{{ method.transport_safe_name|snake_case }}({{ method.input.ident }}()) + assert "operate over REST and cannot be invoked when the transport is initialized with a pre-constructed gRPC channel" in str(exc_info.value) + transport.close() + + +def test_{{ service.name|snake_case }}_{{ method.name|snake_case }}_grpc_rest_stub(): + transport = transports.{{ service.name }}GrpcTransport( + credentials=ga_credentials.AnonymousCredentials(), + ) + assert transport._rest_transport is None + stub = transport.{{ method.transport_safe_name|snake_case }} + assert transport._rest_transport is None + + result1 = stub({{ method.input.ident }}()) + rest_transport = transport._rest_transport + assert rest_transport is not None + assert isinstance(result1, {{ method.client_output.ident }}) + + result2 = stub({{ method.input.ident }}()) + assert isinstance(result2, {{ method.client_output.ident }}) + assert transport._rest_transport is rest_transport + + with mock.patch.object(rest_transport, "close") as mock_rest_close: + transport.close() + mock_rest_close.assert_called_once() + + +def test_{{ service.name|snake_case }}_{{ method.name|snake_case }}_grpc_rest_stub_mtls(): + with mock.patch( + "google.auth.transport.requests.AuthorizedSession.configure_mtls_channel" + ) as mock_configure_mtls_channel: + transport = transports.{{ service.name }}GrpcTransport( + credentials=ga_credentials.AnonymousCredentials(), + client_cert_source_for_mtls=client_cert_source_callback, + ) + mock_configure_mtls_channel.assert_not_called() + stub = transport.{{ method.transport_safe_name|snake_case }} + stub({{ method.input.ident }}()) + mock_configure_mtls_channel.assert_called_once_with( + client_cert_source_callback + ) + transport.close() + + +@pytest.mark.asyncio +async def test_{{ service.name|snake_case }}_{{ method.name|snake_case }}_grpc_asyncio_mtls_error(): + transport = transports.{{ service.name }}GrpcAsyncIOTransport( + credentials=ga_credentials.AnonymousCredentials(), + client_cert_source_for_mtls=client_cert_source_callback, + ) + with pytest.raises(core_exceptions.AsyncRestUnsupportedParameterError) as exc_info: + await transport.{{ method.transport_safe_name|snake_case }}({{ method.input.ident }}()) + assert "Mutual TLS (client_cert_source_for_mtls) is not currently supported for async resumable upload methods." in str(exc_info.value) + await transport.close() + + +@pytest.mark.asyncio +async def test_{{ service.name|snake_case }}_{{ method.name|snake_case }}_grpc_asyncio_channel_without_credentials_error(): + channel = aio.secure_channel('http://localhost/', grpc.local_channel_credentials()) + transport = transports.{{ service.name }}GrpcAsyncIOTransport( + host="localhost:7469", + channel=channel, + ) + with pytest.raises(core_exceptions.GoogleAPICallError) as exc_info: + await transport.{{ method.transport_safe_name|snake_case }}({{ method.input.ident }}()) + assert "operate over REST and cannot be invoked when the transport is initialized with a pre-constructed gRPC channel" in str(exc_info.value) + await transport.close() + + +@pytest.mark.asyncio +async def test_{{ service.name|snake_case }}_{{ method.name|snake_case }}_grpc_asyncio_rest_stub(): + with mock.patch.object(transports.grpc_asyncio, "HAS_ASYNC_REST", True), mock.patch.object( + transports.grpc_asyncio, + "Async{{ service.name }}RestTransport", + create=True, + ) as mock_rest_transport_cls: + mock_rest_transport = mock.MagicMock() + mock_rest_transport.close = mock.AsyncMock() + expected_session = {{ method.client_output_async.ident }}() + mock_rest_transport.{{ method.transport_safe_name|snake_case }} = mock.AsyncMock( + return_value=expected_session + ) + mock_rest_transport_cls.return_value = mock_rest_transport + + transport = transports.{{ service.name }}GrpcAsyncIOTransport( + credentials=ga_credentials.AnonymousCredentials(), + ) + mock_rest_transport_cls.assert_not_called() + + stub = transport.{{ method.transport_safe_name|snake_case }} + result1 = await stub({{ method.input.ident }}()) + result2 = await stub({{ method.input.ident }}()) + + assert result1 is expected_session + assert result2 is expected_session + mock_rest_transport_cls.assert_called_once_with( + host=transport._host, + credentials=transport._credentials, + client_info=transport._client_info, + ) + assert mock_rest_transport.{{ method.transport_safe_name|snake_case }}.call_count == 2 + await transport.close() + mock_rest_transport.close.assert_called_once() + + +@pytest.mark.asyncio +async def test_{{ service.name|snake_case }}_{{ method.name|snake_case }}_grpc_asyncio_rest_stub_sync_credentials(): + if not HAS_ASYNC_REST_EXTRA: + pytest.skip() + transport = transports.{{ service.name }}GrpcAsyncIOTransport( + credentials=ga_credentials.AnonymousCredentials(), + ) + assert transport._rest_transport is None + stub = transport.{{ method.transport_safe_name|snake_case }} + assert transport._rest_transport is None + + result1 = await stub({{ method.input.ident }}()) + rest_transport = transport._rest_transport + assert rest_transport is not None + assert isinstance(result1, {{ method.client_output_async.ident }}) + + result2 = await stub({{ method.input.ident }}()) + assert isinstance(result2, {{ method.client_output_async.ident }}) + assert transport._rest_transport is rest_transport + + with mock.patch.object( + rest_transport, "close", wraps=rest_transport.close + ) as mock_rest_close: + await transport.close() + mock_rest_close.assert_called_once() + + +@pytest.mark.asyncio +async def test_{{ service.name|snake_case }}_{{ method.name|snake_case }}_grpc_asyncio_start_empty_body(): + if not HAS_ASYNC_REST_EXTRA: + pytest.skip() + client = {{ service.async_client_name }}( + credentials=async_anonymous_credentials(), + ) + with mock.patch.object(AsyncAuthorizedSession, "request") as req: + start_response = mock.MagicMock() + start_response.status_code = 200 + start_response.headers = {"x-goog-upload-status": "active", "x-goog-upload-url": "http://localhost/upload"} + start_response.read = mock.AsyncMock(return_value=b"") + start_response.close = mock.AsyncMock(return_value=None) + + expected_result = {{ method.output.ident }}( + {% for field in method.output.fields.values() | rejectattr('message') %} + {% if not field.oneof or field.proto3_optional %} + {{ field.name }}={{ field.mock_value }}, + {% endif %} + {% endfor %} + ) + {% if method.output.ident.is_proto_plus_type %} + json_return_value = {{ method.output.ident }}.to_json(expected_result) + {% else %} + json_return_value = json_format.MessageToJson(expected_result) + {% endif %} + upload_response = mock.MagicMock() + upload_response.status_code = 200 + upload_response.headers = {"x-goog-upload-status": "final"} + upload_response.read = mock.AsyncMock(return_value=json_return_value.encode("UTF-8")) + upload_response.close = mock.AsyncMock(return_value=None) + + req.side_effect = [start_response, upload_response] + retry = retries.AsyncRetry() + response = await client.{{ method.client_method_name|snake_case }}( + {{ method.input.ident }}(), + config=resumable_transfer.ResumableUploadConfig(headers={"x-custom": "val"}), + retry=retry, + timeout=10.0, + ) + assert isinstance(response, {{ method.client_output_async.ident }}) + assert response._start_retry is retry + result = await response.upload(b"test data") + assert isinstance(result, {{ method.output.ident }}) + assert result == expected_result + start_call = req.call_args_list[0] + assert start_call.kwargs["headers"]["Content-Type"] == "application/json" + assert start_call.kwargs["headers"]["x-custom"] == "val" + + +@pytest.mark.asyncio +async def test_{{ service.name|snake_case }}_{{ method.name|snake_case }}_grpc_asyncio_body_and_query_params(): + if not HAS_ASYNC_REST_EXTRA: + pytest.skip() + client = {{ service.async_client_name }}( + credentials=async_anonymous_credentials(), + ) + with mock.patch.object( + AsyncAuthorizedSession, "request" + ) as req, mock.patch.object(path_template, "transcode") as transcode: + request = {{ method.input.ident }}( + {% for field in method.input.fields.values() | rejectattr('message') %} + {% if not field.oneof or field.proto3_optional %} + {{ field.name }}={{ field.mock_value }}, + {% endif %} + {% endfor %} + ) + {% if method.input.ident.is_proto_plus_type %} + pb_request = {{ method.input.ident }}.pb(request) + {% else %} + pb_request = request + {% endif %} + transcode.return_value = { + "uri": "v1/sample_method", + "method": "{{ method.http_options[0].method }}", + "body": pb_request, + "query_params": pb_request, + } + + start_response = mock.MagicMock() + start_response.status_code = 200 + start_response.headers = {"x-goog-upload-status": "active", "x-goog-upload-url": "http://localhost/upload"} + start_response.read = mock.AsyncMock(return_value=b"") + start_response.close = mock.AsyncMock(return_value=None) + + expected_result = {{ method.output.ident }}() + {% if method.output.ident.is_proto_plus_type %} + json_return_value = {{ method.output.ident }}.to_json(expected_result) + {% else %} + json_return_value = json_format.MessageToJson(expected_result) + {% endif %} + upload_response = mock.MagicMock() + upload_response.status_code = 200 + upload_response.headers = {"x-goog-upload-status": "final"} + upload_response.read = mock.AsyncMock(return_value=json_return_value.encode("UTF-8")) + upload_response.close = mock.AsyncMock(return_value=None) + + req.side_effect = [start_response, upload_response] + response = await client.{{ method.client_method_name|snake_case }}(request) + result = await response.upload(b"test data") + assert result == expected_result + + start_call = req.call_args_list[0] + assert start_call.kwargs["headers"]["Content-Type"] == "application/json" + {% for field in method.input.fields.values() | rejectattr('message') %} + {% if not field.oneof or field.proto3_optional %} + assert "{{ field.name | camel_case }}=" in start_call.args[1] + {% endif %} + {% endfor %} + {% if method.http_options[0].body %} + expected_body = json_format.MessageToJson( + pb_request, + use_integers_for_enums={{ opts.rest_numeric_enums }}, + ) + assert start_call.kwargs["data"] == expected_body.encode("UTF-8") + {% endif %} + + +@pytest.mark.asyncio +async def test_{{ service.name|snake_case }}_{{ method.name|snake_case }}_grpc_asyncio_without_async_rest_error(): + with mock.patch.object(transports.grpc_asyncio, "HAS_ASYNC_REST", False): + transport = transports.{{ service.name }}GrpcAsyncIOTransport( + credentials=ga_credentials.AnonymousCredentials(), + ) + with pytest.raises(NotImplementedError) as exc_info: + await transport.{{ method.transport_safe_name|snake_case }}({{ method.input.ident }}()) + assert ( + "Async REST transport is required for async resumable upload methods. " + "Install the library with the `async_rest` extra using " + "`pip install {{ api.naming.warehouse_package_name }}[async_rest]`." + ) in str(exc_info.value) +{% endfor %} +{% endif %} + +{% if service.has_resumable_upload_methods and 'rest' in opts.transport %} +{% for method in service.methods.values() if method.is_resumable_upload %} +def test_{{ service.name|snake_case }}_{{ method.name|snake_case }}_rest_start_empty_body(): + client = {{ service.client_name }}( + credentials=ga_credentials.AnonymousCredentials(), + transport="rest", + ) + with mock.patch.object(type(client.transport._session), "request") as req: + start_response = mock.MagicMock() + start_response.ok = True + start_response.status_code = 200 + start_response.headers = {"x-goog-upload-status": "active", "x-goog-upload-url": "http://localhost/upload"} + start_response.content = b"" + + expected_result = {{ method.output.ident }}( + {% for field in method.output.fields.values() | rejectattr('message') %} + {% if not field.oneof or field.proto3_optional %} + {{ field.name }}={{ field.mock_value }}, + {% endif %} + {% endfor %} + ) + {% if method.output.ident.is_proto_plus_type %} + json_return_value = {{ method.output.ident }}.to_json(expected_result) + {% else %} + json_return_value = json_format.MessageToJson(expected_result) + {% endif %} + upload_response = mock.MagicMock() + upload_response.ok = True + upload_response.status_code = 200 + upload_response.headers = {"x-goog-upload-status": "final"} + upload_response.content = json_return_value.encode("UTF-8") + + req.side_effect = [start_response, upload_response] + retry = retries.Retry() + response = client.{{ method.client_method_name|snake_case }}( + {{ method.input.ident }}(), + config=resumable_transfer.ResumableUploadConfig(headers={"x-custom": "val"}), + retry=retry, + timeout=10.0, + ) + assert isinstance(response, {{ method.client_output.ident }}) + assert response._start_retry is retry + result = response.upload(b"test data") + assert isinstance(result, {{ method.output.ident }}) + assert result == expected_result + start_call = req.call_args_list[0] + assert start_call.kwargs["headers"]["Content-Type"] == "application/json" + assert start_call.kwargs["headers"]["x-custom"] == "val" + + +{% if rest_async_io_enabled %} +@pytest.mark.asyncio +async def test_{{ service.name|snake_case }}_{{ method.name|snake_case }}_rest_asyncio_start_empty_body(): + if not HAS_ASYNC_REST_EXTRA: + pytest.skip() + client = {{ service.async_client_name }}( + credentials=async_anonymous_credentials(), + transport="rest_asyncio", + ) + with mock.patch.object(type(client.transport._session), "request") as req: + start_response = mock.MagicMock() + start_response.status_code = 200 + start_response.headers = {"x-goog-upload-status": "active", "x-goog-upload-url": "http://localhost/upload"} + start_response.read = mock.AsyncMock(return_value=b"") + start_response.close = mock.AsyncMock(return_value=None) + + expected_result = {{ method.output.ident }}( + {% for field in method.output.fields.values() | rejectattr('message') %} + {% if not field.oneof or field.proto3_optional %} + {{ field.name }}={{ field.mock_value }}, + {% endif %} + {% endfor %} + ) + {% if method.output.ident.is_proto_plus_type %} + json_return_value = {{ method.output.ident }}.to_json(expected_result) + {% else %} + json_return_value = json_format.MessageToJson(expected_result) + {% endif %} + upload_response = mock.MagicMock() + upload_response.status_code = 200 + upload_response.headers = {"x-goog-upload-status": "final"} + upload_response.read = mock.AsyncMock(return_value=json_return_value.encode("UTF-8")) + upload_response.close = mock.AsyncMock(return_value=None) + + req.side_effect = [start_response, upload_response] + retry = retries.AsyncRetry() + response = await client.{{ method.client_method_name|snake_case }}( + {{ method.input.ident }}(), + config=resumable_transfer.ResumableUploadConfig(headers={"x-custom": "val"}), + retry=retry, + timeout=10.0, + ) + assert isinstance(response, {{ method.client_output_async.ident }}) + assert response._start_retry is retry + result = await response.upload(b"test data") + assert isinstance(result, {{ method.output.ident }}) + assert result == expected_result + start_call = req.call_args_list[0] + assert start_call.kwargs["headers"]["Content-Type"] == "application/json" + assert start_call.kwargs["headers"]["x-custom"] == "val" +{% endif %} + + +def test_{{ service.name|snake_case }}_{{ method.name|snake_case }}_rest_body_and_query_params(): + client = {{ service.client_name }}( + credentials=ga_credentials.AnonymousCredentials(), + transport="rest", + ) + with mock.patch.object(type(client.transport._session), "request") as req, mock.patch.object( + path_template, "transcode" + ) as transcode: + request = {{ method.input.ident }}( + {% for field in method.input.fields.values() | rejectattr('message') %} + {% if not field.oneof or field.proto3_optional %} + {{ field.name }}={{ field.mock_value }}, + {% endif %} + {% endfor %} + ) + {% if method.input.ident.is_proto_plus_type %} + pb_request = {{ method.input.ident }}.pb(request) + {% else %} + pb_request = request + {% endif %} + transcode.return_value = { + "uri": "v1/sample_method", + "method": "{{ method.http_options[0].method }}", + "body": pb_request, + "query_params": pb_request, + } + + start_response = mock.MagicMock() + start_response.ok = True + start_response.status_code = 200 + start_response.headers = {"x-goog-upload-status": "active", "x-goog-upload-url": "http://localhost/upload"} + start_response.content = b"" + + expected_result = {{ method.output.ident }}() + {% if method.output.ident.is_proto_plus_type %} + json_return_value = {{ method.output.ident }}.to_json(expected_result) + {% else %} + json_return_value = json_format.MessageToJson(expected_result) + {% endif %} + upload_response = mock.MagicMock() + upload_response.ok = True + upload_response.status_code = 200 + upload_response.headers = {"x-goog-upload-status": "final"} + upload_response.content = json_return_value.encode("UTF-8") + + req.side_effect = [start_response, upload_response] + response = client.{{ method.client_method_name|snake_case }}(request) + result = response.upload(b"test data") + assert result == expected_result + + start_call = req.call_args_list[0] + assert start_call.kwargs["headers"]["Content-Type"] == "application/json" + {% for field in method.input.fields.values() | rejectattr('message') %} + {% if not field.oneof or field.proto3_optional %} + assert "{{ field.name | camel_case }}=" in start_call.args[1] + {% endif %} + {% endfor %} + {% if method.http_options[0].body %} + expected_body = json_format.MessageToJson( + pb_request, + use_integers_for_enums={{ opts.rest_numeric_enums }}, + ) + assert start_call.kwargs["data"] == expected_body.encode("UTF-8") + {% endif %} + + +{% if rest_async_io_enabled %} +@pytest.mark.asyncio +async def test_{{ service.name|snake_case }}_{{ method.name|snake_case }}_rest_asyncio_body_and_query_params(): + if not HAS_ASYNC_REST_EXTRA: + pytest.skip() + client = {{ service.async_client_name }}( + credentials=async_anonymous_credentials(), + transport="rest_asyncio", + ) + with mock.patch.object( + type(client.transport._session), "request" + ) as req, mock.patch.object(path_template, "transcode") as transcode: + request = {{ method.input.ident }}( + {% for field in method.input.fields.values() | rejectattr('message') %} + {% if not field.oneof or field.proto3_optional %} + {{ field.name }}={{ field.mock_value }}, + {% endif %} + {% endfor %} + ) + {% if method.input.ident.is_proto_plus_type %} + pb_request = {{ method.input.ident }}.pb(request) + {% else %} + pb_request = request + {% endif %} + transcode.return_value = { + "uri": "v1/sample_method", + "method": "{{ method.http_options[0].method }}", + "body": pb_request, + "query_params": pb_request, + } + + start_response = mock.MagicMock() + start_response.status_code = 200 + start_response.headers = {"x-goog-upload-status": "active", "x-goog-upload-url": "http://localhost/upload"} + start_response.read = mock.AsyncMock(return_value=b"") + start_response.close = mock.AsyncMock(return_value=None) + + expected_result = {{ method.output.ident }}() + {% if method.output.ident.is_proto_plus_type %} + json_return_value = {{ method.output.ident }}.to_json(expected_result) + {% else %} + json_return_value = json_format.MessageToJson(expected_result) + {% endif %} + upload_response = mock.MagicMock() + upload_response.status_code = 200 + upload_response.headers = {"x-goog-upload-status": "final"} + upload_response.read = mock.AsyncMock(return_value=json_return_value.encode("UTF-8")) + upload_response.close = mock.AsyncMock(return_value=None) + + req.side_effect = [start_response, upload_response] + response = await client.{{ method.client_method_name|snake_case }}(request) + result = await response.upload(b"test data") + assert result == expected_result + + start_call = req.call_args_list[0] + assert start_call.kwargs["headers"]["Content-Type"] == "application/json" + {% for field in method.input.fields.values() | rejectattr('message') %} + {% if not field.oneof or field.proto3_optional %} + assert "{{ field.name | camel_case }}=" in start_call.args[1] + {% endif %} + {% endfor %} + {% if method.http_options[0].body %} + expected_body = json_format.MessageToJson( + pb_request, + use_integers_for_enums={{ opts.rest_numeric_enums }}, + ) + assert start_call.kwargs["data"] == expected_body.encode("UTF-8") + {% endif %} +{% endif %} +{% endfor %} +{% endif %} + + # Remove this test when deprecated arguments (api_mtls_endpoint, client_cert_source) are # removed from grpc/grpc_asyncio transport constructor. @pytest.mark.filterwarnings("ignore::FutureWarning") diff --git a/packages/gapic-generator/gapic/templates/tests/unit/gapic/%name_%version/%sub/test_macros.j2 b/packages/gapic-generator/gapic/templates/tests/unit/gapic/%name_%version/%sub/test_macros.j2 index 05c60cbdc9c3..986f46f6c060 100644 --- a/packages/gapic-generator/gapic/templates/tests/unit/gapic/%name_%version/%sub/test_macros.j2 +++ b/packages/gapic-generator/gapic/templates/tests/unit/gapic/%name_%version/%sub/test_macros.j2 @@ -45,6 +45,8 @@ def test_{{ method_name }}(request_type, transport: str = 'grpc'): call.return_value = operations_pb2.Operation(name='operations/spam') {% elif method.server_streaming %} call.return_value = iter([{{ method.output.ident }}()]) + {% elif method.is_resumable_upload %} + call.return_value = {{ method.client_output.ident }}() {% else %} call.return_value = {{ method.output.ident }}( {% for field in method.output.fields.values() | rejectattr('message')%} @@ -92,6 +94,8 @@ def test_{{ method_name }}(request_type, transport: str = 'grpc'): {% elif method.server_streaming %} for message in response: assert isinstance(message, {{ method.output.ident }}) + {% elif method.is_resumable_upload %} + assert isinstance(response, {{ method.client_output.ident }}) {% else %} {% if "next_page_token" in method.output.fields.values()|map(attribute='name', default="") and not method.paged_result_field %} {# Cheeser assertion to force code coverage for bad paginated methods #} @@ -313,6 +317,10 @@ async def test_{{ method_name }}_async(request_type, transport: str = 'grpc_asyn {% elif method.client_streaming and method.server_streaming %} call.return_value = mock.Mock(aio.StreamStreamCall, autospec=True) call.return_value.read = mock.AsyncMock(side_effect=[{{ method.output.ident }}()]) + {% elif method.is_resumable_upload %} + call.return_value = grpc_helpers_async.FakeUnaryUnaryCall( + {{ method.client_output_async.ident }}() + ) {% else %} call.return_value ={{ '' }} {%- if not method.client_streaming and not method.server_streaming -%} @@ -357,6 +365,8 @@ async def test_{{ method_name }}_async(request_type, transport: str = 'grpc_asyn {% elif method.server_streaming %} message = await response.read() assert isinstance(message, {{ method.output.ident }}) + {% elif method.is_resumable_upload %} + assert isinstance(response, {{ method.client_output_async.ident }}) {% else %} assert isinstance(response, {{ method.client_output_async.ident }}) {% for field in method_output.fields.values() | rejectattr('message') %} @@ -1832,18 +1842,33 @@ def test_initialize_client_w_{{transport_name}}(): # Mock the http request call within the method and fake a BadRequest error. with mock.patch.object({{mocked_session}}, 'request') as req, pytest.raises(core_exceptions.BadRequest): # Wrap the value into a proper Response obj + {% if method.is_resumable_upload %} + response_value = mock.MagicMock() + {% else %} response_value = mock.Mock() + {% endif %} {% if is_async %} response_value.read = mock.AsyncMock(return_value=b'{}') + {% if method.is_resumable_upload %} + response_value.close = mock.AsyncMock(return_value=None) + {% endif %} {% else %} json_return_value = '' response_value.json = mock.Mock(return_value={}) + {% if method.is_resumable_upload %} + response_value.ok = False + {% endif %} {% endif %}{# if is_async #} response_value.status_code = 400 response_value.request = mock.Mock() req.return_value = response_value req.return_value.headers = {"header-1": "value-1", "header-2": "value-2"} + {% if method.is_resumable_upload %} + response = {{ await_prefix }}client.{{ method_name }}(request) + {{ await_prefix }}response.upload(b"") + {% else %} {{ await_prefix }}client.{{ method_name }}(request) + {% endif %} {% endif %}{# if 'grpc' in transport #} {% endmacro %} @@ -2050,7 +2075,11 @@ def test_initialize_client_w_{{transport_name}}(): {% endif %}{# method.void #} # Wrap the value into a proper Response obj + {% if method.is_resumable_upload %} + response_value = mock.MagicMock() + {% else %} response_value = mock.Mock() + {% endif %} response_value.status_code = 200 {% if method.void %} json_return_value = '' @@ -2076,9 +2105,33 @@ def test_initialize_client_w_{{transport_name}}(): response_value.content = json_return_value.encode('UTF-8') {% endif %}{# is_async #} {% endif %}{# method.server_streaming #} + {% if method.is_resumable_upload %} + start_response = mock.MagicMock() + start_response.status_code = 200 + start_response.headers = {"x-goog-upload-status": "active", "x-goog-upload-url": "http://localhost/upload"} + response_value.headers = {"x-goog-upload-status": "final", "header-1": "value-1", "header-2": "value-2"} + {% if is_async %} + start_response.read = mock.AsyncMock(return_value=b"") + start_response.close = mock.AsyncMock(return_value=None) + response_value.close = mock.AsyncMock(return_value=None) + {% else %} + start_response.ok = True + start_response.content = b"" + response_value.ok = True + {% endif %} + req.side_effect = [start_response, response_value] + response = {{ await_prefix }}client.{{ method_name }}(request) + {% if is_async %} + assert isinstance(response, {{ method.client_output_async.ident }}) + {% else %} + assert isinstance(response, {{ method.client_output.ident }}) + {% endif %} + response = {{ await_prefix }}response.upload(b"test data") + {% else %} req.return_value = response_value req.return_value.headers = {"header-1": "value-1", "header-2": "value-2"} response = {{ await_prefix }}client.{{ method_name }}(request) + {% endif %} {% if "next_page_token" in method_output.fields.values()|map(attribute='name', default="") and not method.paged_result_field %} {# TODO(https://github.com/googleapis/gapic-generator-python/issues/2199): The following assert statement is added to force # code coverage for bad paginated methods. Investigate what bad paginated methods are, and follow up on why this assertion is required. @@ -2103,7 +2156,9 @@ def test_initialize_client_w_{{transport_name}}(): {% elif method.lro %} json_return_value = json_format.MessageToJson(return_value) {% else %} - {% if is_async %} + {% if method.is_resumable_upload %} + assert isinstance(response, {{ method.output.ident }}) + {% elif is_async %} assert isinstance(response, {{ method.client_output_async.ident }}) {% else %} assert isinstance(response, {{ method.client_output.ident }}) @@ -2257,13 +2312,13 @@ def test_initialize_client_w_{{transport_name}}(): {% if method.lro %} mock.patch.object(operation.Operation, "_set_result_from_operation"), \ {% endif %} - {% if not method.void %} + {% if not method.void and not method.is_resumable_upload %} mock.patch.object(transports.{{async_method_prefix}}{{ service.name }}RestInterceptor, "post_{{method.name|snake_case}}") as post, \ mock.patch.object(transports.{{async_method_prefix}}{{ service.name }}RestInterceptor, "post_{{method.name|snake_case}}_with_metadata") as post_with_metadata, \ {% endif %} mock.patch.object(transports.{{async_method_prefix}}{{ service.name }}RestInterceptor, "pre_{{ method.name|snake_case }}") as pre: pre.assert_not_called() - {% if not method.void %} + {% if not method.void and not method.is_resumable_upload %} post.assert_not_called() post_with_metadata.assert_not_called() {% endif %} @@ -2282,7 +2337,7 @@ def test_initialize_client_w_{{transport_name}}(): req.return_value = mock.Mock() req.return_value.status_code = 200 req.return_value.headers = {"header-1": "value-1", "header-2": "value-2"} - {% if not method.void %} + {% if not method.void and not method.is_resumable_upload %} return_value = {% if method.output.ident.is_proto_plus_type %}{{ method.output.ident }}.to_json({{ method.output.ident }}()){% else %}json_format.MessageToJson({{ method.output.ident }}()){% endif %} {% if method.server_streaming %} @@ -2298,7 +2353,7 @@ def test_initialize_client_w_{{transport_name}}(): req.return_value.content = return_value {% endif %}{# end is_async #} {% endif %}{# end method.server_streaming #} - {% endif %}{# end not method.void #} + {% endif %}{# end not method.void and not method.is_resumable_upload #} request = {{ method.input.ident }}() metadata =[ @@ -2306,7 +2361,7 @@ def test_initialize_client_w_{{transport_name}}(): ("cephalopod", "squid"), ] pre.return_value = request, metadata - {% if not method.void %} + {% if not method.void and not method.is_resumable_upload %} post.return_value = {{ method.output.ident }}() post_with_metadata.return_value = {{ method.output.ident }}(), metadata {% endif %} @@ -2314,7 +2369,7 @@ def test_initialize_client_w_{{transport_name}}(): {{await_prefix}}client.{{ method_name }}(request, metadata=[("key", "val"), ("cephalopod", "squid"),]) pre.assert_called_once() - {% if not method.void %} + {% if not method.void and not method.is_resumable_upload %} post.assert_called_once() post_with_metadata.assert_called_once() {% endif %} diff --git a/packages/gapic-generator/noxfile.py b/packages/gapic-generator/noxfile.py index 2fe717cec79e..b9d1870b9e21 100644 --- a/packages/gapic-generator/noxfile.py +++ b/packages/gapic-generator/noxfile.py @@ -193,10 +193,6 @@ def fragment(session, use_ads_templates=False): ) session.install("-e", ".") - # The specific failure is `Plugin output is unparseable` - if session.python == "3.10": - session.install("google-api-core<2.28") - frag_files = ( [Path(f) for f in session.posargs] if session.posargs else FRAGMENT_FILES ) @@ -246,8 +242,30 @@ def showcase_library( include_service_yaml=True, retry_config=True, rest_async_io_enabled=False, + install_async_rest_extra=False, ): - """Install the generated library into the session for showcase tests.""" + """Install the generated library into the session for showcase tests. + + Args: + session: The nox session object. + templates (str): The template directory to use for code generation. + Defaults to "DEFAULT". + other_opts (typing.Iterable[str]): Additional options passed to + `--python_gapic_opt` during code generation. + include_service_yaml (bool): Whether to download and pass + `showcase_v1beta1.yaml` to the generator. + retry_config (bool): Whether to download and pass + `showcase_grpc_service_config.json` to the generator. + rest_async_io_enabled (bool): Whether to enable the experimental + `rest_async_io_enabled` setting in `showcase_v1beta1.yaml` so + `rest_asyncio` transports are generated for all services. When True, + the `[async_rest]` extra is also installed. + install_async_rest_extra (bool): Whether to install the generated + library with the `[async_rest]` extra and its corresponding + `constraints-{python}-async-rest.txt` file even when + `rest_async_io_enabled` is False in `showcase_v1beta1.yaml` (e.g., + for services with resumable upload methods). + """ session.log("-" * 70) session.log("Note: Showcase must be running for these tests to work.") @@ -260,13 +278,6 @@ def showcase_library( # Install grpcio-tools for protoc session.install("grpcio-tools") - # TODO(https://github.com/googleapis/gapic-generator-python/issues/2473): - # Warnings emitted from google-api-core starting in 2.28 - # appear to cause issues when running protoc. - # The specific failure is `Plugin output is unparseable` - if session.python == "3.10": - session.install("google-api-core<2.28") - # Install a client library for Showcase. with tempfile.TemporaryDirectory() as tmp_dir: # Download the Showcase descriptor. @@ -343,18 +354,30 @@ def showcase_library( "transport=grpc+rest", ) ) + # TODO(https://github.com/googleapis/google-cloud-python/issues/16312): + # Add compliance.proto once this bug is fixed + # We should use `"google/showcase/v1beta1/*.proto",` + protos = ( + "google/showcase/v1beta1/echo.proto", + "google/showcase/v1beta1/identity.proto", + "google/showcase/v1beta1/messaging.proto", + "google/showcase/v1beta1/rest_error.proto", + "google/showcase/v1beta1/sequence.proto", + "google/showcase/v1beta1/testing.proto", + ) + if templates == "DEFAULT": + protos += ("google/showcase/v1beta1/resumable_upload.proto",) cmd_tup = ( "python", "-m", "grpc_tools.protoc", - f"--experimental_allow_proto3_optional", + "--experimental_allow_proto3_optional", f"--descriptor_set_in={tmp_dir}{path.sep}showcase.desc", opts, f"--python_gapic_out={tmp_dir}", - f"google/showcase/v1beta1/echo.proto", - f"google/showcase/v1beta1/identity.proto", - f"google/showcase/v1beta1/messaging.proto", + *protos, ) + session.run( *cmd_tup, external=True, @@ -371,7 +394,8 @@ def showcase_library( f"{tmp_dir}/testing/constraints-{session.python}.txt" ) extras = "" - if rest_async_io_enabled: + if rest_async_io_enabled or install_async_rest_extra: + extras = "[async_rest]" async_rest_constraints_path = str( f"{tmp_dir}/testing/constraints-{session.python}-async-rest.txt" ) @@ -382,8 +406,6 @@ def showcase_library( session.log( f"{async_rest_constraints_path} not found. Using base constraints file" ) - extras = "[async_rest]" - session.install("-e", f"{tmp_dir}{extras}", "-r", constraints_path) else: # The ads templates do not have constraints files. @@ -395,8 +417,10 @@ def showcase_library( @nox.session(python=ALL_PYTHON) +@nox.parametrize("install_async_rest_extra", [False, True]) def showcase( session, + install_async_rest_extra=False, templates="DEFAULT", other_opts: typing.Iterable[str] = (), env: typing.Optional[typing.Dict[str, str]] = {}, @@ -407,7 +431,12 @@ def showcase( (useful for local testing and canary validation). """ - with showcase_library(session, templates=templates, other_opts=other_opts): + with showcase_library( + session, + templates=templates, + other_opts=other_opts, + install_async_rest_extra=install_async_rest_extra, + ): # When opt-in environment variable is set (e.g. in canary CI or local testing), # install the local google-api-core package from source. if os.getenv("INSTALL_LOCAL_CORE") == "true": @@ -535,7 +564,26 @@ def showcase_pqc( ) -def run_showcase_unit_tests(session, fail_under=100, rest_async_io_enabled=False): +def run_showcase_unit_tests( + session, + fail_under=100, + rest_async_io_enabled=False, + install_async_rest_extra=False, +): + """Run the generated Showcase unit test suite with coverage verification. + + Args: + session: The nox session object. + fail_under (int): Minimum required test coverage percentage. + Defaults to 100. + rest_async_io_enabled (bool): Whether `rest_async_io_enabled` was enabled + in `showcase_v1beta1.yaml` during code generation. + install_async_rest_extra (bool): Whether the library was installed with + the `[async_rest]` extra. When both `rest_async_io_enabled` and + `install_async_rest_extra` are False, `**/rest_asyncio.py` is omitted + from coverage since optional `async_rest` dependencies are not + installed. + """ session.install( "coverage", "pytest", @@ -546,6 +594,20 @@ def run_showcase_unit_tests(session, fail_under=100, rest_async_io_enabled=False # Freeze and print python environment package versions session.run("python", "-m", "pip", "freeze") + if ( + not rest_async_io_enabled + and not install_async_rest_extra + and path.exists(".coveragerc") + ): + with open(".coveragerc", "r") as f: + coveragerc = f.read() + if "**/rest_asyncio.py" not in coveragerc: + coveragerc = coveragerc.replace( + "omit =\n", "omit =\n **/rest_asyncio.py\n" + ) + with open(".coveragerc", "w") as f: + f.write(coveragerc) + # Run the tests. session.run( "py.test", @@ -564,15 +626,24 @@ def run_showcase_unit_tests(session, fail_under=100, rest_async_io_enabled=False @nox.session(python=ALL_PYTHON) +@nox.parametrize("install_async_rest_extra", [False, True]) def showcase_unit( session, + install_async_rest_extra=False, templates="DEFAULT", other_opts: typing.Iterable[str] = (), ): """Run the generated unit tests against the Showcase library.""" - with showcase_library(session, templates=templates, other_opts=other_opts) as lib: + with showcase_library( + session, + templates=templates, + other_opts=other_opts, + install_async_rest_extra=install_async_rest_extra, + ) as lib: session.chdir(lib) - run_showcase_unit_tests(session) + run_showcase_unit_tests( + session, install_async_rest_extra=install_async_rest_extra + ) # TODO: `showcase_unit_w_rest_async` nox session runs showcase unit tests with the diff --git a/packages/gapic-generator/requirements.txt b/packages/gapic-generator/requirements.txt index ff61f2e8a44e..38eb25a0b012 100644 --- a/packages/gapic-generator/requirements.txt +++ b/packages/gapic-generator/requirements.txt @@ -611,13 +611,13 @@ frozenlist==1.8.0 \ # via # aiohttp # aiosignal -google-api-core==2.40.0 \ - --hash=sha256:4b9e0a80024c269ae173136d5439f4ed284651d6e5e2773ba5c694f481f9c0f4 \ - --hash=sha256:ebee7d1b138b5362beecec260e6e8988ac97346562c7382ccb6f0ad8435c599f +google-api-core==2.41.0 \ + --hash=sha256:1f3d626b7a8022dda8deef200b845057231ddc7cbf0d999026bdf61ba38d7fdf \ + --hash=sha256:73e89a86baef6680934adeee6fbd0ceaf20c1393ab229b2f9b34efb23b0fdef3 # via -r requirements.in -google-auth==2.59.0 \ - --hash=sha256:43e47b1d3067fb90d77bd9ba21f139bb0577b3f2b9f3e2516fc00f37435efcad \ - --hash=sha256:eb32f44f89f6b577947ebee5887c1db46e6b1a278889ba369a88179643f32240 +google-auth==2.60.0 \ + --hash=sha256:34aa0283ef72c99cd410e35db4417211fa3e6aaa5399183227bbe3b82bd15192 \ + --hash=sha256:e78414cd2e22cc02884768707def024ca32da17695f2c0e6194cdbe1316c4cd4 # via google-api-core googleapis-common-protos==1.75.5 \ --hash=sha256:c7a866fc34ed29a3b10af627a4b9b1dc2433313ca6e959f0ae4feb132047ed72 \ diff --git a/packages/gapic-generator/tests/integration/goldens/redis/google/cloud/redis_v1/services/cloud_redis/client.py b/packages/gapic-generator/tests/integration/goldens/redis/google/cloud/redis_v1/services/cloud_redis/client.py index 7b2e7759cd73..bb62ed511a5c 100755 --- a/packages/gapic-generator/tests/integration/goldens/redis/google/cloud/redis_v1/services/cloud_redis/client.py +++ b/packages/gapic-generator/tests/integration/goldens/redis/google/cloud/redis_v1/services/cloud_redis/client.py @@ -62,13 +62,11 @@ from .transports.grpc import CloudRedisGrpcTransport from .transports.grpc_asyncio import CloudRedisGrpcAsyncIOTransport from .transports.rest import CloudRedisRestTransport -ASYNC_REST_EXCEPTION = None try: from .transports.rest_asyncio import AsyncCloudRedisRestTransport - HAS_ASYNC_REST_DEPENDENCIES = True -except ImportError as e: # pragma: NO COVER + HAS_ASYNC_REST_DEPENDENCIES = True # pragma: NO COVER +except ImportError: # pragma: NO COVER HAS_ASYNC_REST_DEPENDENCIES = False - ASYNC_REST_EXCEPTION = e class CloudRedisClientMeta(type): @@ -99,7 +97,9 @@ def get_transport_class(cls, """ # If a specific transport is requested, return that one. if label == "rest_asyncio" and not HAS_ASYNC_REST_DEPENDENCIES: # pragma: NO COVER - raise ASYNC_REST_EXCEPTION + raise ImportError( + "`rest_asyncio` transport requires the library to be installed with the `async_rest` extra. Install the library with the `async_rest` extra using `pip install google-cloud-redis[async_rest]`" + ) if label: return cls._transport_registry[label] @@ -506,7 +506,7 @@ def __init__(self, *, else cast(Callable[..., CloudRedisTransport], transport) ) - if "rest_asyncio" in str(transport_init): + if "rest_asyncio" in str(transport_init): # pragma: NO COVER unsupported_params = { "google.api_core.client_options.ClientOptions.credentials_file": self._client_options.credentials_file, "google.api_core.client_options.ClientOptions.scopes": self._client_options.scopes, diff --git a/packages/gapic-generator/tests/integration/goldens/redis/google/cloud/redis_v1/services/cloud_redis/transports/__init__.py b/packages/gapic-generator/tests/integration/goldens/redis/google/cloud/redis_v1/services/cloud_redis/transports/__init__.py index 4b81d3f86f0b..4f1a5dd5f81f 100755 --- a/packages/gapic-generator/tests/integration/goldens/redis/google/cloud/redis_v1/services/cloud_redis/transports/__init__.py +++ b/packages/gapic-generator/tests/integration/goldens/redis/google/cloud/redis_v1/services/cloud_redis/transports/__init__.py @@ -24,9 +24,9 @@ ASYNC_REST_CLASSES: Tuple[str, ...] try: from .rest_asyncio import AsyncCloudRedisRestTransport - from .rest_asyncio import AsyncCloudRedisRestInterceptor - ASYNC_REST_CLASSES = ('AsyncCloudRedisRestTransport', 'AsyncCloudRedisRestInterceptor') - HAS_REST_ASYNC = True + from .rest_asyncio import AsyncCloudRedisRestInterceptor # pragma: NO COVER + ASYNC_REST_CLASSES = ('AsyncCloudRedisRestTransport', 'AsyncCloudRedisRestInterceptor') # pragma: NO COVER + HAS_REST_ASYNC = True # pragma: NO COVER except ImportError: # pragma: NO COVER ASYNC_REST_CLASSES = () HAS_REST_ASYNC = False diff --git a/packages/gapic-generator/tests/integration/goldens/redis/google/cloud/redis_v1/services/cloud_redis/transports/rest_asyncio.py b/packages/gapic-generator/tests/integration/goldens/redis/google/cloud/redis_v1/services/cloud_redis/transports/rest_asyncio.py index 4d629a5a8443..fa3f65ce9e36 100755 --- a/packages/gapic-generator/tests/integration/goldens/redis/google/cloud/redis_v1/services/cloud_redis/transports/rest_asyncio.py +++ b/packages/gapic-generator/tests/integration/goldens/redis/google/cloud/redis_v1/services/cloud_redis/transports/rest_asyncio.py @@ -23,6 +23,7 @@ except ImportError as e: # pragma: NO COVER raise ImportError("`rest_asyncio` transport requires the library to be installed with the `async_rest` extra. Install the library with the `async_rest` extra using `pip install google-cloud-redis[async_rest]`") from e +from google.auth import credentials as ga_credentials # type: ignore from google.auth.aio import credentials as ga_credentials_async # type: ignore from google.api_core import exceptions as core_exceptions @@ -770,7 +771,7 @@ class AsyncCloudRedisRestTransport(_BaseCloudRedisRestTransport): def __init__(self, *, host: str = 'redis.googleapis.com', - credentials: Optional[ga_credentials_async.Credentials] = None, + credentials: Optional[Union[ga_credentials.Credentials, ga_credentials_async.Credentials]] = None, client_info: gapic_v1.client_info.ClientInfo = DEFAULT_CLIENT_INFO, url_scheme: str = 'https', interceptor: Optional[AsyncCloudRedisRestInterceptor] = None, @@ -784,7 +785,7 @@ def __init__(self, Args: host (Optional[str]): The hostname to connect to (default: 'redis.googleapis.com'). - credentials (Optional[google.auth.aio.credentials.Credentials]): The + credentials (Optional[Union[google.auth.credentials.Credentials, google.auth.aio.credentials.Credentials]]): The authorization credentials to attach to requests. These credentials identify the application to the service; if none are specified, the client will attempt to ascertain the diff --git a/packages/gapic-generator/tests/integration/goldens/redis/setup.py b/packages/gapic-generator/tests/integration/goldens/redis/setup.py index 29dcfeccbc8b..fdadb0f8c9a8 100755 --- a/packages/gapic-generator/tests/integration/goldens/redis/setup.py +++ b/packages/gapic-generator/tests/integration/goldens/redis/setup.py @@ -53,7 +53,8 @@ ] extras = { "async_rest": [ - "google-auth[aiohttp] >= 2.35.0, <3.0.0" + "google-auth[aiohttp] >= 2.60.0, <3.0.0", + "google-api-core >= 2.41.0, <3.0.0", ], } url = "https://github.com/googleapis/google-cloud-python/tree/main/packages/google-cloud-redis" diff --git a/packages/gapic-generator/tests/integration/goldens/redis/testing/constraints-3.10-async-rest.txt b/packages/gapic-generator/tests/integration/goldens/redis/testing/constraints-3.10-async-rest.txt index c0c1ba32f46f..4a2700a3bf8a 100755 --- a/packages/gapic-generator/tests/integration/goldens/redis/testing/constraints-3.10-async-rest.txt +++ b/packages/gapic-generator/tests/integration/goldens/redis/testing/constraints-3.10-async-rest.txt @@ -6,7 +6,8 @@ # For example, if setup.py has "google-cloud-foo >= 1.14.0, < 2.0.0", # then this file should have google-cloud-foo==1.14.0 google-api-core==2.28.0 -google-auth==2.35.0 +google-auth==2.60.0 +aiohttp==3.8.0 grpcio==1.59.0 proto-plus==1.26.1 protobuf==6.33.5 diff --git a/packages/gapic-generator/tests/integration/goldens/redis_selective/google/cloud/redis_v1/services/cloud_redis/client.py b/packages/gapic-generator/tests/integration/goldens/redis_selective/google/cloud/redis_v1/services/cloud_redis/client.py index 771b0baa9989..22043152a1c6 100755 --- a/packages/gapic-generator/tests/integration/goldens/redis_selective/google/cloud/redis_v1/services/cloud_redis/client.py +++ b/packages/gapic-generator/tests/integration/goldens/redis_selective/google/cloud/redis_v1/services/cloud_redis/client.py @@ -62,13 +62,11 @@ from .transports.grpc import CloudRedisGrpcTransport from .transports.grpc_asyncio import CloudRedisGrpcAsyncIOTransport from .transports.rest import CloudRedisRestTransport -ASYNC_REST_EXCEPTION = None try: from .transports.rest_asyncio import AsyncCloudRedisRestTransport - HAS_ASYNC_REST_DEPENDENCIES = True -except ImportError as e: # pragma: NO COVER + HAS_ASYNC_REST_DEPENDENCIES = True # pragma: NO COVER +except ImportError: # pragma: NO COVER HAS_ASYNC_REST_DEPENDENCIES = False - ASYNC_REST_EXCEPTION = e class CloudRedisClientMeta(type): @@ -99,7 +97,9 @@ def get_transport_class(cls, """ # If a specific transport is requested, return that one. if label == "rest_asyncio" and not HAS_ASYNC_REST_DEPENDENCIES: # pragma: NO COVER - raise ASYNC_REST_EXCEPTION + raise ImportError( + "`rest_asyncio` transport requires the library to be installed with the `async_rest` extra. Install the library with the `async_rest` extra using `pip install google-cloud-redis[async_rest]`" + ) if label: return cls._transport_registry[label] @@ -506,7 +506,7 @@ def __init__(self, *, else cast(Callable[..., CloudRedisTransport], transport) ) - if "rest_asyncio" in str(transport_init): + if "rest_asyncio" in str(transport_init): # pragma: NO COVER unsupported_params = { "google.api_core.client_options.ClientOptions.credentials_file": self._client_options.credentials_file, "google.api_core.client_options.ClientOptions.scopes": self._client_options.scopes, diff --git a/packages/gapic-generator/tests/integration/goldens/redis_selective/google/cloud/redis_v1/services/cloud_redis/transports/__init__.py b/packages/gapic-generator/tests/integration/goldens/redis_selective/google/cloud/redis_v1/services/cloud_redis/transports/__init__.py index 4b81d3f86f0b..4f1a5dd5f81f 100755 --- a/packages/gapic-generator/tests/integration/goldens/redis_selective/google/cloud/redis_v1/services/cloud_redis/transports/__init__.py +++ b/packages/gapic-generator/tests/integration/goldens/redis_selective/google/cloud/redis_v1/services/cloud_redis/transports/__init__.py @@ -24,9 +24,9 @@ ASYNC_REST_CLASSES: Tuple[str, ...] try: from .rest_asyncio import AsyncCloudRedisRestTransport - from .rest_asyncio import AsyncCloudRedisRestInterceptor - ASYNC_REST_CLASSES = ('AsyncCloudRedisRestTransport', 'AsyncCloudRedisRestInterceptor') - HAS_REST_ASYNC = True + from .rest_asyncio import AsyncCloudRedisRestInterceptor # pragma: NO COVER + ASYNC_REST_CLASSES = ('AsyncCloudRedisRestTransport', 'AsyncCloudRedisRestInterceptor') # pragma: NO COVER + HAS_REST_ASYNC = True # pragma: NO COVER except ImportError: # pragma: NO COVER ASYNC_REST_CLASSES = () HAS_REST_ASYNC = False diff --git a/packages/gapic-generator/tests/integration/goldens/redis_selective/google/cloud/redis_v1/services/cloud_redis/transports/rest_asyncio.py b/packages/gapic-generator/tests/integration/goldens/redis_selective/google/cloud/redis_v1/services/cloud_redis/transports/rest_asyncio.py index 960d9639a214..d79be431bd8a 100755 --- a/packages/gapic-generator/tests/integration/goldens/redis_selective/google/cloud/redis_v1/services/cloud_redis/transports/rest_asyncio.py +++ b/packages/gapic-generator/tests/integration/goldens/redis_selective/google/cloud/redis_v1/services/cloud_redis/transports/rest_asyncio.py @@ -23,6 +23,7 @@ except ImportError as e: # pragma: NO COVER raise ImportError("`rest_asyncio` transport requires the library to be installed with the `async_rest` extra. Install the library with the `async_rest` extra using `pip install google-cloud-redis[async_rest]`") from e +from google.auth import credentials as ga_credentials # type: ignore from google.auth.aio import credentials as ga_credentials_async # type: ignore from google.api_core import exceptions as core_exceptions @@ -506,7 +507,7 @@ class AsyncCloudRedisRestTransport(_BaseCloudRedisRestTransport): def __init__(self, *, host: str = 'redis.googleapis.com', - credentials: Optional[ga_credentials_async.Credentials] = None, + credentials: Optional[Union[ga_credentials.Credentials, ga_credentials_async.Credentials]] = None, client_info: gapic_v1.client_info.ClientInfo = DEFAULT_CLIENT_INFO, url_scheme: str = 'https', interceptor: Optional[AsyncCloudRedisRestInterceptor] = None, @@ -520,7 +521,7 @@ def __init__(self, Args: host (Optional[str]): The hostname to connect to (default: 'redis.googleapis.com'). - credentials (Optional[google.auth.aio.credentials.Credentials]): The + credentials (Optional[Union[google.auth.credentials.Credentials, google.auth.aio.credentials.Credentials]]): The authorization credentials to attach to requests. These credentials identify the application to the service; if none are specified, the client will attempt to ascertain the diff --git a/packages/gapic-generator/tests/integration/goldens/redis_selective/setup.py b/packages/gapic-generator/tests/integration/goldens/redis_selective/setup.py index 29dcfeccbc8b..fdadb0f8c9a8 100755 --- a/packages/gapic-generator/tests/integration/goldens/redis_selective/setup.py +++ b/packages/gapic-generator/tests/integration/goldens/redis_selective/setup.py @@ -53,7 +53,8 @@ ] extras = { "async_rest": [ - "google-auth[aiohttp] >= 2.35.0, <3.0.0" + "google-auth[aiohttp] >= 2.60.0, <3.0.0", + "google-api-core >= 2.41.0, <3.0.0", ], } url = "https://github.com/googleapis/google-cloud-python/tree/main/packages/google-cloud-redis" diff --git a/packages/gapic-generator/tests/integration/goldens/redis_selective/testing/constraints-3.10-async-rest.txt b/packages/gapic-generator/tests/integration/goldens/redis_selective/testing/constraints-3.10-async-rest.txt index c0c1ba32f46f..4a2700a3bf8a 100755 --- a/packages/gapic-generator/tests/integration/goldens/redis_selective/testing/constraints-3.10-async-rest.txt +++ b/packages/gapic-generator/tests/integration/goldens/redis_selective/testing/constraints-3.10-async-rest.txt @@ -6,7 +6,8 @@ # For example, if setup.py has "google-cloud-foo >= 1.14.0, < 2.0.0", # then this file should have google-cloud-foo==1.14.0 google-api-core==2.28.0 -google-auth==2.35.0 +google-auth==2.60.0 +aiohttp==3.8.0 grpcio==1.59.0 proto-plus==1.26.1 protobuf==6.33.5 diff --git a/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/resumable_upload_service/async_client.py b/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/resumable_upload_service/async_client.py index 4a5769a6fafc..63b639314785 100755 --- a/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/resumable_upload_service/async_client.py +++ b/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/resumable_upload_service/async_client.py @@ -24,6 +24,7 @@ from google.api_core.client_options import ClientOptions from google.api_core import exceptions as core_exceptions from google.api_core import gapic_v1 +from google.api_core.resumable_transfer import ResumableUploadConfig from google.showcase_v1beta1._compat import setup_request_id from google.api_core import retry_async as retries from google.auth import credentials as ga_credentials # type: ignore @@ -36,6 +37,7 @@ except AttributeError: # pragma: NO COVER OptionalRetry = Union[retries.AsyncRetry, object, None] # type: ignore +from google.api_core import resumable_transfer from google.cloud.location import locations_pb2 # type: ignore from google.iam.v1 import iam_policy_pb2 # type: ignore from google.iam.v1 import policy_pb2 # type: ignore @@ -261,10 +263,11 @@ def __init__(self, *, async def upload_media(self, request: Optional[Union[resumable_upload.UploadMediaRequest, dict]] = None, *, + config: Optional[ResumableUploadConfig] = None, retry: OptionalRetry = gapic_v1.method.DEFAULT, timeout: Union[float, object] = gapic_v1.method.DEFAULT, metadata: Sequence[Tuple[str, Union[str, bytes]]] = (), - ) -> resumable_upload.UploadMediaResponse: + ) -> resumable_transfer.AsyncResumableUploadSession: r"""A method with media_upload annotation enabled. .. code-block:: python @@ -295,6 +298,8 @@ async def sample_upload_media(): Args: request (Optional[Union[google.showcase_v1beta1.types.UploadMediaRequest, dict]]): The request object. + config (Optional[google.api_core.resumable_transfer.ResumableUploadConfig]): + Optional configuration for the resumable upload session. retry (google.api_core.retry_async.AsyncRetry): Designation of what errors, if any, should be retried. timeout (float): The timeout for this request. @@ -304,7 +309,9 @@ async def sample_upload_media(): be of type `bytes`. Returns: - google.showcase_v1beta1.types.UploadMediaResponse: + google.api_core.resumable_transfer.AsyncResumableUploadSession: + An object representing a resumable + upload session. """ # Create or coerce a protobuf request object. @@ -326,6 +333,8 @@ async def sample_upload_media(): retry=retry, timeout=timeout, metadata=metadata, + config=config, + start_retry=retry, ) # Done; return the response. diff --git a/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/resumable_upload_service/client.py b/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/resumable_upload_service/client.py index 20f68773522a..4fedb635bc91 100755 --- a/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/resumable_upload_service/client.py +++ b/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/resumable_upload_service/client.py @@ -28,6 +28,7 @@ from google.api_core import client_options as client_options_lib from google.api_core import exceptions as core_exceptions from google.api_core import gapic_v1 +from google.api_core.resumable_transfer import ResumableUploadConfig from google.showcase_v1beta1._compat import get_universe_domain, get_api_endpoint, get_default_mtls_endpoint, should_use_client_cert, read_environment_variables from google.showcase_v1beta1._compat import setup_request_id from google.api_core import retry as retries @@ -51,6 +52,7 @@ _LOGGER = std_logging.getLogger(__name__) +from google.api_core import resumable_transfer from google.cloud.location import locations_pb2 # type: ignore from google.iam.v1 import iam_policy_pb2 # type: ignore from google.iam.v1 import policy_pb2 # type: ignore @@ -495,10 +497,11 @@ def __init__(self, *, def upload_media(self, request: Optional[Union[resumable_upload.UploadMediaRequest, dict]] = None, *, + config: Optional[ResumableUploadConfig] = None, retry: OptionalRetry = gapic_v1.method.DEFAULT, timeout: Union[float, object] = gapic_v1.method.DEFAULT, metadata: Sequence[Tuple[str, Union[str, bytes]]] = (), - ) -> resumable_upload.UploadMediaResponse: + ) -> resumable_transfer.ResumableUploadSession: r"""A method with media_upload annotation enabled. .. code-block:: python @@ -529,6 +532,8 @@ def sample_upload_media(): Args: request (Union[google.showcase_v1beta1.types.UploadMediaRequest, dict]): The request object. + config (Optional[google.api_core.resumable_transfer.ResumableUploadConfig]): + Optional configuration for the resumable upload session. retry (google.api_core.retry.Retry): Designation of what errors, if any, should be retried. timeout (float): The timeout for this request. @@ -538,7 +543,9 @@ def sample_upload_media(): be of type `bytes`. Returns: - google.showcase_v1beta1.types.UploadMediaResponse: + google.api_core.resumable_transfer.ResumableUploadSession: + An object representing a resumable + upload session. """ # Create or coerce a protobuf request object. @@ -560,6 +567,8 @@ def sample_upload_media(): retry=retry, timeout=timeout, metadata=metadata, + config=config, + start_retry=retry, ) # Done; return the response. diff --git a/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/resumable_upload_service/transports/base.py b/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/resumable_upload_service/transports/base.py index 856b01756ab3..0009a9c91cbb 100755 --- a/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/resumable_upload_service/transports/base.py +++ b/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/resumable_upload_service/transports/base.py @@ -23,6 +23,7 @@ from google.api_core import exceptions as core_exceptions from google.api_core import gapic_v1 from google.api_core import retry as retries +from google.api_core import resumable_transfer from google.auth import credentials as ga_credentials # type: ignore from google.oauth2 import service_account # type: ignore import google.protobuf @@ -116,6 +117,7 @@ def __init__( # Save the credentials. self._credentials = credentials + self._client_info = client_info # Save the hostname. Default to port 443 (HTTPS) if none is specified. if ':' not in host: @@ -196,8 +198,9 @@ def close(self): def upload_media(self) -> Callable[ [resumable_upload.UploadMediaRequest], Union[ - resumable_upload.UploadMediaResponse, - Awaitable[resumable_upload.UploadMediaResponse] + resumable_transfer.ResumableUploadSession, + resumable_transfer.AsyncResumableUploadSession, + Awaitable[resumable_transfer.AsyncResumableUploadSession] ]]: raise NotImplementedError() diff --git a/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/resumable_upload_service/transports/grpc.py b/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/resumable_upload_service/transports/grpc.py index e5d0324cf558..dd52dbc790da 100755 --- a/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/resumable_upload_service/transports/grpc.py +++ b/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/resumable_upload_service/transports/grpc.py @@ -19,6 +19,8 @@ import warnings from typing import Callable, Dict, Optional, Sequence, Tuple, Union +from google.api_core import exceptions as core_exceptions +from google.api_core import resumable_transfer from google.api_core import grpc_helpers from google.api_core import gapic_v1 import google.auth # type: ignore @@ -36,6 +38,7 @@ from google.longrunning import operations_pb2 # type: ignore from google.showcase_v1beta1.types import resumable_upload from .base import ResumableUploadServiceTransport, DEFAULT_CLIENT_INFO +from .rest import ResumableUploadServiceRestTransport try: from google.api_core import client_logging # type: ignore @@ -194,6 +197,13 @@ def __init__(self, *, self._grpc_channel = None self._ssl_channel_credentials = ssl_channel_credentials self._stubs: Dict[str, Callable] = {} + # Resumable upload RPCs operate over HTTP/REST rather than gRPC. + # Since gRPC is the default transport for GAPIC clients and services + # mix standard gRPC RPCs with resumable upload RPCs on the same client, + # gRPC transports delegate resumable upload calls to an internal REST + # transport instance. + self._rest_transport: Optional[ResumableUploadServiceRestTransport] = None + self._client_cert_source_for_mtls = client_cert_source_for_mtls if api_mtls_endpoint: warnings.warn("api_mtls_endpoint is deprecated", DeprecationWarning) @@ -321,14 +331,14 @@ def grpc_channel(self) -> grpc.Channel: @property def upload_media(self) -> Callable[ [resumable_upload.UploadMediaRequest], - resumable_upload.UploadMediaResponse]: + resumable_transfer.ResumableUploadSession]: r"""Return a callable for the upload media method over gRPC. A method with media_upload annotation enabled. Returns: Callable[[~.UploadMediaRequest], - ~.UploadMediaResponse]: + ~.ResumableUploadSession]: A function that, when called, will call the underlying RPC on the server. """ @@ -337,15 +347,38 @@ def upload_media(self) -> Callable[ # gRPC handles serialization and deserialization, so we just need # to pass in the functions for each. if 'upload_media' not in self._stubs: - self._stubs['upload_media'] = self._logged_channel.unary_unary( - '/google.showcase.v1beta1.ResumableUploadService/UploadMedia', - request_serializer=resumable_upload.UploadMediaRequest.serialize, - response_deserializer=resumable_upload.UploadMediaResponse.deserialize, - ) + if not self._credentials: + class _ErrorStub: + def __init__(self, error: Exception): + self._error = error + + def __call__(self, *args, **kwargs): + raise self._error + + self._stubs['upload_media'] = _ErrorStub( + core_exceptions.GoogleAPICallError( + "Resumable upload methods operate over REST and cannot be invoked when the transport is initialized with a pre-constructed gRPC channel. Please supply credentials directly instead of a gRPC channel to use resumable upload functionality." + ) + ) + else: + transport = self + class _RestStub: + def __call__(self, *args, **kwargs): + if transport._rest_transport is None: + transport._rest_transport = ResumableUploadServiceRestTransport( + host=transport._host, + credentials=transport._credentials, + client_info=transport._client_info, + client_cert_source_for_mtls=transport._client_cert_source_for_mtls, + ) + return transport._rest_transport.upload_media(*args, **kwargs) + self._stubs['upload_media'] = _RestStub() return self._stubs['upload_media'] def close(self): self._logged_channel.close() + if self._rest_transport is not None: + self._rest_transport.close() @property def delete_operation( diff --git a/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/resumable_upload_service/transports/grpc_asyncio.py b/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/resumable_upload_service/transports/grpc_asyncio.py index 7bde6f9e3205..40782c9a3092 100755 --- a/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/resumable_upload_service/transports/grpc_asyncio.py +++ b/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/resumable_upload_service/transports/grpc_asyncio.py @@ -18,12 +18,13 @@ import pickle import logging as std_logging import warnings -from typing import Awaitable, Callable, Dict, Optional, Sequence, Tuple, Union +from typing import Any, Awaitable, Callable, Dict, Optional, Sequence, Tuple, Union from google.api_core import gapic_v1 from google.api_core import grpc_helpers_async from google.api_core import exceptions as core_exceptions from google.api_core import retry_async as retries +from google.api_core import resumable_transfer from google.auth import credentials as ga_credentials # type: ignore from google.auth.transport.grpc import SslCredentials # type: ignore from google.protobuf.json_format import MessageToJson @@ -40,6 +41,11 @@ from google.showcase_v1beta1.types import resumable_upload from .base import ResumableUploadServiceTransport, DEFAULT_CLIENT_INFO from .grpc import ResumableUploadServiceGrpcTransport +try: + from .rest_asyncio import AsyncResumableUploadServiceRestTransport + HAS_ASYNC_REST = True # pragma: NO COVER +except ImportError: # pragma: NO COVER + HAS_ASYNC_REST = False try: from google.api_core import client_logging # type: ignore @@ -242,6 +248,13 @@ def __init__(self, *, self._grpc_channel = None self._ssl_channel_credentials = ssl_channel_credentials self._stubs: Dict[str, Callable] = {} + # Resumable upload RPCs operate over HTTP/REST rather than gRPC. + # Since gRPC is the default transport for GAPIC clients and services + # mix standard gRPC RPCs with resumable upload RPCs on the same client, + # gRPC transports delegate resumable upload calls to an internal REST + # transport instance. + self._rest_transport: Optional[Any] = None + self._client_cert_source_for_mtls = client_cert_source_for_mtls if api_mtls_endpoint: warnings.warn("api_mtls_endpoint is deprecated", DeprecationWarning) @@ -327,14 +340,14 @@ def grpc_channel(self) -> aio.Channel: @property def upload_media(self) -> Callable[ [resumable_upload.UploadMediaRequest], - Awaitable[resumable_upload.UploadMediaResponse]]: + Awaitable[resumable_transfer.AsyncResumableUploadSession]]: r"""Return a callable for the upload media method over gRPC. A method with media_upload annotation enabled. Returns: Callable[[~.UploadMediaRequest], - Awaitable[~.UploadMediaResponse]]: + Awaitable[~.AsyncResumableUploadSession]]: A function that, when called, will call the underlying RPC on the server. """ @@ -343,11 +356,47 @@ def upload_media(self) -> Callable[ # gRPC handles serialization and deserialization, so we just need # to pass in the functions for each. if 'upload_media' not in self._stubs: - self._stubs['upload_media'] = self._logged_channel.unary_unary( - '/google.showcase.v1beta1.ResumableUploadService/UploadMedia', - request_serializer=resumable_upload.UploadMediaRequest.serialize, - response_deserializer=resumable_upload.UploadMediaResponse.deserialize, - ) + class _ErrorStub: + def __init__(self, error: Exception): + self._error = error + + def __call__(self, *args, **kwargs): + async def _raise(): + raise self._error + return _raise() + + if not self._credentials: + self._stubs['upload_media'] = _ErrorStub( + core_exceptions.GoogleAPICallError( + "Resumable upload methods operate over REST and cannot be invoked when the transport is initialized with a pre-constructed gRPC channel. Please supply credentials directly instead of a gRPC channel to use resumable upload functionality." + ) + ) + elif self._client_cert_source_for_mtls: + self._stubs['upload_media'] = _ErrorStub( + core_exceptions.AsyncRestUnsupportedParameterError( + "Mutual TLS (client_cert_source_for_mtls) is not currently supported for async resumable upload methods." + ) + ) + elif HAS_ASYNC_REST: + transport = self + class _AsyncRestStub: + def __call__(self, *args, **kwargs): + if transport._rest_transport is None: + transport._rest_transport = AsyncResumableUploadServiceRestTransport( + host=transport._host, + credentials=transport._credentials, + client_info=transport._client_info, + ) + return transport._rest_transport.upload_media(*args, **kwargs) + self._stubs['upload_media'] = _AsyncRestStub() + else: + self._stubs['upload_media'] = _ErrorStub( + NotImplementedError( + "Async REST transport is required for async resumable upload methods. " + "Install the library with the `async_rest` extra using " + "`pip install google-showcase[async_rest]`." + ) + ) return self._stubs['upload_media'] def _prep_wrapped_messages(self, client_info): @@ -410,8 +459,10 @@ def _wrap_method(self, func, *args, **kwargs): kwargs["kind"] = self.kind return gapic_v1.method_async.wrap_method(func, *args, **kwargs) - def close(self): - return self._logged_channel.close() + async def close(self): + await self._logged_channel.close() + if self._rest_transport is not None: + await self._rest_transport.close() @property def kind(self) -> str: diff --git a/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/resumable_upload_service/transports/rest.py b/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/resumable_upload_service/transports/rest.py index 3e29344fc715..032336df4352 100755 --- a/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/resumable_upload_service/transports/rest.py +++ b/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/resumable_upload_service/transports/rest.py @@ -23,6 +23,8 @@ from google.api_core import rest_helpers from google.api_core import rest_streaming from google.api_core import gapic_v1 +import urllib.parse +from google.api_core import resumable_transfer from google.showcase_v1beta1._compat import transcode_request import google.protobuf @@ -85,10 +87,6 @@ def pre_upload_media(self, request, metadata): logging.log(f"Received request: {request}") return request, metadata - def post_upload_media(self, response): - logging.log(f"Received response: {response}") - return response - transport = ResumableUploadServiceRestTransport(interceptor=MyCustomResumableUploadServiceInterceptor()) client = ResumableUploadServiceClient(transport=transport) @@ -102,34 +100,6 @@ def pre_upload_media(self, request: resumable_upload.UploadMediaRequest, metadat """ return request, metadata - def post_upload_media(self, response: resumable_upload.UploadMediaResponse) -> resumable_upload.UploadMediaResponse: - """Post-rpc interceptor for upload_media - - DEPRECATED. Please use the `post_upload_media_with_metadata` - interceptor instead. - - Override in a subclass to read or manipulate the response - after it is returned by the ResumableUploadService server but before - it is returned to user code. This `post_upload_media` interceptor runs - before the `post_upload_media_with_metadata` interceptor. - """ - return response - - def post_upload_media_with_metadata(self, response: resumable_upload.UploadMediaResponse, metadata: Sequence[Tuple[str, Union[str, bytes]]]) -> Tuple[resumable_upload.UploadMediaResponse, Sequence[Tuple[str, Union[str, bytes]]]]: - """Post-rpc interceptor for upload_media - - Override in a subclass to read or manipulate the response or metadata after it - is returned by the ResumableUploadService server but before it is returned to user code. - - We recommend only using this `post_upload_media_with_metadata` - interceptor in new development instead of the `post_upload_media` interceptor. - When both interceptors are used, this `post_upload_media_with_metadata` interceptor runs after the - `post_upload_media` interceptor. The (possibly modified) response returned by - `post_upload_media` will be passed to - `post_upload_media_with_metadata`. - """ - return response, metadata - def pre_list_locations( self, request: locations_pb2.ListLocationsRequest, metadata: Sequence[Tuple[str, Union[str, bytes]]] ) -> Tuple[locations_pb2.ListLocationsRequest, Sequence[Tuple[str, Union[str, bytes]]]]: @@ -416,42 +386,22 @@ class _UploadMedia(_BaseResumableUploadServiceRestTransport._BaseUploadMedia, Re def __hash__(self): return hash("ResumableUploadServiceRestTransport.UploadMedia") - @staticmethod - def _get_response( - host, - metadata, - query_params, - session, - timeout, - transcoded_request, - body=None): - - uri = transcoded_request['uri'] - method = transcoded_request['method'] - headers = dict(metadata) - headers['Content-Type'] = 'application/json' - response = getattr(session, method)( - "{host}{uri}".format(host=host, uri=uri), - timeout=timeout, - headers=headers, - params=rest_helpers.flatten_query_params(query_params, strict=True), - data=body, - ) - return response - def __call__(self, request: resumable_upload.UploadMediaRequest, *, - retry: OptionalRetry=gapic_v1.method.DEFAULT, + config: Optional[resumable_transfer.ResumableUploadConfig]=None, + start_retry: OptionalRetry=gapic_v1.method.DEFAULT, timeout: Optional[float]=None, metadata: Sequence[Tuple[str, Union[str, bytes]]]=(), - ) -> resumable_upload.UploadMediaResponse: + ) -> resumable_transfer.ResumableUploadSession: r"""Call the upload media method over HTTP. Args: request (~.resumable_upload.UploadMediaRequest): The request object. - retry (google.api_core.retry.Retry): Designation of what errors, if any, - should be retried. + config (Optional[google.api_core.resumable_transfer.ResumableUploadConfig]): + Optional configuration for the resumable upload session. + start_retry (google.api_core.retry.Retry): Designation of what errors, if any, + should be retried when initiating the resumable upload session. timeout (float): The timeout for this request. metadata (Sequence[Tuple[str, Union[str, bytes]]]): Key/value pairs which should be sent along with the request as metadata. Normally, each value must be of type `str`, @@ -459,10 +409,11 @@ def __call__(self, be of type `bytes`. Returns: - ~.resumable_upload.UploadMediaResponse: + ~.resumable_transfer.ResumableUploadSession: + An object representing a resumable + upload session. """ - http_options = _BaseResumableUploadServiceRestTransport._BaseUploadMedia._get_http_options() request, metadata = self._interceptor.pre_upload_media(request, metadata) transcoded_request, body, query_params = transcode_request( @@ -476,71 +427,39 @@ def __call__(self, rest_numeric_enums=True, ) - if CLIENT_LOGGING_SUPPORTED and _LOGGER.isEnabledFor(logging.DEBUG): # pragma: NO COVER - request_url = "{host}{uri}".format(host=self._host, uri=transcoded_request['uri']) - method = transcoded_request['method'] - try: - request_payload = type(request).to_json(request) - except: - request_payload = None - http_request = { - "payload": request_payload, - "requestMethod": method, - "requestUrl": request_url, - "headers": dict(metadata), - } - _LOGGER.debug( - f"Sending request for google.showcase_v1beta1.ResumableUploadServiceClient.UploadMedia", - extra = { - "serviceName": "google.showcase.v1beta1.ResumableUploadService", - "rpcName": "UploadMedia", - "httpRequest": http_request, - "metadata": http_request["headers"], - }, - ) - - # Send the request - response = ResumableUploadServiceRestTransport._UploadMedia._get_response(self._host, metadata, query_params, self._session, timeout, transcoded_request, body) - - # In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception - # subclass. - if response.status_code >= 400: - raise core_exceptions.from_http_response(response) - - # Return the response - resp = resumable_upload.UploadMediaResponse() - pb_resp = resumable_upload.UploadMediaResponse.pb(resp) - - json_format.Parse(response.content, pb_resp, ignore_unknown_fields=True) - - resp = self._interceptor.post_upload_media(resp) - response_metadata = [(k, str(v)) for k, v in response.headers.items()] - resp, _ = self._interceptor.post_upload_media_with_metadata(resp, response_metadata) - if CLIENT_LOGGING_SUPPORTED and _LOGGER.isEnabledFor(logging.DEBUG): # pragma: NO COVER - try: - response_payload = resumable_upload.UploadMediaResponse.to_json(response) - except: - response_payload = None - http_response = { - "payload": response_payload, - "headers": dict(response.headers), - "status": response.status_code, - } - _LOGGER.debug( - "Received response for google.showcase_v1beta1.ResumableUploadServiceClient.upload_media", - extra = { - "serviceName": "google.showcase.v1beta1.ResumableUploadService", - "rpcName": "UploadMedia", - "metadata": http_response["headers"], - "httpResponse": http_response, - }, - ) - return resp + uri = transcoded_request["uri"] + params = rest_helpers.flatten_query_params(query_params, strict=True) + query_string = f"?{urllib.parse.urlencode(params)}" if params else "" + upload_url = f"{self._host}{uri}{query_string}" + headers: Dict[str, Any] = {**dict(metadata), **dict((config.headers or {}) if config else {})} + headers["Content-Type"] = "application/json" + if config is None: + config = resumable_transfer.ResumableUploadConfig(headers=headers) + else: + config = dataclasses.replace(config, headers=headers) + + session_kwargs: Dict[str, Any] = ( + {"start_timeout": timeout} + if isinstance(timeout, (int, float)) + else {} + ) + # ``start_retry`` is used instead of ``retry`` because ``_GapicCallable`` + # consumes the ``retry`` argument before invoking the transport callable + # and only forwards extra keyword arguments such as ``start_retry``. + return resumable_transfer.ResumableUploadSession( + upload_url=upload_url, + config=config, + transport=self._session, + response_type=resumable_upload.UploadMediaResponse, + start_retry=start_retry if isinstance(start_retry, retries.Retry) else None, + request_body=body, + **session_kwargs, + ) @property def upload_media(self) -> Callable[ [resumable_upload.UploadMediaRequest], - resumable_upload.UploadMediaResponse]: + resumable_transfer.ResumableUploadSession]: # The return type is fine, but mypy isn't sophisticated enough to determine what's going on here. # In C++ this would require a dynamic_cast return self._UploadMedia(self._session, self._host, self._interceptor) # type: ignore diff --git a/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/resumable_upload_service/transports/rest_asyncio.py b/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/resumable_upload_service/transports/rest_asyncio.py new file mode 100755 index 000000000000..fa206afac970 --- /dev/null +++ b/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/resumable_upload_service/transports/rest_asyncio.py @@ -0,0 +1,280 @@ +# -*- coding: utf-8 -*- +# 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 google.auth +try: + import aiohttp # type: ignore + from google.auth.aio.transport.sessions import AsyncAuthorizedSession # type: ignore + from google.api_core import rest_streaming_async # type: ignore + from google.api_core.operations_v1 import AsyncOperationsRestClient # type: ignore +except ImportError as e: # pragma: NO COVER + raise ImportError("`rest_asyncio` transport requires the library to be installed with the `async_rest` extra. Install the library with the `async_rest` extra using `pip install google-showcase[async_rest]`") from e + +from google.auth import credentials as ga_credentials # type: ignore +from google.auth.aio import credentials as ga_credentials_async # type: ignore + +from google.api_core import exceptions as core_exceptions +from google.api_core import gapic_v1 +import urllib.parse +from google.api_core import resumable_transfer +from google.iam.v1 import iam_policy_pb2 # type: ignore +from google.iam.v1 import policy_pb2 # type: ignore +from google.cloud.location import locations_pb2 # type: ignore +from google.api_core import retry_async as retries +from google.api_core import rest_helpers +from google.api_core import rest_streaming_async # type: ignore +from google.showcase_v1beta1._compat import transcode_request + +import google.protobuf + +from google.protobuf import json_format +from google.iam.v1 import iam_policy_pb2 # type: ignore +from google.iam.v1 import policy_pb2 # type: ignore +from google.cloud.location import locations_pb2 # type: ignore + +import json # type: ignore +import dataclasses +from typing import Any, Dict, List, Callable, Tuple, Optional, Sequence, Union + + +from google.showcase_v1beta1.types import resumable_upload +from google.longrunning import operations_pb2 # type: ignore + + +from .rest_base import _BaseResumableUploadServiceRestTransport + +from .base import DEFAULT_CLIENT_INFO as BASE_DEFAULT_CLIENT_INFO + + +import logging + +try: + from google.api_core import client_logging # type: ignore + CLIENT_LOGGING_SUPPORTED = True # pragma: NO COVER +except ImportError: # pragma: NO COVER + CLIENT_LOGGING_SUPPORTED = False + +_LOGGER = logging.getLogger(__name__) + +try: + OptionalRetry = Union[retries.AsyncRetry, gapic_v1.method._MethodDefault, None] +except AttributeError: # pragma: NO COVER + OptionalRetry = Union[retries.AsyncRetry, object, None] # type: ignore + +DEFAULT_CLIENT_INFO = gapic_v1.client_info.ClientInfo( + gapic_version=BASE_DEFAULT_CLIENT_INFO.gapic_version, + grpc_version=None, + rest_version=f"google-auth@{google.auth.__version__}", +) + +DEFAULT_CLIENT_INFO.protobuf_runtime_version = google.protobuf.__version__ + + +class AsyncResumableUploadServiceRestInterceptor: + """Asynchronous Interceptor for ResumableUploadService. + + Interceptors are used to manipulate requests, request metadata, and responses + in arbitrary ways. + Example use cases include: + * Logging + * Verifying requests according to service or custom semantics + * Stripping extraneous information from responses + + These use cases and more can be enabled by injecting an + instance of a custom subclass when constructing the AsyncResumableUploadServiceRestTransport. + + .. code-block:: python + class MyCustomResumableUploadServiceInterceptor(ResumableUploadServiceRestInterceptor): + async def pre_upload_media(self, request, metadata): + logging.log(f"Received request: {request}") + return request, metadata + + transport = AsyncResumableUploadServiceRestTransport(interceptor=MyCustomResumableUploadServiceInterceptor()) + client = async ResumableUploadServiceClient(transport=transport) + + + """ + async def pre_upload_media(self, request: resumable_upload.UploadMediaRequest, metadata: Sequence[Tuple[str, Union[str, bytes]]]) -> Tuple[resumable_upload.UploadMediaRequest, Sequence[Tuple[str, Union[str, bytes]]]]: + """Pre-rpc interceptor for upload_media + + Override in a subclass to manipulate the request or metadata + before they are sent to the ResumableUploadService server. + """ + return request, metadata + + +@dataclasses.dataclass +class AsyncResumableUploadServiceRestStub: + _session: AsyncAuthorizedSession + _host: str + _interceptor: AsyncResumableUploadServiceRestInterceptor + +class AsyncResumableUploadServiceRestTransport(_BaseResumableUploadServiceRestTransport): + """Asynchronous REST backend transport for ResumableUploadService. + + A service showcasing universal resumable upload protocol + support. + + This class defines the same methods as the primary client, so the + primary client can load the underlying transport implementation + and call it. + + It sends JSON representations of protocol buffers over HTTP/1.1 + """ + def __init__(self, + *, + host: str = 'localhost:7469', + credentials: Optional[Union[ga_credentials.Credentials, ga_credentials_async.Credentials]] = None, + client_info: gapic_v1.client_info.ClientInfo = DEFAULT_CLIENT_INFO, + url_scheme: str = 'https', + interceptor: Optional[AsyncResumableUploadServiceRestInterceptor] = None, + ) -> None: + """Instantiate the transport. + + Args: + host (Optional[str]): + The hostname to connect to (default: 'localhost:7469'). + credentials (Optional[Union[google.auth.credentials.Credentials, google.auth.aio.credentials.Credentials]]): The + authorization credentials to attach to requests. These + credentials identify the application to the service; if none + are specified, the client will attempt to ascertain the + credentials from the environment. + client_info (google.api_core.gapic_v1.client_info.ClientInfo): + The client info used to send a user-agent string along with + API requests. If ``None``, then default info will be used. + Generally, you only need to set this if you are developing + your own client library. + url_scheme (str): the protocol scheme for the API endpoint. Normally + "https", but for testing or local servers, + "http" can be specified. + interceptor (Optional[AsyncResumableUploadServiceRestInterceptor]): Interceptor used + to manipulate requests, request metadata, and responses. + """ + # Run the base constructor + super().__init__( + host=host, + credentials=credentials, + client_info=client_info, + always_use_jwt_access=False, + url_scheme=url_scheme, + api_audience=None + ) + self._session = AsyncAuthorizedSession(self._credentials) # type: ignore + self._interceptor = interceptor or AsyncResumableUploadServiceRestInterceptor() + self._wrap_with_kind = True + self._prep_wrapped_messages(client_info) + + def _prep_wrapped_messages(self, client_info): + """ Precompute the wrapped methods, overriding the base class method to use async wrappers.""" + self._wrapped_methods = { + self.upload_media: self._wrap_method( + self.upload_media, + default_timeout=None, + client_info=client_info, + ), + } + + def _wrap_method(self, func, *args, **kwargs): + if self._wrap_with_kind: # pragma: NO COVER + kwargs["kind"] = self.kind + return gapic_v1.method_async.wrap_method(func, *args, **kwargs) + + class _UploadMedia(_BaseResumableUploadServiceRestTransport._BaseUploadMedia, AsyncResumableUploadServiceRestStub): + def __hash__(self): + return hash("AsyncResumableUploadServiceRestTransport.UploadMedia") + + async def __call__(self, + request: resumable_upload.UploadMediaRequest, *, + config: Optional[resumable_transfer.ResumableUploadConfig]=None, + start_retry: OptionalRetry=gapic_v1.method.DEFAULT, + timeout: Optional[float]=None, + metadata: Sequence[Tuple[str, Union[str, bytes]]]=(), + ) -> resumable_transfer.AsyncResumableUploadSession: + r"""Call the upload media method over HTTP. + + Args: + request (~.resumable_upload.UploadMediaRequest): + The request object. + config (Optional[google.api_core.resumable_transfer.ResumableUploadConfig]): + Optional configuration for the resumable upload session. + start_retry (google.api_core.retry_async.AsyncRetry): Designation of what errors, if any, + should be retried when initiating the resumable upload session. + timeout (float): The timeout for this request. + metadata (Sequence[Tuple[str, Union[str, bytes]]]): Key/value pairs which should be + sent along with the request as metadata. Normally, each value must be of type `str`, + but for metadata keys ending with the suffix `-bin`, the corresponding values must + be of type `bytes`. + + Returns: + ~.resumable_transfer.AsyncResumableUploadSession: + An object representing a resumable + upload session. + + """ + http_options = _BaseResumableUploadServiceRestTransport._BaseUploadMedia._get_http_options() + request, metadata = await self._interceptor.pre_upload_media(request, metadata) + transcoded_request, body, query_params = transcode_request( + http_options, + request, + required_fields_default_values=getattr( + _BaseResumableUploadServiceRestTransport._BaseUploadMedia, + "_BaseUploadMedia__REQUIRED_FIELDS_DEFAULT_VALUES", + None, + ), + rest_numeric_enums=True, + ) + + uri = transcoded_request["uri"] + params = rest_helpers.flatten_query_params(query_params, strict=True) + query_string = f"?{urllib.parse.urlencode(params)}" if params else "" + upload_url = f"{self._host}{uri}{query_string}" + headers: Dict[str, Any] = {**dict(metadata), **dict((config.headers or {}) if config else {})} + headers["Content-Type"] = "application/json" + if config is None: + config = resumable_transfer.ResumableUploadConfig(headers=headers) + else: + config = dataclasses.replace(config, headers=headers) + + session_kwargs: Dict[str, Any] = ( + {"start_timeout": timeout} + if isinstance(timeout, (int, float)) + else {} + ) + # ``start_retry`` is used instead of ``retry`` because ``_GapicCallable`` + # consumes the ``retry`` argument before invoking the transport callable + # and only forwards extra keyword arguments such as ``start_retry``. + return resumable_transfer.AsyncResumableUploadSession( + upload_url=upload_url, + config=config, + transport=self._session, + response_type=resumable_upload.UploadMediaResponse, + start_retry=start_retry if isinstance(start_retry, retries.AsyncRetry) else None, + request_body=body, + **session_kwargs, + ) + + @property + def upload_media(self) -> Callable[ + [resumable_upload.UploadMediaRequest], + resumable_transfer.AsyncResumableUploadSession]: + return self._UploadMedia(self._session, self._host, self._interceptor) # type: ignore + + @property + def kind(self) -> str: + return "rest_asyncio" + + async def close(self): + await self._session.close() diff --git a/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/resumable_upload_service/transports/rest_base.py b/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/resumable_upload_service/transports/rest_base.py index a02842ea30de..207171c714db 100755 --- a/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/resumable_upload_service/transports/rest_base.py +++ b/packages/gapic-generator/tests/integration/goldens/showcase/google/showcase_v1beta1/services/resumable_upload_service/transports/rest_base.py @@ -97,7 +97,7 @@ def __hash__(self): # pragma: NO COVER def _get_http_options(): http_options: List[Dict[str, str]] = [{ 'method': 'post', - 'uri': '/v1beta1/files:upload', + 'uri': '/resumable/upload/v1beta1/files:upload', 'body': '*', }, ] diff --git a/packages/gapic-generator/tests/integration/goldens/showcase/samples/generated_samples/snippet_metadata_google.showcase.v1beta1.json b/packages/gapic-generator/tests/integration/goldens/showcase/samples/generated_samples/snippet_metadata_google.showcase.v1beta1.json index 3d6373f36f36..6720f510f805 100755 --- a/packages/gapic-generator/tests/integration/goldens/showcase/samples/generated_samples/snippet_metadata_google.showcase.v1beta1.json +++ b/packages/gapic-generator/tests/integration/goldens/showcase/samples/generated_samples/snippet_metadata_google.showcase.v1beta1.json @@ -4802,7 +4802,7 @@ "type": "Sequence[Tuple[str, Union[str, bytes]]]" } ], - "resultType": "google.showcase_v1beta1.types.UploadMediaResponse", + "resultType": "google.api_core.resumable_transfer.AsyncResumableUploadSession", "shortName": "upload_media" }, "description": "Sample for UploadMedia", @@ -4878,7 +4878,7 @@ "type": "Sequence[Tuple[str, Union[str, bytes]]]" } ], - "resultType": "google.showcase_v1beta1.types.UploadMediaResponse", + "resultType": "google.api_core.resumable_transfer.ResumableUploadSession", "shortName": "upload_media" }, "description": "Sample for UploadMedia", diff --git a/packages/gapic-generator/tests/integration/goldens/showcase/setup.py b/packages/gapic-generator/tests/integration/goldens/showcase/setup.py index e369d99843c5..263074e55d01 100755 --- a/packages/gapic-generator/tests/integration/goldens/showcase/setup.py +++ b/packages/gapic-generator/tests/integration/goldens/showcase/setup.py @@ -42,7 +42,7 @@ release_status = "Development Status :: 5 - Production/Stable" dependencies = [ - "google-api-core[grpc] >= 2.28.0, <3.0.0", + "google-api-core[grpc] >= 2.41.0, <3.0.0", # Exclude incompatible versions of `google-auth` # See https://github.com/googleapis/google-cloud-python/issues/12364 "google-auth >= 2.14.1, <3.0.0,!=2.24.0,!=2.25.0", @@ -53,6 +53,10 @@ "grpc-google-iam-v1 >= 0.14.2, <1.0.0", ] extras = { + "async_rest": [ + "google-auth[aiohttp] >= 2.60.0, <3.0.0", + "google-api-core >= 2.41.0, <3.0.0", + ], } url = "https://github.com/googleapis/google-cloud-python/tree/main/packages/google-showcase" diff --git a/packages/gapic-generator/tests/integration/goldens/showcase/testing/constraints-3.10-async-rest.txt b/packages/gapic-generator/tests/integration/goldens/showcase/testing/constraints-3.10-async-rest.txt new file mode 100755 index 000000000000..17cd83a2f715 --- /dev/null +++ b/packages/gapic-generator/tests/integration/goldens/showcase/testing/constraints-3.10-async-rest.txt @@ -0,0 +1,14 @@ +# -*- coding: utf-8 -*- +# This constraints file is used to check that lower bounds +# are correct in setup.py +# List all library dependencies and extras in this file, +# pinning their versions to their lower bounds. +# For example, if setup.py has "google-cloud-foo >= 1.14.0, < 2.0.0", +# then this file should have google-cloud-foo==1.14.0 +google-api-core==2.41.0 +google-auth==2.60.0 +aiohttp==3.8.0 +grpcio==1.59.0 +proto-plus==1.26.1 +protobuf==6.33.5 +grpc-google-iam-v1==0.14.2 diff --git a/packages/gapic-generator/tests/integration/goldens/showcase/testing/constraints-3.10.txt b/packages/gapic-generator/tests/integration/goldens/showcase/testing/constraints-3.10.txt index 9c33fedfb264..a75b92057fdb 100755 --- a/packages/gapic-generator/tests/integration/goldens/showcase/testing/constraints-3.10.txt +++ b/packages/gapic-generator/tests/integration/goldens/showcase/testing/constraints-3.10.txt @@ -4,7 +4,7 @@ # pinning their versions to their lower bounds. # For example, if setup.py has "google-cloud-foo >= 1.14.0, < 2.0.0", # then this file should have google-cloud-foo==1.14.0 -google-api-core==2.28.0 +google-api-core==2.41.0 google-auth==2.14.1 grpcio==1.59.0 proto-plus==1.26.1 diff --git a/packages/gapic-generator/tests/integration/goldens/showcase/tests/unit/gapic/showcase_v1beta1/test_resumable_upload_service.py b/packages/gapic-generator/tests/integration/goldens/showcase/tests/unit/gapic/showcase_v1beta1/test_resumable_upload_service.py index dac0fc5053f7..1ff0d43412d7 100755 --- a/packages/gapic-generator/tests/integration/goldens/showcase/tests/unit/gapic/showcase_v1beta1/test_resumable_upload_service.py +++ b/packages/gapic-generator/tests/integration/goldens/showcase/tests/unit/gapic/showcase_v1beta1/test_resumable_upload_service.py @@ -30,6 +30,13 @@ from google.api_core import api_core_version from proto.marshal.rules.dates import DurationRule, TimestampRule from proto.marshal.rules import wrappers +try: + import aiohttp # type: ignore + from google.auth.aio.transport.sessions import AsyncAuthorizedSession + from google.api_core.operations_v1 import AsyncOperationsRestClient + HAS_ASYNC_REST_EXTRA = True +except ImportError: # pragma: NO COVER + HAS_ASYNC_REST_EXTRA = False from requests import Response from requests import Request, PreparedRequest from requests.sessions import Session @@ -47,6 +54,7 @@ from google.api_core import grpc_helpers from google.api_core import grpc_helpers_async from google.api_core import path_template +from google.api_core import resumable_transfer from google.api_core import retry as retries from google.auth import credentials as ga_credentials from google.auth.exceptions import MutualTLSChannelError @@ -829,10 +837,7 @@ def test_upload_media(request_type, transport: str = 'grpc'): type(client.transport.upload_media), '__call__') as call: # Designate an appropriate return value for the call. - call.return_value = resumable_upload.UploadMediaResponse( - name='name_value', - size=443, - ) + call.return_value = resumable_transfer.ResumableUploadSession() response = client.upload_media(request) # Establish that the underlying gRPC stub method was called. @@ -842,9 +847,7 @@ def test_upload_media(request_type, transport: str = 'grpc'): assert args[0] == request # Establish that the response is the type that we expect. - assert isinstance(response, resumable_upload.UploadMediaResponse) - assert response.name == 'name_value' - assert response.size == 443 + assert isinstance(response, resumable_transfer.ResumableUploadSession) def test_upload_media_non_empty_request_with_auto_populated_field(): @@ -961,10 +964,9 @@ async def test_upload_media_async(request_type, transport: str = 'grpc_asyncio') type(client.transport.upload_media), '__call__') as call: # Designate an appropriate return value for the call. - call.return_value =grpc_helpers_async.FakeUnaryUnaryCall(resumable_upload.UploadMediaResponse( - name='name_value', - size=443, - )) + call.return_value = grpc_helpers_async.FakeUnaryUnaryCall( + resumable_transfer.AsyncResumableUploadSession() + ) response = await client.upload_media(request) # Establish that the underlying gRPC stub method was called. @@ -974,9 +976,7 @@ async def test_upload_media_async(request_type, transport: str = 'grpc_asyncio') assert args[0] == request # Establish that the response is the type that we expect. - assert isinstance(response, resumable_upload.UploadMediaResponse) - assert response.name == 'name_value' - assert response.size == 443 + assert isinstance(response, resumable_transfer.AsyncResumableUploadSession) def test_upload_media_rest_use_cached_wrapped_rpc(): @@ -1198,14 +1198,16 @@ def test_upload_media_rest_bad_request(request_type=resumable_upload.UploadMedia # Mock the http request call within the method and fake a BadRequest error. with mock.patch.object(Session, 'request') as req, pytest.raises(core_exceptions.BadRequest): # Wrap the value into a proper Response obj - response_value = mock.Mock() + response_value = mock.MagicMock() json_return_value = '' response_value.json = mock.Mock(return_value={}) + response_value.ok = False response_value.status_code = 400 response_value.request = mock.Mock() req.return_value = response_value req.return_value.headers = {"header-1": "value-1", "header-2": "value-2"} - client.upload_media(request) + response = client.upload_media(request) + response.upload(b"") @pytest.mark.parametrize("request_type", [ @@ -1231,16 +1233,24 @@ def test_upload_media_rest_call_success(request_type): ) # Wrap the value into a proper Response obj - response_value = mock.Mock() + response_value = mock.MagicMock() response_value.status_code = 200 # Convert return value to protobuf type return_value = resumable_upload.UploadMediaResponse.pb(return_value) json_return_value = json_format.MessageToJson(return_value) response_value.content = json_return_value.encode('UTF-8') - req.return_value = response_value - req.return_value.headers = {"header-1": "value-1", "header-2": "value-2"} + start_response = mock.MagicMock() + start_response.status_code = 200 + start_response.headers = {"x-goog-upload-status": "active", "x-goog-upload-url": "http://localhost/upload"} + response_value.headers = {"x-goog-upload-status": "final", "header-1": "value-1", "header-2": "value-2"} + start_response.ok = True + start_response.content = b"" + response_value.ok = True + req.side_effect = [start_response, response_value] response = client.upload_media(request) + assert isinstance(response, resumable_transfer.ResumableUploadSession) + response = response.upload(b"test data") # Establish that the response is the type that we expect. assert isinstance(response, resumable_upload.UploadMediaResponse) @@ -1258,12 +1268,8 @@ def test_upload_media_rest_interceptors(null_interceptor): with mock.patch.object(type(client.transport._session), "request") as req, \ mock.patch.object(path_template, "transcode") as transcode, \ - mock.patch.object(transports.ResumableUploadServiceRestInterceptor, "post_upload_media") as post, \ - mock.patch.object(transports.ResumableUploadServiceRestInterceptor, "post_upload_media_with_metadata") as post_with_metadata, \ mock.patch.object(transports.ResumableUploadServiceRestInterceptor, "pre_upload_media") as pre: pre.assert_not_called() - post.assert_not_called() - post_with_metadata.assert_not_called() pb_message = resumable_upload.UploadMediaRequest.pb(resumable_upload.UploadMediaRequest()) transcode.return_value = { "method": "post", @@ -1275,8 +1281,6 @@ def test_upload_media_rest_interceptors(null_interceptor): req.return_value = mock.Mock() req.return_value.status_code = 200 req.return_value.headers = {"header-1": "value-1", "header-2": "value-2"} - return_value = resumable_upload.UploadMediaResponse.to_json(resumable_upload.UploadMediaResponse()) - req.return_value.content = return_value request = resumable_upload.UploadMediaRequest() metadata =[ @@ -1284,14 +1288,10 @@ def test_upload_media_rest_interceptors(null_interceptor): ("cephalopod", "squid"), ] pre.return_value = request, metadata - post.return_value = resumable_upload.UploadMediaResponse() - post_with_metadata.return_value = resumable_upload.UploadMediaResponse(), metadata client.upload_media(request, metadata=[("key", "val"), ("cephalopod", "squid"),]) pre.assert_called_once() - post.assert_called_once() - post_with_metadata.assert_called_once() def test_list_locations_rest_bad_request(request_type=locations_pb2.ListLocationsRequest): @@ -2103,6 +2103,341 @@ def test_resumable_upload_service_grpc_asyncio_transport_channel(): assert transport._ssl_channel_credentials == None +def test_resumable_upload_service_upload_media_grpc_channel_without_credentials_error(): + channel = grpc.secure_channel('http://localhost/', grpc.local_channel_credentials()) + transport = transports.ResumableUploadServiceGrpcTransport( + host="localhost:7469", + channel=channel, + ) + with pytest.raises(core_exceptions.GoogleAPICallError) as exc_info: + transport.upload_media(resumable_upload.UploadMediaRequest()) + assert "operate over REST and cannot be invoked when the transport is initialized with a pre-constructed gRPC channel" in str(exc_info.value) + transport.close() + + +def test_resumable_upload_service_upload_media_grpc_rest_stub(): + transport = transports.ResumableUploadServiceGrpcTransport( + credentials=ga_credentials.AnonymousCredentials(), + ) + assert transport._rest_transport is None + stub = transport.upload_media + assert transport._rest_transport is None + + result1 = stub(resumable_upload.UploadMediaRequest()) + rest_transport = transport._rest_transport + assert rest_transport is not None + assert isinstance(result1, resumable_transfer.ResumableUploadSession) + + result2 = stub(resumable_upload.UploadMediaRequest()) + assert isinstance(result2, resumable_transfer.ResumableUploadSession) + assert transport._rest_transport is rest_transport + + with mock.patch.object(rest_transport, "close") as mock_rest_close: + transport.close() + mock_rest_close.assert_called_once() + + +def test_resumable_upload_service_upload_media_grpc_rest_stub_mtls(): + with mock.patch( + "google.auth.transport.requests.AuthorizedSession.configure_mtls_channel" + ) as mock_configure_mtls_channel: + transport = transports.ResumableUploadServiceGrpcTransport( + credentials=ga_credentials.AnonymousCredentials(), + client_cert_source_for_mtls=client_cert_source_callback, + ) + mock_configure_mtls_channel.assert_not_called() + stub = transport.upload_media + stub(resumable_upload.UploadMediaRequest()) + mock_configure_mtls_channel.assert_called_once_with( + client_cert_source_callback + ) + transport.close() + + +@pytest.mark.asyncio +async def test_resumable_upload_service_upload_media_grpc_asyncio_mtls_error(): + transport = transports.ResumableUploadServiceGrpcAsyncIOTransport( + credentials=ga_credentials.AnonymousCredentials(), + client_cert_source_for_mtls=client_cert_source_callback, + ) + with pytest.raises(core_exceptions.AsyncRestUnsupportedParameterError) as exc_info: + await transport.upload_media(resumable_upload.UploadMediaRequest()) + assert "Mutual TLS (client_cert_source_for_mtls) is not currently supported for async resumable upload methods." in str(exc_info.value) + await transport.close() + + +@pytest.mark.asyncio +async def test_resumable_upload_service_upload_media_grpc_asyncio_channel_without_credentials_error(): + channel = aio.secure_channel('http://localhost/', grpc.local_channel_credentials()) + transport = transports.ResumableUploadServiceGrpcAsyncIOTransport( + host="localhost:7469", + channel=channel, + ) + with pytest.raises(core_exceptions.GoogleAPICallError) as exc_info: + await transport.upload_media(resumable_upload.UploadMediaRequest()) + assert "operate over REST and cannot be invoked when the transport is initialized with a pre-constructed gRPC channel" in str(exc_info.value) + await transport.close() + + +@pytest.mark.asyncio +async def test_resumable_upload_service_upload_media_grpc_asyncio_rest_stub(): + with mock.patch.object(transports.grpc_asyncio, "HAS_ASYNC_REST", True), mock.patch.object( + transports.grpc_asyncio, + "AsyncResumableUploadServiceRestTransport", + create=True, + ) as mock_rest_transport_cls: + mock_rest_transport = mock.MagicMock() + mock_rest_transport.close = mock.AsyncMock() + expected_session = resumable_transfer.AsyncResumableUploadSession() + mock_rest_transport.upload_media = mock.AsyncMock( + return_value=expected_session + ) + mock_rest_transport_cls.return_value = mock_rest_transport + + transport = transports.ResumableUploadServiceGrpcAsyncIOTransport( + credentials=ga_credentials.AnonymousCredentials(), + ) + mock_rest_transport_cls.assert_not_called() + + stub = transport.upload_media + result1 = await stub(resumable_upload.UploadMediaRequest()) + result2 = await stub(resumable_upload.UploadMediaRequest()) + + assert result1 is expected_session + assert result2 is expected_session + mock_rest_transport_cls.assert_called_once_with( + host=transport._host, + credentials=transport._credentials, + client_info=transport._client_info, + ) + assert mock_rest_transport.upload_media.call_count == 2 + await transport.close() + mock_rest_transport.close.assert_called_once() + + +@pytest.mark.asyncio +async def test_resumable_upload_service_upload_media_grpc_asyncio_rest_stub_sync_credentials(): + if not HAS_ASYNC_REST_EXTRA: + pytest.skip() + transport = transports.ResumableUploadServiceGrpcAsyncIOTransport( + credentials=ga_credentials.AnonymousCredentials(), + ) + assert transport._rest_transport is None + stub = transport.upload_media + assert transport._rest_transport is None + + result1 = await stub(resumable_upload.UploadMediaRequest()) + rest_transport = transport._rest_transport + assert rest_transport is not None + assert isinstance(result1, resumable_transfer.AsyncResumableUploadSession) + + result2 = await stub(resumable_upload.UploadMediaRequest()) + assert isinstance(result2, resumable_transfer.AsyncResumableUploadSession) + assert transport._rest_transport is rest_transport + + with mock.patch.object( + rest_transport, "close", wraps=rest_transport.close + ) as mock_rest_close: + await transport.close() + mock_rest_close.assert_called_once() + + +@pytest.mark.asyncio +async def test_resumable_upload_service_upload_media_grpc_asyncio_start_empty_body(): + if not HAS_ASYNC_REST_EXTRA: + pytest.skip() + client = ResumableUploadServiceAsyncClient( + credentials=async_anonymous_credentials(), + ) + with mock.patch.object(AsyncAuthorizedSession, "request") as req: + start_response = mock.MagicMock() + start_response.status_code = 200 + start_response.headers = {"x-goog-upload-status": "active", "x-goog-upload-url": "http://localhost/upload"} + start_response.read = mock.AsyncMock(return_value=b"") + start_response.close = mock.AsyncMock(return_value=None) + + expected_result = resumable_upload.UploadMediaResponse( + name='name_value', + size=443, + ) + json_return_value = resumable_upload.UploadMediaResponse.to_json(expected_result) + upload_response = mock.MagicMock() + upload_response.status_code = 200 + upload_response.headers = {"x-goog-upload-status": "final"} + upload_response.read = mock.AsyncMock(return_value=json_return_value.encode("UTF-8")) + upload_response.close = mock.AsyncMock(return_value=None) + + req.side_effect = [start_response, upload_response] + retry = retries.AsyncRetry() + response = await client.upload_media( + resumable_upload.UploadMediaRequest(), + config=resumable_transfer.ResumableUploadConfig(headers={"x-custom": "val"}), + retry=retry, + timeout=10.0, + ) + assert isinstance(response, resumable_transfer.AsyncResumableUploadSession) + assert response._start_retry is retry + result = await response.upload(b"test data") + assert isinstance(result, resumable_upload.UploadMediaResponse) + assert result == expected_result + start_call = req.call_args_list[0] + assert start_call.kwargs["headers"]["Content-Type"] == "application/json" + assert start_call.kwargs["headers"]["x-custom"] == "val" + + +@pytest.mark.asyncio +async def test_resumable_upload_service_upload_media_grpc_asyncio_body_and_query_params(): + if not HAS_ASYNC_REST_EXTRA: + pytest.skip() + client = ResumableUploadServiceAsyncClient( + credentials=async_anonymous_credentials(), + ) + with mock.patch.object( + AsyncAuthorizedSession, "request" + ) as req, mock.patch.object(path_template, "transcode") as transcode: + request = resumable_upload.UploadMediaRequest( + name='name_value', + ) + pb_request = resumable_upload.UploadMediaRequest.pb(request) + transcode.return_value = { + "uri": "v1/sample_method", + "method": "post", + "body": pb_request, + "query_params": pb_request, + } + + start_response = mock.MagicMock() + start_response.status_code = 200 + start_response.headers = {"x-goog-upload-status": "active", "x-goog-upload-url": "http://localhost/upload"} + start_response.read = mock.AsyncMock(return_value=b"") + start_response.close = mock.AsyncMock(return_value=None) + + expected_result = resumable_upload.UploadMediaResponse() + json_return_value = resumable_upload.UploadMediaResponse.to_json(expected_result) + upload_response = mock.MagicMock() + upload_response.status_code = 200 + upload_response.headers = {"x-goog-upload-status": "final"} + upload_response.read = mock.AsyncMock(return_value=json_return_value.encode("UTF-8")) + upload_response.close = mock.AsyncMock(return_value=None) + + req.side_effect = [start_response, upload_response] + response = await client.upload_media(request) + result = await response.upload(b"test data") + assert result == expected_result + + start_call = req.call_args_list[0] + assert start_call.kwargs["headers"]["Content-Type"] == "application/json" + assert "name=" in start_call.args[1] + expected_body = json_format.MessageToJson( + pb_request, + use_integers_for_enums=True, + ) + assert start_call.kwargs["data"] == expected_body.encode("UTF-8") + + +@pytest.mark.asyncio +async def test_resumable_upload_service_upload_media_grpc_asyncio_without_async_rest_error(): + with mock.patch.object(transports.grpc_asyncio, "HAS_ASYNC_REST", False): + transport = transports.ResumableUploadServiceGrpcAsyncIOTransport( + credentials=ga_credentials.AnonymousCredentials(), + ) + with pytest.raises(NotImplementedError) as exc_info: + await transport.upload_media(resumable_upload.UploadMediaRequest()) + assert ( + "Async REST transport is required for async resumable upload methods. " + "Install the library with the `async_rest` extra using " + "`pip install google-showcase[async_rest]`." + ) in str(exc_info.value) + +def test_resumable_upload_service_upload_media_rest_start_empty_body(): + client = ResumableUploadServiceClient( + credentials=ga_credentials.AnonymousCredentials(), + transport="rest", + ) + with mock.patch.object(type(client.transport._session), "request") as req: + start_response = mock.MagicMock() + start_response.ok = True + start_response.status_code = 200 + start_response.headers = {"x-goog-upload-status": "active", "x-goog-upload-url": "http://localhost/upload"} + start_response.content = b"" + + expected_result = resumable_upload.UploadMediaResponse( + name='name_value', + size=443, + ) + json_return_value = resumable_upload.UploadMediaResponse.to_json(expected_result) + upload_response = mock.MagicMock() + upload_response.ok = True + upload_response.status_code = 200 + upload_response.headers = {"x-goog-upload-status": "final"} + upload_response.content = json_return_value.encode("UTF-8") + + req.side_effect = [start_response, upload_response] + retry = retries.Retry() + response = client.upload_media( + resumable_upload.UploadMediaRequest(), + config=resumable_transfer.ResumableUploadConfig(headers={"x-custom": "val"}), + retry=retry, + timeout=10.0, + ) + assert isinstance(response, resumable_transfer.ResumableUploadSession) + assert response._start_retry is retry + result = response.upload(b"test data") + assert isinstance(result, resumable_upload.UploadMediaResponse) + assert result == expected_result + start_call = req.call_args_list[0] + assert start_call.kwargs["headers"]["Content-Type"] == "application/json" + assert start_call.kwargs["headers"]["x-custom"] == "val" + + +def test_resumable_upload_service_upload_media_rest_body_and_query_params(): + client = ResumableUploadServiceClient( + credentials=ga_credentials.AnonymousCredentials(), + transport="rest", + ) + with mock.patch.object(type(client.transport._session), "request") as req, mock.patch.object( + path_template, "transcode" + ) as transcode: + request = resumable_upload.UploadMediaRequest( + name='name_value', + ) + pb_request = resumable_upload.UploadMediaRequest.pb(request) + transcode.return_value = { + "uri": "v1/sample_method", + "method": "post", + "body": pb_request, + "query_params": pb_request, + } + + start_response = mock.MagicMock() + start_response.ok = True + start_response.status_code = 200 + start_response.headers = {"x-goog-upload-status": "active", "x-goog-upload-url": "http://localhost/upload"} + start_response.content = b"" + + expected_result = resumable_upload.UploadMediaResponse() + json_return_value = resumable_upload.UploadMediaResponse.to_json(expected_result) + upload_response = mock.MagicMock() + upload_response.ok = True + upload_response.status_code = 200 + upload_response.headers = {"x-goog-upload-status": "final"} + upload_response.content = json_return_value.encode("UTF-8") + + req.side_effect = [start_response, upload_response] + response = client.upload_media(request) + result = response.upload(b"test data") + assert result == expected_result + + start_call = req.call_args_list[0] + assert start_call.kwargs["headers"]["Content-Type"] == "application/json" + assert "name=" in start_call.args[1] + expected_body = json_format.MessageToJson( + pb_request, + use_integers_for_enums=True, + ) + assert start_call.kwargs["data"] == expected_body.encode("UTF-8") + + # Remove this test when deprecated arguments (api_mtls_endpoint, client_cert_source) are # removed from grpc/grpc_asyncio transport constructor. @pytest.mark.filterwarnings("ignore::FutureWarning") diff --git a/packages/gapic-generator/tests/unit/generator/test_generator.py b/packages/gapic-generator/tests/unit/generator/test_generator.py index 9d8545c4192f..3ca6af45f9b9 100644 --- a/packages/gapic-generator/tests/unit/generator/test_generator.py +++ b/packages/gapic-generator/tests/unit/generator/test_generator.py @@ -263,6 +263,80 @@ def test_get_response_ignores_unwanted_transports_and_clients(): } +def test_get_response_resumable_upload_generates_async_client_and_rest_asyncio(): + # Verify that REST transport files (rest.py, rest_base.py, rest_asyncio.py) + # are generated when a service has a resumable upload method and grpc + # transport is enabled, even if rest_async_io_enabled is False, while + # async_client.py and rest_asyncio.py are not generated for rest-only + # without rest_async_io_enabled. + generator_obj = make_generator() + with mock.patch.object(jinja2.FileSystemLoader, "list_templates") as list_templates: + list_templates.return_value = [ + "foo/%service/transports/grpc.py.j2", + "foo/%service/transports/grpc_asyncio.py.j2", + "foo/%service/transports/rest.py.j2", + "foo/%service/transports/rest_asyncio.py.j2", + "foo/%service/transports/rest_base.py.j2", + "foo/%service/transports/__init__.py.j2", + "foo/%service/transports/base.py.j2", + "foo/%service/async_client.py.j2", + "foo/%service/client.py.j2", + ] + + with mock.patch.object(jinja2.Environment, "get_template") as get_template: + get_template.return_value = jinja2.Template("Service: {{ service.name }}") + api_schema = make_api( + make_proto( + descriptor_pb2.FileDescriptorProto( + name="resumable.proto", + package="foo.v1", + message_type=[ + descriptor_pb2.DescriptorProto(name="UploadMediaRequest"), + descriptor_pb2.DescriptorProto(name="UploadMediaResponse"), + ], + service=[ + descriptor_pb2.ServiceDescriptorProto( + name="ResumableUploadService", + method=[ + descriptor_pb2.MethodDescriptorProto( + name="UploadMedia", + input_type=".foo.v1.UploadMediaRequest", + output_type=".foo.v1.UploadMediaResponse", + ), + ], + ), + ], + ), + ) + ) + + cgr = generator_obj.get_response( + api_schema=api_schema, opts=Options.build("transport=rest") + ) + assert {i.name for i in cgr.file} == { + "foo/resumable_upload_service/transports/rest.py", + "foo/resumable_upload_service/transports/rest_base.py", + "foo/resumable_upload_service/transports/__init__.py", + "foo/resumable_upload_service/transports/base.py", + "foo/resumable_upload_service/client.py", + } + + cgr_grpc = generator_obj.get_response( + api_schema=api_schema, opts=Options.build("transport=grpc") + ) + assert {i.name for i in cgr_grpc.file} == { + "foo/resumable_upload_service/transports/grpc.py", + "foo/resumable_upload_service/transports/grpc_asyncio.py", + "foo/resumable_upload_service/transports/rest.py", + "foo/resumable_upload_service/transports/rest_asyncio.py", + "foo/resumable_upload_service/transports/rest_base.py", + "foo/resumable_upload_service/transports/__init__.py", + "foo/resumable_upload_service/transports/base.py", + "foo/resumable_upload_service/async_client.py", + "foo/resumable_upload_service/client.py", + } + + def test_get_response_enumerates_services(): generator_obj = make_generator() with mock.patch.object(jinja2.FileSystemLoader, "list_templates") as list_templates: diff --git a/packages/gapic-generator/tests/unit/schema/wrappers/test_method.py b/packages/gapic-generator/tests/unit/schema/wrappers/test_method.py index 63a5adc0fcb4..d905c5696d4d 100644 --- a/packages/gapic-generator/tests/unit/schema/wrappers/test_method.py +++ b/packages/gapic-generator/tests/unit/schema/wrappers/test_method.py @@ -1124,9 +1124,24 @@ def test_method_is_resumable_upload(): # UploadMedia (and CreateYouTubeVideoUpload) are temporarily hardcoded as # resumable upload method names until resumable upload proto annotations # are adopted in the future. - # Verify that UploadMedia is identified as a resumable upload method. + # Verify that UploadMedia is identified as a resumable upload method and + # configures ResumableUploadSession / AsyncResumableUploadSession as the + # client output while retaining the raw output message in ref_types. method_upload = make_method("UploadMedia") assert method_upload.is_resumable_upload + assert method_upload.client_output.ident.name == "ResumableUploadSession" + assert ( + str(method_upload.client_output.ident) + == "resumable_transfer.ResumableUploadSession" + ) + assert ( + method_upload.client_output_async.ident.name == "AsyncResumableUploadSession" + ) + assert ( + str(method_upload.client_output_async.ident) + == "resumable_transfer.AsyncResumableUploadSession" + ) + assert method_upload.output in method_upload.ref_types # Verify that CreateYouTubeVideoUpload is also recognized as a resumable upload method. method_youtube_upload = make_method("CreateYouTubeVideoUpload")