Skip to content

Commit ee47a1b

Browse files
Merge branch 'master' into fix-content-length-validation
2 parents 5233af9 + 24ed3b3 commit ee47a1b

21 files changed

Lines changed: 170 additions & 101 deletions

CHANGES/12358.misc.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Changed ``zlib_executor_size`` default so compressed payloads are async by default -- by :user:`Dreamsorcerer`.

aiohttp/compression_utils.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -326,9 +326,15 @@ def decompress_sync(
326326
) -> bytes:
327327
"""Decompress the given data."""
328328
if hasattr(self._obj, "decompress"):
329-
result = cast(bytes, self._obj.decompress(data, max_length))
329+
if max_length == ZLIB_MAX_LENGTH_UNLIMITED:
330+
result = cast(bytes, self._obj.decompress(data))
331+
else:
332+
result = cast(bytes, self._obj.decompress(data, max_length))
330333
else:
331-
result = cast(bytes, self._obj.process(data, max_length))
334+
if max_length == ZLIB_MAX_LENGTH_UNLIMITED:
335+
result = cast(bytes, self._obj.process(data))
336+
else:
337+
result = cast(bytes, self._obj.process(data, max_length))
332338
# Only way to know that brotli has no further data is checking we get no output
333339
self._last_empty = result == b""
334340
return result

aiohttp/http_parser.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import asyncio
33
import re
44
import string
5+
import sys
56
from contextlib import suppress
67
from enum import IntEnum
78
from re import Pattern
@@ -1118,10 +1119,12 @@ def feed_data(self, chunk: bytes) -> bool:
11181119
encoding=self.encoding, suppress_deflate_header=True
11191120
)
11201121

1122+
low_water = self.out._low_water
1123+
max_length = (
1124+
0 if low_water >= sys.maxsize else max(self._max_decompress_size, low_water)
1125+
)
11211126
try:
1122-
chunk = self.decompressor.decompress_sync(
1123-
chunk, max_length=self._max_decompress_size
1124-
)
1127+
chunk = self.decompressor.decompress_sync(chunk, max_length=max_length)
11251128
except Exception:
11261129
raise ContentEncodingError(
11271130
"Can not decode content-encoding: %s" % self.encoding

aiohttp/streams.py

Lines changed: 34 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import asyncio
22
import collections
3+
import sys
34
import warnings
45
from collections.abc import Awaitable, Callable
56
from typing import Final, Generic, TypeVar
@@ -67,31 +68,7 @@ async def __anext__(self) -> tuple[bytes, bool]:
6768
return rv
6869

6970

70-
class AsyncStreamReaderMixin:
71-
72-
__slots__ = ()
73-
74-
def __aiter__(self) -> AsyncStreamIterator[bytes]:
75-
return AsyncStreamIterator(self.readline) # type: ignore[attr-defined]
76-
77-
def iter_chunked(self, n: int) -> AsyncStreamIterator[bytes]:
78-
"""Returns an asynchronous iterator that yields chunks of size n."""
79-
return AsyncStreamIterator(lambda: self.read(n)) # type: ignore[attr-defined]
80-
81-
def iter_any(self) -> AsyncStreamIterator[bytes]:
82-
"""Yield all available data as soon as it is received."""
83-
return AsyncStreamIterator(self.readany) # type: ignore[attr-defined]
84-
85-
def iter_chunks(self) -> ChunkTupleAsyncStreamIterator:
86-
"""Yield chunks of data as they are received by the server.
87-
88-
The yielded objects are tuples
89-
of (bytes, bool) as returned by the StreamReader.readchunk method.
90-
"""
91-
return ChunkTupleAsyncStreamIterator(self) # type: ignore[arg-type]
92-
93-
94-
class StreamReader(AsyncStreamReaderMixin):
71+
class StreamReader:
9572
"""An enhancement of asyncio.StreamReader.
9673
9774
Supports asynchronous iteration by line, chunk or as available::
@@ -174,9 +151,35 @@ def __repr__(self) -> str:
174151
info.append("e=%r" % self._exception)
175152
return "<%s>" % " ".join(info)
176153

154+
def __aiter__(self) -> AsyncStreamIterator[bytes]:
155+
return AsyncStreamIterator(self.readline)
156+
157+
def iter_chunked(self, n: int) -> AsyncStreamIterator[bytes]:
158+
"""Returns an asynchronous iterator that yields chunks of size n."""
159+
self.set_read_chunk_size(n)
160+
return AsyncStreamIterator(lambda: self.read(n))
161+
162+
def iter_any(self) -> AsyncStreamIterator[bytes]:
163+
"""Yield all available data as soon as it is received."""
164+
return AsyncStreamIterator(self.readany)
165+
166+
def iter_chunks(self) -> ChunkTupleAsyncStreamIterator:
167+
"""Yield chunks of data as they are received by the server.
168+
169+
The yielded objects are tuples
170+
of (bytes, bool) as returned by the StreamReader.readchunk method.
171+
"""
172+
return ChunkTupleAsyncStreamIterator(self)
173+
177174
def get_read_buffer_limits(self) -> tuple[int, int]:
178175
return (self._low_water, self._high_water)
179176

177+
def set_read_chunk_size(self, n: int) -> None:
178+
"""Raise buffer limits to match the consumer's chunk size."""
179+
if n > self._low_water:
180+
self._low_water = n
181+
self._high_water = n * 2
182+
180183
def exception(self) -> type[BaseException] | BaseException | None:
181184
return self._exception
182185

@@ -410,10 +413,8 @@ async def read(self, n: int = -1) -> bytes:
410413
return b""
411414

412415
if n < 0:
413-
# This used to just loop creating a new waiter hoping to
414-
# collect everything in self._buffer, but that would
415-
# deadlock if the subprocess sends more than self.limit
416-
# bytes. So just call self.readany() until EOF.
416+
# Reading everything — remove decompression chunk limit.
417+
self.set_read_chunk_size(sys.maxsize)
417418
blocks = []
418419
while True:
419420
block = await self.readany()
@@ -422,6 +423,7 @@ async def read(self, n: int = -1) -> bytes:
422423
blocks.append(block)
423424
return b"".join(blocks)
424425

426+
self.set_read_chunk_size(n)
425427
# TODO: should be `if` instead of `while`
426428
# because waiter maybe triggered on chunk end,
427429
# without feeding any data
@@ -595,6 +597,9 @@ async def wait_eof(self) -> None:
595597
def feed_data(self, data: bytes) -> bool:
596598
return False
597599

600+
def set_read_chunk_size(self, n: int) -> None:
601+
return
602+
598603
async def readline(self, *, max_line_length: int | None = None) -> bytes:
599604
return b""
600605

aiohttp/web_request.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -628,6 +628,10 @@ async def read(self) -> bytes:
628628
Returns bytes object with full request content.
629629
"""
630630
if self._read_bytes is None:
631+
# Raise the buffer limits so compressed payloads decompress in
632+
# larger chunks instead of many small pause/resume cycles.
633+
if self._client_max_size:
634+
self._payload.set_read_chunk_size(self._client_max_size)
631635
body = bytearray()
632636
while True:
633637
chunk = await self._payload.readany()

aiohttp/web_response.py

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414

1515
from . import hdrs, payload
1616
from .abc import AbstractStreamWriter
17-
from .compression_utils import ZLibCompressor
17+
from .compression_utils import MAX_SYNC_CHUNK_SIZE, ZLibCompressor
1818
from .helpers import (
1919
ETAG_ANY,
2020
QUOTED_ETAG_RE,
@@ -35,7 +35,6 @@
3535
from .typedefs import JSONBytesEncoder, JSONEncoder, LooseHeaders
3636

3737
REASON_PHRASES = {http_status.value: http_status.phrase for http_status in HTTPStatus}
38-
LARGE_BODY_SIZE = 1024**2
3938

4039
__all__ = (
4140
"ContentCoding",
@@ -547,7 +546,7 @@ def __init__(
547546
headers: LooseHeaders | None = None,
548547
content_type: str | None = None,
549548
charset: str | None = None,
550-
zlib_executor_size: int | None = None,
549+
zlib_executor_size: int = MAX_SYNC_CHUNK_SIZE,
551550
zlib_executor: Executor | None = None,
552551
) -> None:
553552
if body is not None and text is not None:
@@ -726,13 +725,6 @@ async def _do_start_compression(self, coding: ContentCoding) -> None:
726725
executor=self._zlib_executor,
727726
)
728727
assert self._body is not None
729-
if self._zlib_executor_size is None and len(self._body) > LARGE_BODY_SIZE:
730-
warnings.warn(
731-
"Synchronous compression of large response bodies "
732-
f"({len(self._body)} bytes) might block the async event loop. "
733-
"Consider providing a custom value to zlib_executor_size/"
734-
"zlib_executor response properties or disabling compression on it."
735-
)
736728
self._compressed_body = (
737729
await compressor.compress(self._body) + compressor.flush()
738730
)

docs/testing.rst

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -537,6 +537,12 @@ basis, the TestClient object can be used directly::
537537
A full list of the utilities provided can be found at the
538538
:data:`api reference <aiohttp.test_utils>`
539539

540+
For end-to-end client code that talks to an external service, it is
541+
recommended to run a small fake server rather than patching private aiohttp
542+
internals. The ``examples/fake_server.py`` demo shows such an approach: start
543+
a local :class:`~aiohttp.web.Application`, point a custom resolver at it, and
544+
exercise the client against that controlled endpoint.
545+
540546

541547
Testing API Reference
542548
---------------------

requirements/base-ft.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ multidict==6.7.1
3030
# via
3131
# -r requirements/runtime-deps.in
3232
# yarl
33-
packaging==26.0
33+
packaging==26.1
3434
# via gunicorn
3535
propcache==0.4.1
3636
# via

requirements/base.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ multidict==6.7.1
3030
# via
3131
# -r requirements/runtime-deps.in
3232
# yarl
33-
packaging==26.0
33+
packaging==26.1
3434
# via gunicorn
3535
propcache==0.4.1
3636
# via

requirements/constraints.txt

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ blockbuster==1.5.26
3434
# -r requirements/test-common.in
3535
brotli==1.2.0 ; platform_python_implementation == "CPython"
3636
# via -r requirements/runtime-deps.in
37-
build==1.4.2
37+
build==1.4.3
3838
# via pip-tools
3939
certifi==2026.2.25
4040
# via requests
@@ -69,7 +69,7 @@ exceptiongroup==1.3.1
6969
# via pytest
7070
execnet==2.1.2
7171
# via pytest-xdist
72-
filelock==3.25.2
72+
filelock==3.28.0
7373
# via
7474
# python-discovery
7575
# virtualenv
@@ -125,7 +125,7 @@ mypy-extensions==1.1.0
125125
# via mypy
126126
nodeenv==1.10.0
127127
# via pre-commit
128-
packaging==26.0
128+
packaging==26.1
129129
# via
130130
# build
131131
# gunicorn
@@ -160,9 +160,9 @@ pycares==5.0.1
160160
# via aiodns
161161
pycparser==3.0
162162
# via cffi
163-
pydantic==2.13.0
163+
pydantic==2.13.2
164164
# via python-on-whales
165-
pydantic-core==2.46.0
165+
pydantic-core==2.46.2
166166
# via pydantic
167167
pyenchant==3.3.0
168168
# via sphinxcontrib-spelling
@@ -183,7 +183,7 @@ pytest==9.0.3
183183
# pytest-cov
184184
# pytest-mock
185185
# pytest-xdist
186-
pytest-codspeed==4.3.0
186+
pytest-codspeed==4.4.0
187187
# via
188188
# -r requirements/lint.in
189189
# -r requirements/test-common.in
@@ -281,7 +281,7 @@ uvloop==0.21.0 ; platform_system != "Windows"
281281
# -r requirements/lint.in
282282
valkey==6.1.1
283283
# via -r requirements/lint.in
284-
virtualenv==21.2.0
284+
virtualenv==21.2.4
285285
# via pre-commit
286286
wait-for-it==2.3.0
287287
# via -r requirements/test-common.in

0 commit comments

Comments
 (0)