mirror of
https://github.com/kevin1024/vcrpy.git
synced 2025-12-08 16:53:23 +00:00
Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b59d73f25 | ||
|
|
eb394b90d9 | ||
|
|
14931dd47a | ||
|
|
89cdda86d1 | ||
|
|
ad48d71897 | ||
|
|
946ce17a97 | ||
|
|
4d438dac75 | ||
|
|
a234ad6b12 | ||
|
|
1d000ac652 | ||
|
|
21c176ee1e | ||
|
|
4fb5bef8e1 | ||
|
|
9717596e2c | ||
|
|
1660cc3a9f | ||
|
|
4beb023204 | ||
|
|
72eb5345d6 | ||
|
|
fe7d193d1a | ||
|
|
09b7ccf561 | ||
|
|
a4a80b431b | ||
|
|
025a3b422d | ||
|
|
bb05b2fcf7 | ||
|
|
f77ef81877 |
22
README.rst
22
README.rst
@@ -50,6 +50,7 @@ The following http libraries are supported:
|
||||
- requests (both 1.x and 2.x versions)
|
||||
- httplib2
|
||||
- boto
|
||||
- Tornado's AsyncHTTPClient
|
||||
|
||||
Usage
|
||||
-----
|
||||
@@ -108,10 +109,10 @@ If you don't like VCR's defaults, you can set options by instantiating a
|
||||
import vcr
|
||||
|
||||
my_vcr = vcr.VCR(
|
||||
serializer = 'json',
|
||||
cassette_library_dir = 'fixtures/cassettes',
|
||||
record_mode = 'once',
|
||||
match_on = ['uri', 'method'],
|
||||
serializer='json',
|
||||
cassette_library_dir='fixtures/cassettes',
|
||||
record_mode='once',
|
||||
match_on=['uri', 'method'],
|
||||
)
|
||||
|
||||
with my_vcr.use_cassette('test.json'):
|
||||
@@ -416,12 +417,13 @@ that of ``before_record``:
|
||||
.. code:: python
|
||||
|
||||
def scrub_string(string, replacement=''):
|
||||
def before_record_reponse(response):
|
||||
return response['body']['string'] = response['body']['string'].replace(string, replacement)
|
||||
return scrub_string
|
||||
def before_record_response(response):
|
||||
response['body']['string'] = response['body']['string'].replace(string, replacement)
|
||||
return response
|
||||
return before_record_response
|
||||
|
||||
my_vcr = vcr.VCR(
|
||||
before_record=scrub_string(settings.USERNAME, 'username'),
|
||||
before_record_response=scrub_string(settings.USERNAME, 'username'),
|
||||
)
|
||||
with my_vcr.use_cassette('test.yml'):
|
||||
# your http code here
|
||||
@@ -606,6 +608,10 @@ new API in version 1.0.x
|
||||
|
||||
Changelog
|
||||
---------
|
||||
- 1.7.0 [#177] Properly support coroutine/generator decoration. [#178]
|
||||
Support distribute (thanks @graingert). [#163] Make compatibility
|
||||
between python2 and python3 recorded cassettes more robust (thanks
|
||||
@gward).
|
||||
- 1.6.1 [#169] Support conditional requirements in old versions of
|
||||
pip, Fix RST parse errors generated by pandoc, [Tornado] Fix
|
||||
unsupported features exception not being raised, [#166]
|
||||
|
||||
17
setup.py
17
setup.py
@@ -1,6 +1,8 @@
|
||||
#!/usr/bin/env python
|
||||
|
||||
import sys
|
||||
import logging
|
||||
|
||||
from setuptools import setup, find_packages
|
||||
from setuptools.command.test import test as TestCommand
|
||||
import pkg_resources
|
||||
@@ -31,14 +33,23 @@ extras_require = {
|
||||
}
|
||||
|
||||
|
||||
if 'bdist_wheel' not in sys.argv:
|
||||
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(':') and pkg_resources.evaluate_marker(key[1:]):
|
||||
if key.startswith(':'):
|
||||
install_requires.extend(value)
|
||||
|
||||
setup(
|
||||
name='vcrpy',
|
||||
version='1.6.1',
|
||||
version='1.7.0',
|
||||
description=(
|
||||
"Automatically mock your HTTP interactions to simplify and "
|
||||
"speed up testing"
|
||||
|
||||
@@ -275,3 +275,26 @@ def test_cannot_overwrite_cassette_raise_error_disabled(get_client, tmpdir):
|
||||
)
|
||||
|
||||
assert isinstance(response.error, CannotOverwriteExistingCassetteException)
|
||||
|
||||
|
||||
@pytest.mark.gen_test
|
||||
@vcr.use_cassette(path_transformer=vcr.default_vcr.ensure_suffix('.yaml'))
|
||||
def test_tornado_with_decorator_use_cassette(get_client):
|
||||
response = yield get_client().fetch(
|
||||
http.HTTPRequest('http://www.google.com/', method='GET')
|
||||
)
|
||||
assert response.body.decode('utf-8') == "not actually google"
|
||||
|
||||
|
||||
@pytest.mark.gen_test
|
||||
@vcr.use_cassette(path_transformer=vcr.default_vcr.ensure_suffix('.yaml'))
|
||||
def test_tornado_exception_can_be_caught(get_client):
|
||||
try:
|
||||
yield get(get_client(), 'http://httpbin.org/status/500')
|
||||
except http.HTTPError as e:
|
||||
assert e.code == 500
|
||||
|
||||
try:
|
||||
yield get(get_client(), 'http://httpbin.org/status/404')
|
||||
except http.HTTPError as e:
|
||||
assert e.code == 404
|
||||
|
||||
62
tests/integration/test_tornado_exception_can_be_caught.yaml
Normal file
62
tests/integration/test_tornado_exception_can_be_caught.yaml
Normal file
@@ -0,0 +1,62 @@
|
||||
interactions:
|
||||
- request:
|
||||
body: null
|
||||
headers: {}
|
||||
method: GET
|
||||
uri: http://httpbin.org/status/500
|
||||
response:
|
||||
body: {string: !!python/unicode ''}
|
||||
headers:
|
||||
- !!python/tuple
|
||||
- Content-Length
|
||||
- ['0']
|
||||
- !!python/tuple
|
||||
- Server
|
||||
- [nginx]
|
||||
- !!python/tuple
|
||||
- Connection
|
||||
- [close]
|
||||
- !!python/tuple
|
||||
- Access-Control-Allow-Credentials
|
||||
- ['true']
|
||||
- !!python/tuple
|
||||
- Date
|
||||
- ['Thu, 30 Jul 2015 17:32:39 GMT']
|
||||
- !!python/tuple
|
||||
- Access-Control-Allow-Origin
|
||||
- ['*']
|
||||
- !!python/tuple
|
||||
- Content-Type
|
||||
- [text/html; charset=utf-8]
|
||||
status: {code: 500, message: INTERNAL SERVER ERROR}
|
||||
- request:
|
||||
body: null
|
||||
headers: {}
|
||||
method: GET
|
||||
uri: http://httpbin.org/status/404
|
||||
response:
|
||||
body: {string: !!python/unicode ''}
|
||||
headers:
|
||||
- !!python/tuple
|
||||
- Content-Length
|
||||
- ['0']
|
||||
- !!python/tuple
|
||||
- Server
|
||||
- [nginx]
|
||||
- !!python/tuple
|
||||
- Connection
|
||||
- [close]
|
||||
- !!python/tuple
|
||||
- Access-Control-Allow-Credentials
|
||||
- ['true']
|
||||
- !!python/tuple
|
||||
- Date
|
||||
- ['Thu, 30 Jul 2015 17:32:39 GMT']
|
||||
- !!python/tuple
|
||||
- Access-Control-Allow-Origin
|
||||
- ['*']
|
||||
- !!python/tuple
|
||||
- Content-Type
|
||||
- [text/html; charset=utf-8]
|
||||
status: {code: 404, message: NOT FOUND}
|
||||
version: 1
|
||||
@@ -0,0 +1,53 @@
|
||||
interactions:
|
||||
- request:
|
||||
body: null
|
||||
headers: {}
|
||||
method: GET
|
||||
uri: http://www.google.com/
|
||||
response:
|
||||
body: {string: !!python/unicode 'not actually google'}
|
||||
headers:
|
||||
- !!python/tuple
|
||||
- Expires
|
||||
- ['-1']
|
||||
- !!python/tuple
|
||||
- Connection
|
||||
- [close]
|
||||
- !!python/tuple
|
||||
- P3p
|
||||
- ['CP="This is not a P3P policy! See http://www.google.com/support/accounts/bin/answer.py?hl=en&answer=151657
|
||||
for more info."']
|
||||
- !!python/tuple
|
||||
- Alternate-Protocol
|
||||
- ['80:quic,p=0']
|
||||
- !!python/tuple
|
||||
- Accept-Ranges
|
||||
- [none]
|
||||
- !!python/tuple
|
||||
- X-Xss-Protection
|
||||
- [1; mode=block]
|
||||
- !!python/tuple
|
||||
- Vary
|
||||
- [Accept-Encoding]
|
||||
- !!python/tuple
|
||||
- Date
|
||||
- ['Thu, 30 Jul 2015 08:41:40 GMT']
|
||||
- !!python/tuple
|
||||
- Cache-Control
|
||||
- ['private, max-age=0']
|
||||
- !!python/tuple
|
||||
- Content-Type
|
||||
- [text/html; charset=ISO-8859-1]
|
||||
- !!python/tuple
|
||||
- Set-Cookie
|
||||
- ['PREF=ID=1111111111111111:FF=0:TM=1438245700:LM=1438245700:V=1:S=GAzVO0ALebSpC_cJ;
|
||||
expires=Sat, 29-Jul-2017 08:41:40 GMT; path=/; domain=.google.com', 'NID=69=Br7oRAwgmKoK__HC6FEnuxglTFDmFxqP6Md63lKhzW1w6WkDbp3U90CDxnUKvDP6wJH8yxY5Lk5ZnFf66Q1B0d4OsYoKgq0vjfBAYXuCIAWtOuGZEOsFXanXs7pt2Mjx;
|
||||
expires=Fri, 29-Jan-2016 08:41:40 GMT; path=/; domain=.google.com; HttpOnly']
|
||||
- !!python/tuple
|
||||
- X-Frame-Options
|
||||
- [SAMEORIGIN]
|
||||
- !!python/tuple
|
||||
- Server
|
||||
- [gws]
|
||||
status: {code: 200, message: OK}
|
||||
version: 1
|
||||
@@ -253,3 +253,37 @@ def test_func_path_generator():
|
||||
def function_name(cassette):
|
||||
assert cassette._path == os.path.join(os.path.dirname(__file__), 'function_name')
|
||||
function_name()
|
||||
|
||||
|
||||
def test_use_as_decorator_on_coroutine():
|
||||
original_http_connetion = httplib.HTTPConnection
|
||||
@Cassette.use(inject=True)
|
||||
def test_function(cassette):
|
||||
assert httplib.HTTPConnection.cassette is cassette
|
||||
assert httplib.HTTPConnection is not original_http_connetion
|
||||
value = yield 1
|
||||
assert value == 1
|
||||
assert httplib.HTTPConnection.cassette is cassette
|
||||
assert httplib.HTTPConnection is not original_http_connetion
|
||||
value = yield 2
|
||||
assert value == 2
|
||||
coroutine = test_function()
|
||||
value = next(coroutine)
|
||||
while True:
|
||||
try:
|
||||
value = coroutine.send(value)
|
||||
except StopIteration:
|
||||
break
|
||||
|
||||
|
||||
def test_use_as_decorator_on_generator():
|
||||
original_http_connetion = httplib.HTTPConnection
|
||||
@Cassette.use(inject=True)
|
||||
def test_function(cassette):
|
||||
assert httplib.HTTPConnection.cassette is cassette
|
||||
assert httplib.HTTPConnection is not original_http_connetion
|
||||
yield 1
|
||||
assert httplib.HTTPConnection.cassette is cassette
|
||||
assert httplib.HTTPConnection is not original_http_connetion
|
||||
yield 2
|
||||
assert list(test_function()) == [1, 2]
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
# -*- encoding: utf-8 -*-
|
||||
import pytest
|
||||
|
||||
from vcr.compat import mock
|
||||
@@ -27,6 +28,55 @@ def test_deserialize_new_json_cassette():
|
||||
deserialize(f.read(), jsonserializer)
|
||||
|
||||
|
||||
REQBODY_TEMPLATE = u'''\
|
||||
interactions:
|
||||
- request:
|
||||
body: {req_body}
|
||||
headers:
|
||||
Content-Type: [application/x-www-form-urlencoded]
|
||||
Host: [httpbin.org]
|
||||
method: POST
|
||||
uri: http://httpbin.org/post
|
||||
response:
|
||||
body: {{string: ""}}
|
||||
headers:
|
||||
content-length: ['0']
|
||||
content-type: [application/json]
|
||||
status: {{code: 200, message: OK}}
|
||||
'''
|
||||
|
||||
|
||||
# A cassette generated under Python 2 stores the request body as a string,
|
||||
# but the same cassette generated under Python 3 stores it as "!!binary".
|
||||
# Make sure we accept both forms, regardless of whether we're running under
|
||||
# Python 2 or 3.
|
||||
@pytest.mark.parametrize("req_body, expect", [
|
||||
# Cassette written under Python 2 (pure ASCII body)
|
||||
('x=5&y=2', b'x=5&y=2'),
|
||||
# Cassette written under Python 3 (pure ASCII body)
|
||||
('!!binary |\n eD01Jnk9Mg==', b'x=5&y=2'),
|
||||
|
||||
# Request body has non-ASCII chars (x=föo&y=2), encoded in UTF-8.
|
||||
('!!python/str "x=f\\xF6o&y=2"', b'x=f\xc3\xb6o&y=2'),
|
||||
('!!binary |\n eD1mw7ZvJnk9Mg==', b'x=f\xc3\xb6o&y=2'),
|
||||
|
||||
# Same request body, this time encoded in UTF-16. In this case, we
|
||||
# write the same YAML file under both Python 2 and 3, so there's only
|
||||
# one test case here.
|
||||
('!!binary |\n //54AD0AZgD2AG8AJgB5AD0AMgA=',
|
||||
b'\xff\xfex\x00=\x00f\x00\xf6\x00o\x00&\x00y\x00=\x002\x00'),
|
||||
|
||||
# Same again, this time encoded in ISO-8859-1.
|
||||
('!!binary |\n eD1m9m8meT0y', b'x=f\xf6o&y=2'),
|
||||
])
|
||||
def test_deserialize_py2py3_yaml_cassette(tmpdir, req_body, expect):
|
||||
cfile = tmpdir.join('test_cassette.yaml')
|
||||
cfile.write(REQBODY_TEMPLATE.format(req_body=req_body))
|
||||
with open(str(cfile)) as f:
|
||||
(requests, responses) = deserialize(f.read(), yamlserializer)
|
||||
assert requests[0].body == expect
|
||||
|
||||
|
||||
@mock.patch.object(jsonserializer.json, 'dumps',
|
||||
side_effect=UnicodeDecodeError('utf-8', b'unicode error in serialization',
|
||||
0, 10, 'blew up'))
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
"""The container for recorded requests and responses"""
|
||||
import functools
|
||||
import sys
|
||||
import inspect
|
||||
import logging
|
||||
|
||||
|
||||
import wrapt
|
||||
|
||||
# Internal imports
|
||||
from .compat import contextlib, collections
|
||||
from .errors import UnhandledHTTPRequestError
|
||||
from .matchers import requests_match, uri, method
|
||||
@@ -50,14 +48,6 @@ class CassetteContextDecorator(object):
|
||||
# somewhere else.
|
||||
cassette._save()
|
||||
|
||||
@classmethod
|
||||
def key_predicate(cls, key, value):
|
||||
return key in cls._non_cassette_arguments
|
||||
|
||||
@classmethod
|
||||
def _split_keys(cls, kwargs):
|
||||
return partition_dict(cls.key_predicate, kwargs)
|
||||
|
||||
def __enter__(self):
|
||||
# This assertion is here to prevent the dangerous behavior
|
||||
# that would result from forgetting about a __finish before
|
||||
@@ -68,7 +58,10 @@ class CassetteContextDecorator(object):
|
||||
# with context_decorator:
|
||||
# pass
|
||||
assert self.__finish is None, "Cassette already open."
|
||||
other_kwargs, cassette_kwargs = self._split_keys(self._args_getter())
|
||||
other_kwargs, cassette_kwargs = partition_dict(
|
||||
lambda key, _: key in self._non_cassette_arguments,
|
||||
self._args_getter()
|
||||
)
|
||||
if 'path_transformer' in other_kwargs:
|
||||
transformer = other_kwargs['path_transformer']
|
||||
cassette_kwargs['path'] = transformer(cassette_kwargs['path'])
|
||||
@@ -84,27 +77,53 @@ class CassetteContextDecorator(object):
|
||||
# This awkward cloning thing is done to ensure that decorated
|
||||
# functions are reentrant. This is required for thread
|
||||
# safety and the correct operation of recursive functions.
|
||||
args_getter = self._build_args_getter_for_decorator(
|
||||
function, self._args_getter
|
||||
)
|
||||
clone = type(self)(self.cls, args_getter)
|
||||
with clone as cassette:
|
||||
if cassette.inject:
|
||||
return function(cassette, *args, **kwargs)
|
||||
else:
|
||||
return function(*args, **kwargs)
|
||||
args_getter = self._build_args_getter_for_decorator(function)
|
||||
return type(self)(self.cls, args_getter)._execute_function(function, args, kwargs)
|
||||
|
||||
def _execute_function(self, function, args, kwargs):
|
||||
if inspect.isgeneratorfunction(function):
|
||||
handler = self._handle_coroutine
|
||||
else:
|
||||
handler = self._handle_function
|
||||
return handler(function, args, kwargs)
|
||||
|
||||
def _handle_coroutine(self, function, args, kwargs):
|
||||
"""Wraps a coroutine so that we're inside the cassette context for the
|
||||
duration of the coroutine.
|
||||
"""
|
||||
with self as cassette:
|
||||
coroutine = self.__handle_function(cassette, function, args, kwargs)
|
||||
# We don't need to catch StopIteration. The caller (Tornado's
|
||||
# gen.coroutine, for example) will handle that.
|
||||
to_yield = next(coroutine)
|
||||
while True:
|
||||
try:
|
||||
to_send = yield to_yield
|
||||
except Exception:
|
||||
to_yield = coroutine.throw(*sys.exc_info())
|
||||
else:
|
||||
to_yield = coroutine.send(to_send)
|
||||
|
||||
def __handle_function(self, cassette, function, args, kwargs):
|
||||
if cassette.inject:
|
||||
return function(cassette, *args, **kwargs)
|
||||
else:
|
||||
return function(*args, **kwargs)
|
||||
|
||||
def _handle_function(self, function, args, kwargs):
|
||||
with self as cassette:
|
||||
self.__handle_function(cassette, function, args, kwargs)
|
||||
|
||||
@staticmethod
|
||||
def get_function_name(function):
|
||||
return function.__name__
|
||||
|
||||
@classmethod
|
||||
def _build_args_getter_for_decorator(cls, function, args_getter):
|
||||
def _build_args_getter_for_decorator(self, function):
|
||||
def new_args_getter():
|
||||
kwargs = args_getter()
|
||||
kwargs = self._args_getter()
|
||||
if 'path' not in kwargs:
|
||||
name_generator = (kwargs.get('func_path_generator') or
|
||||
cls.get_function_name)
|
||||
self.get_function_name)
|
||||
path = name_generator(function)
|
||||
kwargs['path'] = path
|
||||
return kwargs
|
||||
|
||||
@@ -107,7 +107,7 @@ class VCR(object):
|
||||
matcher_names = kwargs.get('match_on', self.match_on)
|
||||
path_transformer = kwargs.get(
|
||||
'path_transformer',
|
||||
self.path_transformer
|
||||
self.path_transformer or self.ensure_suffix('.yaml')
|
||||
)
|
||||
func_path_generator = kwargs.get(
|
||||
'func_path_generator',
|
||||
|
||||
@@ -43,11 +43,18 @@ def _header_checker(value, header='Content-Type'):
|
||||
return checker
|
||||
|
||||
|
||||
def _transform_json(body):
|
||||
# Request body is always a byte string, but json.loads() wants a text
|
||||
# string. RFC 7159 says the default encoding is UTF-8 (although UTF-16
|
||||
# and UTF-32 are also allowed: hmmmmm).
|
||||
return json.loads(body.decode('utf-8'))
|
||||
|
||||
|
||||
_xml_header_checker = _header_checker('text/xml')
|
||||
_xmlrpc_header_checker = _header_checker('xmlrpc', header='User-Agent')
|
||||
_checker_transformer_pairs = (
|
||||
(_header_checker('application/x-www-form-urlencoded'), urllib.parse.parse_qs),
|
||||
(_header_checker('application/json'), json.loads),
|
||||
(_header_checker('application/json'), _transform_json),
|
||||
(lambda request: _xml_header_checker(request) and _xmlrpc_header_checker(request), xmlrpc_client.loads),
|
||||
)
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from six import BytesIO, binary_type
|
||||
from six import BytesIO, text_type
|
||||
from six.moves.urllib.parse import urlparse, parse_qsl
|
||||
|
||||
|
||||
@@ -29,11 +29,9 @@ class Request(object):
|
||||
self.uri = uri
|
||||
self._was_file = hasattr(body, 'read')
|
||||
if self._was_file:
|
||||
self._body = body.read()
|
||||
if not isinstance(self._body, binary_type):
|
||||
self._body = self._body.encode('utf-8')
|
||||
self.body = body.read()
|
||||
else:
|
||||
self._body = body
|
||||
self.body = body
|
||||
self.headers = {}
|
||||
for key in headers:
|
||||
self.add_header(key, headers[key])
|
||||
@@ -44,6 +42,8 @@ class Request(object):
|
||||
|
||||
@body.setter
|
||||
def body(self, value):
|
||||
if isinstance(value, text_type):
|
||||
value = value.encode('utf-8')
|
||||
self._body = value
|
||||
|
||||
def add_header(self, key, value):
|
||||
|
||||
Reference in New Issue
Block a user