Repository: box/box-python-sdk
Pull request #237 (2017-09-16 00:29:55)

Change downscope_token() to return a TokenResponse object
- Add a TokenResponse object, which is a subclass of BaseAPIJSONObject.
- OAuth2.downscope_token() now returns a TokenResponse object, which
  allows users to get other fields of the response, such as
  'expires_in'.

boxcla: Verified that @anthonywee has signed the CLA. Thanks for the pull request!

Patch:
diff --git a/HISTORY.rst b/HISTORY.rst
index b834075..5cf6878 100644
--- a/HISTORY.rst
+++ b/HISTORY.rst
@@ -134,6 +134,7 @@ Release History
   when it tried to refresh any ``JWTAuth`` object.
 - Fixed an exception that was being raised from ``ExtendableEnumMeta.__dir__()``.
 - CPython 3.6 support.
+- Increased required minimum version of six to 1.9.0.
 
 1.5.3 (2016-05-26)
 ++++++++++++++++++
diff --git a/boxsdk/auth/oauth2.py b/boxsdk/auth/oauth2.py
index 9a7111d..8cac23a 100644
--- a/boxsdk/auth/oauth2.py
+++ b/boxsdk/auth/oauth2.py
@@ -11,9 +11,10 @@
 import six
 from six.moves.urllib.parse import urlencode, urlunsplit  # pylint:disable=import-error,no-name-in-module
 
-from boxsdk.network.default_network import DefaultNetwork
 from boxsdk.config import API
 from boxsdk.exception import BoxOAuthException
+from boxsdk.network.default_network import DefaultNetwork
+from boxsdk.object.base_api_json_object import BaseAPIJSONObject
 from boxsdk.util.text_enum import TextEnum
 
 
@@ -31,6 +32,11 @@ class TokenScope(TextEnum):
     ITEM_DOWNLOAD = 'item_download'
 
 
+class TokenResponse(BaseAPIJSONObject):
+    """ Represents the response for a token request. """
+    pass
+
+
 class OAuth2(object):
     """
     Responsible for handling OAuth2 for the Box API. Can authenticate and refresh tokens.
@@ -294,7 +300,7 @@ def _update_current_tokens(self, access_token, refresh_token):
         """
         self._access_token, self._refresh_token = access_token, refresh_token
 
-    def _send_token_request_without_storing_tokens(self, data, access_token, expect_refresh_token=True):
+    def _execute_token_request(self, data, access_token, expect_refresh_token=True):
         """
         Send the request to acquire or refresh an access token.
 
@@ -307,9 +313,9 @@ def _send_token_request_without_storing_tokens(self, data, access_token, expect_
         :type access_token:
             `unicode` or None
         :return:
-            The access token and refresh token.
+            The response for the token request.
         :rtype:
-            (`unicode`, `unicode`)
+            :class:`TokenResponse`
         """
         self._check_closed()
         url = '{base_auth_url}/token'.format(base_auth_url=API.OAUTH2_API_URL)
@@ -324,15 +330,14 @@ def _send_token_request_without_storing_tokens(self, data, access_token, expect_
         if not network_response.ok:
             raise BoxOAuthException(network_response.status_code, network_response.content, url, 'POST')
         try:
-            response = network_response.json()
-            access_token = response['access_token']
-            refresh_token = response.get('refresh_token', None)
-            if refresh_token is None and expect_refresh_token:
-                raise BoxOAuthException(network_response.status_code, network_response.content, url, 'POST')
-        except (ValueError, KeyError):
+            token_response = TokenResponse(network_response.json())
+        except ValueError:
             raise BoxOAuthException(network_response.status_code, network_response.content, url, 'POST')
 
-        return access_token, refresh_token
+        if ('access_token' not in token_response) or (expect_refresh_token and 'refresh_token' not in token_response):
+            raise BoxOAuthException(network_response.status_code, network_response.content, url, 'POST')
+
+        return token_response
 
     def send_token_request(self, data, access_token, expect_refresh_token=True):
         """
@@ -351,8 +356,10 @@ def send_token_request(self, data, access_token, expect_refresh_token=True):
         :rtype:
             (`unicode`, `unicode`)
         """
-        access_token, refresh_token = self._send_token_request_without_storing_tokens(data, access_token, expect_refresh_token)
-        self._store_tokens(access_token, refresh_token)
+        token_response = self._execute_token_request(data, access_token, expect_refresh_token)
+        # pylint:disable=no-member
+        refresh_token = token_response.refresh_token if 'refresh_token' in token_response else None
+        self._store_tokens(token_response.access_token, refresh_token)
         return self._access_token, self._refresh_token
 
     def revoke(self):
@@ -381,7 +388,7 @@ def revoke(self):
 
     def downscope_token(self, scopes, item=None, additional_data=None):
         """
-        Get a downscoped token for the provided file or folder with the provided scopes.
+        Generate a downscoped token for the provided file or folder with the provided scopes.
 
         :param scope:
             The scope(s) to apply to the resulting token.
@@ -397,9 +404,9 @@ def downscope_token(self, scopes, item=None, additional_data=None):
         :type additional_data:
             `dict`
         :return:
-            The downscoped token
+            The response for the downscope token request.
         :rtype:
-            `unicode`
+            :class:`TokenResponse`
         """
         self._check_closed()
         with self._refresh_lock:
@@ -416,8 +423,7 @@ def downscope_token(self, scopes, item=None, additional_data=None):
             if additional_data:
                 data.update(additional_data)
 
-            access_token, _ = self._send_token_request_without_storing_tokens(data, access_token, expect_refresh_token=False)
-        return access_token
+            return self._execute_token_request(data, access_token, expect_refresh_token=False)
 
     def close(self, revoke=True):
         """Close the auth object.
diff --git a/boxsdk/util/compat.py b/boxsdk/util/compat.py
index 2243af9..4951484 100644
--- a/boxsdk/util/compat.py
+++ b/boxsdk/util/compat.py
@@ -5,6 +5,7 @@
 from datetime import timedelta
 
 import six
+from six.moves import map
 
 
 NoneType = type(None)
@@ -62,19 +63,58 @@ class Subclass(temporary_class):
     ``bases``, then errors might occur. For example, this was a problem when
     used with ``enum.EnumMeta`` in Python 3.6. Here we make sure that
     ``__prepare__()`` is defined on the temporary metaclass, and pass ``bases``
-    to ``meta.__prepare__()``.
+    to ``meta.__prepare__()``. This is fixed in six>=1.11.0 by PR #178 [1].
 
     Since ``temporary_class`` doesn't have the correct bases, in theory this
     could cause other problems, besides the previous one, in certain edge
     cases. To make sure that doesn't become a problem, we make sure that
     ``temporary_class`` has ``bases`` as its bases, just like the final class.
+
+    [1] <https://github.com/benjaminp/six/pull/178>
     """
     temporary_class = six.with_metaclass(meta, *bases, **with_metaclass_kwargs)
     temporary_metaclass = type(temporary_class)
 
-    class TemporaryMetaSubclass(temporary_metaclass):
-        @classmethod
-        def __prepare__(cls, name, this_bases, **kwds):  # pylint:disable=unused-argument
-            return meta.__prepare__(name, bases, **kwds)
+    class TemporaryMetaSubclass(temporary_metaclass, _most_derived_metaclass(meta, bases)):
+
+        if '__prepare__' not in temporary_metaclass.__dict__:
+            # six<1.11.0, __prepare__ is not defined on the temporary metaclass.
+
+            @classmethod
+            def __prepare__(mcs, name, this_bases, **kwds):  # pylint:disable=unused-argument,arguments-differ
+                return meta.__prepare__(name, bases, **kwds)
 
     return type.__new__(TemporaryMetaSubclass, str('temporary_class'), bases, {})
+
+
+def _most_derived_metaclass(meta, bases):
+    """Selects the most derived metaclass of all the given metaclasses.
+
+    This will be the same metaclass that is selected by
+
+    .. code-block:: python
+
+        class temporary_class(*bases, metaclass=meta): pass
+
+    or equivalently by
+
+    .. code-block:: python
+
+        types.prepare_class('temporary_class', bases, metaclass=meta)
+
+    "Most derived" means the item in {meta, type(bases[0]), type(bases[1]), ...}
+    which is a non-strict subclass of every item in that set.
+
+    If no such item exists, then :exc:`TypeError` is raised.
+
+    :type meta:   `type`
+    :type bases:  :class:`Iterable` of `type`
+    """
+    most_derived_metaclass = meta
+    for base_type in map(type, bases):
+        if issubclass(base_type, most_derived_metaclass):
+            most_derived_metaclass = base_type
+        elif not issubclass(most_derived_metaclass, base_type):
+            # Raises TypeError('metaclass conflict: ...')
+            return type.__new__(meta, str('temporary_class'), bases, {})
+    return most_derived_metaclass
diff --git a/boxsdk/version.py b/boxsdk/version.py
index cfe149c..9095d34 100644
--- a/boxsdk/version.py
+++ b/boxsdk/version.py
@@ -3,4 +3,4 @@
 from __future__ import unicode_literals, absolute_import
 
 
-__version__ = '2.0.0a8'
+__version__ = '2.0.0a9'
diff --git a/requirements.txt b/requirements.txt
index 5e33286..5231047 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,7 +1,1 @@
-cryptography>=0.9.2
-redis>=2.10.3
-pyjwt>=1.3.0
-requests>=2.4.3
-requests-toolbelt>=0.4.0
-six >= 1.4.0
 -e .[all]
diff --git a/setup.py b/setup.py
index 4966244..5ca570c 100644
--- a/setup.py
+++ b/setup.py
@@ -56,7 +56,7 @@ def run_tests(self):
 
 def main():
     base_dir = dirname(__file__)
-    install_requires = ['requests>=2.4.3', 'six>=1.4.0', 'requests-toolbelt>=0.4.0']
+    install_requires = ['requests>=2.4.3', 'six>=1.9.0', 'requests-toolbelt>=0.4.0']
     redis_requires = ['redis>=2.10.3']
     jwt_requires = ['pyjwt>=1.3.0', 'cryptography>=0.9.2']
     extra_requires = defaultdict(list)


Test patch:
diff --git a/test/unit/auth/test_oauth2.py b/test/unit/auth/test_oauth2.py
index 830ca16..7859659 100644
--- a/test/unit/auth/test_oauth2.py
+++ b/test/unit/auth/test_oauth2.py
@@ -346,6 +346,45 @@ def test_revoke_sends_revoke_request(
     assert oauth.access_token is None
 
 
+@pytest.fixture
+def check_downscope_token_request(
+        oauth,
+        mock_network_layer,
+        mock_box_session,
+        mock_object_id,
+        make_mock_box_request,
+):
+    def do_check(access_token, item_class, scopes, additional_data, expected_data):
+        dummy_downscoped_token = 'dummy_downscoped_token'
+        dummy_expires_in = 1234
+        mock_network_response, _ = make_mock_box_request(
+            response={'access_token': dummy_downscoped_token, 'expires_in': dummy_expires_in},
+        )
+        mock_network_layer.request.return_value = mock_network_response
+
+        item = item_class(mock_box_session, mock_object_id) if item_class else None
+
+        if additional_data:
+            downscoped_token_response = oauth.downscope_token(scopes, item, additional_data)
+        else:
+            downscoped_token_response = oauth.downscope_token(scopes, item)
+
+        assert downscoped_token_response.access_token == dummy_downscoped_token
+        assert downscoped_token_response.expires_in == dummy_expires_in
+
+        if item:
+            expected_data['resource'] = item.get_url()
+        mock_network_layer.request.assert_called_once_with(
+            'POST',
+            '{0}/token'.format(API.OAUTH2_API_URL),
+            data=expected_data,
+            headers={'content-type': 'application/x-www-form-urlencoded'},
+            access_token=access_token,
+        )
+
+    return do_check
+
+
 @pytest.mark.parametrize(
     'item_class,scopes,expected_scopes',
     [
@@ -356,72 +395,34 @@ def test_revoke_sends_revoke_request(
     ],
 )
 def test_downscope_token_sends_downscope_request(
-        oauth,
         access_token,
-        mock_network_layer,
-        mock_box_session,
-        mock_object_id,
-        make_mock_box_request,
+        check_downscope_token_request,
         item_class,
         scopes,
         expected_scopes,
 ):
-    mock_downscoped_token = 'mock_downscoped_token'
-    mock_network_response, _ = make_mock_box_request(response={'access_token': mock_downscoped_token})
-    mock_network_layer.request.return_value = mock_network_response
-
-    item = item_class(mock_box_session, mock_object_id) if item_class else None
-    downscoped_token = oauth.downscope_token(scopes, item)
-
-    assert downscoped_token == mock_downscoped_token
     expected_data = {
         'subject_token': access_token,
         'subject_token_type': 'urn:ietf:params:oauth:token-type:access_token',
         'scope': expected_scopes,
         'grant_type': 'urn:ietf:params:oauth:grant-type:token-exchange',
     }
-    if item:
-        expected_data['resource'] = item.get_url()
-    mock_network_layer.request.assert_called_once_with(
-        'POST',
-        '{0}/token'.format(API.OAUTH2_API_URL),
-        data=expected_data,
-        headers={'content-type': 'application/x-www-form-urlencoded'},
-        access_token=access_token,
-    )
+    check_downscope_token_request(access_token, item_class, scopes, {}, expected_data)
 
 
 def test_downscope_token_sends_downscope_request_with_additional_data(
-        oauth,
         access_token,
-        mock_network_layer,
-        mock_box_session,
-        mock_object_id,
-        make_mock_box_request,
+        check_downscope_token_request,
 ):
-    mock_downscoped_token = 'mock_downscoped_token'
-    mock_network_response, _ = make_mock_box_request(response={'access_token': mock_downscoped_token})
-    mock_network_layer.request.return_value = mock_network_response
-
-    item = File(mock_box_session, mock_object_id)
     additional_data = {'grant_type': 'new_grant_type', 'extra_data_key': 'extra_data_value'}
-    downscoped_token = oauth.downscope_token([TokenScope.ITEM_READWRITE], item, additional_data)
-
-    assert downscoped_token == mock_downscoped_token
-    mock_network_layer.request.assert_called_once_with(
-        'POST',
-        '{0}/token'.format(API.OAUTH2_API_URL),
-        data={
-            'subject_token': access_token,
-            'subject_token_type': 'urn:ietf:params:oauth:token-type:access_token',
-            'scope': 'item_readwrite',
-            'resource': item.get_url(),
-            'grant_type': 'new_grant_type',
-            'extra_data_key': 'extra_data_value',
-        },
-        headers={'content-type': 'application/x-www-form-urlencoded'},
-        access_token=access_token,
-    )
+    expected_data = {
+        'subject_token': access_token,
+        'subject_token_type': 'urn:ietf:params:oauth:token-type:access_token',
+        'scope': 'item_readwrite',
+        'grant_type': 'new_grant_type',
+        'extra_data_key': 'extra_data_value',
+    }
+    check_downscope_token_request(access_token, File, [TokenScope.ITEM_READWRITE], additional_data, expected_data)
 
 
 def test_tokens_get_updated_after_noop_refresh(client_id, client_secret, access_token, new_access_token, refresh_token, mock_network_layer):