Skip to content

Commit b28316a

Browse files
authored
Enable brotli decompression if it is available (#620)
* Enable brotli decompression if it is available * Apply PR feedback
1 parent 3f78330 commit b28316a

File tree

2 files changed

+77
-32
lines changed

2 files changed

+77
-32
lines changed

tests/integration/test_filter.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import pytest
88

99
import vcr
10+
from vcr.filters import brotli
1011

1112
from ..assertions import assert_cassette_has_one_response, assert_is_json_bytes
1213

@@ -138,6 +139,22 @@ def test_decompress_deflate(tmpdir, httpbin):
138139
assert_is_json_bytes(decoded_response)
139140

140141

142+
def test_decompress_brotli(tmpdir, httpbin):
143+
if brotli is None:
144+
# XXX: this is never true, because brotlipy is installed with "httpbin"
145+
pytest.skip("Brotli is not installed")
146+
147+
url = httpbin.url + "/brotli"
148+
request = Request(url, headers={"Accept-Encoding": ["gzip, deflate, br"]})
149+
cass_file = str(tmpdir.join("brotli_response.yaml"))
150+
with vcr.use_cassette(cass_file, decode_compressed_response=True):
151+
urlopen(request)
152+
with vcr.use_cassette(cass_file) as cass:
153+
decoded_response = urlopen(url).read()
154+
assert_cassette_has_one_response(cass)
155+
assert_is_json_bytes(decoded_response)
156+
157+
141158
def test_decompress_regular(tmpdir, httpbin):
142159
"""Test that it doesn't try to decompress content that isn't compressed"""
143160
url = httpbin.url + "/get"

vcr/filters.py

Lines changed: 60 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,49 @@
66

77
from .util import CaseInsensitiveDict
88

9+
try:
10+
# This supports both brotli & brotlipy packages
11+
import brotli
12+
except ImportError:
13+
try:
14+
import brotlicffi as brotli
15+
except ImportError:
16+
brotli = None
17+
18+
19+
def decompress_deflate(body):
20+
try:
21+
return zlib.decompress(body)
22+
except zlib.error:
23+
# Assume the response was already decompressed
24+
return body
25+
26+
27+
def decompress_gzip(body):
28+
# To (de-)compress gzip format, use wbits = zlib.MAX_WBITS | 16.
29+
try:
30+
return zlib.decompress(body, zlib.MAX_WBITS | 16)
31+
except zlib.error:
32+
# Assume the response was already decompressed
33+
return body
34+
35+
36+
AVAILABLE_DECOMPRESSORS = {
37+
"deflate": decompress_deflate,
38+
"gzip": decompress_gzip,
39+
}
40+
41+
if brotli is not None:
42+
43+
def decompress_brotli(body):
44+
try:
45+
return brotli.decompress(body)
46+
except brotli.error:
47+
# Assume the response was already decompressed
48+
return body
49+
50+
AVAILABLE_DECOMPRESSORS["br"] = decompress_brotli
51+
952

1053
def replace_headers(request, replacements):
1154
"""Replace headers in request according to replacements.
@@ -136,45 +179,30 @@ def remove_post_data_parameters(request, post_data_parameters_to_remove):
136179

137180
def decode_response(response):
138181
"""
139-
If the response is compressed with gzip or deflate:
182+
If the response is compressed with any supported compression (gzip,
183+
deflate, br if available):
140184
1. decompress the response body
141185
2. delete the content-encoding header
142186
3. update content-length header to decompressed length
143187
"""
144188

145-
def is_compressed(headers):
146-
encoding = headers.get("content-encoding", [])
147-
return encoding and encoding[0] in ("gzip", "deflate")
148-
149-
def decompress_body(body, encoding):
150-
"""Returns decompressed body according to encoding using zlib.
151-
to (de-)compress gzip format, use wbits = zlib.MAX_WBITS | 16
152-
"""
153-
if not body:
154-
return ""
155-
if encoding == "gzip":
156-
try:
157-
return zlib.decompress(body, zlib.MAX_WBITS | 16)
158-
except zlib.error:
159-
return body # assumes that the data was already decompressed
160-
else: # encoding == 'deflate'
161-
try:
162-
return zlib.decompress(body)
163-
except zlib.error:
164-
return body # assumes that the data was already decompressed
165-
166189
# Deepcopy here in case `headers` contain objects that could
167190
# be mutated by a shallow copy and corrupt the real response.
168191
response = copy.deepcopy(response)
169192
headers = CaseInsensitiveDict(response["headers"])
170-
if is_compressed(headers):
171-
encoding = headers["content-encoding"][0]
172-
headers["content-encoding"].remove(encoding)
173-
if not headers["content-encoding"]:
174-
del headers["content-encoding"]
175-
176-
new_body = decompress_body(response["body"]["string"], encoding)
177-
response["body"]["string"] = new_body
178-
headers["content-length"] = [str(len(new_body))]
179-
response["headers"] = dict(headers)
193+
content_encoding = headers.get("content-encoding")
194+
if not content_encoding:
195+
return response
196+
decompressor = AVAILABLE_DECOMPRESSORS.get(content_encoding[0])
197+
if not decompressor:
198+
return response
199+
200+
headers["content-encoding"].remove(content_encoding[0])
201+
if not headers["content-encoding"]:
202+
del headers["content-encoding"]
203+
204+
new_body = decompressor(response["body"]["string"])
205+
response["body"]["string"] = new_body
206+
headers["content-length"] = [str(len(new_body))]
207+
response["headers"] = dict(headers)
180208
return response

0 commit comments

Comments
 (0)