Skip to content

Fix MultipartWriter.as_bytes() to apply Content-Encoding and Content-Transfer-Encoding - #13688

Open
aoright wants to merge 2 commits into
aio-libs:masterfrom
aoright:fix-multipart-as-bytes-encoding
Open

aoright wants to merge 2 commits into
aio-libs:masterfrom
aoright:fix-multipart-as-bytes-encoding

Conversation

@aoright

@aoright aoright commented Sep 11, 2026

Copy link
Copy Markdown

Fixes #13495

When a part appended to a non-form-data MultipartWriter carries Content-Encoding or Content-Transfer-Encoding, write() compresses/encodes the part on the wire via MultipartPayloadWriter, but as_bytes() previously returned the raw, untransformed part content.

This fix updates as_bytes() to properly apply encoding via MultipartPayloadWriter to match the wire protocol, adding consistent behavior.

@codecov

codecov Bot commented Sep 11, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 90.00000% with 3 lines in your changes missing coverage. Please review.
✅ Project coverage is 99.02%. Comparing base (5da6d53) to head (bd5ffa7).
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
aiohttp/multipart.py 81.25% 1 Missing and 2 partials ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master   #13688      +/-   ##
==========================================
- Coverage   99.03%   99.02%   -0.01%     
==========================================
  Files         135      135              
  Lines       50940    50969      +29     
  Branches     2677     2680       +3     
==========================================
+ Hits        50446    50472      +26     
- Misses        370      371       +1     
- Partials      124      126       +2     
Flag Coverage Δ
Autobahn 21.93% <3.33%> (-0.02%) ⬇️
CI-GHA 98.91% <90.00%> (-0.01%) ⬇️
OS-Linux 98.69% <90.00%> (-0.01%) ⬇️
OS-Windows 97.31% <90.00%> (-0.01%) ⬇️
OS-macOS 98.18% <90.00%> (+<0.01%) ⬆️
Py-3.10 98.12% <90.00%> (-0.01%) ⬇️
Py-3.11 98.35% <90.00%> (-0.01%) ⬇️
Py-3.12 98.43% <90.00%> (-0.01%) ⬇️
Py-3.13 98.41% <90.00%> (-0.02%) ⬇️
Py-3.14 98.45% <90.00%> (-0.01%) ⬇️
Py-3.14t 97.82% <90.00%> (-0.01%) ⬇️
Py-pypy-3.11 97.40% <90.00%> (-0.01%) ⬇️
VM-macos 98.18% <90.00%> (+<0.01%) ⬆️
VM-ubuntu 98.69% <90.00%> (-0.01%) ⬇️
VM-windows 97.31% <90.00%> (-0.01%) ⬇️
cython-coverage 83.14% <0.00%> (-0.05%) ⬇️

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 Sep 11, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 97 untouched benchmarks
⏩ 83 skipped benchmarks1


Comparing aoright:fix-multipart-as-bytes-encoding (bd5ffa7) with master (5da6d53)

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.

@greptile-apps

greptile-apps Bot commented Sep 11, 2026

Copy link
Copy Markdown

Confidence Score: 3/5

Not safe to merge: buffered multipart serialization can differ from streamed output, and the change adds unnecessary peak memory use for large encoded parts. The repository-required docstring cleanup and changelog fragment are also required before merging.

Two independently reproduced functional problems affect multipart serialization behavior and memory consumption.

Files Needing Attention: aiohttp/multipart.py, tests/test_multipart.py, and CHANGES/13688.bugfix.rst

T-Rex T-Rex Logs

What T-Rex did

  • T-Rex produced proofs for the posted P1 findings in multiple proofs, covering reviewer findings and accompanying checks.
  • T-Rex performed contract validation and identified a difference in SHA-256 results between write and as_bytes paths, with the root cause at aiohttp/multipart.py:1195 due to how the writer processes parts.
  • T-Rex documented the instrumented comparison results, including base64-encoded part size, transformed buffer size, and the final multipart size, confirming an additional full transformed-body copy.

View all artifacts

T-Rex Ran code and verified through T-Rex

Comments Outside Diff (1)

  1. General comment

    P1 MultipartWriter.as_bytes changes chunk-sensitive quoted-printable output

    • Bug
      • For a payload streaming b'hello ', b'world', and b'!', MultipartWriter.write() serializes hello=20world!, while MultipartWriter.as_bytes() serializes hello world!. The multipart bodies have different SHA-256 digests and bytes.
    • Cause
      • as_bytes() collects the payload using part.as_bytes() and sends its concatenation to MultipartPayloadWriter in one call. The streaming path calls part.write(w) and MultipartPayloadWriter applies binascii.b2a_qp() independently to each source chunk; a chunk-ending space is encoded as =20, but the same space within a coalesced chunk is left literal.
    • Fix
      • Make the two APIs use equivalent quoted-printable streaming semantics. For example, have as_bytes() capture encoded output through the payload's write() path, or make quoted-printable encoding stateful/boundary-independent and apply it consistently in both paths.

    T-Rex Ran code and verified through T-Rex

Reviews (1): Last reviewed commit: "[pre-commit.ci] auto fixes from pre-comm..." | Re-trigger Greptile

Comment thread aiohttp/multipart.py
w.enable_compression(_e)
if _te:
w.enable_encoding(_te)
await w.write(part_bytes)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Quoted-printable output diverges

For a payload that writes several chunks, write() applies quoted-printable encoding per chunk, while as_bytes() first combines those chunks and encodes them once. A payload writing b"hello ", b"world", and b"!" emits hello=20world! through write() but hello world! through as_bytes(). Callers using buffered serialization can therefore send multipart bytes that differ from the streamed representation, including different message signatures or checksums.

Knowledge Base Used: Payloads, forms, and multipart bodies

Artifacts

Evidence from the check

  • The authored executable script creates a multi-chunk payload and compares the multipart `write()` and `as_bytes()` paths, demonstrating the chunk-boundary discrepancy.

Command output from the check

  • The command-captured source shows the complete standalone test that was executed against the local pure-Python aiohttp checkout.

Command output from the check

  • Executing the streaming path produced `hello=20world!` from three source chunks, showing chunk-boundary-sensitive encoding.

Command output from the check

  • Executing the as-bytes path produced `hello world!` from the same logical payload, confirming it differs from streaming output.

View artifacts

T-Rex Ran code and verified through T-Rex

Comment thread aiohttp/multipart.py
w.enable_encoding(_te)
await w.write(part_bytes)
await w.write_eof()
parts.append(bytes(writer.buffer))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Full body copy retained

bytes(writer.buffer) duplicates the entire transformed part while the original bytearray remains live, before b"".join(parts) allocates the final multipart body. In a reproduced 12 MiB base64 case, this retained an additional 16 MiB encoded-body allocation. Large encoded multipart payloads therefore consume substantially more peak memory and may fail under memory limits.

Knowledge Base Used: Payloads, forms, and multipart bodies

Artifacts

Evidence from the check

  • The authored probe compares retaining the transformed bytearray with copying it to bytes before final multipart assembly.

Command output from the check

  • The captured source records the complete instrumented comparison used for the allocation measurement.

Command output from the check

  • The retained-bytearray run records memory immediately before and after the final join.

Command output from the check

  • The bytes-copy run records the additional full transformed-body allocation before the final join.

View artifacts

T-Rex Ran code and verified through T-Rex

Comment thread tests/test_multipart.py


async def test_multipart_writer_as_bytes_with_encoding() -> None:
"""Test that MultipartWriter.as_bytes() applies content encoding."""

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Remove redundant docstring

The new docstring repeats the test name and assertion instead of documenting behavior that is not apparent from the code. This violates the repository directive against comments and docstrings that merely restate code, and the repository requirement must be satisfied before merging.

Context Used: AGENTS.md (source)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Comment thread aiohttp/multipart.py
@@ -1174,9 +1174,29 @@ async def as_bytes(self, encoding: str = "utf-8", errors: str = "strict") -> byt
# Add headers

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Add changelog fragment

This changes user-visible serialization of encoded multipart payloads, but no CHANGES/13688.bugfix.rst fragment is included. This violates the repository directive requiring bug-fix changelog fragments, and the repository requirement must be satisfied before merging.

Context Used: AGENTS.md (source)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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()

1 participant