mirror of
https://github.com/kevin1024/vcrpy.git
synced 2025-12-09 09:13:23 +00:00
Add support for custom request matchers
This commit not only changes the default method of matching requests (just match on method and URI instead of the entire request + headers) but also allows the user to add custom matchers.
This commit is contained in:
62
README.md
62
README.md
@@ -53,6 +53,7 @@ my_vcr = vcr.VCR(
|
|||||||
serializer = 'json',
|
serializer = 'json',
|
||||||
cassette_library_dir = 'fixtures/cassettes',
|
cassette_library_dir = 'fixtures/cassettes',
|
||||||
record_mode = 'once',
|
record_mode = 'once',
|
||||||
|
match_on = ['url', 'method'],
|
||||||
)
|
)
|
||||||
|
|
||||||
with my_vcr.use_cassette('test.json'):
|
with my_vcr.use_cassette('test.json'):
|
||||||
@@ -68,6 +69,28 @@ with vcr.use_cassette('test.yml', serializer='json', record_mode='once'):
|
|||||||
|
|
||||||
Note: Per-cassette overrides take precedence over the global config.
|
Note: Per-cassette overrides take precedence over the global config.
|
||||||
|
|
||||||
|
## Request matching
|
||||||
|
|
||||||
|
Request matching is configurable and allows you to change which requests
|
||||||
|
VCR considers identical. The default behavior is `['url', method']`
|
||||||
|
which means that requests with both the same URL and method (ie POST or
|
||||||
|
GET) are considered identical.
|
||||||
|
|
||||||
|
This can be configured by changing the `match_on` setting.
|
||||||
|
|
||||||
|
The following options are available :
|
||||||
|
|
||||||
|
* method (for example, POST or GET)
|
||||||
|
* url (the full URL, including the protocol)
|
||||||
|
* host (the hostname of the server receiving the request)
|
||||||
|
* path (excluding the hostname)
|
||||||
|
* body (the entire request body)
|
||||||
|
* headers (the headers of the request)
|
||||||
|
|
||||||
|
If these options don't work for you, you can also register your own
|
||||||
|
request matcher. This is described in the Advanced section of this
|
||||||
|
README.
|
||||||
|
|
||||||
## Record Modes
|
## Record Modes
|
||||||
VCR supports 4 record modes (with the same behavior as Ruby's VCR):
|
VCR supports 4 record modes (with the same behavior as Ruby's VCR):
|
||||||
|
|
||||||
@@ -188,6 +211,41 @@ with my_vcr.use_cassette('test.bogo'):
|
|||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Register your own request matcher
|
||||||
|
|
||||||
|
Create your own method with the following signature
|
||||||
|
|
||||||
|
```python
|
||||||
|
def my_matcher(r1, r2):
|
||||||
|
```
|
||||||
|
|
||||||
|
Your method receives the two requests and must return True if they
|
||||||
|
match, False if they don't.
|
||||||
|
|
||||||
|
Finally, register your method with VCR to use your
|
||||||
|
new request matcher.
|
||||||
|
|
||||||
|
```
|
||||||
|
import vcr
|
||||||
|
|
||||||
|
def jurassic_matcher(r1, r2):
|
||||||
|
return r1.url == r2.url and 'JURASSIC PARK' in r1.body
|
||||||
|
|
||||||
|
my_vcr = vcr.VCR()
|
||||||
|
my_vcr.register_matcher('jurassic', jurassic_matcher)
|
||||||
|
|
||||||
|
with my_vcr.use_cassette('test.yml', match_on=['jurassic']):
|
||||||
|
# your http here
|
||||||
|
|
||||||
|
# After you register, you can set the default match_on to use your new matcher
|
||||||
|
|
||||||
|
my_vcr.match_on = ['jurassic']
|
||||||
|
|
||||||
|
with my_vcr.use_cassette('test.yml'):
|
||||||
|
# your http here
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
##Installation
|
##Installation
|
||||||
|
|
||||||
VCR.py is a package on PyPI, so you can `pip install vcrpy` (first you may need to `brew install libyaml` [[Homebrew](http://mxcl.github.com/homebrew/)])
|
VCR.py is a package on PyPI, so you can `pip install vcrpy` (first you may need to `brew install libyaml` [[Homebrew](http://mxcl.github.com/homebrew/)])
|
||||||
@@ -203,7 +261,9 @@ There are probably some [bugs](https://github.com/kevin1024/vcrpy/issues?labels=
|
|||||||
##Changelog
|
##Changelog
|
||||||
* 0.3.0: *Backwards incompatible release* - Added support for record
|
* 0.3.0: *Backwards incompatible release* - Added support for record
|
||||||
modes, and changed the default recording behavior to the "once" record
|
modes, and changed the default recording behavior to the "once" record
|
||||||
mode. Please see the documentation on record modes for more. Also,
|
mode. Please see the documentation on record modes for more. Added
|
||||||
|
support for custom request matching, and changed the default request
|
||||||
|
matching behavior to match only on the URL and method. Also,
|
||||||
improved the httplib mocking to add support for the `HTTPConnection.send()`
|
improved the httplib mocking to add support for the `HTTPConnection.send()`
|
||||||
method. This means that requests won't actually be sent until the
|
method. This means that requests won't actually be sent until the
|
||||||
response is read, since I need to record the entire request in order
|
response is read, since I need to record the entire request in order
|
||||||
|
|||||||
2
setup.py
2
setup.py
@@ -39,7 +39,7 @@ setup(name='vcrpy',
|
|||||||
},
|
},
|
||||||
install_requires=['PyYAML'],
|
install_requires=['PyYAML'],
|
||||||
license='MIT',
|
license='MIT',
|
||||||
tests_require=['pytest'],
|
tests_require=['pytest','mock'],
|
||||||
cmdclass={'test': PyTest},
|
cmdclass={'test': PyTest},
|
||||||
classifiers=[
|
classifiers=[
|
||||||
'Development Status :: 3 - Alpha',
|
'Development Status :: 3 - Alpha',
|
||||||
|
|||||||
32
tests/integration/test_register_matcher.py
Normal file
32
tests/integration/test_register_matcher.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import urllib2
|
||||||
|
import vcr
|
||||||
|
|
||||||
|
|
||||||
|
def true_matcher(r1, r2):
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def false_matcher(r1, r2):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def test_registered_serializer_true_matcher(tmpdir):
|
||||||
|
my_vcr = vcr.VCR()
|
||||||
|
my_vcr.register_matcher('true', true_matcher)
|
||||||
|
testfile = str(tmpdir.join('test.yml'))
|
||||||
|
with my_vcr.use_cassette(testfile, match_on=['true']) as cass:
|
||||||
|
# These 2 different urls are stored as the same request
|
||||||
|
urllib2.urlopen('http://httpbin.org/')
|
||||||
|
urllib2.urlopen('https://httpbin.org/get')
|
||||||
|
assert len(cass) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_registered_serializer_false_matcher(tmpdir):
|
||||||
|
my_vcr = vcr.VCR()
|
||||||
|
my_vcr.register_matcher('false', false_matcher)
|
||||||
|
testfile = str(tmpdir.join('test.yml'))
|
||||||
|
with my_vcr.use_cassette(testfile, match_on=['false']) as cass:
|
||||||
|
# These 2 different urls are stored as different requests
|
||||||
|
urllib2.urlopen('http://httpbin.org/')
|
||||||
|
urllib2.urlopen('https://httpbin.org/get')
|
||||||
|
assert len(cass) == 2
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import pytest
|
import pytest
|
||||||
import yaml
|
import yaml
|
||||||
|
import mock
|
||||||
from vcr.cassette import Cassette
|
from vcr.cassette import Cassette
|
||||||
|
|
||||||
|
|
||||||
@@ -46,18 +47,24 @@ def test_cassette_len():
|
|||||||
assert len(a) == 2
|
assert len(a) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def _mock_requests_match(request1, request2, matchers):
|
||||||
|
return request1 == request2
|
||||||
|
|
||||||
|
@mock.patch('vcr.cassette.requests_match', _mock_requests_match)
|
||||||
def test_cassette_contains():
|
def test_cassette_contains():
|
||||||
a = Cassette('test')
|
a = Cassette('test')
|
||||||
a.append('foo', 'bar')
|
a.append('foo', 'bar')
|
||||||
assert 'foo' in a
|
assert 'foo' in a
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch('vcr.cassette.requests_match', _mock_requests_match)
|
||||||
def test_cassette_response_of():
|
def test_cassette_response_of():
|
||||||
a = Cassette('test')
|
a = Cassette('test')
|
||||||
a.append('foo', 'bar')
|
a.append('foo', 'bar')
|
||||||
assert a.response_of('foo') == 'bar'
|
assert a.response_of('foo') == 'bar'
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch('vcr.cassette.requests_match', _mock_requests_match)
|
||||||
def test_cassette_get_missing_response():
|
def test_cassette_get_missing_response():
|
||||||
a = Cassette('test')
|
a = Cassette('test')
|
||||||
with pytest.raises(KeyError):
|
with pytest.raises(KeyError):
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ except ImportError:
|
|||||||
from .patch import install, reset
|
from .patch import install, reset
|
||||||
from .persist import load_cassette, save_cassette
|
from .persist import load_cassette, save_cassette
|
||||||
from .serializers import yamlserializer
|
from .serializers import yamlserializer
|
||||||
|
from .matchers import requests_match, url, method
|
||||||
|
|
||||||
|
|
||||||
class Cassette(object):
|
class Cassette(object):
|
||||||
@@ -22,10 +23,17 @@ class Cassette(object):
|
|||||||
new_cassette._load()
|
new_cassette._load()
|
||||||
return new_cassette
|
return new_cassette
|
||||||
|
|
||||||
def __init__(self, path, serializer=yamlserializer, record_mode='once'):
|
def __init__(self,
|
||||||
|
path,
|
||||||
|
serializer=yamlserializer,
|
||||||
|
record_mode='once',
|
||||||
|
match_on=[url, method]):
|
||||||
self._path = path
|
self._path = path
|
||||||
self._serializer = serializer
|
self._serializer = serializer
|
||||||
self.data = OrderedDict()
|
self._match_on = match_on
|
||||||
|
|
||||||
|
# self.data is the list of (req, resp) tuples
|
||||||
|
self.data = []
|
||||||
self.play_counts = Counter()
|
self.play_counts = Counter()
|
||||||
self.dirty = False
|
self.dirty = False
|
||||||
self.record_mode = record_mode
|
self.record_mode = record_mode
|
||||||
@@ -36,11 +44,11 @@ class Cassette(object):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def requests(self):
|
def requests(self):
|
||||||
return self.data.keys()
|
return [request for (request, response) in self.data]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def responses(self):
|
def responses(self):
|
||||||
return self.data.values()
|
return [response for (request, response) in self.data]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def rewound(self):
|
def rewound(self):
|
||||||
@@ -64,12 +72,25 @@ class Cassette(object):
|
|||||||
|
|
||||||
def append(self, request, response):
|
def append(self, request, response):
|
||||||
'''Add a request, response pair to this cassette'''
|
'''Add a request, response pair to this cassette'''
|
||||||
self.data[request] = response
|
self.data.append((request, response))
|
||||||
self.dirty = True
|
self.dirty = True
|
||||||
|
|
||||||
def response_of(self, request):
|
def response_of(self, request):
|
||||||
'''Find the response corresponding to a request'''
|
'''
|
||||||
return self.data[request]
|
Find the response corresponding to a request
|
||||||
|
|
||||||
|
'''
|
||||||
|
responses = []
|
||||||
|
for stored_request, response in self.data:
|
||||||
|
if requests_match(request, stored_request, self._match_on):
|
||||||
|
responses.append(response)
|
||||||
|
index = self.play_counts[request]
|
||||||
|
try:
|
||||||
|
return responses[index]
|
||||||
|
except IndexError:
|
||||||
|
# I decided that a KeyError is the best exception to raise
|
||||||
|
# if the cassette doesn't contain the request asked for.
|
||||||
|
raise KeyError
|
||||||
|
|
||||||
def _as_dict(self):
|
def _as_dict(self):
|
||||||
return {"requests": self.requests, "responses": self.responses}
|
return {"requests": self.requests, "responses": self.responses}
|
||||||
@@ -106,7 +127,10 @@ class Cassette(object):
|
|||||||
|
|
||||||
def __contains__(self, request):
|
def __contains__(self, request):
|
||||||
'''Return whether or not a request has been stored'''
|
'''Return whether or not a request has been stored'''
|
||||||
return request in self.data
|
for stored_request, response in self.data:
|
||||||
|
if requests_match(stored_request, request, self._match_on):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
def __enter__(self):
|
def __enter__(self):
|
||||||
'''Patch the fetching libraries we know about'''
|
'''Patch the fetching libraries we know about'''
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import os
|
import os
|
||||||
from .cassette import Cassette
|
from .cassette import Cassette
|
||||||
from .serializers import yamlserializer, jsonserializer
|
from .serializers import yamlserializer, jsonserializer
|
||||||
|
from .matchers import method, url, host, path, headers, body
|
||||||
|
|
||||||
|
|
||||||
class VCR(object):
|
class VCR(object):
|
||||||
@@ -9,11 +10,20 @@ class VCR(object):
|
|||||||
cassette_library_dir=None,
|
cassette_library_dir=None,
|
||||||
record_mode="once"):
|
record_mode="once"):
|
||||||
self.serializer = serializer
|
self.serializer = serializer
|
||||||
|
self.match_on = ['url', 'method']
|
||||||
self.cassette_library_dir = cassette_library_dir
|
self.cassette_library_dir = cassette_library_dir
|
||||||
self.serializers = {
|
self.serializers = {
|
||||||
'yaml': yamlserializer,
|
'yaml': yamlserializer,
|
||||||
'json': jsonserializer,
|
'json': jsonserializer,
|
||||||
}
|
}
|
||||||
|
self.matchers = {
|
||||||
|
'method': method,
|
||||||
|
'url': url,
|
||||||
|
'host': host,
|
||||||
|
'path': path,
|
||||||
|
'headers': headers,
|
||||||
|
'body': body,
|
||||||
|
}
|
||||||
self.record_mode = record_mode
|
self.record_mode = record_mode
|
||||||
|
|
||||||
def _get_serializer(self, serializer_name):
|
def _get_serializer(self, serializer_name):
|
||||||
@@ -26,8 +36,19 @@ class VCR(object):
|
|||||||
raise KeyError
|
raise KeyError
|
||||||
return serializer
|
return serializer
|
||||||
|
|
||||||
|
def _get_matchers(self, matcher_names):
|
||||||
|
try:
|
||||||
|
matchers = [self.matchers[m] for m in matcher_names]
|
||||||
|
except KeyError:
|
||||||
|
print "Matcher {0} doesn't exist or isn't registered".format(
|
||||||
|
matcher_name
|
||||||
|
)
|
||||||
|
raise KeyError
|
||||||
|
return matchers
|
||||||
|
|
||||||
def use_cassette(self, path, **kwargs):
|
def use_cassette(self, path, **kwargs):
|
||||||
serializer_name = kwargs.get('serializer', self.serializer)
|
serializer_name = kwargs.get('serializer', self.serializer)
|
||||||
|
matcher_names = kwargs.get('match_on', self.match_on)
|
||||||
cassette_library_dir = kwargs.get(
|
cassette_library_dir = kwargs.get(
|
||||||
'cassette_library_dir',
|
'cassette_library_dir',
|
||||||
self.cassette_library_dir
|
self.cassette_library_dir
|
||||||
@@ -38,6 +59,7 @@ class VCR(object):
|
|||||||
|
|
||||||
merged_config = {
|
merged_config = {
|
||||||
"serializer": self._get_serializer(serializer_name),
|
"serializer": self._get_serializer(serializer_name),
|
||||||
|
"match_on": self._get_matchers(matcher_names),
|
||||||
"record_mode": kwargs.get('record_mode', self.record_mode),
|
"record_mode": kwargs.get('record_mode', self.record_mode),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,3 +67,6 @@ class VCR(object):
|
|||||||
|
|
||||||
def register_serializer(self, name, serializer):
|
def register_serializer(self, name, serializer):
|
||||||
self.serializers[name] = serializer
|
self.serializers[name] = serializer
|
||||||
|
|
||||||
|
def register_matcher(self, name, matcher):
|
||||||
|
self.matchers[name] = matcher
|
||||||
|
|||||||
26
vcr/matchers.py
Normal file
26
vcr/matchers.py
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
def method(r1, r2):
|
||||||
|
return r1.method == r2.method
|
||||||
|
|
||||||
|
|
||||||
|
def url(r1, r2):
|
||||||
|
return r1.url == r2.url
|
||||||
|
|
||||||
|
|
||||||
|
def host(r1, r2):
|
||||||
|
return r1.host == r2.host
|
||||||
|
|
||||||
|
|
||||||
|
def path(r1, r2):
|
||||||
|
return r1.path == r2.path
|
||||||
|
|
||||||
|
|
||||||
|
def body(r1, r2):
|
||||||
|
return r1.body == r2.body
|
||||||
|
|
||||||
|
|
||||||
|
def headers(r1, r2):
|
||||||
|
return r1.headers == r2.headers
|
||||||
|
|
||||||
|
|
||||||
|
def requests_match(r1, r2, matchers):
|
||||||
|
return all(m(r1, r2) for m in matchers)
|
||||||
Reference in New Issue
Block a user