diff --git a/gapic/templates/%namespace/%name_%version/%sub/services/%service/client.py.j2 b/gapic/templates/%namespace/%name_%version/%sub/services/%service/client.py.j2 index 48efc9de0b..9b92f6e6fc 100644 --- a/gapic/templates/%namespace/%name_%version/%sub/services/%service/client.py.j2 +++ b/gapic/templates/%namespace/%name_%version/%sub/services/%service/client.py.j2 @@ -196,15 +196,22 @@ class {{ service.client_name }}(metaclass={{ service.client_name }}Meta): # instance provides an extensibility point for unusual situations. if isinstance(transport, {{ service.name }}Transport): # transport is a {{ service.name }}Transport instance. - if credentials: + if credentials or client_options.credentials_file: raise ValueError('When providing a transport instance, ' 'provide its credentials directly.') + if client_options.scopes: + raise ValueError( + "When providing a transport instance, " + "provide its scopes directly." + ) self._transport = transport else: Transport = type(self).get_transport_class(transport) self._transport = Transport( credentials=credentials, + credentials_file=client_options.credentials_file, host=client_options.api_endpoint, + scopes=client_options.scopes, api_mtls_endpoint=client_options.api_endpoint, client_cert_source=client_options.client_cert_source, ) diff --git a/gapic/templates/%namespace/%name_%version/%sub/services/%service/transports/base.py.j2 b/gapic/templates/%namespace/%name_%version/%sub/services/%service/transports/base.py.j2 index 6eaf999459..8d03088780 100644 --- a/gapic/templates/%namespace/%name_%version/%sub/services/%service/transports/base.py.j2 +++ b/gapic/templates/%namespace/%name_%version/%sub/services/%service/transports/base.py.j2 @@ -5,6 +5,7 @@ import abc import typing from google import auth +from google.api_core import exceptions # type: ignore {%- if service.has_lro %} from google.api_core import operations_v1 # type: ignore {%- endif %} @@ -30,7 +31,9 @@ class {{ service.name }}Transport(abc.ABC): self, *, host: str{% if service.host %} = '{{ service.host }}'{% endif %}, credentials: credentials.Credentials = None, - **kwargs, + credentials_file: typing.Optional[str] = None, + scopes: typing.Optional[typing.Sequence[str]] = AUTH_SCOPES, + **kwargs, ) -> None: """Instantiate the transport. @@ -42,6 +45,10 @@ class {{ service.name }}Transport(abc.ABC): credentials identify the application to the service; if none are specified, the client will attempt to ascertain the credentials from the environment. + credentials_file (Optional[str]): A file with credentials that can + be loaded with :func:`google.auth.load_credentials_from_file`. + This argument is mutually exclusive with credentials. + scope (Optional[Sequence[str]]): A list of scopes. """ # Save the hostname. Default to port 443 (HTTPS) if none is specified. if ':' not in host: @@ -50,8 +57,13 @@ class {{ service.name }}Transport(abc.ABC): # If no credentials are provided, then determine the appropriate # defaults. - if credentials is None: - credentials, _ = auth.default(scopes=self.AUTH_SCOPES) + if credentials and credentials_file: + raise exceptions.DuplicateCredentialArgs("'credentials_file' and 'credentials' are mutually exclusive") + + if credentials_file is not None: + credentials, _ = auth.load_credentials_from_file(credentials_file, scopes=scopes) + elif credentials is None: + credentials, _ = auth.default(scopes=scopes) # Save the credentials. self._credentials = credentials diff --git a/gapic/templates/%namespace/%name_%version/%sub/services/%service/transports/grpc.py.j2 b/gapic/templates/%namespace/%name_%version/%sub/services/%service/transports/grpc.py.j2 index 7288972b8c..245602bf50 100644 --- a/gapic/templates/%namespace/%name_%version/%sub/services/%service/transports/grpc.py.j2 +++ b/gapic/templates/%namespace/%name_%version/%sub/services/%service/transports/grpc.py.j2 @@ -40,6 +40,8 @@ class {{ service.name }}GrpcTransport({{ service.name }}Transport): def __init__(self, *, host: str{% if service.host %} = '{{ service.host }}'{% endif %}, credentials: credentials.Credentials = None, + credentials_file: str = None, + scopes: Sequence[str] = None, channel: grpc.Channel = None, api_mtls_endpoint: str = None, client_cert_source: Callable[[], Tuple[bytes, bytes]] = None) -> None: @@ -54,6 +56,11 @@ class {{ service.name }}GrpcTransport({{ service.name }}Transport): are specified, the client will attempt to ascertain the credentials from the environment. This argument is ignored if ``channel`` is provided. + credentials_file (Optional[str]): A file with credentials that can + be loaded with :func:`google.auth.load_credentials_from_file`. + This argument is ignored if ``channel`` is provided. + scopes (Optional(Sequence[str])): A list of scopes. This argument is + ignored if ``channel`` is provided. channel (Optional[grpc.Channel]): A ``Channel`` instance through which to make calls. api_mtls_endpoint (Optional[str]): The mutual TLS endpoint. If @@ -66,8 +73,10 @@ class {{ service.name }}GrpcTransport({{ service.name }}Transport): is None. Raises: - google.auth.exceptions.MutualTlsChannelError: If mutual TLS transport - creation failed for any reason. + google.auth.exceptions.MutualTLSChannelError: If mutual TLS transport + creation failed for any reason. + google.api_core.exceptions.DuplicateCredentialArgs: If both ``credentials`` + and ``credentials_file`` are passed. """ if channel: # Sanity check: Ensure that channel and credentials are not both @@ -96,18 +105,26 @@ class {{ service.name }}GrpcTransport({{ service.name }}Transport): self._grpc_channel = type(self).create_channel( host, credentials=credentials, + credentials_file=credentials_file, ssl_credentials=ssl_credentials, - scopes=self.AUTH_SCOPES, + scopes=scopes or self.AUTH_SCOPES, ) # Run the base constructor. - super().__init__(host=host, credentials=credentials) + super().__init__( + host=host, + credentials=credentials, + credentials_file=credentials_file, + scopes=scopes or self.AUTH_SCOPES + ) + self._stubs = {} # type: Dict[str, Callable] @classmethod def create_channel(cls, host: str{% if service.host %} = '{{ service.host }}'{% endif %}, credentials: credentials.Credentials = None, + credentials_file: str = None, scopes: Optional[Sequence[str]] = None, **kwargs) -> grpc.Channel: """Create and return a gRPC channel object. @@ -118,6 +135,9 @@ class {{ service.name }}GrpcTransport({{ service.name }}Transport): credentials identify this application to the service. If none are specified, the client will attempt to ascertain the credentials from the environment. + credentials_file (Optional[str]): A file with credentials that can + be loaded with :func:`google.auth.load_credentials_from_file`. + This argument is mutually exclusive with credentials. scopes (Optional[Sequence[str]]): A optional list of scopes needed for this service. These are only used when credentials are not specified and are passed to :func:`google.auth.default`. @@ -125,11 +145,16 @@ class {{ service.name }}GrpcTransport({{ service.name }}Transport): channel creation. Returns: grpc.Channel: A gRPC channel object. + + Raises: + google.api_core.exceptions.DuplicateCredentialArgs: If both ``credentials`` + and ``credentials_file`` are passed. """ scopes = scopes or cls.AUTH_SCOPES return grpc_helpers.create_channel( host, credentials=credentials, + credentials_file=credentials_file, scopes=scopes, **kwargs ) diff --git a/gapic/templates/%namespace/%name_%version/%sub/services/%service/transports/grpc_asyncio.py.j2 b/gapic/templates/%namespace/%name_%version/%sub/services/%service/transports/grpc_asyncio.py.j2 index 53fd1c7188..af182c11b8 100644 --- a/gapic/templates/%namespace/%name_%version/%sub/services/%service/transports/grpc_asyncio.py.j2 +++ b/gapic/templates/%namespace/%name_%version/%sub/services/%service/transports/grpc_asyncio.py.j2 @@ -43,6 +43,7 @@ class {{ service.grpc_asyncio_transport_name }}({{ service.name }}Transport): def create_channel(cls, host: str{% if service.host %} = '{{ service.host }}'{% endif %}, credentials: credentials.Credentials = None, + credentials_file: Optional[str] = None, scopes: Optional[Sequence[str]] = None, **kwargs) -> aio.Channel: """Create and return a gRPC AsyncIO channel object. @@ -53,6 +54,9 @@ class {{ service.grpc_asyncio_transport_name }}({{ service.name }}Transport): credentials identify this application to the service. If none are specified, the client will attempt to ascertain the credentials from the environment. + credentials_file (Optional[str]): A file with credentials that can + be loaded with :func:`google.auth.load_credentials_from_file`. + This argument is ignored if ``channel`` is provided. scopes (Optional[Sequence[str]]): A optional list of scopes needed for this service. These are only used when credentials are not specified and are passed to :func:`google.auth.default`. @@ -65,6 +69,7 @@ class {{ service.grpc_asyncio_transport_name }}({{ service.name }}Transport): return grpc_helpers_async.create_channel( host, credentials=credentials, + credentials_file=credentials_file, scopes=scopes, **kwargs ) @@ -72,6 +77,8 @@ class {{ service.grpc_asyncio_transport_name }}({{ service.name }}Transport): def __init__(self, *, host: str{% if service.host %} = '{{ service.host }}'{% endif %}, credentials: credentials.Credentials = None, + credentials_file: Optional[str] = None, + scopes: Optional[Sequence[str]] = None, channel: aio.Channel = None, api_mtls_endpoint: str = None, client_cert_source: Callable[[], Tuple[bytes, bytes]] = None) -> None: @@ -86,6 +93,12 @@ class {{ service.grpc_asyncio_transport_name }}({{ service.name }}Transport): are specified, the client will attempt to ascertain the credentials from the environment. This argument is ignored if ``channel`` is provided. + credentials_file (Optional[str]): A file with credentials that can + be loaded with :func:`google.auth.load_credentials_from_file`. + This argument is ignored if ``channel`` is provided. + scopes (Optional[Sequence[str]]): A optional list of scopes needed for this + service. These are only used when credentials are not specified and + are passed to :func:`google.auth.default`. channel (Optional[aio.Channel]): A ``Channel`` instance through which to make calls. api_mtls_endpoint (Optional[str]): The mutual TLS endpoint. If @@ -98,8 +111,10 @@ class {{ service.grpc_asyncio_transport_name }}({{ service.name }}Transport): is None. Raises: - google.auth.exceptions.MutualTlsChannelError: If mutual TLS transport + google.auth.exceptions.MutualTlsChannelError: If mutual TLS transport creation failed for any reason. + google.api_core.exceptions.DuplicateCredentialArgs: If both ``credentials`` + and ``credentials_file`` are passed. """ if channel: # Sanity check: Ensure that channel and credentials are not both @@ -125,12 +140,19 @@ class {{ service.grpc_asyncio_transport_name }}({{ service.name }}Transport): self._grpc_channel = type(self).create_channel( host, credentials=credentials, + credentials_file=credentials_file, ssl_credentials=ssl_credentials, - scopes=self.AUTH_SCOPES, + scopes=scopes or self.AUTH_SCOPES, ) # Run the base constructor. - super().__init__(host=host, credentials=credentials) + super().__init__( + host=host, + credentials=credentials, + credentials_file=credentials_file, + scopes=scopes or self.AUTH_SCOPES + ) + self._stubs = {} @property diff --git a/gapic/templates/tests/unit/gapic/%name_%version/%sub/test_%service.py.j2 b/gapic/templates/tests/unit/gapic/%name_%version/%sub/test_%service.py.j2 index 550ca1bac4..ecfe6af704 100644 --- a/gapic/templates/tests/unit/gapic/%name_%version/%sub/test_%service.py.j2 +++ b/gapic/templates/tests/unit/gapic/%name_%version/%sub/test_%service.py.j2 @@ -19,6 +19,7 @@ from {{ (api.naming.module_namespace + (api.naming.versioned_module_name,) + ser from {{ (api.naming.module_namespace + (api.naming.versioned_module_name,) + service.meta.address.subpackage)|join(".") }}.services.{{ service.name|snake_case }} import {{ service.async_client_name }} from {{ (api.naming.module_namespace + (api.naming.versioned_module_name,) + service.meta.address.subpackage)|join(".") }}.services.{{ service.name|snake_case }} import transports from google.api_core import client_options +from google.api_core import exceptions from google.api_core import grpc_helpers from google.api_core import grpc_helpers_async {% if service.has_lro -%} @@ -101,10 +102,12 @@ def test_{{ service.client_name|snake_case }}_client_options(client_class, trans patched.return_value = None client = client_class(client_options=options) patched.assert_called_once_with( - api_mtls_endpoint="squid.clam.whelk", - client_cert_source=None, credentials=None, + credentials_file=None, host="squid.clam.whelk", + scopes=None, + api_mtls_endpoint="squid.clam.whelk", + client_cert_source=None, ) # Check the case api_endpoint is not provided and GOOGLE_API_USE_MTLS is @@ -114,10 +117,12 @@ def test_{{ service.client_name|snake_case }}_client_options(client_class, trans patched.return_value = None client = client_class() patched.assert_called_once_with( - api_mtls_endpoint=client.DEFAULT_ENDPOINT, - client_cert_source=None, credentials=None, + credentials_file=None, host=client.DEFAULT_ENDPOINT, + scopes=None, + api_mtls_endpoint=client.DEFAULT_ENDPOINT, + client_cert_source=None, ) # Check the case api_endpoint is not provided and GOOGLE_API_USE_MTLS is @@ -127,10 +132,12 @@ def test_{{ service.client_name|snake_case }}_client_options(client_class, trans patched.return_value = None client = client_class() patched.assert_called_once_with( - api_mtls_endpoint=client.DEFAULT_MTLS_ENDPOINT, - client_cert_source=None, credentials=None, + credentials_file=None, host=client.DEFAULT_MTLS_ENDPOINT, + scopes=None, + api_mtls_endpoint=client.DEFAULT_MTLS_ENDPOINT, + client_cert_source=None, ) # Check the case api_endpoint is not provided, GOOGLE_API_USE_MTLS is @@ -141,10 +148,13 @@ def test_{{ service.client_name|snake_case }}_client_options(client_class, trans patched.return_value = None client = client_class(client_options=options) patched.assert_called_once_with( - api_mtls_endpoint=client.DEFAULT_MTLS_ENDPOINT, - client_cert_source=client_cert_source_callback, credentials=None, + credentials_file=None, host=client.DEFAULT_MTLS_ENDPOINT, + scopes=None, + api_mtls_endpoint=client.DEFAULT_MTLS_ENDPOINT, + client_cert_source=client_cert_source_callback, + ) # Check the case api_endpoint is not provided, GOOGLE_API_USE_MTLS is @@ -155,10 +165,12 @@ def test_{{ service.client_name|snake_case }}_client_options(client_class, trans patched.return_value = None client = client_class() patched.assert_called_once_with( - api_mtls_endpoint=client.DEFAULT_MTLS_ENDPOINT, - client_cert_source=None, credentials=None, + credentials_file=None, host=client.DEFAULT_MTLS_ENDPOINT, + scopes=None, + api_mtls_endpoint=client.DEFAULT_MTLS_ENDPOINT, + client_cert_source=None, ) # Check the case api_endpoint is not provided, GOOGLE_API_USE_MTLS is @@ -169,10 +181,12 @@ def test_{{ service.client_name|snake_case }}_client_options(client_class, trans patched.return_value = None client = client_class() patched.assert_called_once_with( - api_mtls_endpoint=client.DEFAULT_ENDPOINT, - client_cert_source=None, credentials=None, + credentials_file=None, host=client.DEFAULT_ENDPOINT, + scopes=None, + api_mtls_endpoint=client.DEFAULT_ENDPOINT, + client_cert_source=None, ) # Check the case api_endpoint is not provided and GOOGLE_API_USE_MTLS has @@ -184,6 +198,50 @@ def test_{{ service.client_name|snake_case }}_client_options(client_class, trans del os.environ["GOOGLE_API_USE_MTLS"] +@pytest.mark.parametrize("client_class,transport_class,transport_name", [ + ({{ service.client_name }}, transports.{{ service.grpc_transport_name }}, "grpc"), + ({{ service.async_client_name }}, transports.{{ service.grpc_asyncio_transport_name }}, "grpc_asyncio") +]) +def test_{{ service.client_name|snake_case }}_client_options_scopes(client_class, transport_class, transport_name): + # Check the case api_endpoint is provided. + options = client_options.ClientOptions( + scopes=["1", "2"], + ) + with mock.patch.object(transport_class, '__init__') as patched: + patched.return_value = None + client = client_class(client_options=options) + patched.assert_called_once_with( + credentials=None, + credentials_file=None, + host="localhost:7469", + scopes=["1", "2"], + api_mtls_endpoint="localhost:7469", + client_cert_source=None, + ) + + +@pytest.mark.parametrize("client_class,transport_class,transport_name", [ + ({{ service.client_name }}, transports.{{ service.grpc_transport_name }}, "grpc"), + ({{ service.async_client_name }}, transports.{{ service.grpc_asyncio_transport_name }}, "grpc_asyncio") +]) +def test_{{ service.client_name|snake_case }}_client_options_credentials_file(client_class, transport_class, transport_name): + # Check the case api_endpoint is provided. + options = client_options.ClientOptions( + credentials_file="credentials.json" + ) + with mock.patch.object(transport_class, '__init__') as patched: + patched.return_value = None + client = client_class(client_options=options) + patched.assert_called_once_with( + credentials=None, + credentials_file="credentials.json", + host="localhost:7469", + scopes=None, + api_mtls_endpoint="localhost:7469", + client_cert_source=None, + ) + + def test_{{ service.client_name|snake_case }}_client_options_from_dict(): with mock.patch('{{ (api.naming.module_namespace + (api.naming.versioned_module_name,) + service.meta.address.subpackage)|join(".") }}.services.{{ service.name|snake_case }}.transports.{{ service.name }}GrpcTransport.__init__') as grpc_transport: grpc_transport.return_value = None @@ -191,10 +249,12 @@ def test_{{ service.client_name|snake_case }}_client_options_from_dict(): client_options={'api_endpoint': 'squid.clam.whelk'} ) grpc_transport.assert_called_once_with( - api_mtls_endpoint="squid.clam.whelk", - client_cert_source=None, credentials=None, + credentials_file=None, host="squid.clam.whelk", + scopes=None, + api_mtls_endpoint="squid.clam.whelk", + client_cert_source=None, ) @@ -793,6 +853,27 @@ def test_credentials_transport_error(): transport=transport, ) + # It is an error to provide a credentials file and a transport instance. + transport = transports.{{ service.name }}GrpcTransport( + credentials=credentials.AnonymousCredentials(), + ) + with pytest.raises(ValueError): + client = {{ service.client_name }}( + client_options={"credentials_file": "credentials.json"}, + transport=transport, + ) + + # It is an error to provide scopes and a transport instance. + transport = transports.{{ service.name }}GrpcTransport( + credentials=credentials.AnonymousCredentials(), + ) + with pytest.raises(ValueError): + client = {{ service.client_name }}( + client_options={"scopes": ["1", "2"]}, + transport=transport, + ) + + def test_transport_instance(): # A client may be instantiated with a custom transport instance. @@ -829,6 +910,15 @@ def test_transport_grpc_default(): ) +def test_{{ service.name|snake_case }}_base_transport_error(): + # Passing both a credentials object and credentials_file should raise an error + with pytest.raises(exceptions.DuplicateCredentialArgs): + transport = transports.{{ service.name }}Transport( + credentials=credentials.AnonymousCredentials(), + credentials_file="credentials.json" + ) + + def test_{{ service.name|snake_case }}_base_transport(): # Instantiate the base transport. transport = transports.{{ service.name }}Transport( @@ -854,6 +944,20 @@ def test_{{ service.name|snake_case }}_base_transport(): {% endif %} +def test_{{ service.name|snake_case }}_base_transport_with_credentials_file(): + # Instantiate the base transport with a credentials file + with mock.patch.object(auth, 'load_credentials_from_file') as load_creds: + load_creds.return_value = (credentials.AnonymousCredentials(), None) + transport = transports.{{ service.name }}Transport( + credentials_file="credentials.json", + ) + load_creds.assert_called_once_with("credentials.json", scopes=( + {%- for scope in service.oauth_scopes %} + '{{ scope }}', + {%- endfor %} + )) + + def test_{{ service.name|snake_case }}_auth_adc(): # If no credentials are provided, we should use ADC credentials. with mock.patch.object(auth, 'default') as adc: @@ -960,12 +1064,13 @@ def test_{{ service.name|snake_case }}_grpc_transport_channel_mtls_with_client_c grpc_create_channel.assert_called_once_with( "mtls.squid.clam.whelk:443", credentials=mock_cred, - ssl_credentials=mock_ssl_cred, + credentials_file=None, scopes=( {%- for scope in service.oauth_scopes %} '{{ scope }}', {%- endfor %} ), + ssl_credentials=mock_ssl_cred, ) assert transport.grpc_channel == mock_grpc_channel @@ -997,12 +1102,13 @@ def test_{{ service.name|snake_case }}_grpc_asyncio_transport_channel_mtls_with_ grpc_create_channel.assert_called_once_with( "mtls.squid.clam.whelk:443", credentials=mock_cred, - ssl_credentials=mock_ssl_cred, + credentials_file=None, scopes=( {%- for scope in service.oauth_scopes %} '{{ scope }}', {%- endfor %} ), + ssl_credentials=mock_ssl_cred, ) assert transport.grpc_channel == mock_grpc_channel @@ -1036,12 +1142,13 @@ def test_{{ service.name|snake_case }}_grpc_transport_channel_mtls_with_adc( grpc_create_channel.assert_called_once_with( "mtls.squid.clam.whelk:443", credentials=mock_cred, - ssl_credentials=mock_ssl_cred, + credentials_file=None, scopes=( {%- for scope in service.oauth_scopes %} '{{ scope }}', {%- endfor %} ), + ssl_credentials=mock_ssl_cred, ) assert transport.grpc_channel == mock_grpc_channel @@ -1075,12 +1182,13 @@ def test_{{ service.name|snake_case }}_grpc_asyncio_transport_channel_mtls_with_ grpc_create_channel.assert_called_once_with( "mtls.squid.clam.whelk:443", credentials=mock_cred, - ssl_credentials=mock_ssl_cred, + credentials_file=None, scopes=( {%- for scope in service.oauth_scopes %} '{{ scope }}', {%- endfor %} ), + ssl_credentials=mock_ssl_cred, ) assert transport.grpc_channel == mock_grpc_channel