Add support for google-auth and remove Python 2.6 support (#319)
* `discovery.build` and `discovery.build_from_document` now accept both oauth2client credentials and google-auth credentials.
* `discovery.build` and `discovery.build_from_document` now unambiguously use the http argument for *all* requests, including the request to get the discovery document.
* The `http` and `credentials` arguments to `discovery.build` and `discovery.build_from_document` are now mutally exclusive.
* If neither `http` or `credentials` is specified to `discovery.build` and `discovery.build_from_document`, then Application Default Credentials will be used.
* oauth2client is still the "default" authentication library.
diff --git a/.travis.yml b/.travis.yml
index 43edc6a..b72c5e9 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -4,7 +4,6 @@
cache: pip
env:
matrix:
- - TOX_ENV=py26
- TOX_ENV=py27
- TOX_ENV=py33
- TOX_ENV=py34
diff --git a/googleapiclient/_auth.py b/googleapiclient/_auth.py
new file mode 100644
index 0000000..89a8a02
--- /dev/null
+++ b/googleapiclient/_auth.py
@@ -0,0 +1,90 @@
+# Copyright 2016 Google Inc. All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Helpers for authentication using oauth2client or google-auth."""
+
+import httplib2
+
+try:
+ import google.auth
+ import google_auth_httplib2
+ HAS_GOOGLE_AUTH = True
+except ImportError: # pragma: NO COVER
+ HAS_GOOGLE_AUTH = False
+
+try:
+ import oauth2client
+ import oauth2client.client
+ HAS_OAUTH2CLIENT = True
+except ImportError: # pragma: NO COVER
+ HAS_OAUTH2CLIENT = False
+
+
+def default_credentials():
+ """Returns Application Default Credentials."""
+ if HAS_GOOGLE_AUTH:
+ credentials, _ = google.auth.default()
+ return credentials
+ elif HAS_OAUTH2CLIENT:
+ return oauth2client.client.GoogleCredentials.get_application_default()
+ else:
+ raise EnvironmentError(
+ 'No authentication library is available. Please install either '
+ 'google-auth or oauth2client.')
+
+
+def with_scopes(credentials, scopes):
+ """Scopes the credentials if necessary.
+
+ Args:
+ credentials (Union[
+ google.auth.credentials.Credentials,
+ oauth2client.client.Credentials]): The credentials to scope.
+ scopes (Sequence[str]): The list of scopes.
+
+ Returns:
+ Union[google.auth.credentials.Credentials,
+ oauth2client.client.Credentials]: The scoped credentials.
+ """
+ if HAS_GOOGLE_AUTH and isinstance(
+ credentials, google.auth.credentials.Credentials):
+ return google.auth.credentials.with_scopes_if_required(
+ credentials, scopes)
+ else:
+ try:
+ if credentials.create_scoped_required():
+ return credentials.create_scoped(scopes)
+ else:
+ return credentials
+ except AttributeError:
+ return credentials
+
+
+def authorized_http(credentials):
+ """Returns an http client that is authorized with the given credentials.
+
+ Args:
+ credentials (Union[
+ google.auth.credentials.Credentials,
+ oauth2client.client.Credentials]): The credentials to use.
+
+ Returns:
+ Union[httplib2.Http, google_auth_httplib2.AuthorizedHttp]: An
+ authorized http client.
+ """
+ if HAS_GOOGLE_AUTH and isinstance(
+ credentials, google.auth.credentials.Credentials):
+ return google_auth_httplib2.AuthorizedHttp(credentials)
+ else:
+ return credentials.authorize(httplib2.Http())
diff --git a/googleapiclient/discovery.py b/googleapiclient/discovery.py
index 4e7b736..74f0a09 100644
--- a/googleapiclient/discovery.py
+++ b/googleapiclient/discovery.py
@@ -53,6 +53,7 @@
import uritemplate
# Local imports
+from googleapiclient import _auth
from googleapiclient import mimeparse
from googleapiclient.errors import HttpError
from googleapiclient.errors import InvalidJsonError
@@ -197,7 +198,8 @@
model: googleapiclient.Model, converts to and from the wire format.
requestBuilder: googleapiclient.http.HttpRequest, encapsulator for an HTTP
request.
- credentials: oauth2client.Credentials, credentials to be used for
+ credentials: oauth2client.Credentials or
+ google.auth.credentials.Credentials, credentials to be used for
authentication.
cache_discovery: Boolean, whether or not to cache the discovery doc.
cache: googleapiclient.discovery_cache.base.CacheBase, an optional
@@ -211,15 +213,14 @@
'apiVersion': version
}
- if http is None:
- http = httplib2.Http()
+ discovery_http = http if http is not None else httplib2.Http()
for discovery_url in (discoveryServiceUrl, V2_DISCOVERY_URI,):
requested_url = uritemplate.expand(discovery_url, params)
try:
- content = _retrieve_discovery_doc(requested_url, http, cache_discovery,
- cache)
+ content = _retrieve_discovery_doc(
+ requested_url, discovery_http, cache_discovery, cache)
return build_from_document(content, base=discovery_url, http=http,
developerKey=developerKey, model=model, requestBuilder=requestBuilder,
credentials=credentials)
@@ -316,17 +317,16 @@
model: Model class instance that serializes and de-serializes requests and
responses.
requestBuilder: Takes an http request and packages it up to be executed.
- credentials: object, credentials to be used for authentication.
+ credentials: oauth2client.Credentials or
+ google.auth.credentials.Credentials, credentials to be used for
+ authentication.
Returns:
A Resource object with methods for interacting with the service.
"""
- if http is None:
- http = httplib2.Http()
-
- # future is no longer used.
- future = {}
+ if http is not None and credentials is not None:
+ raise ValueError('Arguments http and credentials are mutually exclusive.')
if isinstance(service, six.string_types):
service = json.loads(service)
@@ -342,31 +342,36 @@
base = urljoin(service['rootUrl'], service['servicePath'])
schema = Schemas(service)
- if credentials:
- # If credentials were passed in, we could have two cases:
- # 1. the scopes were specified, in which case the given credentials
- # are used for authorizing the http;
- # 2. the scopes were not provided (meaning the Application Default
- # Credentials are to be used). In this case, the Application Default
- # Credentials are built and used instead of the original credentials.
- # If there are no scopes found (meaning the given service requires no
- # authentication), there is no authorization of the http.
- if (isinstance(credentials, GoogleCredentials) and
- credentials.create_scoped_required()):
- scopes = service.get('auth', {}).get('oauth2', {}).get('scopes', {})
- if scopes:
- credentials = credentials.create_scoped(list(scopes.keys()))
- else:
- # No need to authorize the http object
- # if the service does not require authentication.
- credentials = None
+ # If the http client is not specified, then we must construct an http client
+ # to make requests. If the service has scopes, then we also need to setup
+ # authentication.
+ if http is None:
+ # Does the service require scopes?
+ scopes = list(
+ service.get('auth', {}).get('oauth2', {}).get('scopes', {}).keys())
- if credentials:
- http = credentials.authorize(http)
+ # If so, then the we need to setup authentication.
+ if scopes:
+ # If the user didn't pass in credentials, attempt to acquire application
+ # default credentials.
+ if credentials is None:
+ credentials = _auth.default_credentials()
+
+ # The credentials need to be scoped.
+ credentials = _auth.with_scopes(credentials, scopes)
+
+ # Create an authorized http instance
+ http = _auth.authorized_http(credentials)
+
+ # If the service doesn't require scopes then there is no need for
+ # authentication.
+ else:
+ http = httplib2.Http()
if model is None:
features = service.get('features', [])
model = JsonModel('dataWrapper' in features)
+
return Resource(http=http, baseUrl=base, model=model,
developerKey=developerKey, requestBuilder=requestBuilder,
resourceDesc=service, rootDesc=service, schema=schema)
diff --git a/setup.py b/setup.py
index b6a5db2..2a63f08 100644
--- a/setup.py
+++ b/setup.py
@@ -21,8 +21,8 @@
import sys
-if sys.version_info < (2, 6):
- print('google-api-python-client requires python version >= 2.6.',
+if sys.version_info < (2, 7):
+ print('google-api-python-client requires python version >= 2.7.',
file=sys.stderr)
sys.exit(1)
if (3, 1) <= sys.version_info < (3, 3):
@@ -69,9 +69,6 @@
'uritemplate>=3.0.0,<4dev',
]
-if sys.version_info < (2, 7):
- install_requires.append('argparse')
-
long_desc = """The Google API Client for Python is a client library for
accessing the Plus, Moderator, and many other Google APIs."""
@@ -92,7 +89,6 @@
keywords="google api client",
classifiers=[
'Programming Language :: Python :: 2',
- 'Programming Language :: Python :: 2.6',
'Programming Language :: Python :: 2.7',
'Programming Language :: Python :: 3',
'Programming Language :: Python :: 3.3',
diff --git a/tests/test__auth.py b/tests/test__auth.py
new file mode 100644
index 0000000..6711ffe
--- /dev/null
+++ b/tests/test__auth.py
@@ -0,0 +1,134 @@
+# Copyright 2016 Google Inc. All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import mock
+
+import google.auth.credentials
+import google_auth_httplib2
+import httplib2
+import oauth2client.client
+import unittest2
+
+from googleapiclient import _auth
+
+
+class TestAuthWithGoogleAuth(unittest2.TestCase):
+ def setUp(self):
+ _auth.HAS_GOOGLE_AUTH = True
+ _auth.HAS_OAUTH2CLIENT = False
+
+ def tearDown(self):
+ _auth.HAS_GOOGLE_AUTH = True
+ _auth.HAS_OAUTH2CLIENT = True
+
+ def test_default_credentials(self):
+ with mock.patch('google.auth.default', autospec=True) as default:
+ default.return_value = (
+ mock.sentinel.credentials, mock.sentinel.project)
+
+ credentials = _auth.default_credentials()
+
+ self.assertEqual(credentials, mock.sentinel.credentials)
+
+ def test_with_scopes_non_scoped(self):
+ credentials = mock.Mock(spec=google.auth.credentials.Credentials)
+
+ returned = _auth.with_scopes(credentials, mock.sentinel.scopes)
+
+ self.assertEqual(credentials, returned)
+
+ def test_with_scopes_scoped(self):
+ class CredentialsWithScopes(
+ google.auth.credentials.Credentials,
+ google.auth.credentials.Scoped):
+ pass
+
+ credentials = mock.Mock(spec=CredentialsWithScopes)
+ credentials.requires_scopes = True
+
+ returned = _auth.with_scopes(credentials, mock.sentinel.scopes)
+
+ self.assertNotEqual(credentials, returned)
+ self.assertEqual(returned, credentials.with_scopes.return_value)
+ credentials.with_scopes.assert_called_once_with(mock.sentinel.scopes)
+
+ def test_authorized_http(self):
+ credentials = mock.Mock(spec=google.auth.credentials.Credentials)
+
+ http = _auth.authorized_http(credentials)
+
+ self.assertIsInstance(http, google_auth_httplib2.AuthorizedHttp)
+ self.assertEqual(http.credentials, credentials)
+
+
+class TestAuthWithOAuth2Client(unittest2.TestCase):
+ def setUp(self):
+ _auth.HAS_GOOGLE_AUTH = False
+ _auth.HAS_OAUTH2CLIENT = True
+
+ def tearDown(self):
+ _auth.HAS_GOOGLE_AUTH = True
+ _auth.HAS_OAUTH2CLIENT = True
+
+ def test_default_credentials(self):
+ default_patch = mock.patch(
+ 'oauth2client.client.GoogleCredentials.get_application_default')
+
+ with default_patch as default:
+ default.return_value = mock.sentinel.credentials
+
+ credentials = _auth.default_credentials()
+
+ self.assertEqual(credentials, mock.sentinel.credentials)
+
+ def test_with_scopes_non_scoped(self):
+ credentials = mock.Mock(spec=oauth2client.client.Credentials)
+
+ returned = _auth.with_scopes(credentials, mock.sentinel.scopes)
+
+ self.assertEqual(credentials, returned)
+
+ def test_with_scopes_scoped(self):
+ credentials = mock.Mock(spec=oauth2client.client.GoogleCredentials)
+ credentials.create_scoped_required.return_value = True
+
+ returned = _auth.with_scopes(credentials, mock.sentinel.scopes)
+
+ self.assertNotEqual(credentials, returned)
+ self.assertEqual(returned, credentials.create_scoped.return_value)
+ credentials.create_scoped.assert_called_once_with(mock.sentinel.scopes)
+
+ def test_authorized_http(self):
+ credentials = mock.Mock(spec=oauth2client.client.Credentials)
+
+ http = _auth.authorized_http(credentials)
+
+ self.assertEqual(http, credentials.authorize.return_value)
+ self.assertIsInstance(
+ credentials.authorize.call_args[0][0], httplib2.Http)
+
+
+class TestAuthWithoutAuth(unittest2.TestCase):
+
+ def setUp(self):
+ _auth.HAS_GOOGLE_AUTH = False
+ _auth.HAS_OAUTH2CLIENT = False
+
+ def tearDown(self):
+ _auth.HAS_GOOGLE_AUTH = True
+ _auth.HAS_OAUTH2CLIENT = True
+
+ def test_default_credentials(self):
+ with self.assertRaises(EnvironmentError):
+ print(_auth.default_credentials())
diff --git a/tests/test_discovery.py b/tests/test_discovery.py
index 7b34606..3e26db9 100644
--- a/tests/test_discovery.py
+++ b/tests/test_discovery.py
@@ -41,6 +41,8 @@
import mock
+import google.auth.credentials
+import google_auth_httplib2
from googleapiclient.discovery import _fix_up_media_upload
from googleapiclient.discovery import _fix_up_method_description
from googleapiclient.discovery import _fix_up_parameters
@@ -72,7 +74,7 @@
from googleapiclient.http import MediaUploadProgress
from googleapiclient.http import tunnel_patch
from oauth2client import GOOGLE_TOKEN_URI
-from oauth2client.client import OAuth2Credentials
+from oauth2client.client import OAuth2Credentials, GoogleCredentials
try:
from oauth2client import util
@@ -365,19 +367,30 @@
with self.assertRaises(UnknownApiNameOrVersion):
plus = build('plus', 'v1', http=http, cache_discovery=False)
+ def test_credentials_and_http_mutually_exclusive(self):
+ http = HttpMock(datafile('plus.json'), {'status': '200'})
+ with self.assertRaises(ValueError):
+ build(
+ 'plus', 'v1', http=http, credentials=mock.sentinel.credentials)
+
class DiscoveryFromDocument(unittest.TestCase):
+ MOCK_CREDENTIALS = mock.Mock(spec=google.auth.credentials.Credentials)
def test_can_build_from_local_document(self):
discovery = open(datafile('plus.json')).read()
- plus = build_from_document(discovery, base="https://www.googleapis.com/")
+ plus = build_from_document(
+ discovery, base="https://www.googleapis.com/",
+ credentials=self.MOCK_CREDENTIALS)
self.assertTrue(plus is not None)
self.assertTrue(hasattr(plus, 'activities'))
def test_can_build_from_local_deserialized_document(self):
discovery = open(datafile('plus.json')).read()
discovery = json.loads(discovery)
- plus = build_from_document(discovery, base="https://www.googleapis.com/")
+ plus = build_from_document(
+ discovery, base="https://www.googleapis.com/",
+ credentials=self.MOCK_CREDENTIALS)
self.assertTrue(plus is not None)
self.assertTrue(hasattr(plus, 'activities'))
@@ -385,13 +398,17 @@
discovery = open(datafile('plus.json')).read()
base = "https://www.example.com/"
- plus = build_from_document(discovery, base=base)
+ plus = build_from_document(
+ discovery, base=base, credentials=self.MOCK_CREDENTIALS)
self.assertEquals("https://www.googleapis.com/plus/v1/", plus._baseUrl)
def test_building_with_optional_http(self):
discovery = open(datafile('plus.json')).read()
- plus = build_from_document(discovery, base="https://www.googleapis.com/")
- self.assertTrue(isinstance(plus._http, httplib2.Http))
+ plus = build_from_document(
+ discovery, base="https://www.googleapis.com/",
+ credentials=self.MOCK_CREDENTIALS)
+ self.assertIsInstance(
+ plus._http, (httplib2.Http, google_auth_httplib2.AuthorizedHttp))
def test_building_with_explicit_http(self):
http = HttpMock()
@@ -687,19 +704,28 @@
self.assertTrue(getattr(plus, 'activities'))
self.assertTrue(getattr(plus, 'people'))
- def test_credentials(self):
- class CredentialsMock:
- def create_scoped_required(self):
- return False
+ def test_oauth2client_credentials(self):
+ credentials = mock.Mock(spec=GoogleCredentials)
+ credentials.create_scoped_required.return_value = False
- def authorize(self, http):
- http.orest = True
+ discovery = open(datafile('plus.json')).read()
+ service = build_from_document(discovery, credentials=credentials)
+ self.assertEqual(service._http, credentials.authorize.return_value)
- self.http = HttpMock(datafile('plus.json'), {'status': '200'})
- build('plus', 'v1', http=self.http, credentials=None)
- self.assertFalse(hasattr(self.http, 'orest'))
- build('plus', 'v1', http=self.http, credentials=CredentialsMock())
- self.assertTrue(hasattr(self.http, 'orest'))
+ def test_google_auth_credentials(self):
+ credentials = mock.Mock(spec=google.auth.credentials.Credentials)
+ discovery = open(datafile('plus.json')).read()
+ service = build_from_document(discovery, credentials=credentials)
+
+ self.assertIsInstance(service._http, google_auth_httplib2.AuthorizedHttp)
+ self.assertEqual(service._http.credentials, credentials)
+
+ def test_no_scopes_no_credentials(self):
+ # Zoo doesn't have scopes
+ discovery = open(datafile('zoo.json')).read()
+ service = build_from_document(discovery)
+ # Should be an ordinary httplib2.Http instance and not AuthorizedHttp.
+ self.assertIsInstance(service._http, httplib2.Http)
def test_full_featured(self):
# Zoo should exercise all discovery facets
diff --git a/tox.ini b/tox.ini
index dcd0243..7b69ac5 100644
--- a/tox.ini
+++ b/tox.ini
@@ -1,5 +1,5 @@
[tox]
-envlist = py{26,27,33,34}-oauth2client{1,2,3,4}
+envlist = py{27,33,34}-oauth2client{1,2,3,4}
[testenv]
deps =
@@ -7,6 +7,8 @@
oauth2client2: oauth2client>=2,<=3dev
oauth2client3: oauth2client>=3,<=4dev
oauth2client4: oauth2client>=4,<=5dev
+ google-auth
+ google-auth-httplib2
keyring
mox
pyopenssl