From a833e2b0ca1ec1adb1acf6c7a1e1d4e8116e1030 Mon Sep 17 00:00:00 2001 From: CrepuscularIRIS Date: Wed, 22 Apr 2026 10:09:19 -0400 Subject: [PATCH 1/4] fix: ensure BodyPartReader.read() returns bytes, not bytearray BodyPartReader.read() accumulated data in a bytearray buffer and returned it directly, violating the documented return type of bytes. Both the plain and decode=True code paths were affected. Convert the internal bytearray to bytes before returning so the API contract matches the documentation and type hints. Fixes #12404 --- CHANGES/12404.bugfix.rst | 3 +++ aiohttp/multipart.py | 4 ++-- tests/test_multipart.py | 19 +++++++++++++++++++ 3 files changed, 24 insertions(+), 2 deletions(-) create mode 100644 CHANGES/12404.bugfix.rst diff --git a/CHANGES/12404.bugfix.rst b/CHANGES/12404.bugfix.rst new file mode 100644 index 00000000000..074d6868715 --- /dev/null +++ b/CHANGES/12404.bugfix.rst @@ -0,0 +1,3 @@ +Fixed ``BodyPartReader.read()`` and ``BodyPartReader.read(decode=True)`` returning +``bytearray`` instead of ``bytes``, violating the documented API contract. +-- by :user:`CrepuscularIRIS`. diff --git a/aiohttp/multipart.py b/aiohttp/multipart.py index 9d5e5d27b84..4d4eee7887f 100644 --- a/aiohttp/multipart.py +++ b/aiohttp/multipart.py @@ -322,8 +322,8 @@ async def read(self, *, decode: bool = False) -> bytes: decoded_data.extend(d) if len(decoded_data) > self._client_max_size: raise self._max_size_error_cls(self._client_max_size) - return decoded_data - return data + return bytes(decoded_data) + return bytes(data) async def read_chunk(self, size: int = chunk_size) -> bytes: """Reads body part content chunk of the specified size. diff --git a/tests/test_multipart.py b/tests/test_multipart.py index 83046ccc034..2a3e395e324 100644 --- a/tests/test_multipart.py +++ b/tests/test_multipart.py @@ -185,6 +185,25 @@ async def test_read(self) -> None: assert b"Hello, world!" == result assert obj.at_eof() + async def test_read_returns_bytes_not_bytearray(self) -> None: + # Regression test for https://github.com/aio-libs/aiohttp/issues/12404 + # read() must return bytes (not bytearray) to honour the documented API contract. + with Stream(b"Hello, world!\r\n--:") as stream: + d = CIMultiDictProxy[str](CIMultiDict()) + obj = aiohttp.BodyPartReader(BOUNDARY, d, stream) + result = await obj.read() + assert isinstance(result, bytes), f"Expected bytes, got {type(result).__name__}" + + async def test_read_decode_returns_bytes_not_bytearray(self) -> None: + # Regression test for https://github.com/aio-libs/aiohttp/issues/12404 + # read(decode=True) must return bytes (not bytearray) even when no + # Content-Transfer-Encoding / Content-Encoding header is present. + with Stream(b"Hello, world!\r\n--:") as stream: + d = CIMultiDictProxy[str](CIMultiDict()) + obj = aiohttp.BodyPartReader(BOUNDARY, d, stream) + result = await obj.read(decode=True) + assert isinstance(result, bytes), f"Expected bytes, got {type(result).__name__}" + async def test_read_chunk_at_eof(self) -> None: with Stream(b"--:") as stream: d = CIMultiDictProxy[str](CIMultiDict()) From e31bc14a51e28837124c72ea2c48916cfe6b683b Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 22 Apr 2026 14:10:45 +0000 Subject: [PATCH 2/4] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- tests/test_multipart.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/test_multipart.py b/tests/test_multipart.py index 2a3e395e324..2ba5c8a5b11 100644 --- a/tests/test_multipart.py +++ b/tests/test_multipart.py @@ -192,7 +192,9 @@ async def test_read_returns_bytes_not_bytearray(self) -> None: d = CIMultiDictProxy[str](CIMultiDict()) obj = aiohttp.BodyPartReader(BOUNDARY, d, stream) result = await obj.read() - assert isinstance(result, bytes), f"Expected bytes, got {type(result).__name__}" + assert isinstance( + result, bytes + ), f"Expected bytes, got {type(result).__name__}" async def test_read_decode_returns_bytes_not_bytearray(self) -> None: # Regression test for https://github.com/aio-libs/aiohttp/issues/12404 @@ -202,7 +204,9 @@ async def test_read_decode_returns_bytes_not_bytearray(self) -> None: d = CIMultiDictProxy[str](CIMultiDict()) obj = aiohttp.BodyPartReader(BOUNDARY, d, stream) result = await obj.read(decode=True) - assert isinstance(result, bytes), f"Expected bytes, got {type(result).__name__}" + assert isinstance( + result, bytes + ), f"Expected bytes, got {type(result).__name__}" async def test_read_chunk_at_eof(self) -> None: with Stream(b"--:") as stream: From c3f30a1412909d8057f62a2b1f7a3573fca44522 Mon Sep 17 00:00:00 2001 From: CrepuscularIRIS Date: Wed, 22 Apr 2026 18:50:07 -0400 Subject: [PATCH 3/4] docs: update BodyPartReader.read() rtype from bytearray to bytes --- docs/multipart_reference.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/multipart_reference.rst b/docs/multipart_reference.rst index 41c5942c7d7..fb0c4f2e064 100644 --- a/docs/multipart_reference.rst +++ b/docs/multipart_reference.rst @@ -44,7 +44,7 @@ Multipart reference from ``Content-Encoding`` header. If it missed data remains untouched - :rtype: bytearray + :rtype: bytes .. method:: read_chunk(size=chunk_size) :async: From e650dc4d1c6de99e856e3730ec38689f73064f2b Mon Sep 17 00:00:00 2001 From: CrepuscularIRIS Date: Thu, 23 Apr 2026 02:11:58 -0400 Subject: [PATCH 4/4] refactor(tests): parametrize byte-type check and simplify assertions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Collapse the two duplicate test methods into one parametrized test, move the isinstance assertion outside the Stream context manager, drop the custom failure message, and use :class: RST roles in the changelog entry — all per code-review feedback. --- CHANGES/12404.bugfix.rst | 2 +- tests/test_multipart.py | 24 ++++++------------------ 2 files changed, 7 insertions(+), 19 deletions(-) diff --git a/CHANGES/12404.bugfix.rst b/CHANGES/12404.bugfix.rst index 074d6868715..1c5e82f8d3a 100644 --- a/CHANGES/12404.bugfix.rst +++ b/CHANGES/12404.bugfix.rst @@ -1,3 +1,3 @@ Fixed ``BodyPartReader.read()`` and ``BodyPartReader.read(decode=True)`` returning -``bytearray`` instead of ``bytes``, violating the documented API contract. +:class:`bytearray` instead of :class:`bytes`, violating the documented API contract. -- by :user:`CrepuscularIRIS`. diff --git a/tests/test_multipart.py b/tests/test_multipart.py index 2ba5c8a5b11..db288a97d07 100644 --- a/tests/test_multipart.py +++ b/tests/test_multipart.py @@ -185,28 +185,16 @@ async def test_read(self) -> None: assert b"Hello, world!" == result assert obj.at_eof() - async def test_read_returns_bytes_not_bytearray(self) -> None: - # Regression test for https://github.com/aio-libs/aiohttp/issues/12404 - # read() must return bytes (not bytearray) to honour the documented API contract. - with Stream(b"Hello, world!\r\n--:") as stream: - d = CIMultiDictProxy[str](CIMultiDict()) - obj = aiohttp.BodyPartReader(BOUNDARY, d, stream) - result = await obj.read() - assert isinstance( - result, bytes - ), f"Expected bytes, got {type(result).__name__}" - - async def test_read_decode_returns_bytes_not_bytearray(self) -> None: + @pytest.mark.parametrize("kwargs", [{}, {"decode": True}]) + async def test_read_returns_bytes_not_bytearray( + self, kwargs: dict[str, bool] + ) -> None: # Regression test for https://github.com/aio-libs/aiohttp/issues/12404 - # read(decode=True) must return bytes (not bytearray) even when no - # Content-Transfer-Encoding / Content-Encoding header is present. with Stream(b"Hello, world!\r\n--:") as stream: d = CIMultiDictProxy[str](CIMultiDict()) obj = aiohttp.BodyPartReader(BOUNDARY, d, stream) - result = await obj.read(decode=True) - assert isinstance( - result, bytes - ), f"Expected bytes, got {type(result).__name__}" + result = await obj.read(**kwargs) + assert isinstance(result, bytes) async def test_read_chunk_at_eof(self) -> None: with Stream(b"--:") as stream: