diff --git a/moto/packages/responses b/moto/packages/responses deleted file mode 160000 index 8d500447e..000000000 --- a/moto/packages/responses +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 8d500447e3d5c2b96ace2eb7ab0f60158e921ed8 diff --git a/moto/packages/responses/.gitignore b/moto/packages/responses/.gitignore new file mode 100644 index 000000000..5d4406b8d --- /dev/null +++ b/moto/packages/responses/.gitignore @@ -0,0 +1,12 @@ +.arcconfig +.coverage +.DS_Store +.idea +*.db +*.egg-info +*.pyc +/htmlcov +/dist +/build +/.cache +/.tox diff --git a/moto/packages/responses/.travis.yml b/moto/packages/responses/.travis.yml new file mode 100644 index 000000000..9ab219db0 --- /dev/null +++ b/moto/packages/responses/.travis.yml @@ -0,0 +1,27 @@ +language: python +sudo: false +python: + - "2.6" + - "2.7" + - "3.3" + - "3.4" + - "3.5" +cache: + directories: + - .pip_download_cache +env: + matrix: + - REQUESTS=requests==2.0 + - REQUESTS=-U requests + - REQUESTS="-e git+git://github.com/kennethreitz/requests.git#egg=requests" + global: + - PIP_DOWNLOAD_CACHE=".pip_download_cache" +matrix: + allow_failures: + - env: 'REQUESTS="-e git+git://github.com/kennethreitz/requests.git#egg=requests"' +install: + - "pip install ${REQUESTS}" + - make develop +script: + - if [[ $TRAVIS_PYTHON_VERSION != 2.6 ]]; then make lint; fi + - py.test . --cov responses --cov-report term-missing diff --git a/moto/packages/responses/CHANGES b/moto/packages/responses/CHANGES new file mode 100644 index 000000000..1bfd7ead8 --- /dev/null +++ b/moto/packages/responses/CHANGES @@ -0,0 +1,32 @@ +Unreleased +---------- + +- Allow empty list/dict as json object (GH-100) + +0.5.1 +----- + +- Add LICENSE, README and CHANGES to the PyPI distribution (GH-97). + +0.5.0 +----- + +- Allow passing a JSON body to `response.add` (GH-82) +- Improve ConnectionError emulation (GH-73) +- Correct assertion in assert_all_requests_are_fired (GH-71) + +0.4.0 +----- + +- Requests 2.0+ is required +- Mocking now happens on the adapter instead of the session + +0.3.0 +----- + +- Add the ability to mock errors (GH-22) +- Add responses.mock context manager (GH-36) +- Support custom adapters (GH-33) +- Add support for regexp error matching (GH-25) +- Add support for dynamic bodies via `responses.add_callback` (GH-24) +- Preserve argspec when using `responses.activate` decorator (GH-18) diff --git a/moto/packages/responses/LICENSE b/moto/packages/responses/LICENSE new file mode 100644 index 000000000..52b44b20a --- /dev/null +++ b/moto/packages/responses/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright 2015 David Cramer + +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. diff --git a/moto/packages/responses/MANIFEST.in b/moto/packages/responses/MANIFEST.in new file mode 100644 index 000000000..ef901684c --- /dev/null +++ b/moto/packages/responses/MANIFEST.in @@ -0,0 +1,2 @@ +include README.rst CHANGES LICENSE +global-exclude *~ diff --git a/moto/packages/responses/Makefile b/moto/packages/responses/Makefile new file mode 100644 index 000000000..9da42c6d1 --- /dev/null +++ b/moto/packages/responses/Makefile @@ -0,0 +1,16 @@ +develop: + pip install -e . + make install-test-requirements + +install-test-requirements: + pip install "file://`pwd`#egg=responses[tests]" + +test: develop lint + @echo "Running Python tests" + py.test . + @echo "" + +lint: + @echo "Linting Python files" + PYFLAKES_NODOCTEST=1 flake8 . + @echo "" diff --git a/moto/packages/responses/README.rst b/moto/packages/responses/README.rst new file mode 100644 index 000000000..5f946fcde --- /dev/null +++ b/moto/packages/responses/README.rst @@ -0,0 +1,190 @@ +Responses +========= + +.. image:: https://travis-ci.org/getsentry/responses.svg?branch=master + :target: https://travis-ci.org/getsentry/responses + +A utility library for mocking out the `requests` Python library. + +.. note:: Responses requires Requests >= 2.0 + +Response body as string +----------------------- + +.. code-block:: python + + import responses + import requests + + @responses.activate + def test_my_api(): + responses.add(responses.GET, 'http://twitter.com/api/1/foobar', + body='{"error": "not found"}', status=404, + content_type='application/json') + + resp = requests.get('http://twitter.com/api/1/foobar') + + assert resp.json() == {"error": "not found"} + + assert len(responses.calls) == 1 + assert responses.calls[0].request.url == 'http://twitter.com/api/1/foobar' + assert responses.calls[0].response.text == '{"error": "not found"}' + +You can also specify a JSON object instead of a body string. + +.. code-block:: python + + import responses + import requests + + @responses.activate + def test_my_api(): + responses.add(responses.GET, 'http://twitter.com/api/1/foobar', + json={"error": "not found"}, status=404) + + resp = requests.get('http://twitter.com/api/1/foobar') + + assert resp.json() == {"error": "not found"} + + assert len(responses.calls) == 1 + assert responses.calls[0].request.url == 'http://twitter.com/api/1/foobar' + assert responses.calls[0].response.text == '{"error": "not found"}' + +Request callback +---------------- + +.. code-block:: python + + import json + + import responses + import requests + + @responses.activate + def test_calc_api(): + + def request_callback(request): + payload = json.loads(request.body) + resp_body = {'value': sum(payload['numbers'])} + headers = {'request-id': '728d329e-0e86-11e4-a748-0c84dc037c13'} + return (200, headers, json.dumps(resp_body)) + + responses.add_callback( + responses.POST, 'http://calc.com/sum', + callback=request_callback, + content_type='application/json', + ) + + resp = requests.post( + 'http://calc.com/sum', + json.dumps({'numbers': [1, 2, 3]}), + headers={'content-type': 'application/json'}, + ) + + assert resp.json() == {'value': 6} + + assert len(responses.calls) == 1 + assert responses.calls[0].request.url == 'http://calc.com/sum' + assert responses.calls[0].response.text == '{"value": 6}' + assert ( + responses.calls[0].response.headers['request-id'] == + '728d329e-0e86-11e4-a748-0c84dc037c13' + ) + +Instead of passing a string URL into `responses.add` or `responses.add_callback` +you can also supply a compiled regular expression. + +.. code-block:: python + + import re + import responses + import requests + + # Instead of + responses.add(responses.GET, 'http://twitter.com/api/1/foobar', + body='{"error": "not found"}', status=404, + content_type='application/json') + + # You can do the following + url_re = re.compile(r'https?://twitter\.com/api/\d+/foobar') + responses.add(responses.GET, url_re, + body='{"error": "not found"}', status=404, + content_type='application/json') + +A response can also throw an exception as follows. + +.. code-block:: python + + import responses + import requests + from requests.exceptions import HTTPError + + exception = HTTPError('Something went wrong') + responses.add(responses.GET, 'http://twitter.com/api/1/foobar', + body=exception) + # All calls to 'http://twitter.com/api/1/foobar' will throw exception. + + +Responses as a context manager +------------------------------ + +.. code-block:: python + + import responses + import requests + + + def test_my_api(): + with responses.RequestsMock() as rsps: + rsps.add(responses.GET, 'http://twitter.com/api/1/foobar', + body='{}', status=200, + content_type='application/json') + resp = requests.get('http://twitter.com/api/1/foobar') + + assert resp.status_code == 200 + + # outside the context manager requests will hit the remote server + resp = requests.get('http://twitter.com/api/1/foobar') + resp.status_code == 404 + + +Assertions on declared responses +-------------------------------- + +When used as a context manager, Responses will, by default, raise an assertion +error if a url was registered but not accessed. This can be disabled by passing +the ``assert_all_requests_are_fired`` value: + +.. code-block:: python + + import responses + import requests + + + def test_my_api(): + with responses.RequestsMock(assert_all_requests_are_fired=False) as rsps: + rsps.add(responses.GET, 'http://twitter.com/api/1/foobar', + body='{}', status=200, + content_type='application/json') + +Multiple Responses +------------------ +You can also use ``assert_all_requests_are_fired`` to add multiple responses for the same url: + +.. code-block:: python + + import responses + import requests + + + def test_my_api(): + with responses.RequestsMock(assert_all_requests_are_fired=True) as rsps: + rsps.add(responses.GET, 'http://twitter.com/api/1/foobar', status=500) + rsps.add(responses.GET, 'http://twitter.com/api/1/foobar', + body='{}', status=200, + content_type='application/json') + + resp = requests.get('http://twitter.com/api/1/foobar') + assert resp.status_code == 500 + resp = requests.get('http://twitter.com/api/1/foobar') + assert resp.status_code == 200 diff --git a/moto/packages/responses/__init__.py b/moto/packages/responses/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/moto/packages/responses/responses.py b/moto/packages/responses/responses.py new file mode 100644 index 000000000..735655664 --- /dev/null +++ b/moto/packages/responses/responses.py @@ -0,0 +1,321 @@ +from __future__ import ( + absolute_import, print_function, division, unicode_literals +) + +import inspect +import json as json_module +import re +import six + +from collections import namedtuple, Sequence, Sized +from functools import update_wrapper +from cookies import Cookies +from requests.utils import cookiejar_from_dict +from requests.exceptions import ConnectionError +from requests.sessions import REDIRECT_STATI + +try: + from requests.packages.urllib3.response import HTTPResponse +except ImportError: + from urllib3.response import HTTPResponse + +if six.PY2: + from urlparse import urlparse, parse_qsl +else: + from urllib.parse import urlparse, parse_qsl + +if six.PY2: + try: + from six import cStringIO as BufferIO + except ImportError: + from six import StringIO as BufferIO +else: + from io import BytesIO as BufferIO + + +Call = namedtuple('Call', ['request', 'response']) + +_wrapper_template = """\ +def wrapper%(signature)s: + with responses: + return func%(funcargs)s +""" + + +def _is_string(s): + return isinstance(s, (six.string_types, six.text_type)) + + +def _is_redirect(response): + try: + # 2.0.0 <= requests <= 2.2 + return response.is_redirect + except AttributeError: + # requests > 2.2 + return ( + # use request.sessions conditional + response.status_code in REDIRECT_STATI and + 'location' in response.headers + ) + + +def get_wrapped(func, wrapper_template, evaldict): + # Preserve the argspec for the wrapped function so that testing + # tools such as pytest can continue to use their fixture injection. + args, a, kw, defaults = inspect.getargspec(func) + + signature = inspect.formatargspec(args, a, kw, defaults) + is_bound_method = hasattr(func, '__self__') + if is_bound_method: + args = args[1:] # Omit 'self' + callargs = inspect.formatargspec(args, a, kw, None) + + ctx = {'signature': signature, 'funcargs': callargs} + six.exec_(wrapper_template % ctx, evaldict) + + wrapper = evaldict['wrapper'] + + update_wrapper(wrapper, func) + if is_bound_method: + wrapper = wrapper.__get__(func.__self__, type(func.__self__)) + return wrapper + + +class CallList(Sequence, Sized): + def __init__(self): + self._calls = [] + + def __iter__(self): + return iter(self._calls) + + def __len__(self): + return len(self._calls) + + def __getitem__(self, idx): + return self._calls[idx] + + def add(self, request, response): + self._calls.append(Call(request, response)) + + def reset(self): + self._calls = [] + + +def _ensure_url_default_path(url, match_querystring): + if _is_string(url) and url.count('/') == 2: + if match_querystring: + return url.replace('?', '/?', 1) + else: + return url + '/' + return url + + +class RequestsMock(object): + DELETE = 'DELETE' + GET = 'GET' + HEAD = 'HEAD' + OPTIONS = 'OPTIONS' + PATCH = 'PATCH' + POST = 'POST' + PUT = 'PUT' + + def __init__(self, assert_all_requests_are_fired=True): + self._calls = CallList() + self.reset() + self.assert_all_requests_are_fired = assert_all_requests_are_fired + + def reset(self): + self._urls = [] + self._calls.reset() + + def add(self, method, url, body='', match_querystring=False, + status=200, adding_headers=None, stream=False, + content_type='text/plain', json=None): + + # if we were passed a `json` argument, + # override the body and content_type + if json is not None: + body = json_module.dumps(json) + content_type = 'application/json' + + # ensure the url has a default path set if the url is a string + url = _ensure_url_default_path(url, match_querystring) + + # body must be bytes + if isinstance(body, six.text_type): + body = body.encode('utf-8') + + self._urls.append({ + 'url': url, + 'method': method, + 'body': body, + 'content_type': content_type, + 'match_querystring': match_querystring, + 'status': status, + 'adding_headers': adding_headers, + 'stream': stream, + }) + + def add_callback(self, method, url, callback, match_querystring=False, + content_type='text/plain'): + # ensure the url has a default path set if the url is a string + # url = _ensure_url_default_path(url, match_querystring) + + self._urls.append({ + 'url': url, + 'method': method, + 'callback': callback, + 'content_type': content_type, + 'match_querystring': match_querystring, + }) + + @property + def calls(self): + return self._calls + + def __enter__(self): + self.start() + return self + + def __exit__(self, type, value, traceback): + success = type is None + self.stop(allow_assert=success) + self.reset() + return success + + def activate(self, func): + evaldict = {'responses': self, 'func': func} + return get_wrapped(func, _wrapper_template, evaldict) + + def _find_match(self, request): + for match in self._urls: + if request.method != match['method']: + continue + + if not self._has_url_match(match, request.url): + continue + + break + else: + return None + if self.assert_all_requests_are_fired: + # for each found match remove the url from the stack + self._urls.remove(match) + return match + + def _has_url_match(self, match, request_url): + url = match['url'] + + if not match['match_querystring']: + request_url = request_url.split('?', 1)[0] + + if _is_string(url): + if match['match_querystring']: + return self._has_strict_url_match(url, request_url) + else: + return url == request_url + elif isinstance(url, re._pattern_type) and url.match(request_url): + return True + else: + return False + + def _has_strict_url_match(self, url, other): + url_parsed = urlparse(url) + other_parsed = urlparse(other) + + if url_parsed[:3] != other_parsed[:3]: + return False + + url_qsl = sorted(parse_qsl(url_parsed.query)) + other_qsl = sorted(parse_qsl(other_parsed.query)) + return url_qsl == other_qsl + + def _on_request(self, adapter, request, **kwargs): + match = self._find_match(request) + # TODO(dcramer): find the correct class for this + if match is None: + error_msg = 'Connection refused: {0} {1}'.format(request.method, + request.url) + response = ConnectionError(error_msg) + response.request = request + + self._calls.add(request, response) + raise response + + if 'body' in match and isinstance(match['body'], Exception): + self._calls.add(request, match['body']) + raise match['body'] + + headers = {} + if match['content_type'] is not None: + headers['Content-Type'] = match['content_type'] + + if 'callback' in match: # use callback + status, r_headers, body = match['callback'](request) + if isinstance(body, six.text_type): + body = body.encode('utf-8') + body = BufferIO(body) + headers.update(r_headers) + + elif 'body' in match: + if match['adding_headers']: + headers.update(match['adding_headers']) + status = match['status'] + body = BufferIO(match['body']) + + response = HTTPResponse( + status=status, + reason=six.moves.http_client.responses[status], + body=body, + headers=headers, + preload_content=False, + ) + + response = adapter.build_response(request, response) + if not match.get('stream'): + response.content # NOQA + + try: + resp_cookies = Cookies.from_request(response.headers['set-cookie']) + response.cookies = cookiejar_from_dict(dict( + (v.name, v.value) + for _, v + in resp_cookies.items() + )) + except (KeyError, TypeError): + pass + + self._calls.add(request, response) + + return response + + def start(self): + try: + from unittest import mock + except ImportError: + import mock + + def unbound_on_send(adapter, request, *a, **kwargs): + return self._on_request(adapter, request, *a, **kwargs) + self._patcher1 = mock.patch('botocore.vendored.requests.adapters.HTTPAdapter.send', + unbound_on_send) + self._patcher1.start() + self._patcher2 = mock.patch('requests.adapters.HTTPAdapter.send', + unbound_on_send) + self._patcher2.start() + + def stop(self, allow_assert=True): + self._patcher1.stop() + self._patcher2.stop() + if allow_assert and self.assert_all_requests_are_fired and self._urls: + raise AssertionError( + 'Not all requests have been executed {0!r}'.format( + [(url['method'], url['url']) for url in self._urls])) + + +# expose default mock namespace +mock = _default_mock = RequestsMock(assert_all_requests_are_fired=False) +__all__ = [] +for __attr in (a for a in dir(_default_mock) if not a.startswith('_')): + __all__.append(__attr) + globals()[__attr] = getattr(_default_mock, __attr) diff --git a/moto/packages/responses/setup.cfg b/moto/packages/responses/setup.cfg new file mode 100644 index 000000000..9b6594f2e --- /dev/null +++ b/moto/packages/responses/setup.cfg @@ -0,0 +1,5 @@ +[pytest] +addopts=--tb=short + +[bdist_wheel] +universal=1 diff --git a/moto/packages/responses/setup.py b/moto/packages/responses/setup.py new file mode 100644 index 000000000..bab522865 --- /dev/null +++ b/moto/packages/responses/setup.py @@ -0,0 +1,98 @@ +#!/usr/bin/env python +""" +responses +========= + +A utility library for mocking out the `requests` Python library. + +:copyright: (c) 2015 David Cramer +:license: Apache 2.0 +""" + +import sys +import logging + +from setuptools import setup +from setuptools.command.test import test as TestCommand +import pkg_resources + + +setup_requires = [] + +if 'test' in sys.argv: + setup_requires.append('pytest') + +install_requires = [ + 'requests>=2.0', + 'cookies', + 'six', +] + +tests_require = [ + 'pytest', + 'coverage >= 3.7.1, < 5.0.0', + 'pytest-cov', + 'flake8', +] + + +extras_require = { + ':python_version in "2.6, 2.7, 3.2"': ['mock'], + 'tests': tests_require, +} + +try: + if 'bdist_wheel' not in sys.argv: + for key, value in extras_require.items(): + if key.startswith(':') and pkg_resources.evaluate_marker(key[1:]): + install_requires.extend(value) +except Exception: + logging.getLogger(__name__).exception( + 'Something went wrong calculating platform specific dependencies, so ' + "you're getting them all!" + ) + for key, value in extras_require.items(): + if key.startswith(':'): + install_requires.extend(value) + + +class PyTest(TestCommand): + def finalize_options(self): + TestCommand.finalize_options(self) + self.test_args = ['test_responses.py'] + self.test_suite = True + + def run_tests(self): + # import here, cause outside the eggs aren't loaded + import pytest + errno = pytest.main(self.test_args) + sys.exit(errno) + + +setup( + name='responses', + version='0.6.0', + author='David Cramer', + description=( + 'A utility library for mocking out the `requests` Python library.' + ), + url='https://github.com/getsentry/responses', + license='Apache 2.0', + long_description=open('README.rst').read(), + py_modules=['responses', 'test_responses'], + zip_safe=False, + install_requires=install_requires, + extras_require=extras_require, + tests_require=tests_require, + setup_requires=setup_requires, + cmdclass={'test': PyTest}, + include_package_data=True, + classifiers=[ + 'Intended Audience :: Developers', + 'Intended Audience :: System Administrators', + 'Operating System :: OS Independent', + 'Programming Language :: Python :: 2', + 'Programming Language :: Python :: 3', + 'Topic :: Software Development' + ], +) diff --git a/moto/packages/responses/test_responses.py b/moto/packages/responses/test_responses.py new file mode 100644 index 000000000..ba0126ad5 --- /dev/null +++ b/moto/packages/responses/test_responses.py @@ -0,0 +1,443 @@ +from __future__ import ( + absolute_import, print_function, division, unicode_literals +) + +import re +import requests +import responses +import pytest + +from inspect import getargspec +from requests.exceptions import ConnectionError, HTTPError + + +def assert_reset(): + assert len(responses._default_mock._urls) == 0 + assert len(responses.calls) == 0 + + +def assert_response(resp, body=None, content_type='text/plain'): + assert resp.status_code == 200 + assert resp.reason == 'OK' + if content_type is not None: + assert resp.headers['Content-Type'] == content_type + else: + assert 'Content-Type' not in resp.headers + assert resp.text == body + + +def test_response(): + @responses.activate + def run(): + responses.add(responses.GET, 'http://example.com', body=b'test') + resp = requests.get('http://example.com') + assert_response(resp, 'test') + assert len(responses.calls) == 1 + assert responses.calls[0].request.url == 'http://example.com/' + assert responses.calls[0].response.content == b'test' + + resp = requests.get('http://example.com?foo=bar') + assert_response(resp, 'test') + assert len(responses.calls) == 2 + assert responses.calls[1].request.url == 'http://example.com/?foo=bar' + assert responses.calls[1].response.content == b'test' + + run() + assert_reset() + + +def test_connection_error(): + @responses.activate + def run(): + responses.add(responses.GET, 'http://example.com') + + with pytest.raises(ConnectionError): + requests.get('http://example.com/foo') + + assert len(responses.calls) == 1 + assert responses.calls[0].request.url == 'http://example.com/foo' + assert type(responses.calls[0].response) is ConnectionError + assert responses.calls[0].response.request + + run() + assert_reset() + + +def test_match_querystring(): + @responses.activate + def run(): + url = 'http://example.com?test=1&foo=bar' + responses.add( + responses.GET, url, + match_querystring=True, body=b'test') + resp = requests.get('http://example.com?test=1&foo=bar') + assert_response(resp, 'test') + resp = requests.get('http://example.com?foo=bar&test=1') + assert_response(resp, 'test') + + run() + assert_reset() + + +def test_match_querystring_error(): + @responses.activate + def run(): + responses.add( + responses.GET, 'http://example.com/?test=1', + match_querystring=True) + + with pytest.raises(ConnectionError): + requests.get('http://example.com/foo/?test=2') + + run() + assert_reset() + + +def test_match_querystring_regex(): + @responses.activate + def run(): + """Note that `match_querystring` value shouldn't matter when passing a + regular expression""" + + responses.add( + responses.GET, re.compile(r'http://example\.com/foo/\?test=1'), + body='test1', match_querystring=True) + + resp = requests.get('http://example.com/foo/?test=1') + assert_response(resp, 'test1') + + responses.add( + responses.GET, re.compile(r'http://example\.com/foo/\?test=2'), + body='test2', match_querystring=False) + + resp = requests.get('http://example.com/foo/?test=2') + assert_response(resp, 'test2') + + run() + assert_reset() + + +def test_match_querystring_error_regex(): + @responses.activate + def run(): + """Note that `match_querystring` value shouldn't matter when passing a + regular expression""" + + responses.add( + responses.GET, re.compile(r'http://example\.com/foo/\?test=1'), + match_querystring=True) + + with pytest.raises(ConnectionError): + requests.get('http://example.com/foo/?test=3') + + responses.add( + responses.GET, re.compile(r'http://example\.com/foo/\?test=2'), + match_querystring=False) + + with pytest.raises(ConnectionError): + requests.get('http://example.com/foo/?test=4') + + run() + assert_reset() + + +def test_accept_string_body(): + @responses.activate + def run(): + url = 'http://example.com/' + responses.add( + responses.GET, url, body='test') + resp = requests.get(url) + assert_response(resp, 'test') + + run() + assert_reset() + + +def test_accept_json_body(): + @responses.activate + def run(): + content_type = 'application/json' + + url = 'http://example.com/' + responses.add( + responses.GET, url, json={"message": "success"}) + resp = requests.get(url) + assert_response(resp, '{"message": "success"}', content_type) + + url = 'http://example.com/1/' + responses.add(responses.GET, url, json=[]) + resp = requests.get(url) + assert_response(resp, '[]', content_type) + + run() + assert_reset() + + +def test_no_content_type(): + @responses.activate + def run(): + url = 'http://example.com/' + responses.add( + responses.GET, url, body='test', content_type=None) + resp = requests.get(url) + assert_response(resp, 'test', content_type=None) + + run() + assert_reset() + + +def test_throw_connection_error_explicit(): + @responses.activate + def run(): + url = 'http://example.com' + exception = HTTPError('HTTP Error') + responses.add( + responses.GET, url, exception) + + with pytest.raises(HTTPError) as HE: + requests.get(url) + + assert str(HE.value) == 'HTTP Error' + + run() + assert_reset() + + +def test_callback(): + body = b'test callback' + status = 400 + reason = 'Bad Request' + headers = {'foo': 'bar'} + url = 'http://example.com/' + + def request_callback(request): + return (status, headers, body) + + @responses.activate + def run(): + responses.add_callback(responses.GET, url, request_callback) + resp = requests.get(url) + assert resp.text == "test callback" + assert resp.status_code == status + assert resp.reason == reason + assert 'foo' in resp.headers + assert resp.headers['foo'] == 'bar' + + run() + assert_reset() + + +def test_callback_no_content_type(): + body = b'test callback' + status = 400 + reason = 'Bad Request' + headers = {'foo': 'bar'} + url = 'http://example.com/' + + def request_callback(request): + return (status, headers, body) + + @responses.activate + def run(): + responses.add_callback( + responses.GET, url, request_callback, content_type=None) + resp = requests.get(url) + assert resp.text == "test callback" + assert resp.status_code == status + assert resp.reason == reason + assert 'foo' in resp.headers + assert 'Content-Type' not in resp.headers + + run() + assert_reset() + + +def test_regular_expression_url(): + @responses.activate + def run(): + url = re.compile(r'https?://(.*\.)?example.com') + responses.add(responses.GET, url, body=b'test') + + resp = requests.get('http://example.com') + assert_response(resp, 'test') + + resp = requests.get('https://example.com') + assert_response(resp, 'test') + + resp = requests.get('https://uk.example.com') + assert_response(resp, 'test') + + with pytest.raises(ConnectionError): + requests.get('https://uk.exaaample.com') + + run() + assert_reset() + + +def test_custom_adapter(): + @responses.activate + def run(): + url = "http://example.com" + responses.add(responses.GET, url, body=b'test') + + calls = [0] + + class DummyAdapter(requests.adapters.HTTPAdapter): + def send(self, *a, **k): + calls[0] += 1 + return super(DummyAdapter, self).send(*a, **k) + + # Test that the adapter is actually used + session = requests.Session() + session.mount("http://", DummyAdapter()) + + resp = session.get(url, allow_redirects=False) + assert calls[0] == 1 + + # Test that the response is still correctly emulated + session = requests.Session() + session.mount("http://", DummyAdapter()) + + resp = session.get(url) + assert_response(resp, 'test') + + run() + + +def test_responses_as_context_manager(): + def run(): + with responses.mock: + responses.add(responses.GET, 'http://example.com', body=b'test') + resp = requests.get('http://example.com') + assert_response(resp, 'test') + assert len(responses.calls) == 1 + assert responses.calls[0].request.url == 'http://example.com/' + assert responses.calls[0].response.content == b'test' + + resp = requests.get('http://example.com?foo=bar') + assert_response(resp, 'test') + assert len(responses.calls) == 2 + assert (responses.calls[1].request.url == + 'http://example.com/?foo=bar') + assert responses.calls[1].response.content == b'test' + + run() + assert_reset() + + +def test_activate_doesnt_change_signature(): + def test_function(a, b=None): + return (a, b) + + decorated_test_function = responses.activate(test_function) + assert getargspec(test_function) == getargspec(decorated_test_function) + assert decorated_test_function(1, 2) == test_function(1, 2) + assert decorated_test_function(3) == test_function(3) + + +def test_activate_doesnt_change_signature_for_method(): + class TestCase(object): + + def test_function(self, a, b=None): + return (self, a, b) + + test_case = TestCase() + argspec = getargspec(test_case.test_function) + decorated_test_function = responses.activate(test_case.test_function) + assert argspec == getargspec(decorated_test_function) + assert decorated_test_function(1, 2) == test_case.test_function(1, 2) + assert decorated_test_function(3) == test_case.test_function(3) + + +def test_response_cookies(): + body = b'test callback' + status = 200 + headers = {'set-cookie': 'session_id=12345; a=b; c=d'} + url = 'http://example.com/' + + def request_callback(request): + return (status, headers, body) + + @responses.activate + def run(): + responses.add_callback(responses.GET, url, request_callback) + resp = requests.get(url) + assert resp.text == "test callback" + assert resp.status_code == status + assert 'session_id' in resp.cookies + assert resp.cookies['session_id'] == '12345' + assert resp.cookies['a'] == 'b' + assert resp.cookies['c'] == 'd' + run() + assert_reset() + + +def test_assert_all_requests_are_fired(): + def run(): + with pytest.raises(AssertionError) as excinfo: + with responses.RequestsMock( + assert_all_requests_are_fired=True) as m: + m.add(responses.GET, 'http://example.com', body=b'test') + assert 'http://example.com' in str(excinfo.value) + assert responses.GET in str(excinfo) + + # check that assert_all_requests_are_fired default to True + with pytest.raises(AssertionError): + with responses.RequestsMock() as m: + m.add(responses.GET, 'http://example.com', body=b'test') + + # check that assert_all_requests_are_fired doesn't swallow exceptions + with pytest.raises(ValueError): + with responses.RequestsMock() as m: + m.add(responses.GET, 'http://example.com', body=b'test') + raise ValueError() + + run() + assert_reset() + + +def test_allow_redirects_samehost(): + redirecting_url = 'http://example.com' + final_url_path = '/1' + final_url = '{0}{1}'.format(redirecting_url, final_url_path) + url_re = re.compile(r'^http://example.com(/)?(\d+)?$') + + def request_callback(request): + # endpoint of chained redirect + if request.url.endswith(final_url_path): + return 200, (), b'test' + # otherwise redirect to an integer path + else: + if request.url.endswith('/0'): + n = 1 + else: + n = 0 + redirect_headers = {'location': '/{0!s}'.format(n)} + return 301, redirect_headers, None + + def run(): + # setup redirect + with responses.mock: + responses.add_callback(responses.GET, url_re, request_callback) + resp_no_redirects = requests.get(redirecting_url, + allow_redirects=False) + assert resp_no_redirects.status_code == 301 + assert len(responses.calls) == 1 # 1x300 + assert responses.calls[0][1].status_code == 301 + assert_reset() + + with responses.mock: + responses.add_callback(responses.GET, url_re, request_callback) + resp_yes_redirects = requests.get(redirecting_url, + allow_redirects=True) + assert len(responses.calls) == 3 # 2x300 + 1x200 + assert len(resp_yes_redirects.history) == 2 + assert resp_yes_redirects.status_code == 200 + assert final_url == resp_yes_redirects.url + status_codes = [call[1].status_code for call in responses.calls] + assert status_codes == [301, 301, 200] + assert_reset() + + run() + assert_reset() diff --git a/moto/packages/responses/tox.ini b/moto/packages/responses/tox.ini new file mode 100644 index 000000000..0a31c03ab --- /dev/null +++ b/moto/packages/responses/tox.ini @@ -0,0 +1,11 @@ + +[tox] +envlist = {py26,py27,py32,py33,py34,py35} + +[testenv] +deps = + pytest + pytest-cov + pytest-flakes +commands = + py.test . --cov responses --cov-report term-missing --flakes