diff --git a/.travis.yml b/.travis.yml index 31239bb09..06fbbeb3c 100644 --- a/.travis.yml +++ b/.travis.yml @@ -11,25 +11,25 @@ before_cache: matrix: include: - python: 2.6 - env: TOX_ENV=py26 + env: TOX_ENV=py26 IS_CI=true - python: 2.7 - env: TOX_ENV=py27 + env: TOX_ENV=py27 IS_CI=true - python: 3.3 - env: TOX_ENV=py33 + env: TOX_ENV=py33 IS_CI=true - python: 3.4 - env: TOX_ENV=py34 + env: TOX_ENV=py34 IS_CI=true - python: 3.5 - env: TOX_ENV=py35 + env: TOX_ENV=py35 IS_CI=true - python: 3.6 - env: TOX_ENV=py36 + env: TOX_ENV=py36 IS_CI=true - python: pypy - env: TOX_ENV=pypy PYPY_VERSION='2.7-5.8.0' + env: TOX_ENV=pypy IS_CI=true PYPY_VERSION='2.7-5.8.0' - python: 2.7 env: TOX_ENV=pep8 - python: 2.7 env: TOX_ENV=pylint - python: 2.7 - env: TOX_ENV=coverage + env: TOX_ENV=coverage IS_CI=true # commands to install dependencies install: diff --git a/HISTORY.rst b/HISTORY.rst index bf4701596..d27e82b1a 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -112,6 +112,13 @@ Release History ``BaseObject`` is the parent of all objects that are a part of the REST API. Another subclass of ``BaseAPIJSONObject``, ``APIJSONObject``, was created to represent pseudo-smart objects such as ``Event`` that are not directly accessible through an API endpoint. +- Renamed ``DefaultNetwork`` to ``RequestsSessionNetwork`` (keeping + ``DefaultNetwork`` as an alias for backwards-compatibility), and added an + optional ``session`` argument, for clients to pass a custom + ``requests.Session`` object. +- Added a pytest plugin (which can be installed with ``pip install + boxsdk[pytest]``) that defines ``betamax``-powered fixtures that clients can + use to test their ``boxsdk``-based applications. - Added ``network_response_constructor`` as an optional property on the ``Network`` interface. Implementations are encouraged to override this property, and use it to construct ``NetworkResponse`` instances. That way, diff --git a/boxsdk/network/default_network.py b/boxsdk/network/default_network.py index 59ea9df92..549cae9f0 100644 --- a/boxsdk/network/default_network.py +++ b/boxsdk/network/default_network.py @@ -9,12 +9,19 @@ from .network_interface import Network, NetworkResponse -class DefaultNetwork(Network): +class RequestsSessionNetwork(Network): """Implementation of the network interface using the requests library.""" - def __init__(self): - super(DefaultNetwork, self).__init__() - self._session = requests.Session() + def __init__(self, session=None): + """Extends baseclass method. + + :param session: + (optional) A specific session to use. + If not given, a default instance will be constructed and used. + :type session: :class:`requests.Session` + """ + super(RequestsSessionNetwork, self).__init__() + self._session = session or requests.Session() def request(self, method, url, access_token, **kwargs): """Base class override. @@ -43,6 +50,9 @@ def network_response_constructor(self): return DefaultNetworkResponse +DefaultNetwork = RequestsSessionNetwork + + class DefaultNetworkResponse(NetworkResponse): """Implementation of the network interface using the requests library.""" diff --git a/boxsdk/pytest_plugin/__init__.py b/boxsdk/pytest_plugin/__init__.py new file mode 100644 index 000000000..8cbce9244 --- /dev/null +++ b/boxsdk/pytest_plugin/__init__.py @@ -0,0 +1,9 @@ +# coding: utf-8 + +from __future__ import absolute_import, unicode_literals + + +__doc__ = """pytest fixtures that can help with testing boxsdk-powered applications.""" # pylint:disable=redefined-builtin + + +pytest_plugins = ['boxsdk.pytest_plugin.betamax'] # pylint:disable=invalid-name diff --git a/boxsdk/pytest_plugin/betamax.py b/boxsdk/pytest_plugin/betamax.py new file mode 100644 index 000000000..6bc8d5914 --- /dev/null +++ b/boxsdk/pytest_plugin/betamax.py @@ -0,0 +1,107 @@ +# coding: utf-8 + +from __future__ import absolute_import, unicode_literals + +import os + +from betamax import Betamax +from betamax_serializers import pretty_json +import pytest +import requests + +from boxsdk import Client +from boxsdk.network.default_network import RequestsSessionNetwork +from boxsdk.session.box_session import BoxSession + + +# pylint:disable=redefined-outer-name + + +@pytest.fixture +def real_requests_session(): + return requests.Session() + + +@pytest.fixture(scope='module') +def betamax_cassette_library_dir(request): + """Each directory test/foo/bar that uses betamax has a directory test/foo/bar/cassettes to hold cassettes.""" + return os.path.join(request.fspath.dirname, 'cassettes') + + +@pytest.fixture +def configure_betamax(betamax_cassette_library_dir): + if not os.path.exists(betamax_cassette_library_dir): + os.makedirs(betamax_cassette_library_dir) + Betamax.register_serializer(pretty_json.PrettyJSONSerializer) + with Betamax.configure() as config: + config.cassette_library_dir = betamax_cassette_library_dir + config.default_cassette_options['re_record_interval'] = 100 + config.default_cassette_options['record'] = 'none' if os.environ.get('IS_CI') else 'once' + + +@pytest.fixture +def betamax_cassette_name(request): + """The betamax cassette name to use for the test. + + The name is the same as the pytest nodeid (e.g. + module_path::parametrized_test_name or + module_path::class_name::parametrized_test_name), but replacing the full + module-path with just the base filename, e.g. test_foo::test_bar[0]. + """ + node_ids = request.node.nodeid.split('::') + node_ids[0] = request.fspath.purebasename + return '::'.join(node_ids) + + +@pytest.fixture(scope='module') +def betamax_use_cassette_kwargs(): + return {} + + +@pytest.fixture +def betamax_recorder(configure_betamax, real_requests_session): # pylint:disable=unused-argument + return Betamax(real_requests_session) + + +@pytest.fixture +def betamax_cassette_recorder(betamax_recorder, betamax_cassette_name, betamax_use_cassette_kwargs): + """Including this fixture causes the test to use a betamax cassette for network requests.""" + betamax_use_cassette_kwargs.setdefault('serialize_with', 'prettyjson') + with betamax_recorder.use_cassette(betamax_cassette_name, **betamax_use_cassette_kwargs) as cassette_recorder: + yield cassette_recorder + + +@pytest.fixture +def betamax_session(betamax_cassette_recorder): + """A betamax-enabled requests.Session instance.""" + return betamax_cassette_recorder.session + + +@pytest.fixture +def betamax_boxsdk_network(betamax_session): + """A betamax-enabled boxsdk.Network instance.""" + return RequestsSessionNetwork(session=betamax_session) + + +@pytest.fixture +def betamax_boxsdk_session(betamax_boxsdk_network, betamax_boxsdk_auth): + """A betamax-enabled boxsdk.BoxSession instance. + + Requires an implementation of the abstract `betamax_boxsdk_auth` fixture, + of type `boxsdk.OAuth2`. + """ + return BoxSession( + oauth=betamax_boxsdk_auth, + network_layer=betamax_boxsdk_network, + default_headers={'Accept-Encoding': ' '}, # Turn off gzip so that raw JSON responses are recorded. + ) + + +@pytest.fixture +def betamax_boxsdk_client(betamax_boxsdk_session, betamax_boxsdk_auth): + """A betamax-enabled boxsdk.Client instance. + + Requires an implementation of the abstract `betamax_boxsdk_auth` fixture, + of type `boxsdk.OAuth2`. + """ + return Client(oauth=betamax_boxsdk_auth, session=betamax_boxsdk_session) diff --git a/requirements-dev.txt b/requirements-dev.txt index a5b26860c..3ce87ebb7 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,4 +1,6 @@ +-e .[pytest] -r requirements.txt +betamax bottle jsonpatch mock>=2.0.0 @@ -9,4 +11,4 @@ pytest-cov pytest-xdist sphinx>=1.5,<1.6 sqlalchemy -tox \ No newline at end of file +tox diff --git a/setup.py b/setup.py index 4966244cb..9c6d63e9a 100644 --- a/setup.py +++ b/setup.py @@ -61,6 +61,7 @@ def main(): jwt_requires = ['pyjwt>=1.3.0', 'cryptography>=0.9.2'] extra_requires = defaultdict(list) extra_requires.update({'jwt': jwt_requires, 'redis': redis_requires, 'all': jwt_requires + redis_requires}) + extra_requires['pytest'] = ['betamax', 'betamax-serializers', 'pytest>=3.0.0'] conditional_dependencies = { # Newer versions of pip and wheel, which support PEP 426, allow # environment markers for conditional dependencies to use operators @@ -96,7 +97,13 @@ def main(): packages=find_packages(exclude=['demo', 'docs', 'test', 'test*', '*test', '*test*']), install_requires=install_requires, extras_require=extra_requires, - tests_require=['pytest', 'pytest-xdist', 'mock', 'sqlalchemy', 'bottle', 'jsonpatch'], + tests_require=['betamax', 'betamax-serializers', 'pytest', 'pytest-xdist', 'mock', 'sqlalchemy', 'bottle', 'jsonpatch'], + entry_points={ + 'pytest11': [ + # pytest fixtures that can help with testing boxsdk-powered applications. + 'boxsdk = boxsdk.pytest_plugin [pytest]', + ], + }, cmdclass={'test': PyTest}, classifiers=CLASSIFIERS, keywords='box oauth2 sdk', diff --git a/test/conftest.py b/test/conftest.py index 673b44ace..c2717e7b4 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -14,6 +14,26 @@ from boxsdk.network.default_network import DefaultNetworkResponse +pytest_plugins = ['boxsdk'] # pylint:disable=invalid-name + + +class RealRequestsSession(requests.Session): + """A real, functioning subclass of requests.Session. + + Instances of this class will always be able to make real network calls, + even if the implementation of `requests.Session.request()` is replaced or + removed via monkey patching. + """ + + # Grab the necessary references before they are replaced or removed. + request = requests.Session.request + + +@pytest.fixture +def real_requests_session(): + return RealRequestsSession() + + @pytest.fixture(autouse=True, scope='session') def logger(): logging.basicConfig(stream=sys.stdout, level=logging.DEBUG) diff --git a/test/unit/conftest.py b/test/unit/conftest.py index 6f9b9d4c3..a05f51dc0 100644 --- a/test/unit/conftest.py +++ b/test/unit/conftest.py @@ -63,9 +63,21 @@ def mock_network_layer(): return mock_network +def _requests_session_request_monkeypatch(*args, **kwargs): # pylint:disable=unused-argument + raise NotImplementedError('request() is disallowed during unit testing') + + @pytest.fixture(autouse=True) def prevent_tests_from_making_real_network_requests(monkeypatch): - monkeypatch.delattr(default_network.requests.Session, 'request') + """Prevent real network requests by monkey patching requests. + + This monkey patch is bypassed for betamax tests, which may make real + network requests when recording a cassette. + + Set a dummy implementation of `requests.Session.request()`, rather than + deleting it, so that `mock.Mock` objects know about its existence. + """ + monkeypatch.setattr(default_network.requests.Session, 'request', _requests_session_request_monkeypatch) @pytest.fixture(scope='function') diff --git a/test/unit/network/cassettes/test_network::test_requests_session_network_works_with_betamax.json b/test/unit/network/cassettes/test_network::test_requests_session_network_works_with_betamax.json new file mode 100644 index 000000000..a725d7d44 --- /dev/null +++ b/test/unit/network/cassettes/test_network::test_requests_session_network_works_with_betamax.json @@ -0,0 +1,64 @@ +{ + "http_interactions": [ + { + "recorded_at": "2017-08-25T21:21:46", + "request": { + "body": { + "encoding": "utf-8", + "string": "" + }, + "headers": { + "Accept": [ + "*/*" + ], + "Accept-Encoding": [ + "gzip, deflate" + ], + "Connection": [ + "keep-alive" + ], + "User-Agent": [ + "python-requests/2.18.4" + ] + }, + "method": "GET", + "uri": "https://api.box.com/2.0/users/me" + }, + "response": { + "body": { + "encoding": null, + "string": "" + }, + "headers": { + "Age": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "Content-Length": [ + "0" + ], + "Date": [ + "Fri, 25 Aug 2017 21:21:45 GMT" + ], + "Server": [ + "ATS" + ], + "Strict-Transport-Security": [ + "max-age=31536000; includeSubDomains" + ], + "WWW-Authenticate": [ + "Bearer realm=\"Service\", error=\"invalid_request\", error_description=\"The access token was not found.\"" + ] + }, + "status": { + "code": 401, + "message": "Unauthorized" + }, + "url": "https://api.box.com/2.0/users/me" + } + } + ], + "recorded_with": "betamax/0.8.0" +} \ No newline at end of file diff --git a/test/unit/network/test_network.py b/test/unit/network/test_network.py index 9a734e18a..a3149b6c9 100644 --- a/test/unit/network/test_network.py +++ b/test/unit/network/test_network.py @@ -2,11 +2,11 @@ from __future__ import absolute_import, unicode_literals -from mock import DEFAULT, Mock, patch +from mock import DEFAULT, Mock, NonCallableMock, patch import pytest -from requests import Response +from requests import Response, Session -from boxsdk.network.default_network import DefaultNetworkResponse, DefaultNetwork +from boxsdk.network.default_network import DefaultNetworkResponse, DefaultNetwork, RequestsSessionNetwork @pytest.fixture @@ -69,3 +69,18 @@ def network_response_constructor(self): network = DefaultNetworkSubclass() response = make_network_request(network) assert isinstance(response, DefaultNetworkResponseSubclass) + + +def test_requests_session_network_accepts_custom_session(): + mock_requests_session = NonCallableMock(spec_set=Session, name='requests_session') + network = RequestsSessionNetwork(session=mock_requests_session) + network.request(method='method', url='url', access_token='access_token') + assert len(mock_requests_session.mock_calls) == 1 + assert mock_requests_session.request.call_count == 1 + mock_requests_session.request.assert_called_once_with('method', 'url') + + +def test_requests_session_network_works_with_betamax(betamax_boxsdk_network): + response = betamax_boxsdk_network.request(method='GET', url='https://api.box.com/2.0/users/me', access_token='access_token') + assert response.ok is False + assert response.status_code == 401 diff --git a/tox.ini b/tox.ini index 65afa6fc2..33740618f 100644 --- a/tox.ini +++ b/tox.ini @@ -22,6 +22,7 @@ envlist = commands = pytest {posargs} deps = -rrequirements-dev.txt +passenv = IS_CI [testenv:rst] deps = @@ -38,6 +39,7 @@ commands = pep8 --ignore=E501,W292 test deps = pep8 +skip_install = True [testenv:pylint] commands = @@ -45,10 +47,12 @@ commands = # pylint:disable W0621(redefined-outer-name) - Using py.test fixtures always breaks this rule. pylint --rcfile=.pylintrc test -d W0621 --ignore=mock_box deps = -rrequirements-dev.txt +skip_install = True [testenv:coverage] commands = py.test --cov boxsdk test/unit test/integration deps = -rrequirements-dev.txt +passenv = IS_CI [testenv:docs] changedir = docs