diff --git a/.gitignore b/.gitignore index b805b4c64..0a831cdbf 100644 --- a/.gitignore +++ b/.gitignore @@ -11,7 +11,7 @@ __pycache__/ # Distribution / packaging bin/ .Python -env/ +env*/ build/ develop-eggs/ dist/ @@ -27,8 +27,10 @@ var/ *.egg MANIFEST .eggs/ +.env*/ .pyenv/ -.venv/ +venv*/ +.venv*/ # PyInstaller # Usually these files are written by a python script from a template diff --git a/.pylintrc b/.pylintrc index f5888bd5e..20209a128 100644 --- a/.pylintrc +++ b/.pylintrc @@ -201,7 +201,7 @@ defining-attr-methods=__init__,__new__,setUp valid-classmethod-first-arg=cls # List of valid names for the first argument in a metaclass class method. -valid-metaclass-classmethod-first-arg=mcs +valid-metaclass-classmethod-first-arg=mcs,metacls [DESIGN] @@ -226,7 +226,7 @@ max-branches=12 max-statements=50 # Maximum number of parents for a class (see R0901). -max-parents=7 +max-parents=14 # Maximum number of attributes for a class (see R0902). max-attributes=15 diff --git a/.travis.yml b/.travis.yml index c75c86092..31239bb09 100644 --- a/.travis.yml +++ b/.travis.yml @@ -20,8 +20,10 @@ matrix: env: TOX_ENV=py34 - python: 3.5 env: TOX_ENV=py35 + - python: 3.6 + env: TOX_ENV=py36 - python: pypy - env: TOX_ENV=pypy PYPY_VERSION='4.0.0' + env: TOX_ENV=pypy PYPY_VERSION='2.7-5.8.0' - python: 2.7 env: TOX_ENV=pep8 - python: 2.7 diff --git a/.travis/install.sh b/.travis/install.sh index e4ea39234..92ba34509 100755 --- a/.travis/install.sh +++ b/.travis/install.sh @@ -41,9 +41,13 @@ if [[ "$(uname -s)" == 'Darwin' ]]; then pyenv install 3.5.0 pyenv global 3.5.0 ;; + py36) + pyenv install 3.6.0 + pyenv global 3.6.0 + ;; pypy) - pyenv install "pypy-${PYPY_VERSION}" - pyenv global "pypy-${PYPY_VERSION}" + pyenv install "pypy${PYPY_VERSION}" + pyenv global "pypy${PYPY_VERSION}" ;; esac pyenv rehash @@ -55,8 +59,8 @@ else export PYENV_ROOT="$PWD/.pyenv" export PATH="$PYENV_ROOT/bin:$PATH" eval "$(pyenv init -)" - pyenv install "pypy-${PYPY_VERSION}" - pyenv global "pypy-${PYPY_VERSION}" + pyenv install "pypy${PYPY_VERSION}" + pyenv global "pypy${PYPY_VERSION}" fi pip install -U virtualenv fi diff --git a/.travis/run.sh b/.travis/run.sh index 7de433046..66b145c7c 100755 --- a/.travis/run.sh +++ b/.travis/run.sh @@ -14,7 +14,7 @@ else export PYENV_ROOT="$PWD/.pyenv" export PATH="$PYENV_ROOT/bin:$PATH" eval "$(pyenv init -)" - pyenv global "pypy-${PYPY_VERSION}" + pyenv global "pypy${PYPY_VERSION}" fi fi source $PWD/.venv/bin/activate diff --git a/HISTORY.rst b/HISTORY.rst index 25def3f5b..eeedd9b5c 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -6,7 +6,18 @@ Release History Upcoming ++++++++ -1.5.3 +1.5.4 +++++++++++++++++++ + +- Bugfix so that the return value of ``JWTAuth.refresh()`` correctly matches + that of the auth interface (by returning a tuple of + ((access token), (refresh token or None)), instead of just the access token). + In particular, this fixes an exception in ``BoxSession`` that always occurred + when it tried to refresh any ``JWTAuth`` object. +- Fixed an exception that was being raised from ``ExtendableEnumMeta.__dir__()``. +- CPython 3.6 support. + +1.5.3 (2016-05-26) ++++++++++++++++++ - Bugfix so that ``JWTAuth`` opens the PEM private key file in ``'rb'`` mode. diff --git a/README.rst b/README.rst index fa551e1e7..ad1fd368f 100644 --- a/README.rst +++ b/README.rst @@ -28,8 +28,34 @@ Installing .. code-block:: console - pip install boxsdk - + pip install "boxsdk>=1.5,<2.0" + +Important Versioning Note +------------------------- + +The production version of the ``boxsdk`` package is the 1.5.x release track. +The code can be found on Github at +. This is what will be +installed with ``pip install boxsdk``. + +The development version of the ``boxsdk`` package is the 2.0.0 release track, +currently in an alpha release. The code can be found on Github at +. This isn't installed by +``pip`` by default, but can be installed if specifying a specific version. For +example, ``pip install boxsdk==2.0.0a5``. We welcome early testing and feedback +on these alpha releases. Note that while we're still in alpha, version 2.0.0 is +subject to change, and there might be breaking changes between alpha releases +(e.g. between 2.0.0a2 and 2.0.0a3). + +The 1.5.x release track will receive bugfix support at least until the final +release of 2.0.0. + +We always recommend pinning to a specific version (e.g. by adding +``boxsdk==1.5.3`` in your ``requirements.txt`` file), so that you don't risk +accidentally upgrading to a new version and breaking your application. This is +particularly important with the upcoming 2.0.0 release. There will be some +breaking changes in version 2.0.0, which might break your application if you +upgrade without adapting your code first. Authorization ------------- @@ -418,8 +444,8 @@ Run all tests using - The tox tests include code style checks via pep8 and pylint. -The tox tests are configured to run on Python 2.6, 2.7, 3.3, 3.4, 3.5, and -PyPy (our CI is configured to run PyPy tests on PyPy 4.0). +The tox tests are configured to run on Python 2.6, 2.7, 3.3, 3.4, 3.5, 3.6, and +PyPy2.7 (our CI is configured to run PyPy2.7 tests on PyPy2.7 5.8.0). Support diff --git a/boxsdk/auth/jwt_auth.py b/boxsdk/auth/jwt_auth.py index 266fff254..3d95cca64 100644 --- a/boxsdk/auth/jwt_auth.py +++ b/boxsdk/auth/jwt_auth.py @@ -193,6 +193,7 @@ def _refresh(self, access_token): """ # pylint:disable=unused-argument if self._user_id is None: - return self.authenticate_instance() + new_access_token = self.authenticate_instance() else: - return self._auth_with_jwt(self._user_id, 'user') + new_access_token = self._auth_with_jwt(self._user_id, 'user') + return new_access_token, None diff --git a/boxsdk/auth/oauth2.py b/boxsdk/auth/oauth2.py index 6b0e9d587..8112a9888 100644 --- a/boxsdk/auth/oauth2.py +++ b/boxsdk/auth/oauth2.py @@ -203,14 +203,13 @@ def refresh(self, access_token_to_refresh): # The lock here is for handling that case that multiple requests fail, due to access token expired, at the # same time to avoid multiple session renewals. if (access_token is None) or (access_token_to_refresh == access_token): - # If the active access token is the same as the token needs to + # If the active access token is the same as the token that needs to # be refreshed, or if we don't currently have any active access # token, we make the request to refresh the token. - return self._refresh(access_token_to_refresh) - else: - # If the active access token (self._access_token) is not the same as the token needs to be refreshed, - # it means the expired token has already been refreshed. Simply return the current active tokens. - return access_token, refresh_token + access_token, refresh_token = self._refresh(access_token_to_refresh) + # Else, if the active access token (self._access_token) is not the same as the token needs to be refreshed, + # it means the expired token has already been refreshed. Simply return the current active tokens. + return access_token, refresh_token @staticmethod def _get_state_csrf_token(): diff --git a/boxsdk/object/events.py b/boxsdk/object/events.py index dbb321f76..39c058f52 100644 --- a/boxsdk/object/events.py +++ b/boxsdk/object/events.py @@ -1,14 +1,13 @@ # coding: utf-8 -from __future__ import unicode_literals - +from __future__ import unicode_literals, absolute_import from requests.exceptions import Timeout -from six import with_metaclass -from boxsdk.object.base_endpoint import BaseEndpoint -from boxsdk.util.enum import ExtendableEnumMeta -from boxsdk.util.lru_cache import LRUCache -from boxsdk.util.text_enum import TextEnum +from .base_endpoint import BaseEndpoint +from ..util.compat import with_metaclass +from ..util.enum import ExtendableEnumMeta +from ..util.lru_cache import LRUCache +from ..util.text_enum import TextEnum # pylint:disable=too-many-ancestors @@ -55,6 +54,7 @@ class Events(BaseEndpoint): def get_url(self, *args): """Base class override.""" + # pylint:disable=arguments-differ return super(Events, self).get_url('events', *args) def get_events(self, limit=100, stream_position=0, stream_type=UserEventsStreamType.ALL): diff --git a/boxsdk/util/compat.py b/boxsdk/util/compat.py index 33fbea5c2..9f5897a47 100644 --- a/boxsdk/util/compat.py +++ b/boxsdk/util/compat.py @@ -1,10 +1,12 @@ # coding: utf-8 -from __future__ import division, unicode_literals - +from __future__ import absolute_import, division, unicode_literals from datetime import timedelta +import six + + if not hasattr(timedelta, 'total_seconds'): def total_seconds(delta): """ @@ -15,3 +17,61 @@ def total_seconds(delta): else: def total_seconds(delta): return delta.total_seconds() + + +def with_metaclass(meta, *bases, **with_metaclass_kwargs): + """Extends the behavior of six.with_metaclass. + + The normal usage (expanded to include temporaries, to make the illustration + easier) is: + + .. code-block:: python + + temporary_class = six.with_metaclass(meta, *bases) + temporary_metaclass = type(temporary_class) + + class Subclass(temporary_class): + ... + + SubclassMeta = type(Subclass) + + In this example: + + - ``temporary_class`` is a class with ``(object,)`` as its bases. + - ``temporary_metaclass`` is a metaclass with ``(meta,)`` as its bases. + - ``Subclass`` is a class with ``bases`` as its bases. + - ``SubclassMeta`` is ``meta``. + + ``six.with_metaclass()`` is defined in such a way that it can make sure + that ``Subclass`` has the correct metaclass and bases, while only using + syntax which is common to both Python 2 and Python 3. + ``temporary_metaclass()`` returns an instance of ``meta``, rather than an + instance of itself / a subclass of ``temporary_class``, which is how + ``SubclassMeta`` ends up being ``meta``, and how the temporaries don't + appear anywhere in the final subclass. + + There are two problems with the current (as of six==1.10.0) implementation + of ``six.with_metaclass()``, which this function solves. + + ``six.with_metaclass()`` does not define ``__prepare__()`` on the temporary + metaclass. This means that ``meta.__prepare__()`` gets called directly, + with bases set to ``(object,)``. If it needed to actually receive + ``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__()``. + + 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. + """ + 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) + + return type.__new__(TemporaryMetaSubclass, str('temporary_class'), bases, {}) diff --git a/boxsdk/util/enum.py b/boxsdk/util/enum.py index 109103393..1988d9663 100644 --- a/boxsdk/util/enum.py +++ b/boxsdk/util/enum.py @@ -1,4 +1,5 @@ # coding: utf-8 +# pylint:disable=no-value-for-parameter from __future__ import absolute_import, unicode_literals @@ -20,7 +21,9 @@ class ExtendableEnumMeta(EnumMeta): This allows you to define hierarchies such as this: - class EnumBase(six.with_metaclass(ExtendableEnumMeta, Enum)): pass + from box.util.compat import with_metaclass + + class EnumBase(with_metaclass(ExtendableEnumMeta, Enum)): pass class Enum1(EnumBase): A = 'A' @@ -99,7 +102,7 @@ def in_(subclass): return any(map(in_, cls.__subclasses__())) def __dir__(cls): - return list(set(super(ExtendableEnumMeta, cls).__dir__()).union(set(map(dir, cls.__subclasses__())))) + return list(set(super(ExtendableEnumMeta, cls).__dir__()).union(*map(dir, cls.__subclasses__()))) def __getitem__(cls, name): try: @@ -129,7 +132,7 @@ def __getattr__(cls, name): # and __getitem__ have the same behavior. And __getitem__ has # the advantage of never grabbing anything other than enum # members. - return cls[name] + return cls[name] # pylint:disable=unsubscriptable-object except KeyError: pass # This needs to be `reraise()`, and not just `raise`. Otherwise, diff --git a/boxsdk/version.py b/boxsdk/version.py index e4cd37ccc..9b4b21f71 100644 --- a/boxsdk/version.py +++ b/boxsdk/version.py @@ -3,4 +3,4 @@ from __future__ import unicode_literals, absolute_import -__version__ = '1.5.3' +__version__ = '1.5.4' diff --git a/requirements-dev.txt b/requirements-dev.txt index 66fa4cd6a..2f495a244 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -3,10 +3,15 @@ bottle jsonpatch mock>=2.0.0 pep8 -pylint + +# Pin to an old version so that new errors don't start appearing on the +# bugfix-only 1.5 branch. Don't propagate this pinned version to the master +# branch. +pylint==1.5.5 + pytest>=2.8.3 pytest-cov pytest-xdist -sphinx +sphinx>=1.5,<1.6 sqlalchemy -tox \ No newline at end of file +tox diff --git a/requirements.txt b/requirements.txt index 0e0ef6918..5e33286f8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,4 +4,4 @@ pyjwt>=1.3.0 requests>=2.4.3 requests-toolbelt>=0.4.0 six >= 1.4.0 --e . +-e .[all] diff --git a/setup.cfg b/setup.cfg index 4503092ce..2195a80c2 100644 --- a/setup.cfg +++ b/setup.cfg @@ -6,3 +6,6 @@ universal=1 [metadata] license_file=LICENSE + +[isort] +known_first_party=test diff --git a/setup.py b/setup.py index ef4fb24ea..64ced4d99 100644 --- a/setup.py +++ b/setup.py @@ -22,6 +22,7 @@ 'Programming Language :: Python :: 3.3', 'Programming Language :: Python :: 3.4', 'Programming Language :: Python :: 3.5', + 'Programming Language :: Python :: 3.6', 'Programming Language :: Python :: Implementation :: CPython', 'Programming Language :: Python :: Implementation :: PyPy', 'Operating System :: OS Independent', diff --git a/test/functional/mock_box/util/chaos_utils.py b/test/functional/mock_box/util/chaos_utils.py index c669587fe..e6df75973 100644 --- a/test/functional/mock_box/util/chaos_utils.py +++ b/test/functional/mock_box/util/chaos_utils.py @@ -1,15 +1,35 @@ # coding: utf-8 -from __future__ import unicode_literals -from bottle import template +from __future__ import absolute_import, unicode_literals + from functools import wraps import json -import jsonpatch from time import sleep + +from bottle import template import six + from test.functional.mock_box.util.http_utils import abort +try: + import jsonpatch +except ValueError: + # jsonpatch==1.14 on Python 3.6 cannot be imported. + # It fails with the following stacktrace: + # + # .tox/py/lib/python3.6/site-packages/jsonpatch.py:114: in + # json.load = get_loadjson() + # .tox/py/lib/python3.6/site-packages/jsonpatch.py:108: in get_loadjson + # argspec = inspect.getargspec(json.load) + # lib/python3.6/inspect.py:1039: in getargspec + # raise ValueError("Function has keyword-only arguments or annotations" + # E ValueError: Function has keyword-only arguments or annotations, use getfullargspec() API which can support them + # + # Until jsonpatch fixes this issue, we cannot use jsonpatch on Python >=3.6. + jsonpatch = None + + def allow_chaos(method): """Decorator for a method to allow erroneous operation.""" method.call_number = 0 @@ -63,10 +83,19 @@ def xml(method): def patch(operations): - json_patch = jsonpatch.JsonPatch(operations) + if jsonpatch: + json_patch = jsonpatch.JsonPatch(operations) + + def patcher(doc): + return json_patch.apply(doc) + + else: - def patcher(doc): - return json_patch.apply(doc) + def patcher(doc): + # If jsonpatch could not be imported, then `@chaos_utils.patch()` + # will be disabled, and will silently return values unmodified, + # without applying the JSON patch operations. + return doc def inner(patched_function): def patched_inner(*args, **kwargs): diff --git a/test/unit/util/test_compat.py b/test/unit/util/test_compat.py index 7fa0f7eeb..42263756b 100644 --- a/test/unit/util/test_compat.py +++ b/test/unit/util/test_compat.py @@ -3,7 +3,7 @@ from __future__ import unicode_literals from datetime import datetime, timedelta import pytest -from boxsdk.util.compat import total_seconds +from boxsdk.util.compat import total_seconds, with_metaclass @pytest.fixture(params=( @@ -19,3 +19,34 @@ def test_total_seconds(total_seconds_data): # pylint:disable=redefined-outer-name delta, seconds = total_seconds_data assert total_seconds(delta) == seconds + + +def test_with_metaclass(): + + class Class1(object): + pass + + class Class2(object): + pass + + bases = (Class1, Class2) + + class Meta(type): + @classmethod + def __prepare__(metacls, name, this_bases, **kwds): # pylint:disable=unused-argument + assert this_bases == bases + return {} + + def __new__(metacls, name, this_bases, namespace, **kwds): + assert this_bases == bases + return super(Meta, metacls).__new__(metacls, name, this_bases, namespace, **kwds) + + temporary_class = with_metaclass(Meta, *bases) + assert isinstance(temporary_class, Meta) + assert temporary_class.__bases__ == bases + + class Subclass(temporary_class): + pass + + assert type(Subclass) is Meta # pylint:disable=unidiomatic-typecheck + assert Subclass.__bases__ == bases diff --git a/test/unit/util/test_enum.py b/test/unit/util/test_enum.py index 5275636a9..4b67f95bc 100644 --- a/test/unit/util/test_enum.py +++ b/test/unit/util/test_enum.py @@ -4,8 +4,8 @@ from enum import Enum import pytest -from six import with_metaclass +from boxsdk.util.compat import with_metaclass from boxsdk.util.enum import ExtendableEnumMeta from boxsdk.util.ordered_dict import OrderedDict @@ -169,3 +169,7 @@ def test_len(EnumBaseWithSubclassesDefined, enum_members): def test_reversed(EnumBaseWithSubclassesDefined): EnumBase = EnumBaseWithSubclassesDefined assert list(reversed(list(reversed(EnumBase)))) == list(EnumBase) + + +def test_dir(EnumBaseWithSubclassesDefined): + assert set(enum_member_names).issubset(dir(EnumBaseWithSubclassesDefined)) diff --git a/tox.ini b/tox.ini index 6ebc3d35b..b00ce278f 100644 --- a/tox.ini +++ b/tox.ini @@ -10,6 +10,7 @@ envlist = py33, py34, py35, + py36, pypy, pep8, pylint, @@ -52,7 +53,7 @@ deps = -rrequirements-dev.txt [testenv:docs] changedir = docs deps = - sphinx + -rrequirements-dev.txt commands = sphinx-apidoc -f -o source ../boxsdk make html