Repository: kevin1024/vcrpy
Pull request #681 (2023-01-22 09:44:57)

For custom cassette persisters no longer catch ValueError
I had to spend some time digging before I realized that serialization/deserialization errors were being swallowed here


Patch:
diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
index 6b7e743..ec457ee 100644
--- a/.github/workflows/main.yml
+++ b/.github/workflows/main.yml
@@ -28,8 +28,8 @@ jobs:
           pip3 install --upgrade pip
           pip3 install codecov tox tox-gh-actions
 
-      - name: Run tests with tox
-        run: tox
+      - name: Run online tests with tox
+        run: tox -- -m online
 
       - name: Run offline tests with tox with no access to the Internet
         run: |
diff --git a/docs/advanced.rst b/docs/advanced.rst
index cf93dcc..9cba3ba 100644
--- a/docs/advanced.rst
+++ b/docs/advanced.rst
@@ -136,7 +136,8 @@ Create your own persistence class, see the example below:
 
 Your custom persister must implement both ``load_cassette`` and ``save_cassette``
 methods.  The ``load_cassette`` method must return a deserialized cassette or raise
-``ValueError`` if no cassette is found.
+either ``CassetteNotFoundError`` if no cassette is found, or ``CassetteDecodeError``
+if the cassette cannot be successfully deserialized.
 
 Once the persister class is defined, register with VCR like so...
 
diff --git a/docs/changelog.rst b/docs/changelog.rst
index ac8348e..2631677 100644
--- a/docs/changelog.rst
+++ b/docs/changelog.rst
@@ -7,6 +7,17 @@ For a full list of triaged issues, bugs and PRs and what release they are target
 
 All help in providing PRs to close out bug issues is appreciated. Even if that is providing a repo that fully replicates issues. We have very generous contributors that have added these to bug issues which meant another contributor picked up the bug and closed it out.
 
+-  4.4.0
+    - HUGE thanks to @hartwork for all the work done on this release!
+    - Bring vcr/unittest in to vcrpy as a full feature of vcr instead of a separate library.  Big thanks to @hartwork for doing this and to @agriffis for originally creating the library
+    - Make decompression robust towards already decompressed input (thanks @hartwork)
+    - Bugfix: Add read1 method (fixes compatibility with biopython), thanks @mghantous
+    - Bugfix: Prevent filters from corrupting request (thanks @abramclark)
+    - Bugfix: Add support for `response.raw.stream()` to fix urllib v2 compat
+    - Bugfix: Replace `assert` with `raise AssertionError`: fixes support for `PYTHONOPTIMIZE=1`
+    - Add pytest.mark.online to run test suite offline, thanks @jspricke
+    - use python3 and pip3 binaries to ease debian packaging (thanks @hartwork)
+    - Add codespell (thanks @mghantous)
 -  4.3.1
     - Support urllib3 v1 and v2.  NOTE: there is an issue running urllib3 v2 on
       Python older than 3.10, so this is currently blocked in the requirements.
diff --git a/vcr/__init__.py b/vcr/__init__.py
index ba29ce7..a48bd46 100644
--- a/vcr/__init__.py
+++ b/vcr/__init__.py
@@ -4,7 +4,7 @@ from logging import NullHandler
 from .config import VCR
 from .record_mode import RecordMode as mode  # noqa import is not used in this file
 
-__version__ = "4.3.1"
+__version__ = "4.4.0"
 
 logging.getLogger(__name__).addHandler(NullHandler())
 
diff --git a/vcr/cassette.py b/vcr/cassette.py
index 5822afa..77ffe61 100644
--- a/vcr/cassette.py
+++ b/vcr/cassette.py
@@ -11,7 +11,7 @@ from ._handle_coroutine import handle_coroutine
 from .errors import UnhandledHTTPRequestError
 from .matchers import get_matchers_results, method, requests_match, uri
 from .patch import CassettePatcherBuilder
-from .persisters.filesystem import FilesystemPersister
+from .persisters.filesystem import CassetteDecodeError, CassetteNotFoundError, FilesystemPersister
 from .record_mode import RecordMode
 from .serializers import yamlserializer
 from .util import partition_dict
@@ -352,7 +352,7 @@ class Cassette:
                 self.append(request, response)
             self.dirty = False
             self.rewound = True
-        except ValueError:
+        except (CassetteDecodeError, CassetteNotFoundError):
             pass
 
     def __str__(self):
diff --git a/vcr/filters.py b/vcr/filters.py
index 751c5d4..ac07fb0 100644
--- a/vcr/filters.py
+++ b/vcr/filters.py
@@ -153,9 +153,15 @@ def decode_response(response):
         if not body:
             return ""
         if encoding == "gzip":
-            return zlib.decompress(body, zlib.MAX_WBITS | 16)
+            try:
+                return zlib.decompress(body, zlib.MAX_WBITS | 16)
+            except zlib.error:
+                return body  # assumes that the data was already decompressed
         else:  # encoding == 'deflate'
-            return zlib.decompress(body)
+            try:
+                return zlib.decompress(body)
+            except zlib.error:
+                return body  # assumes that the data was already decompressed
 
     # Deepcopy here in case `headers` contain objects that could
     # be mutated by a shallow copy and corrupt the real response.
diff --git a/vcr/matchers.py b/vcr/matchers.py
index 5ed288a..0880cc4 100644
--- a/vcr/matchers.py
+++ b/vcr/matchers.py
@@ -9,35 +9,43 @@ log = logging.getLogger(__name__)
 
 
 def method(r1, r2):
-    assert r1.method == r2.method, "{} != {}".format(r1.method, r2.method)
+    if r1.method != r2.method:
+        raise AssertionError("{} != {}".format(r1.method, r2.method))
 
 
 def uri(r1, r2):
-    assert r1.uri == r2.uri, "{} != {}".format(r1.uri, r2.uri)
+    if r1.uri != r2.uri:
+        raise AssertionError("{} != {}".format(r1.uri, r2.uri))
 
 
 def host(r1, r2):
-    assert r1.host == r2.host, "{} != {}".format(r1.host, r2.host)
+    if r1.host != r2.host:
+        raise AssertionError("{} != {}".format(r1.host, r2.host))
 
 
 def scheme(r1, r2):
-    assert r1.scheme == r2.scheme, "{} != {}".format(r1.scheme, r2.scheme)
+    if r1.scheme != r2.scheme:
+        raise AssertionError("{} != {}".format(r1.scheme, r2.scheme))
 
 
 def port(r1, r2):
-    assert r1.port == r2.port, "{} != {}".format(r1.port, r2.port)
+    if r1.port != r2.port:
+        raise AssertionError("{} != {}".format(r1.port, r2.port))
 
 
 def path(r1, r2):
-    assert r1.path == r2.path, "{} != {}".format(r1.path, r2.path)
+    if r1.path != r2.path:
+        raise AssertionError("{} != {}".format(r1.path, r2.path))
 
 
 def query(r1, r2):
-    assert r1.query == r2.query, "{} != {}".format(r1.query, r2.query)
+    if r1.query != r2.query:
+        raise AssertionError("{} != {}".format(r1.query, r2.query))
 
 
 def raw_body(r1, r2):
-    assert read_body(r1) == read_body(r2)
+    if read_body(r1) != read_body(r2):
+        raise AssertionError
 
 
 def body(r1, r2):
@@ -45,11 +53,13 @@ def body(r1, r2):
     r2_transformer = _get_transformer(r2)
     if transformer != r2_transformer:
         transformer = _identity
-    assert transformer(read_body(r1)) == transformer(read_body(r2))
+    if transformer(read_body(r1)) != transformer(read_body(r2)):
+        raise AssertionError
 
 
 def headers(r1, r2):
-    assert r1.headers == r2.headers, "{} != {}".format(r1.headers, r2.headers)
+    if r1.headers != r2.headers:
+        raise AssertionError("{} != {}".format(r1.headers, r2.headers))
 
 
 def _header_checker(value, header="Content-Type"):
diff --git a/vcr/persisters/filesystem.py b/vcr/persisters/filesystem.py
index e971063..d7bd451 100644
--- a/vcr/persisters/filesystem.py
+++ b/vcr/persisters/filesystem.py
@@ -5,17 +5,25 @@ from pathlib import Path
 from ..serialize import deserialize, serialize
 
 
+class CassetteNotFoundError(FileNotFoundError):
+    pass
+
+
+class CassetteDecodeError(ValueError):
+    pass
+
+
 class FilesystemPersister:
     @classmethod
     def load_cassette(cls, cassette_path, serializer):
         cassette_path = Path(cassette_path)  # if cassette path is already Path this is no operation
         if not cassette_path.is_file():
-            raise ValueError("Cassette not found.")
+            raise CassetteNotFoundError()
         try:
             with cassette_path.open() as f:
                 data = f.read()
-        except UnicodeEncodeError as err:
-            raise ValueError("Can't read Cassette, Encoding is broken") from err
+        except UnicodeDecodeError as err:
+            raise CassetteDecodeError("Can't read Cassette, Encoding is broken") from err
 
         return deserialize(data, serializer)
 
diff --git a/vcr/stubs/__init__.py b/vcr/stubs/__init__.py
index 7c7dbe0..2a34ea1 100644
--- a/vcr/stubs/__init__.py
+++ b/vcr/stubs/__init__.py
@@ -170,6 +170,13 @@ class VCRHTTPResponse(HTTPResponse):
     def drain_conn(self):
         pass
 
+    def stream(self, amt=65536, decode_content=None):
+        while True:
+            b = self._content.read(amt)
+            yield b
+            if not b:
+                break
+
 
 class VCRConnection:
     # A reference to the cassette that's currently being patched in


Test patch:
diff --git a/tests/assertions.py b/tests/assertions.py
index 7f1df80..60c432b 100644
--- a/tests/assertions.py
+++ b/tests/assertions.py
@@ -11,9 +11,10 @@ def assert_cassette_has_one_response(cass):
     assert cass.play_count == 1
 
 
-def assert_is_json(a_string):
+def assert_is_json_bytes(b: bytes):
+    assert isinstance(b, bytes)
     try:
-        json.loads(a_string.decode("utf-8"))
+        json.loads(b.decode("utf-8"))
     except Exception:
         assert False
     assert True
diff --git a/tests/integration/test_filter.py b/tests/integration/test_filter.py
index 237d0c9..a6910f1 100644
--- a/tests/integration/test_filter.py
+++ b/tests/integration/test_filter.py
@@ -5,7 +5,7 @@ from urllib.parse import urlencode
 from urllib.request import Request, urlopen
 
 import pytest
-from assertions import assert_cassette_has_one_response, assert_is_json
+from assertions import assert_cassette_has_one_response, assert_is_json_bytes
 
 import vcr
 
@@ -105,7 +105,7 @@ def test_decompress_gzip(tmpdir, httpbin):
     with vcr.use_cassette(cass_file) as cass:
         decoded_response = urlopen(url).read()
         assert_cassette_has_one_response(cass)
-    assert_is_json(decoded_response)
+    assert_is_json_bytes(decoded_response)
 
 
 def test_decomptess_empty_body(tmpdir, httpbin):
@@ -129,7 +129,7 @@ def test_decompress_deflate(tmpdir, httpbin):
     with vcr.use_cassette(cass_file) as cass:
         decoded_response = urlopen(url).read()
         assert_cassette_has_one_response(cass)
-    assert_is_json(decoded_response)
+    assert_is_json_bytes(decoded_response)
 
 
 def test_decompress_regular(tmpdir, httpbin):
@@ -141,7 +141,7 @@ def test_decompress_regular(tmpdir, httpbin):
     with vcr.use_cassette(cass_file) as cass:
         resp = urlopen(url).read()
         assert_cassette_has_one_response(cass)
-    assert_is_json(resp)
+    assert_is_json_bytes(resp)
 
 
 def test_before_record_request_corruption(tmpdir, httpbin):
diff --git a/tests/integration/test_register_persister.py b/tests/integration/test_register_persister.py
index 1391b98..42b8736 100644
--- a/tests/integration/test_register_persister.py
+++ b/tests/integration/test_register_persister.py
@@ -5,9 +5,11 @@
 import os
 from urllib.request import urlopen
 
+import pytest
+
 # Internal imports
 import vcr
-from vcr.persisters.filesystem import FilesystemPersister
+from vcr.persisters.filesystem import CassetteDecodeError, CassetteNotFoundError, FilesystemPersister
 
 
 class CustomFilesystemPersister:
@@ -25,6 +27,19 @@ class CustomFilesystemPersister:
         FilesystemPersister.save_cassette(cassette_path, cassette_dict, serializer)
 
 
+class BadPersister(FilesystemPersister):
+    """A bad persister that raises different errors."""
+
+    @staticmethod
+    def load_cassette(cassette_path, serializer):
+        if "nonexistent" in cassette_path:
+            raise CassetteNotFoundError()
+        elif "encoding" in cassette_path:
+            raise CassetteDecodeError()
+        else:
+            raise ValueError("buggy persister")
+
+
 def test_save_cassette_with_custom_persister(tmpdir, httpbin):
     """Ensure you can save a cassette using custom persister"""
     my_vcr = vcr.VCR()
@@ -53,3 +68,22 @@ def test_load_cassette_with_custom_persister(tmpdir, httpbin):
     with my_vcr.use_cassette(test_fixture, serializer="json"):
         response = urlopen(httpbin.url).read()
         assert b"difficult sometimes" in response
+
+
+def test_load_cassette_persister_exception_handling(tmpdir, httpbin):
+    """
+    Ensure expected errors from persister are swallowed while unexpected ones
+    are passed up the call stack.
+    """
+    my_vcr = vcr.VCR()
+    my_vcr.register_persister(BadPersister)
+
+    with my_vcr.use_cassette("bad/nonexistent") as cass:
+        assert len(cass) == 0
+
+    with my_vcr.use_cassette("bad/encoding") as cass:
+        assert len(cass) == 0
+
+    with pytest.raises(ValueError):
+        with my_vcr.use_cassette("bad/buggy") as cass:
+            pass
diff --git a/tests/integration/test_requests.py b/tests/integration/test_requests.py
index c083dca..3bcd9fd 100644
--- a/tests/integration/test_requests.py
+++ b/tests/integration/test_requests.py
@@ -1,6 +1,6 @@
 """Test requests' interaction with vcr"""
 import pytest
-from assertions import assert_cassette_empty, assert_is_json
+from assertions import assert_cassette_empty, assert_is_json_bytes
 
 import vcr
 
@@ -144,6 +144,17 @@ def test_redirects(tmpdir, httpbin_both):
         assert cass.play_count == 2
 
 
+def test_raw_stream(tmpdir, httpbin):
+    expected_response = requests.get(httpbin.url, stream=True)
+    expected_content = b"".join(expected_response.raw.stream())
+
+    for _ in range(2):  # one for recording, one for cassette reply
+        with vcr.use_cassette(str(tmpdir.join("raw_stream.yaml"))):
+            actual_response = requests.get(httpbin.url, stream=True)
+            actual_content = b"".join(actual_response.raw.stream())
+        assert actual_content == expected_content
+
+
 def test_cross_scheme(tmpdir, httpbin_secure, httpbin):
     """Ensure that requests between schemes are treated separately"""
     # First fetch a url under http, and then again under https and then
@@ -156,20 +167,40 @@ def test_cross_scheme(tmpdir, httpbin_secure, httpbin):
         assert len(cass) == 2
 
 
-def test_gzip(tmpdir, httpbin_both):
+def test_gzip__decode_compressed_response_false(tmpdir, httpbin_both):
     """
     Ensure that requests (actually urllib3) is able to automatically decompress
     the response body
     """
+    for _ in range(2):  # one for recording, one for re-playing
+        with vcr.use_cassette(str(tmpdir.join("gzip.yaml"))):
+            response = requests.get(httpbin_both + "/gzip")
+            assert response.headers["content-encoding"] == "gzip"  # i.e. not removed
+            assert_is_json_bytes(response.content)  # i.e. uncompressed bytes
+
+
+def test_gzip__decode_compressed_response_true(tmpdir, httpbin_both):
     url = httpbin_both + "/gzip"
-    response = requests.get(url)
 
-    with vcr.use_cassette(str(tmpdir.join("gzip.yaml"))):
-        response = requests.get(url)
-        assert_is_json(response.content)
+    expected_response = requests.get(url)
+    expected_content = expected_response.content
+    assert expected_response.headers["content-encoding"] == "gzip"  # self-test
 
-    with vcr.use_cassette(str(tmpdir.join("gzip.yaml"))):
-        assert_is_json(response.content)
+    with vcr.use_cassette(
+        str(tmpdir.join("decode_compressed.yaml")), decode_compressed_response=True
+    ) as cassette:
+        r = requests.get(url)
+        assert r.headers["content-encoding"] == "gzip"  # i.e. not removed
+        assert r.content == expected_content
+
+    # Has the cassette body been decompressed?
+    cassette_response_body = cassette.responses[0]["body"]["string"]
+    assert isinstance(cassette_response_body, str)
+
+    with vcr.use_cassette(str(tmpdir.join("decode_compressed.yaml")), decode_compressed_response=True):
+        r = requests.get(url)
+        assert "content-encoding" not in r.headers  # i.e. removed
+        assert r.content == expected_content
 
 
 def test_session_and_connection_close(tmpdir, httpbin):
diff --git a/tests/integration/test_stubs.py b/tests/integration/test_stubs.py
index 384d78d..18f02f5 100644
--- a/tests/integration/test_stubs.py
+++ b/tests/integration/test_stubs.py
@@ -2,7 +2,7 @@ import http.client as httplib
 import json
 import zlib
 
-from assertions import assert_is_json
+from assertions import assert_is_json_bytes
 
 import vcr
 
@@ -84,7 +84,7 @@ def test_original_decoded_response_is_not_modified(tmpdir, httpbin):
         inside = conn.getresponse()
 
         assert "content-encoding" not in inside.headers
-        assert_is_json(inside.read())
+        assert_is_json_bytes(inside.read())
 
 
 def _make_before_record_response(fields, replacement="[REDACTED]"):
diff --git a/tests/integration/test_tornado.py b/tests/integration/test_tornado.py
index f0868a1..545c317 100644
--- a/tests/integration/test_tornado.py
+++ b/tests/integration/test_tornado.py
@@ -4,7 +4,7 @@
 import json
 
 import pytest
-from assertions import assert_cassette_empty, assert_is_json
+from assertions import assert_cassette_empty, assert_is_json_bytes
 
 import vcr
 from vcr.errors import CannotOverwriteExistingCassetteException
@@ -195,11 +195,11 @@ def test_gzip(get_client, tmpdir, scheme):
 
     with vcr.use_cassette(str(tmpdir.join("gzip.yaml"))):
         response = yield get(get_client(), url, **kwargs)
-        assert_is_json(response.body)
+        assert_is_json_bytes(response.body)
 
     with vcr.use_cassette(str(tmpdir.join("gzip.yaml"))) as cass:
         response = yield get(get_client(), url, **kwargs)
-        assert_is_json(response.body)
+        assert_is_json_bytes(response.body)
         assert 1 == cass.play_count
 
 
diff --git a/tests/integration/test_urllib3.py b/tests/integration/test_urllib3.py
index 8ec5248..7c788ec 100644
--- a/tests/integration/test_urllib3.py
+++ b/tests/integration/test_urllib3.py
@@ -4,7 +4,7 @@
 
 import pytest
 import pytest_httpbin
-from assertions import assert_cassette_empty, assert_is_json
+from assertions import assert_cassette_empty, assert_is_json_bytes
 
 import vcr
 from vcr.patch import force_reset
@@ -136,10 +136,10 @@ def test_gzip(tmpdir, httpbin_both, verify_pool_mgr):
 
     with vcr.use_cassette(str(tmpdir.join("gzip.yaml"))):
         response = verify_pool_mgr.request("GET", url)
-        assert_is_json(response.data)
+        assert_is_json_bytes(response.data)
 
     with vcr.use_cassette(str(tmpdir.join("gzip.yaml"))):
-        assert_is_json(response.data)
+        assert_is_json_bytes(response.data)
 
 
 def test_https_with_cert_validation_disabled(tmpdir, httpbin_secure, pool_mgr):
diff --git a/tests/unit/test_cassettes.py b/tests/unit/test_cassettes.py
index 41e3df5..cbe9de1 100644
--- a/tests/unit/test_cassettes.py
+++ b/tests/unit/test_cassettes.py
@@ -29,6 +29,19 @@ def test_cassette_load(tmpdir):
     assert len(a_cassette) == 1
 
 
+def test_cassette_load_nonexistent():
+    a_cassette = Cassette.load(path="something/nonexistent.yml")
+    assert len(a_cassette) == 0
+
+
+def test_cassette_load_invalid_encoding(tmpdir):
+    a_file = tmpdir.join("invalid_encoding.yml")
+    with open(a_file, "wb") as fd:
+        fd.write(b"\xda")
+    a_cassette = Cassette.load(path=str(a_file))
+    assert len(a_cassette) == 0
+
+
 def test_cassette_not_played():
     a = Cassette("test")
     assert not a.play_count
diff --git a/tests/unit/test_filters.py b/tests/unit/test_filters.py
index 370699d..003e08e 100644
--- a/tests/unit/test_filters.py
+++ b/tests/unit/test_filters.py
@@ -298,6 +298,18 @@ def test_decode_response_deflate():
     assert decoded_response["headers"]["content-length"] == [str(len(body))]
 
 
+def test_decode_response_deflate_already_decompressed():
+    body = b"deflate message"
+    gzip_response = {
+        "body": {"string": body},
+        "headers": {
+            "content-encoding": ["deflate"],
+        },
+    }
+    decoded_response = decode_response(gzip_response)
+    assert decoded_response["body"]["string"] == body
+
+
 def test_decode_response_gzip():
     body = b"gzip message"
 
@@ -325,3 +337,15 @@ def test_decode_response_gzip():
     decoded_response = decode_response(gzip_response)
     assert decoded_response["body"]["string"] == body
     assert decoded_response["headers"]["content-length"] == [str(len(body))]
+
+
+def test_decode_response_gzip_already_decompressed():
+    body = b"gzip message"
+    gzip_response = {
+        "body": {"string": body},
+        "headers": {
+            "content-encoding": ["gzip"],
+        },
+    }
+    decoded_response = decode_response(gzip_response)
+    assert decoded_response["body"]["string"] == body