diff --git a/.gitignore b/.gitignore index b87e1ed5..8c18b5e7 100644 --- a/.gitignore +++ b/.gitignore @@ -57,4 +57,7 @@ system_tests/local_test_setup # Make sure a generated file isn't accidentally committed. pylintrc -pylintrc.test \ No newline at end of file +pylintrc.test + +# pytype +pytype_output diff --git a/google/api_core/exceptions.py b/google/api_core/exceptions.py index d1459ab2..b9c46ca0 100644 --- a/google/api_core/exceptions.py +++ b/google/api_core/exceptions.py @@ -41,6 +41,12 @@ class GoogleAPIError(Exception): pass +class DuplicateCredentialArgs(GoogleAPIError): + """Raised when multiple credentials are passed.""" + + pass + + @six.python_2_unicode_compatible class RetryError(GoogleAPIError): """Raised when a function has exhausted all of its available retries. diff --git a/google/api_core/grpc_helpers.py b/google/api_core/grpc_helpers.py index b617ddf8..2203968e 100644 --- a/google/api_core/grpc_helpers.py +++ b/google/api_core/grpc_helpers.py @@ -176,13 +176,16 @@ def wrap_errors(callable_): return _wrap_unary_errors(callable_) -def _create_composite_credentials(credentials=None, scopes=None, ssl_credentials=None): +def _create_composite_credentials(credentials=None, credentials_file=None, scopes=None, ssl_credentials=None): """Create the composite credentials for secure channels. Args: credentials (google.auth.credentials.Credentials): The credentials. If not specified, then this function will attempt to ascertain the credentials from the environment using :func:`google.auth.default`. + credentials_file (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 (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`. @@ -191,14 +194,22 @@ def _create_composite_credentials(credentials=None, scopes=None, ssl_credentials Returns: grpc.ChannelCredentials: The composed channel credentials object. + + Raises: + google.api_core.DuplicateCredentialArgs: If both a credentials object and credentials_file are passed. """ - if credentials is None: - credentials, _ = google.auth.default(scopes=scopes) - else: - credentials = google.auth.credentials.with_scopes_if_required( - credentials, scopes + if credentials and credentials_file: + raise exceptions.DuplicateCredentialArgs( + "'credentials' and 'credentials_file' are mutually exclusive." ) + if credentials_file: + credentials, _ = google.auth.load_credentials_from_file(credentials_file, scopes=scopes) + elif credentials: + credentials = google.auth.credentials.with_scopes_if_required(credentials, scopes) + else: + credentials, _ = google.auth.default(scopes=scopes) + request = google.auth.transport.requests.Request() # Create the metadata plugin for inserting the authorization header. @@ -218,7 +229,7 @@ def _create_composite_credentials(credentials=None, scopes=None, ssl_credentials ) -def create_channel(target, credentials=None, scopes=None, ssl_credentials=None, **kwargs): +def create_channel(target, credentials=None, scopes=None, ssl_credentials=None, credentials_file=None, **kwargs): """Create a secure channel with credentials. Args: @@ -231,14 +242,24 @@ def create_channel(target, credentials=None, scopes=None, ssl_credentials=None, are passed to :func:`google.auth.default`. ssl_credentials (grpc.ChannelCredentials): Optional SSL channel credentials. This can be used to specify different certificates. + credentials_file (str): A file with credentials that can be loaded with + :func:`google.auth.load_credentials_from_file`. This argument is + mutually exclusive with credentials. kwargs: Additional key-word args passed to :func:`grpc_gcp.secure_channel` or :func:`grpc.secure_channel`. Returns: grpc.Channel: The created channel. + + Raises: + google.api_core.DuplicateCredentialArgs: If both a credentials object and credentials_file are passed. """ + composite_credentials = _create_composite_credentials( - credentials, scopes, ssl_credentials + credentials=credentials, + credentials_file=credentials_file, + scopes=scopes, + ssl_credentials=ssl_credentials ) if HAS_GRPC_GCP: diff --git a/google/api_core/grpc_helpers_async.py b/google/api_core/grpc_helpers_async.py index 9ded803c..1dfe8b9a 100644 --- a/google/api_core/grpc_helpers_async.py +++ b/google/api_core/grpc_helpers_async.py @@ -206,7 +206,7 @@ def wrap_errors(callable_): return _wrap_stream_errors(callable_) -def create_channel(target, credentials=None, scopes=None, ssl_credentials=None, **kwargs): +def create_channel(target, credentials=None, scopes=None, ssl_credentials=None, credentials_file=None, **kwargs): """Create an AsyncIO secure channel with credentials. Args: @@ -219,13 +219,23 @@ def create_channel(target, credentials=None, scopes=None, ssl_credentials=None, are passed to :func:`google.auth.default`. ssl_credentials (grpc.ChannelCredentials): Optional SSL channel credentials. This can be used to specify different certificates. + credentials_file (str): A file with credentials that can be loaded with + :func:`google.auth.load_credentials_from_file`. This argument is + mutually exclusive with credentials. kwargs: Additional key-word args passed to :func:`aio.secure_channel`. Returns: aio.Channel: The created channel. + + Raises: + google.api_core.DuplicateCredentialArgs: If both a credentials object and credentials_file are passed. """ + composite_credentials = grpc_helpers._create_composite_credentials( - credentials, scopes, ssl_credentials + credentials=credentials, + credentials_file=credentials_file, + scopes=scopes, + ssl_credentials=ssl_credentials ) return aio.secure_channel(target, composite_credentials, **kwargs) diff --git a/setup.py b/setup.py index 9fb7977e..7f65fd04 100644 --- a/setup.py +++ b/setup.py @@ -31,7 +31,7 @@ dependencies = [ "googleapis-common-protos >= 1.6.0, < 2.0dev", "protobuf >= 3.12.0", - "google-auth >= 1.14.0, < 2.0dev", + "google-auth >= 1.18.0, < 2.0dev", "requests >= 2.18.0, < 3.0.0dev", "setuptools >= 34.0.0", "six >= 1.10.0", diff --git a/tests/asyncio/test_grpc_helpers_async.py b/tests/asyncio/test_grpc_helpers_async.py index 00539521..d56c4c60 100644 --- a/tests/asyncio/test_grpc_helpers_async.py +++ b/tests/asyncio/test_grpc_helpers_async.py @@ -317,6 +317,19 @@ def test_create_channel_implicit_with_scopes( grpc_secure_channel.assert_called_once_with(target, composite_creds) +def test_create_channel_explicit_with_duplicate_credentials(): + target = "example:443" + + with pytest.raises(exceptions.DuplicateCredentialArgs) as excinfo: + grpc_helpers_async.create_channel( + target, + credentials_file="credentials.json", + credentials=mock.sentinel.credentials + ) + + assert "mutually exclusive" in str(excinfo.value) + + @mock.patch("grpc.composite_channel_credentials") @mock.patch("google.auth.credentials.with_scopes_if_required") @mock.patch("grpc.experimental.aio.secure_channel") @@ -350,6 +363,49 @@ def test_create_channel_explicit_scoped(grpc_secure_channel, composite_creds_cal grpc_secure_channel.assert_called_once_with(target, composite_creds) +@mock.patch("grpc.composite_channel_credentials") +@mock.patch("grpc.experimental.aio.secure_channel") +@mock.patch( + "google.auth.load_credentials_from_file", + return_value=(mock.sentinel.credentials, mock.sentinel.project) +) +def test_create_channnel_with_credentials_file(load_credentials_from_file, grpc_secure_channel, composite_creds_call): + target = "example.com:443" + + credentials_file = "/path/to/credentials/file.json" + composite_creds = composite_creds_call.return_value + + channel = grpc_helpers_async.create_channel( + target, credentials_file=credentials_file + ) + + google.auth.load_credentials_from_file.assert_called_once_with(credentials_file, scopes=None) + assert channel is grpc_secure_channel.return_value + grpc_secure_channel.assert_called_once_with(target, composite_creds) + + +@mock.patch("grpc.composite_channel_credentials") +@mock.patch("grpc.experimental.aio.secure_channel") +@mock.patch( + "google.auth.load_credentials_from_file", + return_value=(mock.sentinel.credentials, mock.sentinel.project) +) +def test_create_channel_with_credentials_file_and_scopes(load_credentials_from_file, grpc_secure_channel, composite_creds_call): + target = "example.com:443" + scopes = ["1", "2"] + + credentials_file = "/path/to/credentials/file.json" + composite_creds = composite_creds_call.return_value + + channel = grpc_helpers_async.create_channel( + target, credentials_file=credentials_file, scopes=scopes + ) + + google.auth.load_credentials_from_file.assert_called_once_with(credentials_file, scopes=scopes) + assert channel is grpc_secure_channel.return_value + grpc_secure_channel.assert_called_once_with(target, composite_creds) + + @pytest.mark.skipif(grpc_helpers_async.HAS_GRPC_GCP, reason="grpc_gcp module not available") @mock.patch("grpc.experimental.aio.secure_channel") def test_create_channel_without_grpc_gcp(grpc_secure_channel): diff --git a/tests/unit/test_grpc_helpers.py b/tests/unit/test_grpc_helpers.py index ef845143..e2f36662 100644 --- a/tests/unit/test_grpc_helpers.py +++ b/tests/unit/test_grpc_helpers.py @@ -285,6 +285,17 @@ def test_create_channel_implicit_with_scopes( grpc_secure_channel.assert_called_once_with(target, composite_creds) +def test_create_channel_explicit_with_duplicate_credentials(): + target = "example.com:443" + + with pytest.raises(exceptions.DuplicateCredentialArgs): + grpc_helpers.create_channel( + target, + credentials_file="credentials.json", + credentials=mock.sentinel.credentials + ) + + @mock.patch("grpc.composite_channel_credentials") @mock.patch("google.auth.credentials.with_scopes_if_required") @mock.patch("grpc.secure_channel") @@ -324,6 +335,56 @@ def test_create_channel_explicit_scoped(grpc_secure_channel, composite_creds_cal grpc_secure_channel.assert_called_once_with(target, composite_creds) +@mock.patch("grpc.composite_channel_credentials") +@mock.patch("grpc.secure_channel") +@mock.patch( + "google.auth.load_credentials_from_file", + return_value=(mock.sentinel.credentials, mock.sentinel.project) +) +def test_create_channel_with_credentials_file(load_credentials_from_file, grpc_secure_channel, composite_creds_call): + target = "example.com:443" + + credentials_file = "/path/to/credentials/file.json" + composite_creds = composite_creds_call.return_value + + channel = grpc_helpers.create_channel( + target, credentials_file=credentials_file + ) + + google.auth.load_credentials_from_file.assert_called_once_with(credentials_file, scopes=None) + + assert channel is grpc_secure_channel.return_value + if grpc_helpers.HAS_GRPC_GCP: + grpc_secure_channel.assert_called_once_with(target, composite_creds, None) + else: + grpc_secure_channel.assert_called_once_with(target, composite_creds) + + +@mock.patch("grpc.composite_channel_credentials") +@mock.patch("grpc.secure_channel") +@mock.patch( + "google.auth.load_credentials_from_file", + return_value=(mock.sentinel.credentials, mock.sentinel.project) +) +def test_create_channel_with_credentials_file_and_scopes(load_credentials_from_file, grpc_secure_channel, composite_creds_call): + target = "example.com:443" + scopes = ["1", "2"] + + credentials_file = "/path/to/credentials/file.json" + composite_creds = composite_creds_call.return_value + + channel = grpc_helpers.create_channel( + target, credentials_file=credentials_file, scopes=scopes + ) + + google.auth.load_credentials_from_file.assert_called_once_with(credentials_file, scopes=scopes) + assert channel is grpc_secure_channel.return_value + if grpc_helpers.HAS_GRPC_GCP: + grpc_secure_channel.assert_called_once_with(target, composite_creds, None) + else: + grpc_secure_channel.assert_called_once_with(target, composite_creds) + + @pytest.mark.skipif( not grpc_helpers.HAS_GRPC_GCP, reason="grpc_gcp module not available" )