Skip to content

Commit cb90060

Browse files
fix: add max_length support to Gzip/Brotli decoders for urllib3 2.6+ (#495)
## Summary Fixes#491. Supersedes #492 (stale). `urllib3 >= 2.6.0` now passes `max_length` to `decoder.decompress()` and accesses `decoder.has_unconsumed_tail`. Our custom `_GzipDecoder` and `_BrotliDecoder` wrappers didn't accept these, causing `TypeError` at runtime. This PR: - Updates `_GzipDecoder.decompress` and `_BrotliDecoder.decompress` to accept and forward `max_length`, with a `TypeError` fallback for older urllib3 - Adds `has_unconsumed_tail` property to `_BrotliDecoder` (the proxy class that needs it explicitly) - Applies the same fix to the async `_GzipDecoder` in `google/_async_resumable_media/` - Adds test coverage for the new `max_length` forwarding, the fallback path, and `has_unconsumed_tail` ## Feedback from #492 addressed - **`max_length=-1` default** (chandra-siri) — uses `-1` to match [urllib3's default](https://github.com/urllib3/urllib3/blob/bfe8e198a13800e3ee8ef8124a8928acb170c843/src/urllib3/response.py#L55), not `None` - **`has_unconsumed_tail` only on `_BrotliDecoder`** (chandra-siri) — `_GzipDecoder` inherits this from the parent `urllib3.response.GzipDecoder`, so no override is needed - **Lint failure** (BrennaEpp) — no trailing whitespace - **Missing test coverage for fallback paths** (BrennaEpp) — added tests for `max_length` forwarding, `TypeError` fallback, and `has_unconsumed_tail` (with `AttributeError` fallback for older urllib3) ## Test plan - [x] `pytest tests/unit/requests/test_download.py` — all pass - [x] `pytest tests_async/unit/requests/test_download.py` — all pass - [x] Full unit suite (514 tests) — all pass - [x] No lint issues (flake8 clean) 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Chalmer Lowe <chalmerlowe@google.com>
1 parent 1bfd9a7 commit cb90060

4 files changed

Lines changed: 113 additions & 6 deletions

File tree

‎packages/google-resumable-media/google/_async_resumable_media/requests/download.py‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -452,14 +452,19 @@ def __init__(self, checksum):
452452
super(_GzipDecoder, self).__init__()
453453
self._checksum=checksum
454454

455-
defdecompress(self, data):
455+
defdecompress(self, data, max_length=-1):
456456
"""Decompress the bytes.
457457
458458
Args:
459459
data (bytes): The compressed bytes to be decompressed.
460+
max_length (int): Maximum number of bytes to return. -1 for no
461+
limit. Forwarded to the underlying decoder when supported.
460462
461463
Returns:
462464
bytes: The decompressed bytes from ``data``.
463465
"""
464466
self._checksum.update(data)
465-
returnsuper(_GzipDecoder, self).decompress(data)
467+
try:
468+
returnsuper(_GzipDecoder, self).decompress(data, max_length=max_length)
469+
exceptTypeError:
470+
returnsuper(_GzipDecoder, self).decompress(data)

‎packages/google-resumable-media/google/resumable_media/requests/download.py‎

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -667,17 +667,22 @@ def __init__(self, checksum):
667667
super().__init__()
668668
self._checksum=checksum
669669

670-
defdecompress(self, data):
670+
defdecompress(self, data, max_length=-1):
671671
"""Decompress the bytes.
672672
673673
Args:
674674
data (bytes): The compressed bytes to be decompressed.
675+
max_length (int): Maximum number of bytes to return. -1 for no
676+
limit. Forwarded to the underlying decoder when supported.
675677
676678
Returns:
677679
bytes: The decompressed bytes from ``data``.
678680
"""
679681
self._checksum.update(data)
680-
returnsuper().decompress(data)
682+
try:
683+
returnsuper().decompress(data, max_length=max_length)
684+
exceptTypeError:
685+
returnsuper().decompress(data)
681686

682687

683688
# urllib3.response.BrotliDecoder might not exist depending on whether brotli is
@@ -703,17 +708,29 @@ def __init__(self, checksum):
703708
self._decoder=urllib3.response.BrotliDecoder()
704709
self._checksum=checksum
705710

706-
defdecompress(self, data):
711+
defdecompress(self, data, max_length=-1):
707712
"""Decompress the bytes.
708713
709714
Args:
710715
data (bytes): The compressed bytes to be decompressed.
716+
max_length (int): Maximum number of bytes to return. -1 for no
717+
limit. Forwarded to the underlying decoder when supported.
711718
712719
Returns:
713720
bytes: The decompressed bytes from ``data``.
714721
"""
715722
self._checksum.update(data)
716-
returnself._decoder.decompress(data)
723+
try:
724+
returnself._decoder.decompress(data, max_length=max_length)
725+
exceptTypeError:
726+
returnself._decoder.decompress(data)
727+
728+
@property
729+
defhas_unconsumed_tail(self):
730+
try:
731+
returnself._decoder.has_unconsumed_tail
732+
exceptAttributeError:
733+
returnFalse
717734

718735
defflush(self):
719736
returnself._decoder.flush()

‎packages/google-resumable-media/tests/unit/requests/test_download.py‎

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1274,6 +1274,37 @@ def test_decompress(self):
12741274
assertresult==b""
12751275
md5_hash.update.assert_called_once_with(data)
12761276

1277+
deftest_decompress_with_max_length(self):
1278+
md5_hash=mock.Mock(spec=["update"])
1279+
decoder=download_mod._GzipDecoder(md5_hash)
1280+
1281+
withmock.patch.object(
1282+
type(decoder).__bases__[0], "decompress"
1283+
) asmock_super_decompress:
1284+
mock_super_decompress.return_value=b"decompressed"
1285+
data=b"\x1f\x8b\x08\x08"
1286+
result=decoder.decompress(data, max_length=10)
1287+
1288+
assertresult==b"decompressed"
1289+
md5_hash.update.assert_called_once_with(data)
1290+
mock_super_decompress.assert_called_once_with(data, max_length=10)
1291+
1292+
deftest_decompress_with_max_length_fallback(self):
1293+
md5_hash=mock.Mock(spec=["update"])
1294+
decoder=download_mod._GzipDecoder(md5_hash)
1295+
1296+
withmock.patch.object(
1297+
type(decoder).__bases__[0],
1298+
"decompress",
1299+
side_effect=[TypeError, b"decompressed"],
1300+
) asmock_super_decompress:
1301+
data=b"\x1f\x8b\x08\x08"
1302+
result=decoder.decompress(data, max_length=10)
1303+
1304+
assertresult==b"decompressed"
1305+
md5_hash.update.assert_called_once_with(data)
1306+
assertmock_super_decompress.call_count==2
1307+
12771308

12781309
classTest_BrotliDecoder(object):
12791310
deftest_constructor(self):
@@ -1290,6 +1321,45 @@ def test_decompress(self):
12901321
assertresult==b""
12911322
md5_hash.update.assert_called_once_with(data)
12921323

1324+
deftest_decompress_with_max_length(self):
1325+
md5_hash=mock.Mock(spec=["update"])
1326+
decoder=download_mod._BrotliDecoder(md5_hash)
1327+
1328+
decoder._decoder=mock.Mock(spec=["decompress"])
1329+
decoder._decoder.decompress.return_value=b"decompressed"
1330+
1331+
data=b"compressed"
1332+
result=decoder.decompress(data, max_length=10)
1333+
1334+
assertresult==b"decompressed"
1335+
md5_hash.update.assert_called_once_with(data)
1336+
decoder._decoder.decompress.assert_called_once_with(data, max_length=10)
1337+
1338+
deftest_decompress_with_max_length_fallback(self):
1339+
md5_hash=mock.Mock(spec=["update"])
1340+
decoder=download_mod._BrotliDecoder(md5_hash)
1341+
1342+
decoder._decoder=mock.Mock(spec=["decompress"])
1343+
decoder._decoder.decompress.side_effect= [TypeError, b"decompressed"]
1344+
1345+
data=b"compressed"
1346+
result=decoder.decompress(data, max_length=10)
1347+
1348+
assertresult==b"decompressed"
1349+
md5_hash.update.assert_called_once_with(data)
1350+
assertdecoder._decoder.decompress.call_count==2
1351+
1352+
deftest_has_unconsumed_tail(self):
1353+
decoder=download_mod._BrotliDecoder(mock.sentinel.md5_hash)
1354+
decoder._decoder=mock.Mock(spec=["has_unconsumed_tail"])
1355+
decoder._decoder.has_unconsumed_tail=True
1356+
assertdecoder.has_unconsumed_tailisTrue
1357+
1358+
deftest_has_unconsumed_tail_fallback(self):
1359+
decoder=download_mod._BrotliDecoder(mock.sentinel.md5_hash)
1360+
decoder._decoder=mock.Mock(spec=[])
1361+
assertdecoder.has_unconsumed_tailisFalse
1362+
12931363

12941364
def_mock_response(status_code=http.client.OK, chunks=(), headers=None):
12951365
ifheadersisNone:

‎packages/google-resumable-media/tests_async/unit/requests/test_download.py‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -761,6 +761,21 @@ def test_decompress(self):
761761
assertresult==b""
762762
md5_hash.update.assert_called_once_with(data)
763763

764+
deftest_decompress_with_max_length(self):
765+
md5_hash=mock.Mock(spec=["update"])
766+
decoder=download_mod._GzipDecoder(md5_hash)
767+
768+
withmock.patch.object(
769+
type(decoder).__bases__[0], "decompress"
770+
) asmock_super_decompress:
771+
mock_super_decompress.return_value=b"decompressed"
772+
data=b"\x1f\x8b\x08\x08"
773+
result=decoder.decompress(data, max_length=10)
774+
775+
assertresult==b"decompressed"
776+
md5_hash.update.assert_called_once_with(data)
777+
mock_super_decompress.assert_called_once_with(data, max_length=10)
778+
764779

765780
classAsyncIter:
766781
def__init__(self, items):

0 commit comments

Comments
 (0)