Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Create RequestsSessionNetwork #226

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 8 additions & 8 deletions .travis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
7 changes: 7 additions & 0 deletions HISTORY.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
18 changes: 14 additions & 4 deletions boxsdk/network/default_network.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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."""

Expand Down
9 changes: 9 additions & 0 deletions boxsdk/pytest_plugin/__init__.py
Original file line number Diff line number Diff line change
@@ -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
107 changes: 107 additions & 0 deletions boxsdk/pytest_plugin/betamax.py
Original file line number Diff line number Diff line change
@@ -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)
4 changes: 3 additions & 1 deletion requirements-dev.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
-e .[pytest]
-r requirements.txt
betamax
bottle
jsonpatch
mock>=2.0.0
Expand All @@ -9,4 +11,4 @@ pytest-cov
pytest-xdist
sphinx>=1.5,<1.6
sqlalchemy
tox
tox
9 changes: 8 additions & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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',
Expand Down
20 changes: 20 additions & 0 deletions test/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
14 changes: 13 additions & 1 deletion test/unit/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
Original file line number Diff line number Diff line change
@@ -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"
}
21 changes: 18 additions & 3 deletions test/unit/network/test_network.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Loading