From d32f860e3de1ca3e5e601ce62a91c300efb9eaef Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Thu, 27 Aug 2026 05:52:12 +0000 Subject: [PATCH 1/7] Restore `AsyncIterator[bytes]` on `aiter_bytes()` and `aiter_raw()` `#1126` widened the return annotation of `Response.aiter_bytes()` and `Response.aiter_raw()` from `AsyncIterator[bytes]` to `AsyncGenerator[bytes, None]`, so that `contextlib.aclosing()` would accept them. That made overriding either method in a `Response` subclass a typing error, and made it a runtime error to return anything other than an async generator, since `aclosing()` calls `aclose()` unconditionally. Narrow both annotations back, and close the iterator only when it is actually an async generator. This is the same guard `aiter_raw()` already applies to `self.stream`. The sync counterparts are unaffected: `iter_bytes()` and `iter_raw()` were never widened, and neither uses `contextlib.closing()`. Closes #1158 --- src/httpx2/httpx2/_models.py | 17 ++++++++---- tests/httpx2/models/test_responses.py | 38 +++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 5 deletions(-) diff --git a/src/httpx2/httpx2/_models.py b/src/httpx2/httpx2/_models.py index 0272f36d..3f9ea4b8 100644 --- a/src/httpx2/httpx2/_models.py +++ b/src/httpx2/httpx2/_models.py @@ -1,7 +1,6 @@ from __future__ import annotations import codecs -import contextlib import datetime import email.message import json as jsonlib @@ -972,11 +971,15 @@ 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() + try: self._content = b"".join([part async for part in parts]) + finally: + if isinstance(parts, AsyncGenerator): + await parts.aclose() 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,11 +992,15 @@ 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() + try: async for raw_bytes in raw_stream: for decoded in decoder.decode(raw_bytes): for chunk in chunker.decode(decoded): yield chunk + finally: + if isinstance(raw_stream, AsyncGenerator): + await raw_stream.aclose() for decoded in decoder.flush(): for chunk in chunker.decode(decoded): yield chunk # pragma: no cover @@ -1028,7 +1035,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 f37c719a..85147655 100644 --- a/tests/httpx2/models/test_responses.py +++ b/tests/httpx2/models/test_responses.py @@ -27,6 +27,22 @@ async def async_streaming_body() -> typing.AsyncIterator[bytes]: yield b"world!" +class PlainAsyncIterator: + """An async iterator that is not an async generator, and so has no `aclose()`.""" + + 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") @@ -492,6 +508,28 @@ async def test_aiter_raw_with_chunksize() -> None: assert parts == [b"Hello, world!"] +@pytest.mark.anyio +async def test_aiter_bytes_override_with_plain_async_iterator() -> None: + # Subclasses may override the async iteration methods with any async iterator, + # and not only with an async generator. + 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_with_plain_async_iterator() -> 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( From 6c80f174c4e46e824d288fb32a887c2ee0c9f9d2 Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Thu, 27 Aug 2026 05:52:42 +0000 Subject: [PATCH 2/7] Add changelog entry --- src/httpx2/CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) 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 From 0ea708f4d3d6b5847f1d538fdaa9468a2b64da72 Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Mon, 14 Sep 2026 12:46:09 +0200 Subject: [PATCH 3/7] Close custom asynchronous iterators when supported --- src/httpx2/httpx2/_models.py | 13 +++++--- tests/httpx2/models/test_responses.py | 45 ++++++++++++++++++++++++--- 2 files changed, 50 insertions(+), 8 deletions(-) diff --git a/src/httpx2/httpx2/_models.py b/src/httpx2/httpx2/_models.py index 3f9ea4b8..9bc3af09 100644 --- a/src/httpx2/httpx2/_models.py +++ b/src/httpx2/httpx2/_models.py @@ -7,7 +7,7 @@ import re import typing import urllib.request -from collections.abc import AsyncGenerator, Mapping +from collections.abc import Mapping from http.cookiejar import Cookie, CookieJar from ._content import ByteStream, UnattachedStream, encode_request, encode_response @@ -54,6 +54,11 @@ _T = typing.TypeVar("_T") +@typing.runtime_checkable +class _AsyncClosable(typing.Protocol): + async def aclose(self) -> None: ... + + def _is_known_encoding(encoding: str) -> bool: """ Return `True` if `encoding` is a known codec. @@ -975,7 +980,7 @@ async def aread(self) -> bytes: try: self._content = b"".join([part async for part in parts]) finally: - if isinstance(parts, AsyncGenerator): + if isinstance(parts, _AsyncClosable): await parts.aclose() return self._content @@ -999,7 +1004,7 @@ async def aiter_bytes(self, chunk_size: int | None = None) -> typing.AsyncIterat for chunk in chunker.decode(decoded): yield chunk finally: - if isinstance(raw_stream, AsyncGenerator): + if isinstance(raw_stream, _AsyncClosable): await raw_stream.aclose() for decoded in decoder.flush(): for chunk in chunker.decode(decoded): @@ -1061,7 +1066,7 @@ async def aiter_raw(self, chunk_size: int | None = None) -> typing.AsyncIterator for chunk in chunker.flush(): yield chunk finally: - if isinstance(stream, AsyncGenerator): + if isinstance(stream, _AsyncClosable): await stream.aclose() await self.aclose() diff --git a/tests/httpx2/models/test_responses.py b/tests/httpx2/models/test_responses.py index 81d41e48..bef1e280 100644 --- a/tests/httpx2/models/test_responses.py +++ b/tests/httpx2/models/test_responses.py @@ -28,8 +28,6 @@ async def async_streaming_body() -> typing.AsyncIterator[bytes]: class PlainAsyncIterator: - """An async iterator that is not an async generator, and so has no `aclose()`.""" - def __init__(self, chunks: list[bytes]) -> None: self._chunks = iter(chunks) @@ -43,6 +41,21 @@ async def __anext__(self) -> bytes: raise StopAsyncIteration from None +class ClosableAsyncIterator: + def __init__(self, chunks: list[bytes]) -> None: + self._iterator = PlainAsyncIterator(chunks) + self.is_closed = False + + def __aiter__(self) -> ClosableAsyncIterator: + return self + + async def __anext__(self) -> bytes: + return await self._iterator.__anext__() + + async def aclose(self) -> None: + self.is_closed = True + + def autodetect(content: bytes) -> str | None: return chardet.detect(content).get("encoding") @@ -511,8 +524,6 @@ async def test_aiter_raw_with_chunksize() -> None: @pytest.mark.anyio async def test_aiter_bytes_override_with_plain_async_iterator() -> None: - # Subclasses may override the async iteration methods with any async iterator, - # and not only with an async generator. class CustomResponse(httpx2.Response): def aiter_bytes(self, chunk_size: int | None = None) -> typing.AsyncIterator[bytes]: return PlainAsyncIterator([b"Hello, ", b"world!"]) @@ -521,6 +532,19 @@ def aiter_bytes(self, chunk_size: int | None = None) -> typing.AsyncIterator[byt assert await response.aread() == b"Hello, world!" +@pytest.mark.anyio +async def test_aread_closes_custom_async_iterator() -> None: + iterator = ClosableAsyncIterator([b"Hello, ", b"world!"]) + + class CustomResponse(httpx2.Response): + def aiter_bytes(self, chunk_size: int | None = None) -> typing.AsyncIterator[bytes]: + return iterator + + response = CustomResponse(200, content=async_streaming_body()) + assert await response.aread() == b"Hello, world!" + assert iterator.is_closed + + @pytest.mark.anyio async def test_aiter_raw_override_with_plain_async_iterator() -> None: class CustomResponse(httpx2.Response): @@ -531,6 +555,19 @@ def aiter_raw(self, chunk_size: int | None = None) -> typing.AsyncIterator[bytes assert await response.aread() == b"Hello, world!" +@pytest.mark.anyio +async def test_aiter_bytes_closes_custom_raw_async_iterator() -> None: + iterator = ClosableAsyncIterator([b"Hello, ", b"world!"]) + + class CustomResponse(httpx2.Response): + def aiter_raw(self, chunk_size: int | None = None) -> typing.AsyncIterator[bytes]: + return iterator + + response = CustomResponse(200, content=async_streaming_body()) + assert await response.aread() == b"Hello, world!" + assert iterator.is_closed + + @pytest.mark.anyio async def test_aiter_raw_on_sync() -> None: response = httpx2.Response( From 0258f7580415bdfc7a09ad58c0a4480d3013f4cd Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Mon, 14 Sep 2026 12:55:03 +0200 Subject: [PATCH 4/7] Keep async generator cleanup unchanged --- src/httpx2/httpx2/_models.py | 24 +++------- tests/httpx2/models/test_responses.py | 69 ++++----------------------- 2 files changed, 15 insertions(+), 78 deletions(-) diff --git a/src/httpx2/httpx2/_models.py b/src/httpx2/httpx2/_models.py index 9bc3af09..9896c2dd 100644 --- a/src/httpx2/httpx2/_models.py +++ b/src/httpx2/httpx2/_models.py @@ -1,13 +1,14 @@ from __future__ import annotations import codecs +import contextlib import datetime import email.message import json as jsonlib import re import typing import urllib.request -from collections.abc import Mapping +from collections.abc import AsyncGenerator, Mapping from http.cookiejar import Cookie, CookieJar from ._content import ByteStream, UnattachedStream, encode_request, encode_response @@ -54,11 +55,6 @@ _T = typing.TypeVar("_T") -@typing.runtime_checkable -class _AsyncClosable(typing.Protocol): - async def aclose(self) -> None: ... - - def _is_known_encoding(encoding: str) -> bool: """ Return `True` if `encoding` is a known codec. @@ -976,12 +972,9 @@ async def aread(self) -> bytes: Read and return the response content. """ if not hasattr(self, "_content"): - parts = self.aiter_bytes() - try: + parts = typing.cast(typing.AsyncGenerator[bytes, None], self.aiter_bytes()) + async with contextlib.aclosing(parts): self._content = b"".join([part async for part in parts]) - finally: - if isinstance(parts, _AsyncClosable): - await parts.aclose() return self._content async def aiter_bytes(self, chunk_size: int | None = None) -> typing.AsyncIterator[bytes]: @@ -997,15 +990,12 @@ async def aiter_bytes(self, chunk_size: int | None = None) -> typing.AsyncIterat decoder = self._get_content_decoder() chunker = ByteChunker(chunk_size=chunk_size) with request_context(request=self._request): - raw_stream = self.aiter_raw() - try: + raw_stream = typing.cast(typing.AsyncGenerator[bytes, None], self.aiter_raw()) + async with contextlib.aclosing(raw_stream): async for raw_bytes in raw_stream: for decoded in decoder.decode(raw_bytes): for chunk in chunker.decode(decoded): yield chunk - finally: - if isinstance(raw_stream, _AsyncClosable): - await raw_stream.aclose() for decoded in decoder.flush(): for chunk in chunker.decode(decoded): yield chunk # pragma: no cover @@ -1066,7 +1056,7 @@ async def aiter_raw(self, chunk_size: int | None = None) -> typing.AsyncIterator for chunk in chunker.flush(): yield chunk finally: - if isinstance(stream, _AsyncClosable): + if isinstance(stream, AsyncGenerator): await stream.aclose() await self.aclose() diff --git a/tests/httpx2/models/test_responses.py b/tests/httpx2/models/test_responses.py index bef1e280..df7cf30a 100644 --- a/tests/httpx2/models/test_responses.py +++ b/tests/httpx2/models/test_responses.py @@ -27,35 +27,6 @@ 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 - - -class ClosableAsyncIterator: - def __init__(self, chunks: list[bytes]) -> None: - self._iterator = PlainAsyncIterator(chunks) - self.is_closed = False - - def __aiter__(self) -> ClosableAsyncIterator: - return self - - async def __anext__(self) -> bytes: - return await self._iterator.__anext__() - - async def aclose(self) -> None: - self.is_closed = True - - def autodetect(content: bytes) -> str | None: return chardet.detect(content).get("encoding") @@ -523,49 +494,25 @@ async def test_aiter_raw_with_chunksize() -> None: @pytest.mark.anyio -async def test_aiter_bytes_override_with_plain_async_iterator() -> None: +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!"]) + async def aiter_bytes(self, chunk_size: int | None = None) -> typing.AsyncIterator[bytes]: + yield b"Hello, " + yield b"world!" response = CustomResponse(200, content=async_streaming_body()) assert await response.aread() == b"Hello, world!" @pytest.mark.anyio -async def test_aread_closes_custom_async_iterator() -> None: - iterator = ClosableAsyncIterator([b"Hello, ", b"world!"]) - - class CustomResponse(httpx2.Response): - def aiter_bytes(self, chunk_size: int | None = None) -> typing.AsyncIterator[bytes]: - return iterator - - response = CustomResponse(200, content=async_streaming_body()) - assert await response.aread() == b"Hello, world!" - assert iterator.is_closed - - -@pytest.mark.anyio -async def test_aiter_raw_override_with_plain_async_iterator() -> 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_bytes_closes_custom_raw_async_iterator() -> None: - iterator = ClosableAsyncIterator([b"Hello, ", b"world!"]) - +async def test_aiter_raw_override() -> None: class CustomResponse(httpx2.Response): - def aiter_raw(self, chunk_size: int | None = None) -> typing.AsyncIterator[bytes]: - return iterator + async def aiter_raw(self, chunk_size: int | None = None) -> typing.AsyncIterator[bytes]: + yield b"Hello, " + yield b"world!" response = CustomResponse(200, content=async_streaming_body()) assert await response.aread() == b"Hello, world!" - assert iterator.is_closed @pytest.mark.anyio From 8045a42622de31424a478110a7ffe72621704950 Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Mon, 14 Sep 2026 12:58:36 +0200 Subject: [PATCH 5/7] Avoid casting asynchronous iterators --- src/httpx2/httpx2/_models.py | 17 +++++++++++++---- tests/httpx2/models/test_responses.py | 24 ++++++++++++++++++------ 2 files changed, 31 insertions(+), 10 deletions(-) diff --git a/src/httpx2/httpx2/_models.py b/src/httpx2/httpx2/_models.py index 9896c2dd..a50fa0a6 100644 --- a/src/httpx2/httpx2/_models.py +++ b/src/httpx2/httpx2/_models.py @@ -55,6 +55,17 @@ _T = typing.TypeVar("_T") +@contextlib.asynccontextmanager +async def _close_async_generator( + iterator: typing.AsyncIterator[_T], +) -> typing.AsyncIterator[typing.AsyncIterator[_T]]: + if isinstance(iterator, AsyncGenerator): + async with contextlib.aclosing(iterator): + yield iterator + else: + yield iterator + + def _is_known_encoding(encoding: str) -> bool: """ Return `True` if `encoding` is a known codec. @@ -972,8 +983,7 @@ async def aread(self) -> bytes: Read and return the response content. """ if not hasattr(self, "_content"): - parts = typing.cast(typing.AsyncGenerator[bytes, None], self.aiter_bytes()) - async with contextlib.aclosing(parts): + async with _close_async_generator(self.aiter_bytes()) as parts: self._content = b"".join([part async for part in parts]) return self._content @@ -990,8 +1000,7 @@ async def aiter_bytes(self, chunk_size: int | None = None) -> typing.AsyncIterat decoder = self._get_content_decoder() chunker = ByteChunker(chunk_size=chunk_size) with request_context(request=self._request): - raw_stream = typing.cast(typing.AsyncGenerator[bytes, None], self.aiter_raw()) - async with contextlib.aclosing(raw_stream): + async with _close_async_generator(self.aiter_raw()) as raw_stream: async for raw_bytes in raw_stream: for decoded in decoder.decode(raw_bytes): for chunk in chunker.decode(decoded): diff --git a/tests/httpx2/models/test_responses.py b/tests/httpx2/models/test_responses.py index df7cf30a..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") @@ -496,9 +510,8 @@ async def test_aiter_raw_with_chunksize() -> None: @pytest.mark.anyio async def test_aiter_bytes_override() -> None: class CustomResponse(httpx2.Response): - async def aiter_bytes(self, chunk_size: int | None = None) -> typing.AsyncIterator[bytes]: - yield b"Hello, " - yield b"world!" + 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!" @@ -507,9 +520,8 @@ async def aiter_bytes(self, chunk_size: int | None = None) -> typing.AsyncIterat @pytest.mark.anyio async def test_aiter_raw_override() -> None: class CustomResponse(httpx2.Response): - async def aiter_raw(self, chunk_size: int | None = None) -> typing.AsyncIterator[bytes]: - yield b"Hello, " - yield b"world!" + 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!" From 625b3192b2579988f7b60ab79d78c66b96ac9164 Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Mon, 14 Sep 2026 13:00:39 +0200 Subject: [PATCH 6/7] Avoid nested generator cleanup contexts --- src/httpx2/httpx2/_models.py | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/src/httpx2/httpx2/_models.py b/src/httpx2/httpx2/_models.py index a50fa0a6..6b6b971a 100644 --- a/src/httpx2/httpx2/_models.py +++ b/src/httpx2/httpx2/_models.py @@ -55,15 +55,12 @@ _T = typing.TypeVar("_T") -@contextlib.asynccontextmanager -async def _close_async_generator( +def _aclosing_if_supported( iterator: typing.AsyncIterator[_T], -) -> typing.AsyncIterator[typing.AsyncIterator[_T]]: +) -> contextlib.AbstractAsyncContextManager[typing.AsyncIterator[_T]]: if isinstance(iterator, AsyncGenerator): - async with contextlib.aclosing(iterator): - yield iterator - else: - yield iterator + return contextlib.aclosing(iterator) + return contextlib.nullcontext(iterator) def _is_known_encoding(encoding: str) -> bool: @@ -983,7 +980,7 @@ async def aread(self) -> bytes: Read and return the response content. """ if not hasattr(self, "_content"): - async with _close_async_generator(self.aiter_bytes()) as parts: + async with _aclosing_if_supported(self.aiter_bytes()) as parts: self._content = b"".join([part async for part in parts]) return self._content @@ -1000,7 +997,7 @@ async def aiter_bytes(self, chunk_size: int | None = None) -> typing.AsyncIterat decoder = self._get_content_decoder() chunker = ByteChunker(chunk_size=chunk_size) with request_context(request=self._request): - async with _close_async_generator(self.aiter_raw()) as raw_stream: + async with _aclosing_if_supported(self.aiter_raw()) as raw_stream: async for raw_bytes in raw_stream: for decoded in decoder.decode(raw_bytes): for chunk in chunker.decode(decoded): From 897559512fb94177496816ba94acf46059dcc6bd Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Mon, 14 Sep 2026 13:15:44 +0200 Subject: [PATCH 7/7] Inline asynchronous iterator cleanup --- src/httpx2/httpx2/_models.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/httpx2/httpx2/_models.py b/src/httpx2/httpx2/_models.py index 6b6b971a..7e5979d5 100644 --- a/src/httpx2/httpx2/_models.py +++ b/src/httpx2/httpx2/_models.py @@ -55,14 +55,6 @@ _T = typing.TypeVar("_T") -def _aclosing_if_supported( - iterator: typing.AsyncIterator[_T], -) -> contextlib.AbstractAsyncContextManager[typing.AsyncIterator[_T]]: - if isinstance(iterator, AsyncGenerator): - return contextlib.aclosing(iterator) - return contextlib.nullcontext(iterator) - - def _is_known_encoding(encoding: str) -> bool: """ Return `True` if `encoding` is a known codec. @@ -980,7 +972,9 @@ async def aread(self) -> bytes: Read and return the response content. """ if not hasattr(self, "_content"): - async with _aclosing_if_supported(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 @@ -997,7 +991,13 @@ async def aiter_bytes(self, chunk_size: int | None = None) -> typing.AsyncIterat decoder = self._get_content_decoder() chunker = ByteChunker(chunk_size=chunk_size) with request_context(request=self._request): - async with _aclosing_if_supported(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):