Repository: google/google-api-python-client
Pull request #319 (2016-12-20 21:53:53)

Add support for google-auth
This also obviates the need to pass credentials into `discovery.build` or `discovery.build_from_document` if you're using ADC. This library can now automatically acquire those.

jonparrott: FYI @dhermes - I don't know if you care to look at this.

Patch:
diff --git a/.travis.yml b/.travis.yml
index 43edc6ae2..b72c5e986 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -4,7 +4,6 @@ sudo: false
 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 000000000..89a8a0203
--- /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 4e7b73638..74f0a09e6 100644
--- a/googleapiclient/discovery.py
+++ b/googleapiclient/discovery.py
@@ -53,6 +53,7 @@ import httplib2
 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 @@ def build(serviceName,
     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 @@ def build(serviceName,
       '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 @@ def build_from_document(
     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 @@ def build_from_document(
   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/googleapiclient/http.py b/googleapiclient/http.py
index 14580f0c2..0ef10b955 100644
--- a/googleapiclient/http.py
+++ b/googleapiclient/http.py
@@ -996,7 +996,11 @@ class HttpRequest(object):
     elif resp.status == 308:
       self._in_error_state = False
       # A "308 Resume Incomplete" indicates we are not done.
-      self.resumable_progress = int(resp['range'].split('-')[1]) + 1
+      try:
+        self.resumable_progress = int(resp['range'].split('-')[1]) + 1
+      except KeyError:
+        # If resp doesn't contain range header, resumable progress is 0
+        self.resumable_progress = 0
       if 'location' in resp:
         self.resumable_uri = resp['location']
     else:
diff --git a/setup.py b/setup.py
index b6a5db250..2a63f08f6 100644
--- a/setup.py
+++ b/setup.py
@@ -21,8 +21,8 @@ from __future__ import print_function
 
 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 @@ install_requires = [
     '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 @@ setup(
     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/tox.ini b/tox.ini
index dcd02436f..7b69ac59e 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 @@ deps =
        oauth2client2: oauth2client>=2,<=3dev
        oauth2client3: oauth2client>=3,<=4dev
        oauth2client4: oauth2client>=4,<=5dev
+       google-auth
+       google-auth-httplib2
        keyring
        mox
        pyopenssl


Test patch:
diff --git a/tests/test__auth.py b/tests/test__auth.py
new file mode 100644
index 000000000..6711ffe6a
--- /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 210717d26..3e26db941 100644
--- a/tests/test_discovery.py
+++ b/tests/test_discovery.py
@@ -41,6 +41,8 @@ import unittest2 as unittest
 
 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 MediaUpload
 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 @@ class DiscoveryErrors(unittest.TestCase):
       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 @@ class DiscoveryFromDocument(unittest.TestCase):
     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 @@ class Discovery(unittest.TestCase):
     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
@@ -868,10 +894,12 @@ class Discovery(unittest.TestCase):
       ({'status': '200',
         'location': 'http://upload.example.com'}, ''),
       ({'status': '308',
-        'location': 'http://upload.example.com/2',
-        'range': '0-12'}, ''),
+        'location': 'http://upload.example.com/2'}, ''),
       ({'status': '308',
         'location': 'http://upload.example.com/3',
+        'range': '0-12'}, ''),
+      ({'status': '308',
+        'location': 'http://upload.example.com/4',
         'range': '0-%d' % (media_upload.size() - 2)}, ''),
       ({'status': '200'}, '{"foo": "bar"}'),
       ])
@@ -879,17 +907,23 @@ class Discovery(unittest.TestCase):
     status, body = request.next_chunk(http=http)
     self.assertEquals(None, body)
     self.assertTrue(isinstance(status, MediaUploadProgress))
-    self.assertEquals(13, status.resumable_progress)
+    self.assertEquals(0, status.resumable_progress)
 
     # Two requests should have been made and the resumable_uri should have been
     # updated for each one.
     self.assertEquals(request.resumable_uri, 'http://upload.example.com/2')
-
+    self.assertEquals(media_upload, request.resumable)
+    self.assertEquals(0, request.resumable_progress)
+    
+    # This next chuck call should upload the first chunk
+    status, body = request.next_chunk(http=http)
+    self.assertEquals(request.resumable_uri, 'http://upload.example.com/3')
     self.assertEquals(media_upload, request.resumable)
     self.assertEquals(13, request.resumable_progress)
 
+    # This call will upload the next chunk
     status, body = request.next_chunk(http=http)
-    self.assertEquals(request.resumable_uri, 'http://upload.example.com/3')
+    self.assertEquals(request.resumable_uri, 'http://upload.example.com/4')
     self.assertEquals(media_upload.size()-1, request.resumable_progress)
     self.assertEquals('{"data": {}}', request.body)