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)