Skip to content

gh-107862: Add Hypothesis property tests for the codecs machinery - #160155

Open
pganssle wants to merge 10 commits into
python:mainfrom
pganssle:codecs-hypothesis
Open

pganssle wants to merge 10 commits into
python:mainfrom
pganssle:codecs-hypothesis

Conversation

@pganssle

@pganssle pganssle commented Oct 22, 2026 •

Copy link
Copy Markdown
Member

Part of #107862, on top of #160356 (example.xfail() in the stubs). This adds Lib/test/test_codecs_property.py, Hypothesis property tests for the codec machinery, in the same shape as #159960 for the compression modules.

The existing codec tests check each codec against fixed strings, and test_basics runs the incremental and stream interfaces on "abc123" only. The new file runs every codec in all_unicode_encodings on generated input and compares against the stateless codec functions:

  • incremental encode and decode for any chunking of the input, under every error handler, and getstate()/setstate() round trips (the conditions documented for IncrementalDecoder.getstate());
  • StreamWriter then StreamReader with arbitrary write and read sizes, the reader against the stateless decoder under every error handler, the chars argument of the pure Python reader, and readline()/read() interleaving against str.splitlines();
  • the error handlers run back through the codec: backslashreplace output decoded via unicode_escape, xmlcharrefreplace via html.unescape, surrogateescape round trips on the ASCII-compatible single-byte codecs;
  • charmap_decode/charmap_encode with generated tables against a small oracle, and charmap_build maps against equivalent dicts;
  • encode/decode round trips, escape_encode/escape_decode, the escape codecs with surrogates, the bytes transform codecs and rot_13, and name normalization in codecs.lookup.

With Hypothesis the file takes about five seconds; 3000 examples per test also pass. Without it the explicit examples replay through the stubs (23 tests in both modes).

The by-design differences are written down in the module rather than hidden in the strategies: BOM-less utf-16/32 is rejected incrementally (tested as such), utf-7 accepts lone surrogates and closes its shift sequence at every chunk, cp932/cp950/euc_jp/shift_jis fold a few compatibility characters, idna applies nameprep, raw_unicode_escape leaves backslashes alone, and surrogateescape only round-trips on codecs where every byte decodes injectively.

Six real divergences turned up. Each is pinned as @example(...).xfail(reason="gh-...") on the test it belongs to, so the test fails if the input starts passing, and the input is carved out of the generated data only where the comments say so:

  1. The incremental decoders of euc_jp, euc_jis_2004, euc_jisx0213 and gb18030 drop input after an incomplete trailing sequence under surrogateescape: b'\x806'.decode('gb18030', 'surrogateescape') is '\udc806', the incremental decoder gives '\udc80' (also through TextIOWrapper). Filed as euc_jp and gb18030 incremental decoders drop input after an incomplete trailing sequence under surrogateescape #160307.
  2. The ISO-2022 encoders write U+001B as a bare ESC byte that their decoders reject or misread ('\x1b\xa0'.encode('iso2022_jp_2004').decode('iso2022_jp_2004') is '\x1b\x1b$(Q)"'); iso2022_kr does the same with SO and SI. Filed as ISO-2022 encoders accept U+001B (and iso2022_kr U+000E/U+000F) although the encoding cannot represent them #160302.
  3. codecs.StreamReader.read() never decodes with final=True, so an incomplete trailing character is silently dropped: codecs.getreader('utf-8')(io.BytesIO(b'ab\xe2')).read() is 'ab', with no error in strict mode and no replacement character in replace mode. Filed as codecs.StreamReader.read() silently drops an incomplete character at the end of the stream #160309 (same mechanism as codecs.StreamReader doesn't pass final=1 to the UTF-8 codec #56717, open since 2011).
  4. The punycode incremental codecs transform each chunk on its own ('a' then 'b' encodes to b'a-b-', not b'ab-'). test_codecs already lists punycode as "broken in stateful mode". Many incremental codecs don’t handle fragmented data #64331 mentions it in passing. Filed as The punycode incremental encoder and decoder transform each chunk separately #160308.
  5. '\u3164'.encode('euc_kr') gives the Hangul filler b'\xa4\xd4', which euc_kr cannot decode on its own (cp949 can). This was euc_kr char '0x3164' decode('ksx1001') cause UnicodeDecodeError  #101863, closed as not a bug; the test records the asymmetry either way.
  6. iso2022_jp_3 encodes U+9B1D but decodes the result as U+9B1C: in jisx0213_2000_2_decoder the 2000-table emulation macro is followed by if instead of else if, so the table lookup overwrites it. Filed as iso2022_jp_3 encodes U+9B1D but decodes the result as U+9B1C #160306.

…sis stubs

Hypothesis lets a test pin a known-failing input with
`@example(...).xfail(reason=..., raises=...)`, which is the natural way
to record a bug found by a property test without taking the input out of
the strategy or writing a separate expectedFailure test. The stub
`example` is a plain decorator function, so any test using `.xfail()` or
`.via()` failed to import when Hypothesis is not installed.

This turns the stub into a class with those two methods. An expected
failure is strict, as in Hypothesis: when the examples are replayed the
test must raise one of the `raises` exceptions for that input, and an
unexpected pass fails the test. `.via()` is a no-op, as it is upstream.
The existing codec tests check each codec against fixed strings, and
test_basics runs the incremental and stream interfaces on "abc123" only.
This adds test_codecs_property.py, which runs every codec in
all_unicode_encodings through the incremental codecs (any chunking,
getstate/setstate round trips), StreamReader/StreamWriter, the error
handlers (backslashreplace decoded back through unicode_escape,
xmlcharrefreplace through html.unescape, surrogateescape round trips),
charmap_decode/charmap_encode against a small oracle, name normalization
in codecs.lookup, and the transform codecs, all on generated input and
compared against the stateless codec functions.

Six divergences turned up, each recorded as an expectedFailure with the
minimal input as an explicit example and carved out of the broad tests
only where the comments say so:

- euc_jp, euc_jis_2004, euc_jisx0213 and gb18030 incremental decoders
  drop input after an incomplete trailing sequence under surrogateescape.
- The ISO-2022 encoders emit U+001B as a bare ESC (iso2022_kr also SO and
  SI) which their decoders reject or misread.
- StreamReader.read() never decodes with final=True, so an incomplete
  trailing character is silently dropped.
- The punycode incremental codecs transform each chunk on its own.
- euc_kr encodes U+3164 to the Hangul filler bytes it cannot decode alone.
- iso2022_jp_3 decodes plane 2 0x7D3B with the 2004 table because the
  2000 emulation is followed by "if" rather than "else if".

The by-design differences (BOM-less utf-16/32 incrementally, utf-7 and
lone surrogates, the many-to-one compatibility mappings in cp932, cp950,
euc_jp and shift_jis, surrogateescape on non-ASCII-compatible codecs) are
documented in the module rather than hidden in the strategies.

With Hypothesis the file runs in about five seconds; 3000 examples per
test pass as well. Without it the explicit examples replay through the
stubs.
…xpected failures

The StreamReader truncation is pythongh-56717, open since 2011 with the same
diagnosis, and the euc_kr filler asymmetry was closed as not a bug in
pythongh-101863; the punycode chunking is mentioned in pythongh-64331.
…ailures

Previously the expectedFailure tests drew from the same broad strategies
as the properties they were carved out of, so most of their generated
inputs passed and only the explicit examples made them fail. Each one
now generates just the known-failing shape (a trailing incomplete
three- or four-byte sequence, a trailing ESC/SO/SI, a filler or the
0x7D3B character between printable ASCII, a chopped multi-byte character,
two non-empty ASCII punycode chunks), so the whole test flips to passing
once the bug is fixed. A search for a passing input in each strategy
finds none.
…mples

Previously each known bug had its own expectedFailure test generating
only the failing inputs. Hypothesis already has a way to say that: an
`@example(...).xfail(reason=..., raises=...)` on the test the input
belongs to, which is strict in both Hypothesis and (after pythongh-160356) the
stubs. Each bug is now a few such examples on test_decode_in_chunks,
test_encode_in_chunks, test_encode_decode or test_reader_matches_decode,
and the carve-outs in the strategies point at them.

Also from review:

- MULTIBYTE_ENCODINGS is an issubclass check against
  _multibytecodec.MultibyteIncrementalDecoder rather than a look at the
  repr of the MRO.
- Only raw_unicode_escape is listed out of the surrogateescape codecs
  by hand, and for the right reason (a truncated \u escape raises);
  unicode_escape and utf_7 already fail _lossless_bytes.
- xmlcharrefreplace text leaves out the control characters and
  noncharacters whose references html.unescape drops, not only "&" and
  the C1 range.
- The charmap encoding tables leave out U+FFFE, which charmap_build
  treats as undefined, and the two encode tests share the strategy.
- test_unicode_escape_with_surrogates compared an expression with
  itself; it now checks the unicode_escape round trip and the
  raw_unicode_escape round trip on text without backslashes.
- StreamReader.read(size) takes a byte count, so the reads are no
  longer expected to return at most that many characters (big5hkscs
  decodes one sequence to two). test_reader_read_chars covers the
  chars argument of the pure Python reader, which the multibyte
  readers do not have, and test_reader_matches_decode compares the
  reader with the stateless decoder under every error handler.
@bedevere-app bedevere-app Bot added the type-feature A feature request or enhancement label Oct 22, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

awaiting core review 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.

1 participant