Skip to content

Commit

Permalink
feat: add quota_project, credentials_file, and scopes support
Browse files Browse the repository at this point in the history
  • Loading branch information
busunkim96 committed Sep 4, 2020
1 parent 5028fe7 commit 48b84fe
Show file tree
Hide file tree
Showing 7 changed files with 188 additions and 50 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
106 changes: 78 additions & 28 deletions tests/test_discovery.py
Expand Up @@ -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
Expand Down Expand Up @@ -118,23 +120,21 @@ def assert_discovery_uri(testcase, actual, service_name, version, discovery):
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()

Expand Down Expand Up @@ -468,6 +468,29 @@ def test_credentials_and_http_mutually_exclusive(self):
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)
Expand Down Expand Up @@ -566,10 +589,8 @@ def test_api_endpoint_override_from_client_options_mapping_object(self):
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)

Expand All @@ -584,6 +605,44 @@ def test_api_endpoint_override_from_client_options_dict(self):

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/"
Expand Down Expand Up @@ -912,33 +971,24 @@ def test_api_endpoint_override_from_client_options_dict(self):
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)

Expand Down

0 comments on commit 48b84fe

Please sign in to comment.