feat: add quota_project, credentials_file, and scopes support (#1022)

Add support for client options:
* quota_project_id
* credentials_file
* scopes

These are only available when default credentials are used.
diff --git a/googleapiclient/_auth.py b/googleapiclient/_auth.py
index 8a2f673..d045fc1 100644
--- a/googleapiclient/_auth.py
+++ b/googleapiclient/_auth.py
@@ -38,12 +38,27 @@
     HAS_OAUTH2CLIENT = False
 
 
-def default_credentials():
+def credentials_from_file(filename, scopes=None, quota_project_id=None):
+    """Returns credentials loaded from a file."""
+    if HAS_GOOGLE_AUTH:
+        credentials, _ = google.auth.load_credentials_from_file(filename, scopes=scopes, quota_project_id=quota_project_id)
+        return credentials
+    else:
+        raise EnvironmentError(
+        "client_options.credentials_file is only supported in google-auth.")
+
+
+def default_credentials(scopes=None, quota_project_id=None):
     """Returns Application Default Credentials."""
     if HAS_GOOGLE_AUTH:
-        credentials, _ = google.auth.default()
+        credentials, _ = google.auth.default(scopes=scopes, quota_project_id=quota_project_id)
         return credentials
     elif HAS_OAUTH2CLIENT:
+        if scopes is not None or quota_project_id is not None:
+            raise EnvironmentError(
+                "client_options.scopes and client_options.quota_project_id are not supported in oauth2client."
+                "Please install google-auth."
+            )
         return oauth2client.client.GoogleCredentials.get_application_default()
     else:
         raise EnvironmentError(
diff --git a/googleapiclient/discovery.py b/googleapiclient/discovery.py
index 13bdf96..eec7e00 100644
--- a/googleapiclient/discovery.py
+++ b/googleapiclient/discovery.py
@@ -30,6 +30,7 @@
 # Standard library imports
 import copy
 from collections import OrderedDict
+
 try:
     from email.generator import BytesGenerator
 except ImportError:
@@ -260,14 +261,17 @@
     else:
         discovery_http = http
 
-    for discovery_url in \
-            _discovery_service_uri_options(discoveryServiceUrl, version):
+    for discovery_url in _discovery_service_uri_options(discoveryServiceUrl, version):
         requested_url = uritemplate.expand(discovery_url, params)
 
         try:
             content = _retrieve_discovery_doc(
-                requested_url, discovery_http, cache_discovery, cache,
-                developerKey, num_retries=num_retries
+                requested_url,
+                discovery_http,
+                cache_discovery,
+                cache,
+                developerKey,
+                num_retries=num_retries,
             )
             return build_from_document(
                 content,
@@ -308,13 +312,15 @@
     # V1 Discovery won't work if the requested version is None
     if discoveryServiceUrl == V1_DISCOVERY_URI and version is None:
         logger.warning(
-            "Discovery V1 does not support empty versions. Defaulting to V2...")
+            "Discovery V1 does not support empty versions. Defaulting to V2..."
+        )
         urls.pop(0)
     return list(OrderedDict.fromkeys(urls))
 
 
-def _retrieve_discovery_doc(url, http, cache_discovery,
-    cache=None, developerKey=None, num_retries=1):
+def _retrieve_discovery_doc(
+    url, http, cache_discovery, cache=None, developerKey=None, num_retries=1
+):
     """Retrieves the discovery_doc from cache or the internet.
 
   Args:
@@ -444,8 +450,20 @@
       setting up mutual TLS channel.
   """
 
-    if http is not None and credentials is not None:
-        raise ValueError("Arguments http and credentials are mutually exclusive.")
+    if client_options is None:
+        client_options = google.api_core.client_options.ClientOptions()
+    if isinstance(client_options, six.moves.collections_abc.Mapping):
+        client_options = google.api_core.client_options.from_dict(client_options)
+
+    if http is not None:
+        # if http is passed, the user cannot provide credentials
+        banned_options = [
+            (credentials, "credentials"),
+            (client_options.credentials_file, "client_options.credentials_file"),
+        ]
+        for option, name in banned_options:
+            if option is not None:
+                raise ValueError("Arguments http and {} are mutually exclusive".format(name))
 
     if isinstance(service, six.string_types):
         service = json.loads(service)
@@ -463,11 +481,8 @@
 
     # If an API Endpoint is provided on client options, use that as the base URL
     base = urljoin(service["rootUrl"], service["servicePath"])
-    if client_options:
-        if isinstance(client_options, six.moves.collections_abc.Mapping):
-            client_options = google.api_core.client_options.from_dict(client_options)
-        if client_options.api_endpoint:
-            base = client_options.api_endpoint
+    if client_options.api_endpoint:
+        base = client_options.api_endpoint
 
     schema = Schemas(service)
 
@@ -483,13 +498,30 @@
         # If so, then the we need to setup authentication if no developerKey is
         # specified.
         if scopes and not developerKey:
+            # Make sure the user didn't pass multiple credentials
+            if client_options.credentials_file and credentials:
+                raise google.api_core.exceptions.DuplicateCredentialArgs(
+                    "client_options.credentials_file and credentials are mutually exclusive."
+            )
+            # Check for credentials file via client options
+            if client_options.credentials_file:
+                credentials = _auth.credentials_from_file(
+                    client_options.credentials_file,
+                    scopes=client_options.scopes,
+                    quota_project_id=client_options.quota_project_id,
+                )
             # If the user didn't pass in credentials, attempt to acquire application
             # default credentials.
             if credentials is None:
-                credentials = _auth.default_credentials()
+                credentials = _auth.default_credentials(
+                    scopes=client_options.scopes,
+                    quota_project_id=client_options.quota_project_id,
+                )
 
             # The credentials need to be scoped.
-            credentials = _auth.with_scopes(credentials, scopes)
+            # If the user provided scopes via client_options don't override them
+            if not client_options.scopes:
+                credentials = _auth.with_scopes(credentials, scopes)
 
         # If credentials are provided, create an authorized http instance;
         # otherwise, skip authentication.
@@ -519,7 +551,9 @@
                 and client_options.client_encrypted_cert_source
             ):
                 client_cert_to_use = client_options.client_encrypted_cert_source
-            elif adc_cert_path and adc_key_path and mtls.has_default_client_cert_source():
+            elif (
+                adc_cert_path and adc_key_path and mtls.has_default_client_cert_source()
+            ):
                 client_cert_to_use = mtls.default_client_encrypted_cert_source(
                     adc_cert_path, adc_key_path
                 )
diff --git a/noxfile.py b/noxfile.py
index d5bc2b3..1f95edf 100644
--- a/noxfile.py
+++ b/noxfile.py
@@ -44,7 +44,7 @@
     )
 
 
-@nox.session(python=["2.7", "3.5", "3.6", "3.7"])
+@nox.session(python=["2.7", "3.5", "3.6", "3.7", "3.8"])
 @nox.parametrize(
     "oauth2client",
     [
diff --git a/setup.py b/setup.py
index 0a45061..2542143 100644
--- a/setup.py
+++ b/setup.py
@@ -41,7 +41,7 @@
     "httplib2>=0.9.2,<1dev",
     "google-auth>=1.16.0",
     "google-auth-httplib2>=0.0.3",
-    "google-api-core>=1.18.0,<2dev",
+    "google-api-core>=1.21.0,<2dev",
     "six>=1.6.1,<2dev",
     "uritemplate>=3.0.0,<4dev",
 ]
diff --git a/tests/test__auth.py b/tests/test__auth.py
index b65ed81..9c4ea65 100644
--- a/tests/test__auth.py
+++ b/tests/test__auth.py
@@ -40,6 +40,35 @@
 
             self.assertEqual(credentials, mock.sentinel.credentials)
 
+    def test_credentials_from_file(self):
+        with mock.patch(
+            "google.auth.load_credentials_from_file", autospec=True
+        ) as default:
+            default.return_value = (mock.sentinel.credentials, mock.sentinel.project)
+
+            credentials = _auth.credentials_from_file("credentials.json")
+
+            self.assertEqual(credentials, mock.sentinel.credentials)
+            default.assert_called_once_with(
+                "credentials.json", scopes=None, quota_project_id=None
+            )
+
+    def test_default_credentials_with_scopes(self):
+        with mock.patch("google.auth.default", autospec=True) as default:
+            default.return_value = (mock.sentinel.credentials, mock.sentinel.project)
+            credentials = _auth.default_credentials(scopes=["1", "2"])
+
+            default.assert_called_once_with(scopes=["1", "2"], quota_project_id=None)
+            self.assertEqual(credentials, mock.sentinel.credentials)
+
+    def test_default_credentials_with_quota_project(self):
+        with mock.patch("google.auth.default", autospec=True) as default:
+            default.return_value = (mock.sentinel.credentials, mock.sentinel.project)
+            credentials = _auth.default_credentials(quota_project_id="my-project")
+
+            default.assert_called_once_with(scopes=None, quota_project_id="my-project")
+            self.assertEqual(credentials, mock.sentinel.credentials)
+
     def test_with_scopes_non_scoped(self):
         credentials = mock.Mock(spec=google.auth.credentials.Credentials)
 
@@ -95,6 +124,16 @@
 
             self.assertEqual(credentials, mock.sentinel.credentials)
 
+    def test_credentials_from_file(self):
+        with self.assertRaises(EnvironmentError):
+            credentials = _auth.credentials_from_file("credentials.json")
+
+    def test_default_credentials_with_scopes_and_quota_project(self):
+        with self.assertRaises(EnvironmentError):
+            credentials = _auth.default_credentials(
+                scopes=["1", "2"], quota_project_id="my-project"
+            )
+
     def test_with_scopes_non_scoped(self):
         credentials = mock.Mock(spec=oauth2client.client.Credentials)
 
diff --git a/tests/test_discovery.py b/tests/test_discovery.py
index 7e44a3e..1abb5c8 100644
--- a/tests/test_discovery.py
+++ b/tests/test_discovery.py
@@ -47,6 +47,8 @@
 from google.auth.transport import mtls
 from google.auth.exceptions import MutualTLSChannelError
 import google_auth_httplib2
+import google.api_core.exceptions
+
 from googleapiclient.discovery import _fix_up_media_upload
 from googleapiclient.discovery import _fix_up_method_description
 from googleapiclient.discovery import _fix_up_parameters
@@ -118,23 +120,21 @@
     assertUrisEqual(testcase, expanded_requested_uri, actual)
 
 
-def validate_discovery_requests(testcase, http_mock, service_name,
-                                version, discovery):
+def validate_discovery_requests(testcase, http_mock, service_name, version, discovery):
     """Validates that there have > 0 calls to Http Discovery
      and that LAST discovery URI used was the one that was expected
     for a given service and version."""
     testcase.assertTrue(len(http_mock.request_sequence) > 0)
     if len(http_mock.request_sequence) > 0:
         actual_uri = http_mock.request_sequence[-1][0]
-        assert_discovery_uri(testcase,
-                             actual_uri, service_name, version, discovery)
+        assert_discovery_uri(testcase, actual_uri, service_name, version, discovery)
 
 
 def datafile(filename):
     return os.path.join(DATA_DIR, filename)
 
 
-def read_datafile(filename, mode='r'):
+def read_datafile(filename, mode="r"):
     with open(datafile(filename), mode=mode) as f:
         return f.read()
 
@@ -468,6 +468,29 @@
         with self.assertRaises(ValueError):
             build("plus", "v1", http=http, credentials=mock.sentinel.credentials)
 
+    def test_credentials_file_and_http_mutually_exclusive(self):
+        http = HttpMock(datafile("plus.json"), {"status": "200"})
+        with self.assertRaises(ValueError):
+            build(
+                "plus",
+                "v1",
+                http=http,
+                client_options=google.api_core.client_options.ClientOptions(
+                    credentials_file="credentials.json"
+                ),
+            )
+
+    def test_credentials_and_credentials_file_mutually_exclusive(self):
+        with self.assertRaises(google.api_core.exceptions.DuplicateCredentialArgs):
+            build(
+                "plus",
+                "v1",
+                credentials=mock.sentinel.credentials,
+                client_options=google.api_core.client_options.ClientOptions(
+                    credentials_file="credentials.json"
+                ),
+            )
+
 
 class DiscoveryFromDocument(unittest.TestCase):
     MOCK_CREDENTIALS = mock.Mock(spec=google.auth.credentials.Credentials)
@@ -566,10 +589,8 @@
         discovery = read_datafile("plus.json")
         api_endpoint = "https://foo.googleapis.com/"
         mapping_object = defaultdict(str)
-        mapping_object['api_endpoint'] = api_endpoint
-        plus = build_from_document(
-            discovery, client_options=mapping_object
-        )
+        mapping_object["api_endpoint"] = api_endpoint
+        plus = build_from_document(discovery, client_options=mapping_object)
 
         self.assertEqual(plus._baseUrl, api_endpoint)
 
@@ -584,6 +605,44 @@
 
         self.assertEqual(plus._baseUrl, api_endpoint)
 
+    def test_scopes_from_client_options(self):
+        discovery = read_datafile("plus.json")
+
+        with mock.patch("googleapiclient._auth.default_credentials") as default:
+            plus = build_from_document(
+                discovery, client_options={"scopes": ["1", "2"]},
+            )
+
+        default.assert_called_once_with(scopes=["1", "2"], quota_project_id=None)
+
+    def test_quota_project_from_client_options(self):
+        discovery = read_datafile("plus.json")
+
+        with mock.patch("googleapiclient._auth.default_credentials") as default:
+            plus = build_from_document(
+                discovery,
+                client_options=google.api_core.client_options.ClientOptions(
+                    quota_project_id="my-project"
+                ),
+            )
+
+        default.assert_called_once_with(scopes=None, quota_project_id="my-project")
+
+    def test_credentials_file_from_client_options(self):
+        discovery = read_datafile("plus.json")
+
+        with mock.patch("googleapiclient._auth.credentials_from_file") as default:
+            plus = build_from_document(
+                discovery,
+                client_options=google.api_core.client_options.ClientOptions(
+                    credentials_file="credentials.json"
+                ),
+            )
+
+        default.assert_called_once_with(
+            "credentials.json", scopes=None, quota_project_id=None
+        )
+
 
 REGULAR_ENDPOINT = "https://www.googleapis.com/plus/v1/"
 MTLS_ENDPOINT = "https://www.mtls.googleapis.com/plus/v1/"
@@ -912,33 +971,24 @@
         self.assertEqual(zoo._baseUrl, api_endpoint)
 
     def test_discovery_with_empty_version_uses_v2(self):
-        http = HttpMockSequence(
-            [
-                ({"status": "200"}, read_datafile("zoo.json", "rb")),
-            ]
-        )
+        http = HttpMockSequence([({"status": "200"}, read_datafile("zoo.json", "rb")),])
         build("zoo", version=None, http=http, cache_discovery=False)
         validate_discovery_requests(self, http, "zoo", None, V2_DISCOVERY_URI)
 
     def test_discovery_with_empty_version_preserves_custom_uri(self):
-        http = HttpMockSequence(
-            [
-                ({"status": "200"}, read_datafile("zoo.json", "rb")),
-            ]
-        )
+        http = HttpMockSequence([({"status": "200"}, read_datafile("zoo.json", "rb")),])
         custom_discovery_uri = "https://foo.bar/$discovery"
         build(
-            "zoo", version=None, http=http,
-            cache_discovery=False, discoveryServiceUrl=custom_discovery_uri)
-        validate_discovery_requests(
-            self, http, "zoo", None, custom_discovery_uri)
+            "zoo",
+            version=None,
+            http=http,
+            cache_discovery=False,
+            discoveryServiceUrl=custom_discovery_uri,
+        )
+        validate_discovery_requests(self, http, "zoo", None, custom_discovery_uri)
 
     def test_discovery_with_valid_version_uses_v1(self):
-        http = HttpMockSequence(
-            [
-                ({"status": "200"}, read_datafile("zoo.json", "rb")),
-            ]
-        )
+        http = HttpMockSequence([({"status": "200"}, read_datafile("zoo.json", "rb")),])
         build("zoo", version="v123", http=http, cache_discovery=False)
         validate_discovery_requests(self, http, "zoo", "v123", V1_DISCOVERY_URI)
 
@@ -1255,7 +1305,7 @@
     def test_batch_request_from_default(self):
         self.http = HttpMock(datafile("plus.json"), {"status": "200"})
         # plus does not define a batchPath
-        plus = build("plus", "v1", http=self.http)
+        plus = build("plus", "v1", http=self.http, cache_discovery=False)
         batch_request = plus.new_batch_http_request()
         self.assertEqual(batch_request._batch_uri, "https://www.googleapis.com/batch")
 
diff --git a/tests/test_http.py b/tests/test_http.py
index 88b9d59..2d74a7e 100644
--- a/tests/test_http.py
+++ b/tests/test_http.py
@@ -1651,7 +1651,7 @@
         socket.setdefaulttimeout(0)
         http = build_http()
         self.assertEqual(http.timeout, 0)
-    
+
     def test_build_http_default_308_is_excluded_as_redirect(self):
         http = build_http()
         self.assertTrue(308 not in http.redirect_codes)