From a66f462dcd3d988a72a2b7c0d00ee1746e617157 Mon Sep 17 00:00:00 2001 From: Kevin McCarthy Date: Mon, 16 Sep 2013 20:09:25 -1000 Subject: [PATCH] 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. --- README.md | 62 +++++++++++++++++++++- setup.py | 2 +- tests/integration/test_register_matcher.py | 32 +++++++++++ tests/unit/test_cassettes.py | 7 +++ vcr/cassette.py | 40 +++++++++++--- vcr/config.py | 25 +++++++++ vcr/matchers.py | 26 +++++++++ 7 files changed, 184 insertions(+), 10 deletions(-) create mode 100644 tests/integration/test_register_matcher.py create mode 100644 vcr/matchers.py diff --git a/README.md b/README.md index 739849f..5f6128d 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,7 @@ my_vcr = vcr.VCR( serializer = 'json', cassette_library_dir = 'fixtures/cassettes', record_mode = 'once', + match_on = ['url', 'method'], ) 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. +## 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 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 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 * 0.3.0: *Backwards incompatible release* - Added support for 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()` 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 diff --git a/setup.py b/setup.py index d626dec..be8e50b 100644 --- a/setup.py +++ b/setup.py @@ -39,7 +39,7 @@ setup(name='vcrpy', }, install_requires=['PyYAML'], license='MIT', - tests_require=['pytest'], + tests_require=['pytest','mock'], cmdclass={'test': PyTest}, classifiers=[ 'Development Status :: 3 - Alpha', diff --git a/tests/integration/test_register_matcher.py b/tests/integration/test_register_matcher.py new file mode 100644 index 0000000..8bd11b4 --- /dev/null +++ b/tests/integration/test_register_matcher.py @@ -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 diff --git a/tests/unit/test_cassettes.py b/tests/unit/test_cassettes.py index e0743c1..0d5de47 100644 --- a/tests/unit/test_cassettes.py +++ b/tests/unit/test_cassettes.py @@ -1,5 +1,6 @@ import pytest import yaml +import mock from vcr.cassette import Cassette @@ -46,18 +47,24 @@ def test_cassette_len(): 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(): a = Cassette('test') a.append('foo', 'bar') assert 'foo' in a +@mock.patch('vcr.cassette.requests_match', _mock_requests_match) def test_cassette_response_of(): a = Cassette('test') a.append('foo', 'bar') assert a.response_of('foo') == 'bar' +@mock.patch('vcr.cassette.requests_match', _mock_requests_match) def test_cassette_get_missing_response(): a = Cassette('test') with pytest.raises(KeyError): diff --git a/vcr/cassette.py b/vcr/cassette.py index ded9936..b6d1bd1 100644 --- a/vcr/cassette.py +++ b/vcr/cassette.py @@ -10,6 +10,7 @@ except ImportError: from .patch import install, reset from .persist import load_cassette, save_cassette from .serializers import yamlserializer +from .matchers import requests_match, url, method class Cassette(object): @@ -22,10 +23,17 @@ class Cassette(object): new_cassette._load() 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._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.dirty = False self.record_mode = record_mode @@ -36,11 +44,11 @@ class Cassette(object): @property def requests(self): - return self.data.keys() + return [request for (request, response) in self.data] @property def responses(self): - return self.data.values() + return [response for (request, response) in self.data] @property def rewound(self): @@ -64,12 +72,25 @@ class Cassette(object): def append(self, request, response): '''Add a request, response pair to this cassette''' - self.data[request] = response + self.data.append((request, response)) self.dirty = True 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): return {"requests": self.requests, "responses": self.responses} @@ -106,7 +127,10 @@ class Cassette(object): def __contains__(self, request): '''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): '''Patch the fetching libraries we know about''' diff --git a/vcr/config.py b/vcr/config.py index 2c47eea..aef77e7 100644 --- a/vcr/config.py +++ b/vcr/config.py @@ -1,6 +1,7 @@ import os from .cassette import Cassette from .serializers import yamlserializer, jsonserializer +from .matchers import method, url, host, path, headers, body class VCR(object): @@ -9,11 +10,20 @@ class VCR(object): cassette_library_dir=None, record_mode="once"): self.serializer = serializer + self.match_on = ['url', 'method'] self.cassette_library_dir = cassette_library_dir self.serializers = { 'yaml': yamlserializer, 'json': jsonserializer, } + self.matchers = { + 'method': method, + 'url': url, + 'host': host, + 'path': path, + 'headers': headers, + 'body': body, + } self.record_mode = record_mode def _get_serializer(self, serializer_name): @@ -26,8 +36,19 @@ class VCR(object): raise KeyError 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): serializer_name = kwargs.get('serializer', self.serializer) + matcher_names = kwargs.get('match_on', self.match_on) cassette_library_dir = kwargs.get( 'cassette_library_dir', self.cassette_library_dir @@ -38,6 +59,7 @@ class VCR(object): merged_config = { "serializer": self._get_serializer(serializer_name), + "match_on": self._get_matchers(matcher_names), "record_mode": kwargs.get('record_mode', self.record_mode), } @@ -45,3 +67,6 @@ class VCR(object): def register_serializer(self, name, serializer): self.serializers[name] = serializer + + def register_matcher(self, name, matcher): + self.matchers[name] = matcher diff --git a/vcr/matchers.py b/vcr/matchers.py new file mode 100644 index 0000000..502c0c8 --- /dev/null +++ b/vcr/matchers.py @@ -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)