diff --git a/src/httpx2/CHANGELOG.md b/src/httpx2/CHANGELOG.md index a74acdbd..adc9b2c7 100644 --- a/src/httpx2/CHANGELOG.md +++ b/src/httpx2/CHANGELOG.md @@ -4,6 +4,14 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). +## Unreleased + +### Fixed + +* Restore `AsyncIterator[bytes]` as the return annotation of `Response.aiter_bytes()` and + `Response.aiter_raw()`, so that they can be overridden with any async iterator. + ([#1168](https://github.com/pydantic/httpx2/pull/1168)) + ## 2.12.0 (August 18th, 2026) ### Changed diff --git a/src/httpx2/httpx2/_models.py b/src/httpx2/httpx2/_models.py index 0272f36d..7e5979d5 100644 --- a/src/httpx2/httpx2/_models.py +++ b/src/httpx2/httpx2/_models.py @@ -972,11 +972,13 @@ async def aread(self) -> bytes: Read and return the response content. """ if not hasattr(self, "_content"): - async with contextlib.aclosing(self.aiter_bytes()) as parts: + parts = self.aiter_bytes() + closing = contextlib.aclosing(parts) if isinstance(parts, AsyncGenerator) else contextlib.nullcontext() + async with closing: self._content = b"".join([part async for part in parts]) return self._content - async def aiter_bytes(self, chunk_size: int | None = None) -> typing.AsyncGenerator[bytes, None]: + async def aiter_bytes(self, chunk_size: int | None = None) -> typing.AsyncIterator[bytes]: """ A byte-iterator over the decoded response content. This allows us to handle gzip, deflate, brotli, and zstd encoded responses. @@ -989,7 +991,13 @@ async def aiter_bytes(self, chunk_size: int | None = None) -> typing.AsyncGenera decoder = self._get_content_decoder() chunker = ByteChunker(chunk_size=chunk_size) with request_context(request=self._request): - async with contextlib.aclosing(self.aiter_raw()) as raw_stream: + raw_stream = self.aiter_raw() + closing = ( + contextlib.aclosing(raw_stream) + if isinstance(raw_stream, AsyncGenerator) + else contextlib.nullcontext() + ) + async with closing: async for raw_bytes in raw_stream: for decoded in decoder.decode(raw_bytes): for chunk in chunker.decode(decoded): @@ -1028,7 +1036,7 @@ async def aiter_lines(self) -> typing.AsyncIterator[str]: for line in decoder.flush(): yield line - async def aiter_raw(self, chunk_size: int | None = None) -> typing.AsyncGenerator[bytes, None]: + async def aiter_raw(self, chunk_size: int | None = None) -> typing.AsyncIterator[bytes]: """ A byte-iterator over the raw response content. """ diff --git a/tests/httpx2/models/test_responses.py b/tests/httpx2/models/test_responses.py index 9ecf815b..fe3b0040 100644 --- a/tests/httpx2/models/test_responses.py +++ b/tests/httpx2/models/test_responses.py @@ -27,6 +27,20 @@ async def async_streaming_body() -> typing.AsyncIterator[bytes]: yield b"world!" +class PlainAsyncIterator: + def __init__(self, chunks: list[bytes]) -> None: + self._chunks = iter(chunks) + + def __aiter__(self) -> PlainAsyncIterator: + return self + + async def __anext__(self) -> bytes: + try: + return next(self._chunks) + except StopIteration: + raise StopAsyncIteration from None + + def autodetect(content: bytes) -> str | None: return chardet.detect(content).get("encoding") @@ -493,6 +507,26 @@ async def test_aiter_raw_with_chunksize() -> None: assert parts == [b"Hello, world!"] +@pytest.mark.anyio +async def test_aiter_bytes_override() -> None: + class CustomResponse(httpx2.Response): + def aiter_bytes(self, chunk_size: int | None = None) -> typing.AsyncIterator[bytes]: + return PlainAsyncIterator([b"Hello, ", b"world!"]) + + response = CustomResponse(200, content=async_streaming_body()) + assert await response.aread() == b"Hello, world!" + + +@pytest.mark.anyio +async def test_aiter_raw_override() -> None: + class CustomResponse(httpx2.Response): + def aiter_raw(self, chunk_size: int | None = None) -> typing.AsyncIterator[bytes]: + return PlainAsyncIterator([b"Hello, ", b"world!"]) + + response = CustomResponse(200, content=async_streaming_body()) + assert await response.aread() == b"Hello, world!" + + @pytest.mark.anyio async def test_aiter_raw_on_sync() -> None: response = httpx2.Response(