diff --git a/README.md b/README.md index f0b1ce4..739849f 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,7 @@ import vcr my_vcr = vcr.VCR( serializer = 'json', cassette_library_dir = 'fixtures/cassettes', + record_mode = 'once', ) with my_vcr.use_cassette('test.json'): @@ -61,12 +62,53 @@ with my_vcr.use_cassette('test.json'): Otherwise, you can override options each time you use a cassette. ```python -with vcr.use_cassette('test.yml', serializer='json'): +with vcr.use_cassette('test.yml', serializer='json', record_mode='once'): # your http code here ``` Note: Per-cassette overrides take precedence over the global config. +## Record Modes +VCR supports 4 record modes (with the same behavior as Ruby's VCR): + +### once + + * Replay previously recorded interactions. + * Record new interactions if there is no cassette file. + * Cause an error to be raised for new requests if there is a cassette file. + +It is similar to the :new_episodes record mode, but will prevent new, +unexpected requests from being made (i.e. because the request URI +changed). + +once is the default record mode, used when you do not set one. + +### new_episodes + +* Record new interactions. +* Replay previously recorded interactions. +It is similar to the once record mode, but will always record new +interactions, even if you have an existing recorded one that is similar, +but not identical. + +This was the default behavior in versions < 0.3.0 + +### none + +* Replay previously recorded interactions. +* Cause an error to be raised for any new requests. +This is useful when your code makes potentially dangerous +HTTP requests. The none record mode guarantees that no +new HTTP requests will be made. + +### all + +* Record new interactions. +* Never replay previously recorded interactions. +This can be temporarily used to force VCR to re-record +a cassette (i.e. to ensure the responses are not out of date) +or can be used when you simply want to log all HTTP requests. + ## Advanced Features If you want, VCR.py can return information about the cassette it is @@ -159,6 +201,16 @@ This library is a work in progress, so the API might change on you. There are probably some [bugs](https://github.com/kevin1024/vcrpy/issues?labels=bug&page=1&state=open) floating around too. ##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, + 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 + to match up the appropriate response. I don't think this should cause + any issues unless you are sending requests without ever loading the + response (which none of the standard httplib wrappers do, as far as I + know. * 0.2.1: Fixed missing modules in setup.py * 0.2.0: Added configuration API, which lets you configure some settings on VCR (see the README). Also, VCR no longer saves cassettes if they diff --git a/setup.py b/setup.py index 9f3a584..d626dec 100644 --- a/setup.py +++ b/setup.py @@ -19,7 +19,7 @@ class PyTest(TestCommand): sys.exit(errno) setup(name='vcrpy', - version='0.2.1', + version='0.3.0', description="A Python port of Ruby's VCR to make mocking HTTP easier", author='Kevin McCarthy', author_email='me@kevinmccarthy.org', diff --git a/tests/integration/test_disksaver.py b/tests/integration/test_disksaver.py index bb13457..c0a4c3a 100644 --- a/tests/integration/test_disksaver.py +++ b/tests/integration/test_disksaver.py @@ -45,7 +45,7 @@ def test_disk_saver_write(tmpdir): # the mtime doesn't change time.sleep(1) - with vcr.use_cassette(fname) as cass: + with vcr.use_cassette(fname, record_mode='any') as cass: urllib2.urlopen('http://www.iana.org/domains/reserved').read() urllib2.urlopen('http://httpbin.org/').read() assert cass.play_count == 1 diff --git a/tests/integration/test_record_mode.py b/tests/integration/test_record_mode.py new file mode 100644 index 0000000..7217908 --- /dev/null +++ b/tests/integration/test_record_mode.py @@ -0,0 +1,86 @@ +import os +import urllib2 +import pytest +import vcr + + +def test_once_record_mode(tmpdir): + testfile = str(tmpdir.join('recordmode.yml')) + with vcr.use_cassette(testfile, record_mode="once"): + # cassette file doesn't exist, so create. + response = urllib2.urlopen('http://httpbin.org/').read() + + with vcr.use_cassette(testfile, record_mode="once") as cass: + # make the same request again + response = urllib2.urlopen('http://httpbin.org/').read() + + # the first time, it's played from the cassette. + # but, try to access something else from the same cassette, and an + # exception is raised. + with pytest.raises(Exception): + response = urllib2.urlopen('http://httpbin.org/get').read() + + +def test_new_episodes_record_mode(tmpdir): + testfile = str(tmpdir.join('recordmode.yml')) + + with vcr.use_cassette(testfile, record_mode="new_episodes"): + # cassette file doesn't exist, so create. + response = urllib2.urlopen('http://httpbin.org/').read() + + with vcr.use_cassette(testfile, record_mode="new_episodes") as cass: + # make the same request again + response = urllib2.urlopen('http://httpbin.org/').read() + + # in the "new_episodes" record mode, we can add more requests to + # a cassette without repurcussions. + response = urllib2.urlopen('http://httpbin.org/get').read() + + # the first interaction was not re-recorded, but the second was added + assert cass.play_count == 1 + + +def test_all_record_mode(tmpdir): + testfile = str(tmpdir.join('recordmode.yml')) + + with vcr.use_cassette(testfile, record_mode="all"): + # cassette file doesn't exist, so create. + response = urllib2.urlopen('http://httpbin.org/').read() + + with vcr.use_cassette(testfile, record_mode="all") as cass: + # make the same request again + response = urllib2.urlopen('http://httpbin.org/').read() + + # in the "all" record mode, we can add more requests to + # a cassette without repurcussions. + response = urllib2.urlopen('http://httpbin.org/get').read() + + # The cassette was never actually played, even though it existed. + # that's because, in "all" mode, the requests all go directly to + # the source and bypass the cassette. + assert cass.play_count == 0 + + +def test_none_record_mode(tmpdir): + # Cassette file doesn't exist, yet we are trying to make a request. + # raise hell. + testfile = str(tmpdir.join('recordmode.yml')) + with vcr.use_cassette(testfile, record_mode="none"): + with pytest.raises(Exception): + response = urllib2.urlopen('http://httpbin.org/').read() + + +def test_none_record_mode_with_existing_cassette(tmpdir): + # create a cassette file + testfile = str(tmpdir.join('recordmode.yml')) + + with vcr.use_cassette(testfile, record_mode="all"): + response = urllib2.urlopen('http://httpbin.org/').read() + + # play from cassette file + with vcr.use_cassette(testfile, record_mode="none") as cass: + response = urllib2.urlopen('http://httpbin.org/').read() + assert cass.play_count == 1 + # but if I try to hit the net, raise an exception. + with pytest.raises(Exception): + response = urllib2.urlopen('http://httpbin.org/get').read() diff --git a/vcr/cassette.py b/vcr/cassette.py index 271f7be..ded9936 100644 --- a/vcr/cassette.py +++ b/vcr/cassette.py @@ -14,6 +14,7 @@ from .serializers import yamlserializer class Cassette(object): '''A container for recorded requests and responses''' + @classmethod def load(cls, path, **kwargs): '''Load in the cassette stored at the provided path''' @@ -21,12 +22,13 @@ class Cassette(object): new_cassette._load() return new_cassette - def __init__(self, path, serializer=yamlserializer): + def __init__(self, path, serializer=yamlserializer, record_mode='once'): self._path = path self._serializer = serializer self.data = OrderedDict() self.play_counts = Counter() self.dirty = False + self.record_mode = record_mode @property def play_count(self): @@ -40,6 +42,20 @@ class Cassette(object): def responses(self): return self.data.values() + @property + def rewound(self): + """ + If the cassette has already been recorded in another session, and has + been loaded again fresh from disk, it has been "rewound". This means + that it should be write-only, depending on the record mode specified + """ + return not self.dirty and self.play_count + + @property + def write_protected(self): + return self.rewound and self.record_mode == 'once' or \ + self.record_mode == 'none' + def mark_played(self, request): ''' Alert the cassette of a request that's been played diff --git a/vcr/config.py b/vcr/config.py index ee97c47..2c47eea 100644 --- a/vcr/config.py +++ b/vcr/config.py @@ -4,13 +4,17 @@ from .serializers import yamlserializer, jsonserializer class VCR(object): - def __init__(self, serializer='yaml', cassette_library_dir=None): + def __init__(self, + serializer='yaml', + cassette_library_dir=None, + record_mode="once"): self.serializer = serializer self.cassette_library_dir = cassette_library_dir self.serializers = { 'yaml': yamlserializer, 'json': jsonserializer, } + self.record_mode = record_mode def _get_serializer(self, serializer_name): try: @@ -34,6 +38,7 @@ class VCR(object): merged_config = { "serializer": self._get_serializer(serializer_name), + "record_mode": kwargs.get('record_mode', self.record_mode), } return Cassette.load(path, **merged_config) diff --git a/vcr/stubs/__init__.py b/vcr/stubs/__init__.py index b6f7f7d..6510ce9 100644 --- a/vcr/stubs/__init__.py +++ b/vcr/stubs/__init__.py @@ -142,13 +142,17 @@ class VCRConnectionMixin: '''Retrieve a the response''' # Check to see if the cassette has a response for this request. If so, # then return it - if self._vcr_request in self.cassette: + if self._vcr_request in self.cassette and \ + self.cassette.record_mode != "all": response = self.cassette.response_of(self._vcr_request) # Alert the cassette to the fact that we've served another # response for the provided requests self.cassette.mark_played(self._vcr_request) return VCRHTTPResponse(response) else: + if self.cassette.write_protected: + raise Exception("cassette is write protected") + # Otherwise, we should send the request, then get the response # and return it.