diff --git a/googleapiclient/_auth.py b/googleapiclient/_auth.py index 8a2f673ddcc..d045fc147d1 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 13bdf967818..eec7e003446 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 @@ 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, @@ -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: @@ -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) @@ -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) @@ -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. @@ -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 ) diff --git a/noxfile.py b/noxfile.py index d5bc2b30743..1f95edfdca9 100644 --- a/noxfile.py +++ b/noxfile.py @@ -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", [ diff --git a/setup.py b/setup.py index 0a45061ee7a..254214338f7 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 b65ed81d46a..9c4ea65c8dd 100644 --- a/tests/test__auth.py +++ b/tests/test__auth.py @@ -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) @@ -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) diff --git a/tests/test_discovery.py b/tests/test_discovery.py index 7e44a3ea3b1..1abb5c82f08 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 @@ 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() @@ -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) @@ -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) @@ -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/" @@ -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) @@ -1255,7 +1305,7 @@ def test_batch_request_from_discovery(self): 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 88b9d5925f1..2d74a7e4368 100644 --- a/tests/test_http.py +++ b/tests/test_http.py @@ -1651,7 +1651,7 @@ def test_build_http_default_timeout_can_be_set_to_zero(self): 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)