Skip to content

gh-107862: Add Hypothesis tests for the compression modules - #159960

Open
pganssle wants to merge 8 commits into
python:mainfrom
pganssle:compression-hypothesis
Open

pganssle wants to merge 8 commits into
python:mainfrom
pganssle:compression-hypothesis

Conversation

@pganssle

@pganssle pganssle commented Oct 20, 2026 •

Copy link
Copy Markdown
Member

Builds on the hypothesis.stateful stubs from #159770 (now merged).

The compression modules wrap external libraries, but the incremental layer is CPython code with one shared contract: decompress(data, max_length) with eof, needs_input and unused_data on BZ2Decompressor, LZMADecompressor, zlib._ZlibDecompressor and ZstdDecompressor; the older zlib.decompressobj protocol with unconsumed_tail; and the read-mode file objects built on DecompressReader. The existing tests cover these with fixed chunk sizes only. This adds Lib/test/test_compression_property.py:

  • A RuleBasedStateMachine per backend (bz2, gzip, lzma, zlib, raw deflate, zstd) that feeds one stream in random pieces with random max_length values and checks the output prefix, the max_length bound, eof, unused_data and EOFError after eof.
  • @given companions of the same contract that replay under the stubs: chunked decompress, truncated streams, chunked compress round trips, repeated max_length=0, and invalid input (random bytes, a flipped byte, a truncated stream) where only the backend's own error type may escape.
  • Compressor flush modes for zlib (Z_*_FLUSH) and zstd (CONTINUE/FLUSH_BLOCK/FLUSH_FRAME with last_mode), checked against the one-shot decompressors.
  • The zlib.decompressobj contract (unconsumed_tail, unused_data after eof, flush() on truncated streams, zdict).
  • A state machine per file type (BZ2File, GzipFile, LZMAFile, ZstdFile) over multi-member input, comparing read, read1, peek, readinto, readline, seek and tell against io.BytesIO on the plaintext.
  • zstd frame properties: get_frame_size/get_frame_info, multi-frame streams, raw dictionaries in every loading mode, and set_pledged_input_size.

The strategies use .map() only (no filters or assume()), so every test is also usable as a fuzz target via fuzz_one_input. With Hypothesis the file takes about 8 s; under the stubs the machines skip and the explicit examples replay.

It also converts the ChecksumCombineMixin loops in test_zlib.py (3 × 1000 unseeded random iterations) to @given. The Adler-32 initial values are now drawn from valid checksums only, since adler32_combine() is only defined on those and the old loop avoided invalid ones by chance.

Three bugs were found; each is recorded as an expectedFailure test with a minimal reproducer as an @example, not worked around: #159999, #160000 and #160001.

  1. BZ2Decompressor and zlib._ZlibDecompressor set needs_input=True while output is still buffered #159999: bz2.BZ2Decompressor and zlib._ZlibDecompressor report needs_input=True while output is still pending: when the input buffer empties they set it without checking whether max_length cut the output. LZMADecompressor and ZstdDecompressor get this right. DecompressReader.read trusts needs_input, so a reader can raise EOFError with data still buffered.
    >>> d = zlib._ZlibDecompressor()
    >>> d.decompress(zlib.compress(b'\x00', 1)[:4], 0)
    b''
    >>> d.needs_input
    True
    >>> d.decompress(b'')
    b'\x00'
  2. LZMADecompressor.decompress(b'', max_length=0) raises LZMAError on the second consecutive call #160000: lzma.LZMADecompressor raises on the second of two consecutive max_length=0 calls with no new input; liblzma's "no progress" LZMA_BUF_ERROR leaks through. The other three backends return b''. The broad lzma tests leave out max_length=0 so that they keep checking everything else; the carve-out is commented and pinned by test_repeated_max_length_zero.
    >>> d = lzma.LZMADecompressor()
    >>> d.decompress(b'', 0); d.decompress(b'', 0)
    b''
    _lzma.LZMAError: Insufficient buffer space
  3. zlib._ZlibDecompressor(zdict=...) raises SystemError on a zlib-wrapped stream #160001: zlib._ZlibDecompressor(zdict=...) on a zlib-wrapped stream raises SystemError: ... returned NULL without setting an exception: the dictionary is only applied up front for raw deflate, and the Z_NEED_DICT branch in decompress_buf jumps to error without setting one. zlib.decompressobj handles the same input.
    >>> zd = b'hello world ' * 10
    >>> c = zlib.compressobj(zdict=zd); s = c.compress(b'hello world hello') + c.flush()
    >>> zlib._ZlibDecompressor(zdict=zd).decompress(s)
    SystemError: <method 'decompress' of 'zlib._ZlibDecompressor' objects> returned NULL without setting an exception

One caveat on the expectedFailure tests for (1): the reproducers depend on exactly where the compressor places block boundaries, so a build against a zlib fork with different output (zlib-ng, for example) could turn them into unexpected successes. If that happens on a buildbot the fix is to add an example found on that platform.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this needs a blurb, also there seem to be two?

@StanFromIreland

Copy link
Copy Markdown
Member

Maybe these would be better suited for https://github.com/python/library-fuzzers, that way, there's more compute thrown at them?

@pganssle

Copy link
Copy Markdown
Member Author

Sorry this was supposed to be a draft PR.

Maybe these would be better suited for https://github.com/python/library-fuzzers, that way, there's more compute thrown at them?

Hm... I don't love that these are in separate locations. I feel like one of the strengths of property tests is that they scale pretty well with compute — we use them as basically test parameterization via @example, they are a useful way to sample a subset of the full space on each PR when exhaustive tests aren't reasonable and you can turn them into fuzz tests just by instrumenting them differently. I feel like we should try and make use of this property and have some way to just point fuzzers at certain tests in the normal test suite.

The incremental decompressors in bz2, lzma, zlib and compression.zstd,
the older zlib.decompressobj protocol, and the DecompressReader file
layer are CPython code sharing one contract, but the existing tests only
exercise them with fixed chunk sizes. This adds property tests that feed
random data through them in random pieces with random max_length values,
with the module-level one-shot functions as the oracle, plus a stateful
test per backend for the decompressor protocol and for the read-mode file
objects. The strategies avoid filters so the tests can also be used as
fuzz targets.

Writing these turned up three bugs, each pinned by an expectedFailure
test with the minimal reproducer as an example:

- bz2.BZ2Decompressor and zlib._ZlibDecompressor report needs_input=True
  when max_length cut the output and more is still buffered.
- lzma.LZMADecompressor raises LZMAError("Insufficient buffer space") on
  the second of two consecutive max_length=0 calls with no new input;
  the other backends return b''. The broad lzma tests leave out
  max_length=0 so that they keep checking everything else.
- zlib._ZlibDecompressor(zdict=...) raises SystemError on a zlib-wrapped
  stream, because the Z_NEED_DICT path returns NULL without setting an
  exception.
These ran 1000 iterations over random 32 and 64 byte inputs with
unseeded random initial values, so a failure could not be reproduced.
Hypothesis gives the same coverage with shrinking and a persistent
example database.

The Adler-32 initial values are now restricted to valid checksums (both
16-bit sums below 65521). adler32_combine() is only defined on those,
and the old test only avoided the invalid region by chance, since
random.randint(1, 0x80000000) rarely produced a low half of 65521 or
more.
@pganssle
pganssle force-pushed the compression-hypothesis branch from ac1f687 to 91cc017 Compare October 20, 2026 20:39
Comment thread Lib/test/test_compression_property.py
Comment thread Lib/test/test_compression_property.py Outdated
Comment thread Lib/test/test_compression_property.py Outdated
Comment thread Lib/test/test_compression_property.py Outdated
@pganssle pganssle added tests Tests in the Lib/test dir and removed type-feature A feature request or enhancement labels Oct 20, 2026
…ession tests

Review feedback on the first version:

- Cycling through the per-call values (max_length, flush mode) is now
  itertools.cycle zipped with the chunks instead of a hand-rolled index
  helper.
- The backend tables use functools.partial rather than lambdas wherever
  the call can be expressed that way.
- The state machines checked with plain assert statements. They now go
  through a small base class that borrows the unittest assertion methods
  from a throwaway TestCase, so a failing rule reports the same diffs as
  everything else in the suite.

The chunk splitter stays a custom generator: itertools.batched only makes
equal-size pieces, and the point of the sizes list is to put the call
boundaries at arbitrary positions in the stream (the pinned needs_input
reproducers depend on that).
@bedevere-app bedevere-app Bot added the type-feature A feature request or enhancement label Oct 20, 2026
…xplicit

The companion test classes stay ordinary class statements, since they are
the ones with content worth reading and grepping for. The per-backend
machine subclass only adds a backend attribute and Machine.TestCase is
generated by Hypothesis anyway, so _machine_test() now builds both from
a name, a machine base, a backend factory and a requires_* decorator.

The backend factories return None when their module is missing, which
removes the conditional backend assignments from every class. The
per-backend overrides for the known bugs (and the lzma max_length=0
carve-out) are mixins listed first in the bases, so the MRO shows which
backends carry one.
The test pinning pythongh-160001 expects a SystemError, but in a debug build
the missing exception trips a C assertion in the call machinery before
the SystemError is raised, which aborts the test worker.  All CI builds
are debug builds, so the test is skipped there until the bug is fixed.

The gzip examples for pythongh-159999 also depend on where inflate stops
consuming input, and none of them triggered the bug with zlib-ng, which
the Windows builds use.  This adds an example found against zlib-ng
2.2.4 in zlib-compat mode; the zlib wrapper and raw deflate examples
already fail with both libraries.

The comments now also reference the filed issues.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

awaiting core review skip news tests Tests in the Lib/test dir type-feature A feature request or enhancement

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants