feat: allow credentials files to be passed for channel creation (#50)

Co-authored-by: Dov Shlachter <dovs@google.com>
diff --git a/.gitignore b/.gitignore
index b87e1ed..8c18b5e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -57,4 +57,7 @@
 
 # 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 d1459ab..b9c46ca 100644
--- a/google/api_core/exceptions.py
+++ b/google/api_core/exceptions.py
@@ -41,6 +41,12 @@
     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 b617ddf..2203968 100644
--- a/google/api_core/grpc_helpers.py
+++ b/google/api_core/grpc_helpers.py
@@ -176,13 +176,16 @@
         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 @@
 
     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_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 @@
             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 9ded803..1dfe8b9 100644
--- a/google/api_core/grpc_helpers_async.py
+++ b/google/api_core/grpc_helpers_async.py
@@ -206,7 +206,7 @@
         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 @@
             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 9fb7977..7f65fd0 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 0053952..d56c4c6 100644
--- a/tests/asyncio/test_grpc_helpers_async.py
+++ b/tests/asyncio/test_grpc_helpers_async.py
@@ -317,6 +317,19 @@
     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 @@
     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 ef84514..e2f3666 100644
--- a/tests/unit/test_grpc_helpers.py
+++ b/tests/unit/test_grpc_helpers.py
@@ -285,6 +285,17 @@
         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 @@
         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"
 )