Skip to content

Commit

Permalink
feat: add quota_project, credentials_file, and scopes support (#1022)
Browse files Browse the repository at this point in the history
Add support for client options:
* quota_project_id
* credentials_file
* scopes

These are only available when default credentials are used.
  • Loading branch information
busunkim96 committed Sep 12, 2020
1 parent 5028fe7 commit 790e702
Show file tree
Hide file tree
Showing 7 changed files with 189 additions and 51 deletions.
19 changes: 17 additions & 2 deletions googleapiclient/_auth.py
Expand Up @@ -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(
Expand Down
68 changes: 51 additions & 17 deletions googleapiclient/discovery.py
Expand Up @@ -30,6 +30,7 @@
# Standard library imports
import copy
from collections import OrderedDict

try:
from email.generator import BytesGenerator
except ImportError:
Expand Down Expand Up @@ -260,14 +261,17 @@ def build(
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,
Expand Down Expand Up @@ -308,13 +312,15 @@ def _discovery_service_uri_options(discoveryServiceUrl, version):
# 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:
Expand Down Expand Up @@ -444,8 +450,20 @@ def build_from_document(
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)
Expand All @@ -463,11 +481,8 @@ def build_from_document(

# 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)

Expand All @@ -483,13 +498,30 @@ def build_from_document(
# 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.
Expand Down Expand Up @@ -519,7 +551,9 @@ def build_from_document(
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
)
Expand Down
2 changes: 1 addition & 1 deletion noxfile.py
Expand Up @@ -44,7 +44,7 @@ def lint(session):
)


@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",
[
Expand Down
2 changes: 1 addition & 1 deletion setup.py
Expand Up @@ -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",
]
Expand Down
39 changes: 39 additions & 0 deletions tests/test__auth.py
Expand Up @@ -40,6 +40,35 @@ def test_default_credentials(self):

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)

Expand Down Expand Up @@ -95,6 +124,16 @@ def test_default_credentials(self):

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)

Expand Down

0 comments on commit 790e702

Please sign in to comment.