Skip to content

Apply part encodings in MultipartWriter.as_bytes() - #13496

Open
2sumtech wants to merge 5 commits into
aio-libs:masterfrom
2sumtech:fix/multipart-as-bytes-encodings
Open

Apply part encodings in MultipartWriter.as_bytes()#13496
2sumtech wants to merge 5 commits into
aio-libs:masterfrom
2sumtech:fix/multipart-as-bytes-encodings

Conversation

@2sumtech

@2sumtech 2sumtech commented Aug 19, 2026

Copy link
Copy Markdown

What do these changes do?

MultipartWriter.as_bytes() ignored the per-part Content-Encoding / Content-Transfer-Encoding that write() applies via MultipartPayloadWriter, so its result did not match the wire body — the output contradicted the parts' own headers, and digest authentication with qop=auth-int always failed for such payloads (the middleware hashes as_bytes() while the wire carries the encoded body). This applies the same transformations (ZLibCompressor for gzip/deflate, base64/quoted-printable for CTE) in as_bytes(), making the output byte-identical to write().

Fixes #13495

Are there changes in behavior for the user?

as_bytes() now returns compressed/encoded part bodies for non-form-data multiparts that declare those headers — matching what is actually sent on the wire.

Testing

New parametrized tests assert byte-equality between as_bytes() and the write() wire output for gzip/deflate Content-Encoding and base64/quoted-printable CTE, plus content round-trips: 6 failed without the fix, 6 pass with it. Full test_multipart.py + test_formdata.py + test_client_middleware_digest_auth.py + test_payload.py: 448 passed. flake8/mypy clean on touched files; CHANGES fragment 13495.bugfix.rst and CONTRIBUTORS.txt entry included.

Disclosure

Drafted with Claude Code (Fable 5); reviewed by @2sumtech.

@psf-chronographer psf-chronographer Bot added the bot:chronographer:provided There is a change note present in this PR label Aug 19, 2026
@codecov

codecov Bot commented Aug 19, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 99.00%. Comparing base (8c8906a) to head (126ba04).
✅ All tests successful. No failed tests found.

Additional details and impacted files
@@           Coverage Diff            @@
##           master   #13496    +/-   ##
========================================
  Coverage   99.00%   99.00%            
========================================
  Files         132      132            
  Lines       49635    49772   +137     
  Branches     2575     2590    +15     
========================================
+ Hits        49141    49279   +138     
  Misses        370      370            
+ Partials      124      123     -1     
Flag Coverage Δ
Autobahn 22.01% <12.94%> (-0.03%) ⬇️
CI-GHA 98.91% <100.00%> (+<0.01%) ⬆️
OS-Linux 98.69% <100.00%> (-0.01%) ⬇️
OS-Windows 97.02% <100.00%> (+0.01%) ⬆️
OS-macOS 97.94% <100.00%> (+<0.01%) ⬆️
Py-3.10 98.13% <100.00%> (+<0.01%) ⬆️
Py-3.11 98.37% <100.00%> (+<0.01%) ⬆️
Py-3.12 98.46% <100.00%> (+<0.01%) ⬆️
Py-3.13 98.45% <100.00%> (+<0.01%) ⬆️
Py-3.14 98.47% <100.00%> (+<0.01%) ⬆️
Py-3.14t 97.56% <100.00%> (-0.01%) ⬇️
Py-pypy-3.11 97.42% <100.00%> (+0.01%) ⬆️
VM-macos 97.94% <100.00%> (+<0.01%) ⬆️
VM-ubuntu 98.69% <100.00%> (-0.01%) ⬇️
VM-windows 97.02% <100.00%> (+0.01%) ⬆️
cython-coverage 82.11% <8.51%> (-0.15%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

@codspeed-hq

codspeed-hq Bot commented Aug 19, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 84 untouched benchmarks
⏩ 83 skipped benchmarks1


Comparing 2sumtech:fix/multipart-as-bytes-encodings (126ba04) with master (8c8906a)

Open in CodSpeed

Footnotes

  1. 83 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

@aiolibsbot

Copy link
Copy Markdown
Contributor

PR Review — Apply part encodings in MultipartWriter.as_bytes()

Correct, well-targeted fix — as_bytes() now mirrors write()'s per-part encoding. Merge-ready; four non-blocking notes.

Specific things done well:

  • The transformations match write()'s ordering exactly (compress → transfer-encode), including the suppress_deflate_header=True flag and the identity/binary normalisation already done in append_payload(), so no special-casing was needed for identity, binary, or form-data parts.
  • The tests assert against bytes(buf) — the real write() output — rather than a hand-written expected literal. That is the right assertion for a "these two paths must agree" bug and is what makes the fix trustworthy.
  • I verified the end-to-end qop=auth-int flow on the PR head: as_bytes() is idempotent across calls, and hash-then-write stays consistent, so the middleware fix actually holds.
  • Verified locally on the PR branch: 445 passed / 6 skipped across test_multipart.py, test_formdata.py, test_client_middleware_digest_auth.py, test_payload.py; flake8 and black clean on the touched files. (mypy is not installed in this environment — unverified.)
  • Docs entry is correctly placed under the MultipartWriter directive, which also resolves the :meth: cross-reference the changelog fragment needs.

Key points:

  • quoted-printable still diverges from write() for parts written in multiple chunks (>256 KB) — b2a_qp restarts its 76-column counter per call, so soft line breaks land at different offsets. Reproduced: a 400 KB QP part differs at byte 269338. gzip/deflate/base64 are chunk-invariant and match at any size, so this is the only residual gap.
  • decode()'s docstring still points users at as_bytes().decode() as a drop-in, but the two now disagree for encoded parts — and raise UnicodeDecodeError for gzip ones.
  • Missing the CHANGES/13496.bugfix.rst -> 13495.bugfix.rst symlink that AGENTS.md asks for (six such symlinks already exist in CHANGES/).
  • No test for a part carrying both Content-Encoding and Content-Transfer-Encoding — the composed path works today (verified manually) but is the one most exposed to a future ordering regression.

🟢 Suggestions

1. quoted-printable still diverges from write() for multi-chunk payloads
aiohttp/multipart.py:1151-1152

The binascii.b2a_qp(part_bytes) call encodes the whole body at once, but write() calls b2a_qp per chunk in MultipartPayloadWriter.write() (multipart.py:1257). b2a_qp inserts soft line breaks (=\n) every 76 columns and restarts its column counter on each call, so the two paths place the breaks at different offsets once a part is written in more than one chunk.

I verified this empirically against the PR head — a 400 KB file part with Content-Transfer-Encoding: quoted-printable (written in 2 chunks of 262144 + 137856 bytes):

qp no-newline 400k: match=False  wire=410823  as_bytes=410823
  first diff at byte 269338
  wire : b'AAAA...AAA=\nAAAAAAAA'   # break lands at the chunk boundary
  asb  : b'AAAA...AA=\nAAAAAAAAAA'  # break lands on the 76-col grid

Why it matters: this is exactly the case the PR sets out to fix. A qop=auth-int digest hash over a large quoted-printable part will still silently mismatch the wire body, so the auth failure the PR describes persists for that input — just for a narrower set of payloads than before. Note the two encodings are both valid QP; only the bytes differ.

Both in-memory cases and gzip/deflate/base64 at any size do match (zlib and the base64 3-byte buffering in MultipartPayloadWriter are chunk-invariant), so this is the only residual gap. Options: chunk as_bytes() the same way write() does, or note the limitation in the changelog/docs so the remaining case is known rather than assumed fixed.

elif te_encoding == "quoted-printable":
    part_bytes = binascii.b2a_qp(part_bytes)
2. decode() docstring now recommends a non-equivalent replacement
aiohttp/multipart.py:1112

decode() still iterates for part, _e, _te in self._parts and emits raw part bodies, so after this change decode() and as_bytes().decode() no longer agree for encoded parts — but the docstring directs users to swap one for the other:

WARNING: This method may do blocking I/O ... Use as_bytes().decode() instead.

Verified on the PR head:

  • Content-Transfer-Encoding: base64decode() yields the body 'hello', as_bytes().decode() yields 'aGVsbG8='.
  • Content-Encoding: gzipdecode() yields 'hello', as_bytes().decode() raises UnicodeDecodeError on the gzip bytes.

Why it matters: anyone following that guidance to migrate off the blocking method gets either different output or an exception, with nothing in the changelog pointing at it. Worth either updating the docstring to say the replacement now reflects the wire body (and can be non-UTF-8), or applying the same transformations in decode() so the two stay in step.

        WARNING: This method may do blocking I/O if parts contain file payloads.
        It should not be called in the event loop. Use as_bytes().decode() instead.
3. Missing PR-numbered changelog symlink
CHANGES/13495.bugfix.rst:1

AGENTS.md asks for both numbers when a PR fixes an issue:

Both issue and PR number wanted: keep the issue-numbered file and symlink: ln -s 1234.bugfix.rst CHANGES/1240.bugfix.rst

Only the issue-numbered fragment 13495.bugfix.rst is present. This is a live convention in the tree — CHANGES/ currently carries six such symlinks (13330.bugfix.rst -> 13329.bugfix.rst, 13249.bugfix.rst -> 13203.bugfix.rst, 13122.feature.rst -> 13006.feature.rst, …).

Fix: ln -s 13495.bugfix.rst CHANGES/13496.bugfix.rst

4. No test for combined Content-Encoding + Content-Transfer-Encoding
tests/test_multipart.py:1331-1380

The three new tests cover compression and transfer-encoding in isolation, but not both on one part — which is the branch where ordering matters (write() composes them as compress-then-encode via enable_compression() + enable_encoding(), and the new code has to mirror that order).

I verified manually that the composed path does match today (gzip + base64 over a 5000-byte part: match=True), so this is a coverage gap, not a bug. But the ordering is precisely the thing a future refactor of either path could silently invert, and nothing would catch it.

Suggest one more case in the same style:

writer.append("Time to Relax!", {
    CONTENT_ENCODING: "gzip",
    CONTENT_TRANSFER_ENCODING: "base64",
})
await writer.write(stream)
assert await writer.as_bytes() == bytes(buf)

Checklist

  • Fix matches write()'s encoding semantics — suggestion #1
  • New behaviour covered by tests — suggestion #4
  • Related test suites pass locally (445 passed)
  • Lint/format clean (flake8, black)
  • Changelog fragment follows AGENTS.md conventions — suggestion #3
  • Docs updated for user-visible behaviour change — suggestion #2
  • CONTRIBUTORS.txt entry in alphabetical position
  • No scope creep beyond the stated fix
  • No double-encoding risk (as_bytes does not cache into the write path)
  • Form-data multiparts unaffected

Automated review by Kōan (Claude) HEAD=ca142f2 6 min 8s

@2sumtech
2sumtech marked this pull request as ready for review August 19, 2026 16:09
@greptile-apps

greptile-apps Bot commented Aug 19, 2026

Copy link
Copy Markdown

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains; the earlier quoted-printable chunk-divergence and unbounded partial-line buffering issues are both addressed by the shared fixed-boundary encoder and streaming block flush.

Reviews (3): Last reviewed commit: "Bound the quoted-printable partial-line ..." | Re-trigger Greptile

Comment thread aiohttp/multipart.py Outdated
MultipartPayloadWriter applied binascii.b2a_qp to each payload chunk
independently, while as_bytes() encodes the complete body in one pass.
b2a_qp derives the line-ending convention, soft line-break positions,
and trailing-whitespace quoting from the buffer it is given, so a line
ending, long line, or trailing whitespace split across chunk boundaries
produced different wire bytes than as_bytes() (breaking the entity hash
for digest auth with qop=auth-int), and a CRLF split across chunks was
transmitted in a form that decodes to different body bytes.

Encode quoted-printable data one \n-terminated line at a time in both
paths, buffering the partial trailing line in MultipartPayloadWriter the
same way the base64 path buffers unaligned groups, so the output is a
pure function of the payload bytes regardless of chunking.
AGENTS.md asks for the issue-numbered fragment plus a symlink named
after the PR, so towncrier credits both.
Comment thread aiohttp/multipart.py
The per-line encoder held a partial line in memory until a newline
arrived, so a newline-free payload segment accumulated in full and
nothing reached the transport until EOF, defeating backpressure.

Encode each line in fixed 4096-byte blocks joined by soft line breaks,
with block boundaries at fixed offsets from the start of the line, and
flush every complete block of the pending partial line as it arrives.
Block-wise output differs from a single b2a_qp call (each call restarts
its soft-break column counter), so as_bytes() uses the same block
segmentation: the wire bytes stay a pure function of the payload bytes
regardless of chunking and still decode to the original body, while the
writer retains at most one block between write() calls.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bot:chronographer:provided There is a change note present in this PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

MultipartWriter.as_bytes() does not apply part Content-Encoding / Content-Transfer-Encoding, diverging from write()

2 participants