From 77c81d798953a62a689e29cf833ccfcf66671765 Mon Sep 17 00:00:00 2001 From: Rasmus Faber-Espensen Date: Tue, 8 Sep 2026 15:09:49 +0200 Subject: [PATCH 1/5] gh-156002: Keep reading through third-party zipfile decompressors GH-156003 made ZipExtFile._read1() call decompress(data, max_length) on non-deflate decompressors and consult needs_input before reading more. A decompressor installed by replacing _get_decompressor() (zipfile-zstd, zipfile-deflate64, ...) may support neither, and every read through it then failed with AttributeError. Give _decompressor_needs_input() a default for decompressors that report nothing, and only take the bounded path for decompressors that do report needs_input (the stdlib ones); others are read unbounded, as before. --- Lib/test/test_zipfile/test_core.py | 58 +++++++++++++++++++ Lib/zipfile/__init__.py | 33 ++++++++--- ...-09-08-13-06-29.gh-issue-156002.vmOC8T.rst | 5 ++ 3 files changed, 89 insertions(+), 7 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2026-09-08-13-06-29.gh-issue-156002.vmOC8T.rst diff --git a/Lib/test/test_zipfile/test_core.py b/Lib/test/test_zipfile/test_core.py index fdf2cd26f8c7c64..ee769d1daeb9d87 100644 --- a/Lib/test/test_zipfile/test_core.py +++ b/Lib/test/test_zipfile/test_core.py @@ -4916,6 +4916,64 @@ class ZstdBoundedDecompressTests(AbstractBoundedDecompressTests, compression = zipfile.ZIP_ZSTANDARD +class ThirdPartyDecompressorTests(unittest.TestCase): + # A decompressor installed by replacing _get_decompressor() may support + # neither decompress(data, max_length) nor needs_input. ZipExtFile must + # still read through it (unbounded, as before bounded decompression). + COMPRESSION = 99 + + class Compressor: + def compress(self, data): + return data + + def flush(self): + return b'' + + class Decompressor: + eof = False + + def decompress(self, data): + return data + + def setUp(self): + orig_check_compression = zipfile._check_compression + orig_get_compressor = zipfile._get_compressor + orig_get_decompressor = zipfile._get_decompressor + + def check_compression(compression): + if compression != self.COMPRESSION: + orig_check_compression(compression) + + def get_compressor(compress_type, compresslevel=None): + if compress_type == self.COMPRESSION: + return self.Compressor() + return orig_get_compressor(compress_type, compresslevel) + + def get_decompressor(compress_type): + if compress_type == self.COMPRESSION: + return self.Decompressor() + return orig_get_decompressor(compress_type) + + self.enterContext(mock.patch.object( + zipfile, '_check_compression', check_compression)) + self.enterContext(mock.patch.object( + zipfile, '_get_compressor', get_compressor)) + self.enterContext(mock.patch.object( + zipfile, '_get_decompressor', get_decompressor)) + + def test_read_through_third_party_decompressor(self): + data = bytes(range(256)) * 256 + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w", compression=self.COMPRESSION) as zf: + zf.writestr("member", data) + with zipfile.ZipFile(io.BytesIO(buf.getvalue())) as zf: + self.assertEqual(zf.read("member"), data) + with zf.open("member") as f: + self.assertEqual(f.read(100), data[:100]) + f.seek(-100, os.SEEK_END) + self.assertEqual(f.read(), data[-100:]) + + class AbstractBadCrcTests: def test_testzip_with_bad_crc(self): """Tests that files with bad CRCs return their name from testzip.""" diff --git a/Lib/zipfile/__init__.py b/Lib/zipfile/__init__.py index 0accf324c90e3fd..db263103b078177 100644 --- a/Lib/zipfile/__init__.py +++ b/Lib/zipfile/__init__.py @@ -893,11 +893,23 @@ def _get_compressor(compress_type, compresslevel=None): return None -def _decompressor_needs_input(decompressor): +def _decompressor_needs_input(decompressor, default): # bz2/zstd expose the stdlib decompressor's public needs_input; the LZMA # wrapper keeps it private (_needs_input) to avoid adding public API. + # A decompressor with neither attribute reports *default*. needs_input = getattr(decompressor, "needs_input", None) - return decompressor._needs_input if needs_input is None else needs_input + if needs_input is None: + needs_input = getattr(decompressor, "_needs_input", default) + return needs_input + + +def _decompressor_bounds_output(decompressor): + # The stdlib bzip2/LZMA/Zstandard decompressors report needs_input and + # accept decompress(data, max_length). A third-party decompressor + # installed by replacing _get_decompressor() may support neither; it is + # then read unbounded, as before the bounded-decompression fix. + return (hasattr(decompressor, "needs_input") + or hasattr(decompressor, "_needs_input")) def _get_decompressor(compress_type): @@ -1007,7 +1019,7 @@ def __init__(self, fileobj, mode, zipinfo, pwd=None, self._compress_left = zipinfo.compress_size self._left = zipinfo.file_size - self._decompressor = _get_decompressor(self._compress_type) + self._set_decompressor() self._eof = False self._readbuffer = b'' @@ -1190,6 +1202,10 @@ def read1(self, n): break return buf + def _set_decompressor(self): + self._decompressor = _get_decompressor(self._compress_type) + self._decompress_bounded = _decompressor_bounds_output(self._decompressor) + def _read1(self, n): # Read up to n compressed bytes with at most one read() system call, # decrypt and decompress them. @@ -1207,7 +1223,7 @@ def _read1(self, n): else: # bzip2/lzma/zstd: a bounded decompress() call may leave input # buffered inside the decompressor; drain that before reading more. - if _decompressor_needs_input(self._decompressor): + if _decompressor_needs_input(self._decompressor, default=True): data = self._read2(n) else: data = b'' @@ -1222,14 +1238,17 @@ def _read1(self, n): not self._decompressor.unconsumed_tail) if self._eof: data += self._decompressor.flush() - else: + elif self._decompress_bounded: # Bound the output of a single decompress() call (mirroring the # DEFLATE path above) so that a small compressed member cannot # expand into one unbounded read. data = self._decompressor.decompress(data, max(n, self.MIN_READ_SIZE)) self._eof = (self._decompressor.eof or self._compress_left <= 0 and - _decompressor_needs_input(self._decompressor)) + _decompressor_needs_input(self._decompressor, default=True)) + else: + data = self._decompressor.decompress(data) + self._eof = self._decompressor.eof or self._compress_left <= 0 data = data[:self._left] self._left -= len(data) @@ -1318,7 +1337,7 @@ def seek(self, offset, whence=os.SEEK_SET): self._left = self._orig_file_size self._readbuffer = b'' self._offset = 0 - self._decompressor = _get_decompressor(self._compress_type) + self._set_decompressor() self._eof = False read_offset = new_pos if self._decrypter is not None: diff --git a/Misc/NEWS.d/next/Library/2026-09-08-13-06-29.gh-issue-156002.vmOC8T.rst b/Misc/NEWS.d/next/Library/2026-09-08-13-06-29.gh-issue-156002.vmOC8T.rst new file mode 100644 index 000000000000000..25ae9c8d7ebb248 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-09-08-13-06-29.gh-issue-156002.vmOC8T.rst @@ -0,0 +1,5 @@ +:mod:`zipfile` again reads members through a third-party decompressor +installed by replacing ``_get_decompressor()``, unbounded as before the +bounded-decompression fix, instead of raising :exc:`AttributeError`. The +bound still applies to the standard library's bzip2, LZMA and Zstandard +decompressors. From 3c2b6023c8847136467a32381397f3e9d3554676 Mon Sep 17 00:00:00 2001 From: Rasmus Faber-Espensen Date: Tue, 8 Sep 2026 15:23:55 +0200 Subject: [PATCH 2/5] Rewind in the third-party decompressor test; wrap two long lines --- Lib/test/test_zipfile/test_core.py | 4 ++++ Lib/zipfile/__init__.py | 6 ++++-- .../2026-09-08-13-06-29.gh-issue-156002.vmOC8T.rst | 8 ++++---- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/Lib/test/test_zipfile/test_core.py b/Lib/test/test_zipfile/test_core.py index ee769d1daeb9d87..6b689df5e3391ae 100644 --- a/Lib/test/test_zipfile/test_core.py +++ b/Lib/test/test_zipfile/test_core.py @@ -4970,8 +4970,12 @@ def test_read_through_third_party_decompressor(self): self.assertEqual(zf.read("member"), data) with zf.open("member") as f: self.assertEqual(f.read(100), data[:100]) + self.assertEqual(f.read1(100), data[100:200]) f.seek(-100, os.SEEK_END) self.assertEqual(f.read(), data[-100:]) + # Rewinding past the read buffer re-creates the decompressor. + f.seek(0) + self.assertEqual(f.read(), data) class AbstractBadCrcTests: diff --git a/Lib/zipfile/__init__.py b/Lib/zipfile/__init__.py index db263103b078177..759cf771b10a7c0 100644 --- a/Lib/zipfile/__init__.py +++ b/Lib/zipfile/__init__.py @@ -1204,7 +1204,8 @@ def read1(self, n): def _set_decompressor(self): self._decompressor = _get_decompressor(self._compress_type) - self._decompress_bounded = _decompressor_bounds_output(self._decompressor) + self._decompress_bounded = _decompressor_bounds_output( + self._decompressor) def _read1(self, n): # Read up to n compressed bytes with at most one read() system call, @@ -1245,7 +1246,8 @@ def _read1(self, n): data = self._decompressor.decompress(data, max(n, self.MIN_READ_SIZE)) self._eof = (self._decompressor.eof or self._compress_left <= 0 and - _decompressor_needs_input(self._decompressor, default=True)) + _decompressor_needs_input(self._decompressor, + default=True)) else: data = self._decompressor.decompress(data) self._eof = self._decompressor.eof or self._compress_left <= 0 diff --git a/Misc/NEWS.d/next/Library/2026-09-08-13-06-29.gh-issue-156002.vmOC8T.rst b/Misc/NEWS.d/next/Library/2026-09-08-13-06-29.gh-issue-156002.vmOC8T.rst index 25ae9c8d7ebb248..a633e46bcdc52d5 100644 --- a/Misc/NEWS.d/next/Library/2026-09-08-13-06-29.gh-issue-156002.vmOC8T.rst +++ b/Misc/NEWS.d/next/Library/2026-09-08-13-06-29.gh-issue-156002.vmOC8T.rst @@ -1,5 +1,5 @@ :mod:`zipfile` again reads members through a third-party decompressor -installed by replacing ``_get_decompressor()``, unbounded as before the -bounded-decompression fix, instead of raising :exc:`AttributeError`. The -bound still applies to the standard library's bzip2, LZMA and Zstandard -decompressors. +installed by replacing the private ``_get_decompressor()``, unbounded as +before the bounded-decompression fix, instead of raising +:exc:`AttributeError`. The bound still applies to the standard library's +bzip2, LZMA and Zstandard decompressors. From 26d3267297896f82249d550cd4dc58109c043237 Mon Sep 17 00:00:00 2001 From: Petr Viktorin Date: Wed, 9 Sep 2026 14:45:11 +0200 Subject: [PATCH 3/5] Rename tests; check that the "compression" happened --- Lib/test/test_zipfile/test_core.py | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/Lib/test/test_zipfile/test_core.py b/Lib/test/test_zipfile/test_core.py index 6b689df5e3391ae..dbcaf1b14f0be7f 100644 --- a/Lib/test/test_zipfile/test_core.py +++ b/Lib/test/test_zipfile/test_core.py @@ -4916,24 +4916,29 @@ class ZstdBoundedDecompressTests(AbstractBoundedDecompressTests, compression = zipfile.ZIP_ZSTANDARD -class ThirdPartyDecompressorTests(unittest.TestCase): - # A decompressor installed by replacing _get_decompressor() may support - # neither decompress(data, max_length) nor needs_input. ZipExtFile must - # still read through it (unbounded, as before bounded decompression). +class MonkeypatchedDecompressorTests(unittest.TestCase): + # Some third-party projects monkey-patch _get_decompressor() to add + # additional compression schemes. This can break at any time as the + # internal compressor objects change. + # To protect users, we try to keep this case working (until it becomes + # too big of a burden, or we make the API public). + # See also: GH-156002 and GH-113756. COMPRESSION = 99 class Compressor: + """Compressor with only the original BZ2Compressor API""" def compress(self, data): - return data + return data.swapcase() def flush(self): return b'' class Decompressor: + """Decmpressor with only the 3.3+ BZ2Decompressor API""" eof = False def decompress(self, data): - return data + return data.swapcase() def setUp(self): orig_check_compression = zipfile._check_compression @@ -4961,11 +4966,12 @@ def get_decompressor(compress_type): self.enterContext(mock.patch.object( zipfile, '_get_decompressor', get_decompressor)) - def test_read_through_third_party_decompressor(self): - data = bytes(range(256)) * 256 + def test_roundtrip_monkeypatched_decompressor(self): + data = bytes(range(256)) * 8 buf = io.BytesIO() with zipfile.ZipFile(buf, "w", compression=self.COMPRESSION) as zf: zf.writestr("member", data) + self.assertIn(data.swapcase(), buf.getvalue()) with zipfile.ZipFile(io.BytesIO(buf.getvalue())) as zf: self.assertEqual(zf.read("member"), data) with zf.open("member") as f: From 214649445c65189cfe09fed29833ddcb1777644d Mon Sep 17 00:00:00 2001 From: Petr Viktorin Date: Wed, 9 Sep 2026 15:28:42 +0200 Subject: [PATCH 4/5] Make LZMADecompressor.needs_input public; simplify implementation Calling decompress() with one argument is a maintenance burden, so mark it as deprecated in 3.16. --- Lib/_py_warnings.py | 5 +- Lib/test/test_zipfile/test_core.py | 11 ++-- Lib/zipfile/__init__.py | 55 +++++++------------ ...-09-08-13-06-29.gh-issue-156002.vmOC8T.rst | 9 +-- 4 files changed, 35 insertions(+), 45 deletions(-) diff --git a/Lib/_py_warnings.py b/Lib/_py_warnings.py index ab09913de6812dd..c82d3a21981d0f0 100644 --- a/Lib/_py_warnings.py +++ b/Lib/_py_warnings.py @@ -873,7 +873,8 @@ def wrapper(*args, **kwargs): _DEPRECATED_MSG = "{name!r} is deprecated and slated for removal in Python {remove}" -def _deprecated(name, message=_DEPRECATED_MSG, *, remove, _version=sys.version_info): +def _deprecated(name, message=_DEPRECATED_MSG, *, remove, _version=sys.version_info, + stacklevel=3): """Warn that *name* is deprecated or should be removed. RuntimeError is raised if *remove* specifies a major/minor tuple older than @@ -889,7 +890,7 @@ def _deprecated(name, message=_DEPRECATED_MSG, *, remove, _version=sys.version_i raise RuntimeError(msg) else: msg = message.format(name=name, remove=remove_formatted) - _wm.warn(msg, DeprecationWarning, stacklevel=3) + _wm.warn(msg, DeprecationWarning, stacklevel=stacklevel) # Private utility function called by _PyErr_WarnUnawaitedCoroutine diff --git a/Lib/test/test_zipfile/test_core.py b/Lib/test/test_zipfile/test_core.py index dbcaf1b14f0be7f..7f064a6d38f5a21 100644 --- a/Lib/test/test_zipfile/test_core.py +++ b/Lib/test/test_zipfile/test_core.py @@ -33,7 +33,9 @@ with_source_date_epoch, without_source_date_epoch, ) from test.support.import_helper import ensure_lazy_imports -from test.support.warnings_helper import check_no_resource_warning +from test.support.warnings_helper import ( + check_no_resource_warning, ignore_warnings, +) TESTFN2 = TESTFN + "2" @@ -4920,8 +4922,7 @@ class MonkeypatchedDecompressorTests(unittest.TestCase): # Some third-party projects monkey-patch _get_decompressor() to add # additional compression schemes. This can break at any time as the # internal compressor objects change. - # To protect users, we try to keep this case working (until it becomes - # too big of a burden, or we make the API public). + # To protect users, we try to keep this case working (see ). # See also: GH-156002 and GH-113756. COMPRESSION = 99 @@ -4972,7 +4973,9 @@ def test_roundtrip_monkeypatched_decompressor(self): with zipfile.ZipFile(buf, "w", compression=self.COMPRESSION) as zf: zf.writestr("member", data) self.assertIn(data.swapcase(), buf.getvalue()) - with zipfile.ZipFile(io.BytesIO(buf.getvalue())) as zf: + with (ignore_warnings(category=DeprecationWarning, + message='.*two argumentzs.*'), + zipfile.ZipFile(io.BytesIO(buf.getvalue())) as zf): self.assertEqual(zf.read("member"), data) with zf.open("member") as f: self.assertEqual(f.read(100), data[:100]) diff --git a/Lib/zipfile/__init__.py b/Lib/zipfile/__init__.py index 759cf771b10a7c0..d817bdd8769e7d7 100644 --- a/Lib/zipfile/__init__.py +++ b/Lib/zipfile/__init__.py @@ -802,7 +802,7 @@ def unused_data(self): return b'' @property - def _needs_input(self): + def needs_input(self): # While the LZMA properties header is still being buffered, more input # is required; afterwards defer to the wrapped decompressor so a bounded # decompress() call can be drained across reads. @@ -893,25 +893,6 @@ def _get_compressor(compress_type, compresslevel=None): return None -def _decompressor_needs_input(decompressor, default): - # bz2/zstd expose the stdlib decompressor's public needs_input; the LZMA - # wrapper keeps it private (_needs_input) to avoid adding public API. - # A decompressor with neither attribute reports *default*. - needs_input = getattr(decompressor, "needs_input", None) - if needs_input is None: - needs_input = getattr(decompressor, "_needs_input", default) - return needs_input - - -def _decompressor_bounds_output(decompressor): - # The stdlib bzip2/LZMA/Zstandard decompressors report needs_input and - # accept decompress(data, max_length). A third-party decompressor - # installed by replacing _get_decompressor() may support neither; it is - # then read unbounded, as before the bounded-decompression fix. - return (hasattr(decompressor, "needs_input") - or hasattr(decompressor, "_needs_input")) - - def _get_decompressor(compress_type): _check_compression(compress_type) if compress_type == ZIP_STORED: @@ -1019,7 +1000,7 @@ def __init__(self, fileobj, mode, zipinfo, pwd=None, self._compress_left = zipinfo.compress_size self._left = zipinfo.file_size - self._set_decompressor() + self._decompressor = _get_decompressor(self._compress_type) self._eof = False self._readbuffer = b'' @@ -1202,11 +1183,6 @@ def read1(self, n): break return buf - def _set_decompressor(self): - self._decompressor = _get_decompressor(self._compress_type) - self._decompress_bounded = _decompressor_bounds_output( - self._decompressor) - def _read1(self, n): # Read up to n compressed bytes with at most one read() system call, # decrypt and decompress them. @@ -1224,7 +1200,7 @@ def _read1(self, n): else: # bzip2/lzma/zstd: a bounded decompress() call may leave input # buffered inside the decompressor; drain that before reading more. - if _decompressor_needs_input(self._decompressor, default=True): + if getattr(self._decompressor, "needs_input", True): data = self._read2(n) else: data = b'' @@ -1239,18 +1215,27 @@ def _read1(self, n): not self._decompressor.unconsumed_tail) if self._eof: data += self._decompressor.flush() - elif self._decompress_bounded: + else: # Bound the output of a single decompress() call (mirroring the # DEFLATE path above) so that a small compressed member cannot # expand into one unbounded read. - data = self._decompressor.decompress(data, max(n, self.MIN_READ_SIZE)) + try: + data = self._decompressor.decompress(data, max(n, self.MIN_READ_SIZE)) + except TypeError: + # See MonkeypatchedDecompressorTests in test_core.py + warnings._deprecated( + 'one-argument decompress()', + 'The decompress() method of ' + + type(self._decompressor).__name__ + + ' should take two arguments, data and max_length.' + + ' One-argument calls will stop working before' + + ' Python 3.21.', + remove=(3, 21), + stacklevel=4) + data = self._decompressor.decompress(data) self._eof = (self._decompressor.eof or self._compress_left <= 0 and - _decompressor_needs_input(self._decompressor, - default=True)) - else: - data = self._decompressor.decompress(data) - self._eof = self._decompressor.eof or self._compress_left <= 0 + getattr(self._decompressor, "needs_input", True)) data = data[:self._left] self._left -= len(data) @@ -1339,7 +1324,7 @@ def seek(self, offset, whence=os.SEEK_SET): self._left = self._orig_file_size self._readbuffer = b'' self._offset = 0 - self._set_decompressor() + self._decompressor = _get_decompressor(self._compress_type) self._eof = False read_offset = new_pos if self._decrypter is not None: diff --git a/Misc/NEWS.d/next/Library/2026-09-08-13-06-29.gh-issue-156002.vmOC8T.rst b/Misc/NEWS.d/next/Library/2026-09-08-13-06-29.gh-issue-156002.vmOC8T.rst index a633e46bcdc52d5..c14dfda08f117ee 100644 --- a/Misc/NEWS.d/next/Library/2026-09-08-13-06-29.gh-issue-156002.vmOC8T.rst +++ b/Misc/NEWS.d/next/Library/2026-09-08-13-06-29.gh-issue-156002.vmOC8T.rst @@ -1,5 +1,6 @@ :mod:`zipfile` again reads members through a third-party decompressor -installed by replacing the private ``_get_decompressor()``, unbounded as -before the bounded-decompression fix, instead of raising -:exc:`AttributeError`. The bound still applies to the standard library's -bzip2, LZMA and Zstandard decompressors. +installed by monkey-patching the private ``_get_decompressor()`` to return an +object that only implements old BZ2Decompressor API from Python 3.3. +Calling to decompress() with one argument is deprecated. +Note that decompressors without ``needs_input`` and two-argument +``decompress()`` are vulnerable to :cve:`2026-15310`. From 1431f15a21e2bd4b4e114beef55f1ff34fab2dc0 Mon Sep 17 00:00:00 2001 From: Rasmus Faber-Espensen Date: Wed, 9 Sep 2026 16:38:22 +0200 Subject: [PATCH 5/5] Fix typos and other nits. --- Lib/test/test_zipfile/test_core.py | 8 ++++---- .../2026-09-08-13-06-29.gh-issue-156002.vmOC8T.rst | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Lib/test/test_zipfile/test_core.py b/Lib/test/test_zipfile/test_core.py index 7f064a6d38f5a21..708db4d4387df5e 100644 --- a/Lib/test/test_zipfile/test_core.py +++ b/Lib/test/test_zipfile/test_core.py @@ -4922,8 +4922,8 @@ class MonkeypatchedDecompressorTests(unittest.TestCase): # Some third-party projects monkey-patch _get_decompressor() to add # additional compression schemes. This can break at any time as the # internal compressor objects change. - # To protect users, we try to keep this case working (see ). - # See also: GH-156002 and GH-113756. + # To protect users, we try to keep this case working. + # See also: GH-156002 and GH-113767. COMPRESSION = 99 class Compressor: @@ -4935,7 +4935,7 @@ def flush(self): return b'' class Decompressor: - """Decmpressor with only the 3.3+ BZ2Decompressor API""" + """Decompressor with only the 3.3+ BZ2Decompressor API""" eof = False def decompress(self, data): @@ -4974,7 +4974,7 @@ def test_roundtrip_monkeypatched_decompressor(self): zf.writestr("member", data) self.assertIn(data.swapcase(), buf.getvalue()) with (ignore_warnings(category=DeprecationWarning, - message='.*two argumentzs.*'), + message='.*two arguments.*'), zipfile.ZipFile(io.BytesIO(buf.getvalue())) as zf): self.assertEqual(zf.read("member"), data) with zf.open("member") as f: diff --git a/Misc/NEWS.d/next/Library/2026-09-08-13-06-29.gh-issue-156002.vmOC8T.rst b/Misc/NEWS.d/next/Library/2026-09-08-13-06-29.gh-issue-156002.vmOC8T.rst index c14dfda08f117ee..3fc3b6d4c2279be 100644 --- a/Misc/NEWS.d/next/Library/2026-09-08-13-06-29.gh-issue-156002.vmOC8T.rst +++ b/Misc/NEWS.d/next/Library/2026-09-08-13-06-29.gh-issue-156002.vmOC8T.rst @@ -1,6 +1,6 @@ :mod:`zipfile` again reads members through a third-party decompressor installed by monkey-patching the private ``_get_decompressor()`` to return an object that only implements old BZ2Decompressor API from Python 3.3. -Calling to decompress() with one argument is deprecated. +Calling decompress() with one argument is deprecated. Note that decompressors without ``needs_input`` and two-argument ``decompress()`` are vulnerable to :cve:`2026-15310`.