From bfe5feb7a56b78c70fbe28f3644fbb0c1c61b0ae Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 10 May 2026 23:11:32 +0000 Subject: [PATCH 1/3] Bump cryptography from 47.0.0 to 48.0.0 (#12455) Bumps [cryptography](https://github.com/pyca/cryptography) from 47.0.0 to 48.0.0.
Changelog

Sourced from cryptography's changelog.

48.0.0 - 2026-05-04


* **BACKWARDS INCOMPATIBLE:** Support for Python 3.8 has been removed.
  ``cryptography`` now requires Python 3.9 or later.
* **BACKWARDS INCOMPATIBLE:** Loading an X.509 CRL whose inner
  ``TBSCertList.signature`` algorithm does not match the outer
``signatureAlgorithm`` now raises ``ValueError``. Previously, such CRLs
were parsed successfully and only rejected during signature validation.
* Added support for :doc:`/hazmat/primitives/asymmetric/mlkem` and
  :doc:`/hazmat/primitives/asymmetric/mldsa` when using OpenSSL 3.5.0 or
later, in addition to the existing AWS-LC and BoringSSL support. This
means
  post-quantum algorithms are now available to users of our wheels.
  • Note: Going forward, we do not guarantee that all functionality
    in cryptography will be available when building against
    OpenSSL. See :doc:/statements/state-of-openssl for more information.

.. _v47-0-0:

Commits

Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements/constraints.txt | 2 +- requirements/dev.txt | 2 +- requirements/lint.txt | 26 ++++++++++++++++++++--- requirements/test-common.txt | 41 ++++++++++++++++++++++++++++++++++-- requirements/test-ft.txt | 2 +- requirements/test.txt | 2 +- 6 files changed, 66 insertions(+), 9 deletions(-) diff --git a/requirements/constraints.txt b/requirements/constraints.txt index 00cb4310cfd..d9f3bf4051e 100644 --- a/requirements/constraints.txt +++ b/requirements/constraints.txt @@ -70,7 +70,7 @@ coverage==7.13.5 # via # -r requirements/test-common.in # pytest-cov -cryptography==47.0.0 +cryptography==48.0.0 # via trustme cython==3.2.4 # via -r requirements/cython.in diff --git a/requirements/dev.txt b/requirements/dev.txt index f8f43bc4e0c..7bbccc6e139 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -70,7 +70,7 @@ coverage==7.13.5 # via # -r requirements/test-common.in # pytest-cov -cryptography==47.0.0 +cryptography==48.0.0 # via trustme distlib==0.4.0 # via virtualenv diff --git a/requirements/lint.txt b/requirements/lint.txt index b95a311dc74..b8eb0c7d394 100644 --- a/requirements/lint.txt +++ b/requirements/lint.txt @@ -17,7 +17,13 @@ annotated-types==0.7.0 ast-serialize==0.3.0 # via mypy async-timeout==5.0.1 - # via valkey + # via + # aiohttp + # valkey +attrs==26.1.0 + # via aiohttp +backports-asyncio-runner==1.2.0 + # via pytest-asyncio backports-zstd==1.3.0 ; implementation_name == "cpython" and python_version < "3.14" # via -r requirements/lint.in blockbuster==1.5.26 @@ -31,7 +37,7 @@ cfgv==3.5.0 # via pre-commit click==8.3.3 # via slotscheck -cryptography==47.0.0 +cryptography==48.0.0 # via trustme distlib==0.4.0 # via virtualenv @@ -45,10 +51,16 @@ forbiddenfruit==0.1.4 # via blockbuster freezegun==1.5.5 # via -r requirements/lint.in +frozenlist==1.8.0 + # via + # aiohttp + # aiosignal identify==2.6.19 # via pre-commit idna==3.11 - # via trustme + # via + # trustme + # yarl iniconfig==2.3.0 # via pytest isal==1.7.2 @@ -59,6 +71,10 @@ markdown-it-py==4.2.0 # via rich mdurl==0.1.2 # via markdown-it-py +multidict==6.7.1 + # via + # aiohttp + # yarl mypy==2.0.0 ; implementation_name == "cpython" # via -r requirements/lint.in mypy-extensions==1.1.0 @@ -133,12 +149,14 @@ trustme==1.2.1 # via -r requirements/lint.in typing-extensions==4.15.0 # via + # aiosignal # cryptography # exceptiongroup # multidict # mypy # pydantic # pydantic-core + # pytest-asyncio # python-on-whales # typing-inspection # virtualenv @@ -150,5 +168,7 @@ valkey==6.1.1 # via -r requirements/lint.in virtualenv==21.3.1 # via pre-commit +yarl==1.23.0 + # via aiohttp zlib-ng==1.0.0 # via -r requirements/lint.in diff --git a/requirements/test-common.txt b/requirements/test-common.txt index 17925250939..de9d62a88a3 100644 --- a/requirements/test-common.txt +++ b/requirements/test-common.txt @@ -4,10 +4,22 @@ # # pip-compile --allow-unsafe --output-file=requirements/test-common.txt --strip-extras requirements/test-common.in # +aiohappyeyeballs==2.6.1 + # via aiohttp +aiohttp==3.13.5 + # via pytest-aiohttp +aiosignal==1.4.0 + # via aiohttp annotated-types==0.7.0 # via pydantic ast-serialize==0.3.0 # via mypy +async-timeout==5.0.1 + # via aiohttp +attrs==26.1.0 + # via aiohttp +backports-asyncio-runner==1.2.0 + # via pytest-asyncio blockbuster==1.5.26 # via -r requirements/test-common.in cffi==2.0.0 @@ -20,7 +32,7 @@ coverage==7.13.5 # via # -r requirements/test-common.in # pytest-cov -cryptography==47.0.0 +cryptography==48.0.0 # via trustme exceptiongroup==1.3.1 # via pytest @@ -30,8 +42,14 @@ forbiddenfruit==0.1.4 # via blockbuster freezegun==1.5.5 # via -r requirements/test-common.in +frozenlist==1.8.0 + # via + # aiohttp + # aiosignal idna==3.11 - # via trustme + # via + # trustme + # yarl iniconfig==2.3.0 # via pytest isal==1.8.0 ; python_version < "3.14" @@ -42,6 +60,10 @@ markdown-it-py==4.2.0 # via rich mdurl==0.1.2 # via markdown-it-py +multidict==6.7.1 + # via + # aiohttp + # yarl mypy==2.0.0 ; implementation_name == "cpython" # via -r requirements/test-common.in mypy-extensions==1.1.0 @@ -56,6 +78,10 @@ pluggy==1.6.0 # via # pytest # pytest-cov +propcache==0.5.2 + # via + # aiohttp + # yarl proxy-py==2.4.10 # via -r requirements/test-common.in pycparser==3.0 @@ -71,10 +97,16 @@ pygments==2.20.0 pytest==9.0.3 # via # -r requirements/test-common.in + # pytest-aiohttp + # pytest-asyncio # pytest-codspeed # pytest-cov # pytest-mock # pytest-xdist +pytest-aiohttp==1.1.0 + # via -r requirements/test-common.in +pytest-asyncio==1.3.0 + # via pytest-aiohttp pytest-codspeed==4.5.0 # via -r requirements/test-common.in pytest-cov==7.1.0 @@ -102,16 +134,21 @@ trustme==1.2.1 ; platform_machine != "i686" # via -r requirements/test-common.in typing-extensions==4.15.0 # via + # aiosignal # cryptography # exceptiongroup + # multidict # mypy # pydantic # pydantic-core + # pytest-asyncio # python-on-whales # typing-inspection typing-inspection==0.4.2 # via pydantic wait-for-it==2.3.0 # via -r requirements/test-common.in +yarl==1.23.0 + # via aiohttp zlib-ng==1.0.0 # via -r requirements/test-common.in diff --git a/requirements/test-ft.txt b/requirements/test-ft.txt index db20ecf5538..9d153f5aa1f 100644 --- a/requirements/test-ft.txt +++ b/requirements/test-ft.txt @@ -45,7 +45,7 @@ coverage==7.13.5 # via # -r requirements/test-common.in # pytest-cov -cryptography==47.0.0 +cryptography==48.0.0 # via trustme exceptiongroup==1.3.1 # via pytest diff --git a/requirements/test.txt b/requirements/test.txt index 9d7b155a462..e330060e3e7 100644 --- a/requirements/test.txt +++ b/requirements/test.txt @@ -45,7 +45,7 @@ coverage==7.13.5 # via # -r requirements/test-common.in # pytest-cov -cryptography==47.0.0 +cryptography==48.0.0 # via trustme exceptiongroup==1.3.1 # via pytest From 76d3a7f12aa088346fafb1cfd82f9c591010d336 Mon Sep 17 00:00:00 2001 From: Andrew Karelin <36686667+AndrewKarelin@users.noreply.github.com> Date: Mon, 11 May 2026 04:26:56 +0500 Subject: [PATCH 2/3] Don't re-await main_task in run_app finally when it's already done (#12493) --- CHANGES/12493.bugfix | 3 +++ aiohttp/web.py | 15 ++++++++++++--- tests/test_run_app.py | 23 +++++++++++++++++++++++ 3 files changed, 38 insertions(+), 3 deletions(-) create mode 100644 CHANGES/12493.bugfix diff --git a/CHANGES/12493.bugfix b/CHANGES/12493.bugfix new file mode 100644 index 00000000000..7a68daec0ba --- /dev/null +++ b/CHANGES/12493.bugfix @@ -0,0 +1,3 @@ +Fixed :func:`aiohttp.web.run_app` losing inner traceback frames when an +exception is raised during application startup (e.g. inside +``cleanup_ctx`` or ``on_startup``). Regression since 3.10.6. diff --git a/aiohttp/web.py b/aiohttp/web.py index 15cfcc99b98..ae2f098c554 100644 --- a/aiohttp/web.py +++ b/aiohttp/web.py @@ -488,9 +488,18 @@ def run_app( pass finally: try: - main_task.cancel() - with suppress(asyncio.CancelledError): - loop.run_until_complete(main_task) + # Skip when ``main_task`` is already done (e.g. raised during startup). + # Re-running ``loop.run_until_complete`` on a finished task calls + # ``Future.result`` again, which does + # ``raise self._exception.with_traceback(self._exception_tb)`` and + # resets ``exc.__traceback__`` to the originally saved tb — by then + # shallow — clobbering the deep traceback the caller would otherwise + # see (frames from ``cleanup_ctx`` / ``on_startup`` and the user code + # that actually raised). + if not main_task.done(): + main_task.cancel() + with suppress(asyncio.CancelledError): + loop.run_until_complete(main_task) finally: _cancel_tasks(asyncio.all_tasks(loop), loop) loop.run_until_complete(loop.shutdown_asyncgens()) diff --git a/tests/test_run_app.py b/tests/test_run_app.py index 5518440bec0..130ee8862dd 100644 --- a/tests/test_run_app.py +++ b/tests/test_run_app.py @@ -9,6 +9,7 @@ import subprocess import sys import time +import traceback from collections.abc import AsyncIterator, Awaitable, Callable, Coroutine, Iterator from typing import Any, NoReturn from unittest import mock @@ -121,6 +122,28 @@ def test_run_app_close_loop(patched_loop: asyncio.AbstractEventLoop) -> None: assert patched_loop.is_closed() +def test_run_app_preserves_startup_traceback( + patched_loop: asyncio.AbstractEventLoop, +) -> None: + # Regression: when an exception is raised during startup (here in a + # cleanup_ctx async generator), the user code frame must remain in the + # traceback that propagates out of run_app. Previously the second + # loop.run_until_complete(main_task) in run_app's finally clobbered it. + + async def failing_ctx(_app: web.Application) -> AsyncIterator[None]: + raise RuntimeError("boom from failing_ctx") + yield # type: ignore[unreachable] # required to make this an async generator + + app = web.Application() + app.cleanup_ctx.append(failing_ctx) + + with pytest.raises(RuntimeError, match="boom from failing_ctx") as exc_info: + web.run_app(app, print=None, loop=patched_loop) + + frames = [f.name for f in traceback.extract_tb(exc_info.tb)] + assert "failing_ctx" in frames, frames + + mock_unix_server_single = [ mock.call(mock.ANY, "/tmp/testsock1.sock", ssl=None, backlog=128), ] From e8f43710c510d90e192e4e28b45dd7ae1d2db7c7 Mon Sep 17 00:00:00 2001 From: Sam Bull Date: Mon, 11 May 2026 00:45:49 +0100 Subject: [PATCH 3/3] Change headers to a dict that parses comma-separated values (#7679) Co-authored-by: Rodrigo Nogueira --- CHANGES/12296.bugfix.rst | 5 + aiohttp/_http_parser.pyx | 14 ++- aiohttp/client_exceptions.py | 5 +- aiohttp/client_reqrep.py | 19 ++- aiohttp/helpers.py | 79 ++++++++++++- aiohttp/http_parser.py | 29 ++--- aiohttp/multipart.py | 19 ++- aiohttp/test_utils.py | 7 +- aiohttp/web_request.py | 72 ++++-------- tests/test_benchmarks_web_urldispatcher.py | 5 +- tests/test_client_exceptions.py | 10 +- tests/test_client_functional.py | 6 - tests/test_client_request.py | 4 +- tests/test_client_response.py | 117 ++++++++++--------- tests/test_http_parser.py | 112 +++++++++++++++--- tests/test_multipart.py | 130 ++++++++++----------- tests/test_test_utils.py | 5 +- tests/test_web_functional.py | 5 +- tests/test_web_request.py | 12 +- tests/test_web_response.py | 4 +- 20 files changed, 403 insertions(+), 256 deletions(-) create mode 100644 CHANGES/12296.bugfix.rst diff --git a/CHANGES/12296.bugfix.rst b/CHANGES/12296.bugfix.rst new file mode 100644 index 00000000000..ea790514286 --- /dev/null +++ b/CHANGES/12296.bugfix.rst @@ -0,0 +1,5 @@ +Normalized parsing of list-style ``Connection`` and ``Transfer-Encoding`` +headers so repeated field lines and comma-joined values are handled +consistently in the HTTP parser, without changing ``CIMultiDict`` +storage semantics. +-- by :user:`rodrigobnogueira`. diff --git a/aiohttp/_http_parser.pyx b/aiohttp/_http_parser.pyx index ac942ec1076..b6dd21fe83a 100644 --- a/aiohttp/_http_parser.pyx +++ b/aiohttp/_http_parser.pyx @@ -13,12 +13,13 @@ from cpython.mem cimport PyMem_Free, PyMem_Malloc from libc.limits cimport ULLONG_MAX from libc.string cimport memcpy -from multidict import CIMultiDict as _CIMultiDict, CIMultiDictProxy as _CIMultiDictProxy +from multidict import CIMultiDict as _CIMultiDict from yarl import URL as _URL from aiohttp import hdrs from aiohttp.helpers import DEBUG, set_exception +from .helpers import HeadersDictProxy as _HeadersDictProxy from .http_exceptions import ( BadHttpMessage, BadHttpMethod, @@ -61,7 +62,7 @@ __all__ = ('HttpRequestParser', 'HttpResponseParser', cdef object URL = _URL cdef object URL_build = URL.build cdef object CIMultiDict = _CIMultiDict -cdef object CIMultiDictProxy = _CIMultiDictProxy +cdef object HeadersDictProxy = _HeadersDictProxy cdef object HttpVersion = _HttpVersion cdef object HttpVersion10 = _HttpVersion10 cdef object HttpVersion11 = _HttpVersion11 @@ -76,6 +77,7 @@ cdef tuple EMPTY_FEED_DATA_RESULT = ((), False, b"") # In lax mode (response parser default), the check is skipped entirely # since real-world servers (e.g. Google APIs, Werkzeug) commonly send # duplicate headers like Content-Type or Server. +# https://www.rfc-editor.org/rfc/rfc9110.html#section-5.5-6 cdef frozenset SINGLETON_HEADERS = frozenset({ hdrs.CONTENT_LENGTH, hdrs.CONTENT_LOCATION, @@ -129,7 +131,7 @@ cdef class RawRequestMessage: cdef readonly str method cdef readonly str path cdef readonly object version # HttpVersion - cdef readonly object headers # CIMultiDict + cdef readonly object headers # HeadersDictProxy cdef readonly object raw_headers # tuple cdef readonly object should_close cdef readonly object compression @@ -229,7 +231,7 @@ cdef class RawResponseMessage: cdef readonly object version # HttpVersion cdef readonly int code cdef readonly str reason - cdef readonly object headers # CIMultiDict + cdef readonly object headers # HeadersDictProxy cdef readonly object raw_headers # tuple cdef readonly object should_close cdef readonly object compression @@ -316,7 +318,7 @@ cdef class HttpParser: bytearray _buf str _path str _reason - list _headers + object _headers set _seen_singletons list _raw_headers bint _upgraded @@ -463,7 +465,7 @@ cdef class HttpParser: chunked = self._cparser.flags & cparser.F_CHUNKED raw_headers = tuple(self._raw_headers) - headers = CIMultiDictProxy(CIMultiDict(self._headers)) + headers = HeadersDictProxy(CIMultiDict(self._headers)) if self._cparser.type == cparser.HTTP_REQUEST: if http_version == HttpVersion11 and hdrs.HOST not in headers: diff --git a/aiohttp/client_exceptions.py b/aiohttp/client_exceptions.py index 3b66c7fdcd2..054613dac17 100644 --- a/aiohttp/client_exceptions.py +++ b/aiohttp/client_exceptions.py @@ -1,10 +1,9 @@ """HTTP related errors.""" import asyncio +from collections.abc import Mapping from typing import TYPE_CHECKING, Union -from multidict import MultiMapping - from .typedefs import StrOrURL try: @@ -73,7 +72,7 @@ def __init__( *, status: int | None = None, message: str = "", - headers: MultiMapping[str] | None = None, + headers: Mapping[str, str] | None = None, ) -> None: self.request_info = request_info if status is not None: diff --git a/aiohttp/client_reqrep.py b/aiohttp/client_reqrep.py index 25160f198bc..815f9c2dd61 100644 --- a/aiohttp/client_reqrep.py +++ b/aiohttp/client_reqrep.py @@ -38,6 +38,7 @@ _SENTINEL, BaseTimerContext, BasicAuth, + HeadersDictProxy, HeadersMixin, TimerNoop, frozen_dataclass_decorator, @@ -193,7 +194,7 @@ class ClientResponse(HeadersMixin): content: StreamReader = None # type: ignore[assignment] # Payload stream _body: bytes | None = None - _headers: CIMultiDictProxy[str] = None # type: ignore[assignment] + _headers: HeadersDictProxy = None # type: ignore[assignment] _history: tuple["ClientResponse", ...] = () _raw_headers: RawHeaders = None # type: ignore[assignment] @@ -324,7 +325,7 @@ def host(self) -> str: return self._url.host @reify - def headers(self) -> "CIMultiDictProxy[str]": + def headers(self) -> HeadersDictProxy: return self._headers @reify @@ -393,14 +394,8 @@ def history(self) -> tuple["ClientResponse", ...]: @reify def links(self) -> "MultiDictProxy[MultiDictProxy[str | URL]]": - links_str = ", ".join(self.headers.getall("link", [])) - - if not links_str: - return MultiDictProxy(MultiDict()) - links: MultiDict[MultiDictProxy[str | URL]] = MultiDict() - - for val in re.split(r",(?=\s*<)", links_str): + for val in self.headers.getall("link"): match = re.match(r"\s*<(.*)>(.*)", val) if match is None: # Malformed link continue @@ -462,14 +457,14 @@ async def start(self, connection: "Connection") -> "ClientResponse": self.reason = message.reason # headers - self._headers = message.headers # type is CIMultiDictProxy - self._raw_headers = message.raw_headers # type is Tuple[bytes, bytes] + self._headers = message.headers + self._raw_headers = message.raw_headers # payload self.content = payload # cookies - if cookie_hdrs := self.headers.getall(hdrs.SET_COOKIE, ()): + if cookie_hdrs := self.headers._md.getall(hdrs.SET_COOKIE, ()): # Store raw cookie headers for CookieJar self._raw_cookie_headers = tuple(cookie_hdrs) return self diff --git a/aiohttp/helpers.py b/aiohttp/helpers.py index 9f27e3860a8..3e5677a46bd 100644 --- a/aiohttp/helpers.py +++ b/aiohttp/helpers.py @@ -44,7 +44,7 @@ from urllib.parse import quote from urllib.request import getproxies, proxy_bypass -from multidict import CIMultiDict, MultiDict, MultiDictProxy, MultiMapping +from multidict import CIMultiDict, MultiDict, MultiDictProxy from propcache.api import under_cached_property as reify from yarl import URL @@ -71,6 +71,37 @@ # https://github.com/python/cpython/blob/1857a40807daeae3a1bf5efb682de9c9ae6df845/Lib/asyncio/selector_events.py#L766 DEFAULT_CHUNK_SIZE = 2**18 # 256 KiB COOKIE_MAX_LENGTH = 4096 +_QUOTED_PAIR_SUB = re.compile(r"\\(.)") +_QUOTED_STRING = r'"(?:[^"\\]|\\.)*"' +_ESCAPED_COMMENT = r"(?:[^()\\]|\\.)*" +# Matches one element in a comma-separated header list. +# Group 1: content of a top-level quoted-string (quotes stripped). +# Group 2: an unquoted element (may contain parameter quoted-strings / comments). +_LIST_ELEMENT_RE = re.compile( + rf""" + [ \t]* + (?: + "( (?:[^"\\]|\\.)* )" # group 1: top-level quoted-string + | ( # group 2: unquoted element + (?: + (?<=[^\s]=) {_QUOTED_STRING} # parameter quoted value + | (?<=\s) \( {_ESCAPED_COMMENT} \) # comment + | [^,] # any non-comma character + )+? + ) + ) + [ \t]* (?:,|\Z) + """, + re.VERBOSE, +) +# Finds parameter quoted-strings and comments inside an unquoted element for unescaping. +_PROTECTED_RE = re.compile( + rf""" + (?<=[^\s]=) {_QUOTED_STRING} # parameter quoted-string + | (?<=\s) \( {_ESCAPED_COMMENT} \) # comment + """, + re.VERBOSE, +) _T = TypeVar("_T") _S = TypeVar("_S") @@ -753,10 +784,54 @@ def ceil_timeout( return async_timeout.timeout_at(when) +class HeadersDictProxy(Mapping[str, str]): + def __init__(self, md: CIMultiDict[str]): + self._md = md + + def getall(self, key: str) -> tuple[str, ...]: + val = self.get(key, "") + unescape = _QUOTED_PAIR_SUB.sub + values = [] + for m in _LIST_ELEMENT_RE.finditer(val): + qs = m.group(1) + if qs is not None: + values.append(unescape(r"\1", qs)) + else: + raw = m.group(2).strip() + if raw: + values.append( + _PROTECTED_RE.sub(lambda p: unescape(r"\1", p.group()), raw) + ) + return tuple(values) + + def __eq__(self, other: object) -> bool: + return self._md.__eq__(other) + + def __getitem__(self, key: str) -> str: + return ", ".join(self._md.getall(key)) + + def __iter__(self) -> Iterator[str]: + # We need to deduplicate keys from MultiDict + # But, we also need to retain ordering + seen = set() + for k in self._md.__iter__(): + if k in seen: + continue + seen.add(k) + yield k + + def __len__(self) -> int: + return len(set(self._md.keys())) + + def __repr__(self) -> str: + body = ", ".join(f"'{k}': {v!r}" for k, v in self.items()) + return f"<{self.__class__.__name__}({body})>" + + class HeadersMixin: """Mixin for handling headers.""" - _headers: MultiMapping[str] + _headers: Mapping[str, str] _content_type: str | None = None _content_dict: dict[str, str] | None = None _stored_content_type: str | None | _SENTINEL = sentinel diff --git a/aiohttp/http_parser.py b/aiohttp/http_parser.py index 60c26e58577..468218f6c51 100644 --- a/aiohttp/http_parser.py +++ b/aiohttp/http_parser.py @@ -17,7 +17,7 @@ TypeVar, ) -from multidict import CIMultiDict, CIMultiDictProxy, istr +from multidict import CIMultiDict, istr from yarl import URL from . import hdrs @@ -37,6 +37,7 @@ EMPTY_BODY_STATUS_CODES, NO_EXTENSIONS, BaseTimerContext, + HeadersDictProxy, set_exception, ) from .http_exceptions import ( @@ -66,6 +67,8 @@ "RawResponseMessage", ) +_T = TypeVar("_T") + _SEP = Literal[b"\r\n", b"\n"] ASCIISET: Final[set[str]] = set(string.printable) @@ -112,7 +115,7 @@ class RawRequestMessage(NamedTuple): method: str path: str version: HttpVersion - headers: CIMultiDictProxy[str] + headers: HeadersDictProxy raw_headers: RawHeaders should_close: bool compression: str | None @@ -125,7 +128,7 @@ class RawResponseMessage(NamedTuple): version: HttpVersion code: int reason: str - headers: CIMultiDictProxy[str] + headers: HeadersDictProxy raw_headers: RawHeaders should_close: bool compression: str | None @@ -161,9 +164,7 @@ def __init__(self, max_field_size: int = 8190, lax: bool = False) -> None: self.max_field_size = max_field_size self._lax = lax - def parse_headers( - self, lines: list[bytes] - ) -> tuple["CIMultiDictProxy[str]", RawHeaders]: + def parse_headers(self, lines: list[bytes]) -> tuple[HeadersDictProxy, RawHeaders]: headers: CIMultiDict[str] = CIMultiDict() # note: "raw" does not mean inclusion of OWS before/after the field value raw_headers = [] @@ -237,10 +238,10 @@ def parse_headers( headers.add(name, value) raw_headers.append((bname, bvalue)) - return (CIMultiDictProxy(headers), tuple(raw_headers)) + return (HeadersDictProxy(headers), tuple(raw_headers)) -def _is_supported_upgrade(headers: CIMultiDictProxy[str]) -> bool: +def _is_supported_upgrade(headers: HeadersDictProxy) -> bool: """Check if the upgrade header is supported.""" u = headers.get(hdrs.UPGRADE, "") # .lower() can transform non-ascii characters. @@ -544,9 +545,7 @@ def get_content_length() -> int | None: def parse_headers( self, lines: list[bytes] - ) -> tuple[ - "CIMultiDictProxy[str]", RawHeaders, bool | None, str | None, bool, bool - ]: + ) -> tuple[HeadersDictProxy, RawHeaders, bool | None, str | None, bool, bool]: """Parses RFC 5322 headers from a stream. Line continuations are supported. Returns list of header name @@ -560,12 +559,14 @@ def parse_headers( # keep-alive and protocol switching # RFC 9110 section 7.6.1 defines Connection as a comma-separated list. - conn_values = headers.getall(hdrs.CONNECTION, ()) + # We use a simple comma split here rather than getall() for performance, + # as the target tokens (close, keep-alive, upgrade) are simple ASCII + # values that never contain commas. + conn_values = headers.get(hdrs.CONNECTION) if conn_values: conn_tokens = { token.lower() - for conn_value in conn_values - for token in (part.strip(" \t") for part in conn_value.split(",")) + for token in (part.strip(" \t") for part in conn_values.split(",")) if token and token.isascii() } diff --git a/aiohttp/multipart.py b/aiohttp/multipart.py index 9d5e5d27b84..f5ff30804d1 100644 --- a/aiohttp/multipart.py +++ b/aiohttp/multipart.py @@ -11,7 +11,7 @@ from typing import TYPE_CHECKING, Any, Union, cast from urllib.parse import parse_qsl, unquote, urlencode -from multidict import CIMultiDict, CIMultiDictProxy +from multidict import CIMultiDict from .abc import AbstractStreamWriter from .compression_utils import ZLibCompressor, ZLibDecompressor @@ -22,7 +22,14 @@ CONTENT_TRANSFER_ENCODING, CONTENT_TYPE, ) -from .helpers import CHAR, DEFAULT_CHUNK_SIZE, TOKEN, parse_mimetype, reify +from .helpers import ( + CHAR, + DEFAULT_CHUNK_SIZE, + TOKEN, + HeadersDictProxy, + parse_mimetype, + reify, +) from .http import HeadersParser from .http_exceptions import BadHttpMessage from .log import internal_logger @@ -258,7 +265,7 @@ class BodyPartReader: def __init__( self, boundary: bytes, - headers: "CIMultiDictProxy[str]", + headers: HeadersDictProxy, content: StreamReader, *, subtype: str = "mixed", @@ -765,7 +772,7 @@ async def fetch_next_part( def _get_part_reader( self, - headers: "CIMultiDictProxy[str]", + headers: HeadersDictProxy, ) -> Union["MultipartReader", BodyPartReader]: """Dispatches the response by the `Content-Type` header. @@ -852,7 +859,7 @@ async def _read_boundary(self) -> None: else: raise ValueError(f"Invalid boundary {chunk!r}, expected {self._boundary!r}") - async def _read_headers(self) -> "CIMultiDictProxy[str]": + async def _read_headers(self) -> HeadersDictProxy: lines = [] while True: chunk = await self._content.readline(max_line_length=self._max_field_size) @@ -863,7 +870,7 @@ async def _read_headers(self) -> "CIMultiDictProxy[str]": if len(lines) > self._max_headers: raise BadHttpMessage("Too many headers received") parser = HeadersParser(max_field_size=self._max_field_size) - headers, raw_headers = parser.parse_headers(lines) + headers, _ = parser.parse_headers(lines) return headers async def _maybe_release_last_part(self) -> None: diff --git a/aiohttp/test_utils.py b/aiohttp/test_utils.py index 1179cadb37a..6bbe590b18c 100644 --- a/aiohttp/test_utils.py +++ b/aiohttp/test_utils.py @@ -12,7 +12,7 @@ from unittest import IsolatedAsyncioTestCase, mock from aiosignal import Signal -from multidict import CIMultiDict, CIMultiDictProxy +from multidict import CIMultiDict from yarl import URL import aiohttp @@ -27,6 +27,7 @@ from .abc import AbstractCookieJar, AbstractStreamWriter from .client_reqrep import ClientResponse from .client_ws import ClientWebSocketResponse +from .helpers import HeadersDictProxy from .http import HttpVersion, RawRequestMessage from .streams import EMPTY_PAYLOAD, StreamReader from .typedefs import LooseHeaders, StrOrURL @@ -597,12 +598,12 @@ def make_mocked_request( closing = True if headers: - headers = CIMultiDictProxy(CIMultiDict(headers)) + headers = HeadersDictProxy(CIMultiDict(headers)) raw_hdrs = tuple( (k.encode("utf-8"), v.encode("utf-8")) for k, v in headers.items() ) else: - headers = CIMultiDictProxy(CIMultiDict()) + headers = HeadersDictProxy(CIMultiDict()) raw_hdrs = () chunked = "chunked" in headers.get(hdrs.TRANSFER_ENCODING, "").lower() diff --git a/aiohttp/web_request.py b/aiohttp/web_request.py index 2034e0b3f83..ee85c2af6f2 100644 --- a/aiohttp/web_request.py +++ b/aiohttp/web_request.py @@ -13,7 +13,7 @@ from typing import TYPE_CHECKING, Any, Final, Optional, TypeVar, cast, overload from urllib.parse import parse_qsl -from multidict import CIMultiDict, CIMultiDictProxy, MultiDict, MultiDictProxy +from multidict import CIMultiDict, MultiDict, MultiDictProxy from yarl import URL from . import hdrs @@ -26,6 +26,7 @@ LIST_QUOTED_ETAG_RE, ChainMapProxy, ETag, + HeadersDictProxy, HeadersMixin, RequestKey, frozen_dataclass_decorator, @@ -76,7 +77,7 @@ class FileField: filename: str file: io.BufferedReader content_type: str - headers: CIMultiDictProxy[str] + headers: HeadersDictProxy _TCHAR: Final[str] = string.digits + string.ascii_letters + r"!#$%&'*+.^_`|~-" @@ -90,16 +91,10 @@ class FileField: # qdtext includes 0x5C to escape 0x5D ('\]') # qdtext excludes obs-text (because obsoleted, and encoding not specified) -_QUOTED_PAIR: Final[str] = r"\\[\t !-~]" - -_QUOTED_STRING: Final[str] = rf'"(?:{_QUOTED_PAIR}|{_QDTEXT})*"' - # This does not have a ReDOS/performance concern as long as it used with re.match(). -_FORWARDED_PAIR: Final[str] = rf"({_TOKEN})=({_TOKEN}|{_QUOTED_STRING})(:\d{{1,4}})?" - -_QUOTED_PAIR_REPLACE_RE: Final[Pattern[str]] = re.compile(r"\\([\t !-~])") -# same pattern as _QUOTED_PAIR but contains a capture group - +_FORWARDED_PAIR: Final[str] = ( + rf'[ \t]*({_TOKEN})=({_TOKEN}|".*")(:\d{{1,4}})?[ \t]*(?:\Z|;)' +) _FORWARDED_PAIR_RE: Final[Pattern[str]] = re.compile(_FORWARDED_PAIR) ############################################################ @@ -139,7 +134,7 @@ def __init__( self._payload_writer = payload_writer self._payload = payload - self._headers: CIMultiDictProxy[str] = message.headers + self._headers: HeadersDictProxy = message.headers self._method = message.method self._version = message.version self._cache: dict[str, Any] = {} @@ -203,10 +198,11 @@ def clone( dct["path"] = str(new_url) if headers is not sentinel: # a copy semantic - new_headers = CIMultiDictProxy(CIMultiDict(headers)) + new_headers = HeadersDictProxy(CIMultiDict(headers)) dct["headers"] = new_headers dct["raw_headers"] = tuple( - (k.encode("utf-8"), v.encode("utf-8")) for k, v in new_headers.items() + (k.encode("utf-8"), v.encode("utf-8")) + for k, v in new_headers._md.items() ) message = self._message._replace(**dct) @@ -314,44 +310,26 @@ def forwarded(self) -> tuple[Mapping[str, str], ...]: Returns a tuple containing one or more immutable dicts """ elems = [] - for field_value in self._message.headers.getall(hdrs.FORWARDED, ()): - length = len(field_value) + for field_value in self._message.headers.getall(hdrs.FORWARDED): pos = 0 - need_separator = False elem: dict[str, str] = {} elems.append(types.MappingProxyType(elem)) - while 0 <= pos < length: + while 0 <= pos < len(field_value): match = _FORWARDED_PAIR_RE.match(field_value, pos) if match is not None: # got a valid forwarded-pair - if need_separator: - # bad syntax here, skip to next comma - pos = field_value.find(",", pos) - else: - name, value, port = match.groups() - if value[0] == '"': - # quoted string: remove quotes and unescape - value = _QUOTED_PAIR_REPLACE_RE.sub(r"\1", value[1:-1]) - if port: - value += port - elem[name.lower()] = value - pos += len(match.group(0)) - need_separator = True - elif field_value[pos] == ",": # next forwarded-element - need_separator = False - elem = {} - elems.append(types.MappingProxyType(elem)) - pos += 1 - elif field_value[pos] == ";": # next forwarded-pair - need_separator = False - pos += 1 - elif field_value[pos] in " \t": - # Allow whitespace even between forwarded-pairs, though - # RFC 7239 doesn't. This simplifies code and is in line - # with Postel's law. - pos += 1 + name, value, port = match.groups() + if value[0] == value[-1] == '"': + value = value[1:-1] + if port: + value += port + elem[name.lower()] = value + pos += len(match.group(0)) + elif not field_value[pos : field_value.find(";", pos)].strip(" \t"): + # Empty value + pos = field_value.find(";", pos) + 1 else: - # bad syntax here, skip to next comma - pos = field_value.find(",", pos) + # bad syntax here, skip to next field value + break return tuple(elems) @reify @@ -467,7 +445,7 @@ def query_string(self) -> str: return self._rel_url.query_string @reify - def headers(self) -> CIMultiDictProxy[str]: + def headers(self) -> HeadersDictProxy: """A case-insensitive multidict proxy with all headers.""" return self._headers diff --git a/tests/test_benchmarks_web_urldispatcher.py b/tests/test_benchmarks_web_urldispatcher.py index 5992d55a21a..4fe8bcae929 100644 --- a/tests/test_benchmarks_web_urldispatcher.py +++ b/tests/test_benchmarks_web_urldispatcher.py @@ -10,12 +10,13 @@ from unittest import mock import pytest -from multidict import CIMultiDict, CIMultiDictProxy +from multidict import CIMultiDict from pytest_codspeed import BenchmarkFixture from yarl import URL import aiohttp from aiohttp import web +from aiohttp.helpers import HeadersDictProxy from aiohttp.http import HttpVersion, RawRequestMessage @@ -38,7 +39,7 @@ def _mock_request(method: str, path: str) -> web.Request: method, path, HttpVersion(1, 1), - CIMultiDictProxy(CIMultiDict()), + HeadersDictProxy(CIMultiDict()), (), False, None, diff --git a/tests/test_client_exceptions.py b/tests/test_client_exceptions.py index a7bf4db05ef..b95325fb152 100644 --- a/tests/test_client_exceptions.py +++ b/tests/test_client_exceptions.py @@ -6,6 +6,7 @@ from yarl import URL from aiohttp import client, client_reqrep +from aiohttp.helpers import HeadersDictProxy class TestClientResponseError: @@ -43,7 +44,7 @@ def test_pickle(self) -> None: history=(), status=400, message="Something wrong", - headers=CIMultiDict(foo="bar"), + headers=HeadersDictProxy(CIMultiDict(foo="bar")), ) err.foo = "bar" # type: ignore[attr-defined] for proto in range(pickle.HIGHEST_PROTOCOL + 1): @@ -66,11 +67,12 @@ def test_repr(self) -> None: history=(), status=400, message="Something wrong", - headers=CIMultiDict(), + headers=HeadersDictProxy(CIMultiDict()), ) assert repr(err) == ( "ClientResponseError(%r, (), status=400, " - "message='Something wrong', headers=)" % (self.request_info,) + "message='Something wrong', headers=)" + % (self.request_info,) ) def test_str(self) -> None: @@ -79,7 +81,7 @@ def test_str(self) -> None: history=(), status=400, message="Something wrong", - headers=CIMultiDict(), + headers=HeadersDictProxy(CIMultiDict()), ) assert str(err) == ("400, message='Something wrong', url='http://example.com'") diff --git a/tests/test_client_functional.py b/tests/test_client_functional.py index 8c4a9eb34a3..c98f3b2a168 100644 --- a/tests/test_client_functional.py +++ b/tests/test_client_functional.py @@ -5548,12 +5548,6 @@ async def handler(request: web.Request) -> web.Response: # and .www.amazon.it domains resp = await session.get(f"http://www.amazon.it:{port}/") - # Check headers - cookie_headers = resp.headers.getall("Set-Cookie") - assert ( - len(cookie_headers) == 12 - ), f"Expected 12 headers, got {len(cookie_headers)}" - # Check parsed cookies - SimpleCookie only keeps the last # cookie with each name. So we expect 10 unique cookie names # (not 12) diff --git a/tests/test_client_request.py b/tests/test_client_request.py index eb71690f518..e028433d61d 100644 --- a/tests/test_client_request.py +++ b/tests/test_client_request.py @@ -27,7 +27,7 @@ from aiohttp.compression_utils import ZLibBackend from aiohttp.connector import Connection from aiohttp.hdrs import METH_DELETE -from aiohttp.helpers import TimerNoop +from aiohttp.helpers import HeadersDictProxy, TimerNoop from aiohttp.http import HttpVersion10, HttpVersion11, StreamWriter from aiohttp.multipart import MultipartWriter @@ -1631,7 +1631,7 @@ async def start(self, connection: Connection) -> ClientResponse: conn = connection self.status = 123 self.reason = "Test OK" - self._headers = CIMultiDictProxy(CIMultiDict()) + self._headers = HeadersDictProxy(CIMultiDict()) self.cookies = SimpleCookie() return self diff --git a/tests/test_client_response.py b/tests/test_client_response.py index edca472b4ee..dfc1a1f3fdb 100644 --- a/tests/test_client_response.py +++ b/tests/test_client_response.py @@ -8,15 +8,15 @@ from unittest import mock import pytest -from multidict import CIMultiDict, CIMultiDictProxy +from multidict import CIMultiDict from pytest_mock import MockerFixture from yarl import URL import aiohttp -from aiohttp import ClientSession, hdrs, http +from aiohttp import ClientSession, http from aiohttp.client_reqrep import ClientResponse from aiohttp.connector import Connection -from aiohttp.helpers import TimerNoop +from aiohttp.helpers import HeadersDictProxy, TimerNoop from aiohttp.multipart import BadContentDispositionHeader from aiohttp.tracing import Trace @@ -406,7 +406,7 @@ def side_effect(*args: object, **kwargs: object) -> "asyncio.Future[bytes]": return fut h = {"Content-Type": "application/json;charset=cp1251"} - response._headers = CIMultiDictProxy(CIMultiDict(h)) + response._headers = HeadersDictProxy(CIMultiDict(h)) content = response.content = mock.Mock() content.read.side_effect = side_effect @@ -438,7 +438,7 @@ def side_effect(*args: object, **kwargs: object) -> "asyncio.Future[bytes]": # lie about the encoding h = {"Content-Type": "application/json;charset=utf-8"} - response._headers = CIMultiDictProxy(CIMultiDict(h)) + response._headers = HeadersDictProxy(CIMultiDict(h)) content = response.content = mock.Mock() content.read.side_effect = side_effect with pytest.raises(UnicodeDecodeError): @@ -472,7 +472,7 @@ def side_effect(*args: object, **kwargs: object) -> "asyncio.Future[bytes]": return fut h = {"Content-Type": "text/html; charset=\udc81gutf-8\udc81\udc8d"} - response._headers = CIMultiDictProxy(CIMultiDict(h)) + response._headers = HeadersDictProxy(CIMultiDict(h)) content = response.content = mock.Mock() content.read.side_effect = side_effect @@ -504,7 +504,7 @@ def side_effect(*args: object, **kwargs: object) -> "asyncio.Future[bytes]": return fut h = {"Content-Type": "application/json"} - response._headers = CIMultiDictProxy(CIMultiDict(h)) + response._headers = HeadersDictProxy(CIMultiDict(h)) content = response.content = mock.Mock() content.read.side_effect = side_effect with mock.patch.object(response, "get_encoding") as m: @@ -538,7 +538,7 @@ def side_effect(*args: object, **kwargs: object) -> "asyncio.Future[bytes]": return fut h = {"Content-Type": content_type} - response._headers = CIMultiDictProxy(CIMultiDict(h)) + response._headers = HeadersDictProxy(CIMultiDict(h)) content = response.content = mock.Mock() content.read.side_effect = side_effect @@ -565,7 +565,7 @@ async def test_get_encoding_body_none(session: ClientSession) -> None: ) h = {"Content-Type": "text/html"} - response._headers = CIMultiDictProxy(CIMultiDict(h)) + response._headers = HeadersDictProxy(CIMultiDict(h)) content = response.content = mock.Mock() content.read.side_effect = AssertionError @@ -599,7 +599,7 @@ def side_effect(*args: object, **kwargs: object) -> "asyncio.Future[bytes]": return fut h = {"Content-Type": "application/json;charset=cp1251"} - response._headers = CIMultiDictProxy(CIMultiDict(h)) + response._headers = HeadersDictProxy(CIMultiDict(h)) content = response.content = mock.Mock() content.read.side_effect = side_effect @@ -630,7 +630,7 @@ def side_effect(*args: object, **kwargs: object) -> "asyncio.Future[bytes]": return fut h = {"Content-Type": "application/json;charset=cp1251"} - response._headers = CIMultiDictProxy(CIMultiDict(h)) + response._headers = HeadersDictProxy(CIMultiDict(h)) content = response.content = mock.Mock() content.read.side_effect = side_effect @@ -661,7 +661,7 @@ def side_effect(*args: object, **kwargs: object) -> "asyncio.Future[bytes]": return fut h = {"Content-Type": "application/this.is-1_content+subtype+json;charset=cp1251"} - response._headers = CIMultiDictProxy(CIMultiDict(h)) + response._headers = HeadersDictProxy(CIMultiDict(h)) content = response.content = mock.Mock() content.read.side_effect = side_effect @@ -692,7 +692,7 @@ def side_effect(*args: object, **kwargs: object) -> "asyncio.Future[bytes]": return fut h = {"Content-Type": "custom/type;charset=cp1251"} - response._headers = CIMultiDictProxy(CIMultiDict(h)) + response._headers = HeadersDictProxy(CIMultiDict(h)) content = response.content = mock.Mock() content.read.side_effect = side_effect @@ -716,7 +716,7 @@ async def test_json_custom_loader(session: ClientSession) -> None: original_url=url, ) h = {"Content-Type": "application/json;charset=cp1251"} - response._headers = CIMultiDictProxy(CIMultiDict(h)) + response._headers = HeadersDictProxy(CIMultiDict(h)) response._body = b"data" def custom(content: str) -> str: @@ -741,7 +741,7 @@ async def test_json_invalid_content_type(session: ClientSession) -> None: original_url=url, ) h = {"Content-Type": "data/octet-stream"} - response._headers = CIMultiDictProxy(CIMultiDict(h)) + response._headers = HeadersDictProxy(CIMultiDict(h)) response._body = b"" response.status = 500 @@ -767,7 +767,7 @@ async def test_json_no_content(session: ClientSession) -> None: original_url=url, ) h = {"Content-Type": "application/json"} - response._headers = CIMultiDictProxy(CIMultiDict(h)) + response._headers = HeadersDictProxy(CIMultiDict(h)) response._body = b"" with pytest.raises(JSONDecodeError): @@ -796,7 +796,7 @@ def side_effect(*args: object, **kwargs: object) -> "asyncio.Future[bytes]": return fut h = {"Content-Type": "application/json;charset=utf8"} - response._headers = CIMultiDictProxy(CIMultiDict(h)) + response._headers = HeadersDictProxy(CIMultiDict(h)) content = response.content = mock.Mock() content.read.side_effect = side_effect with mock.patch.object(response, "get_encoding") as m: @@ -824,7 +824,7 @@ def test_get_encoding_unknown( ) h = {"Content-Type": "application/json"} - response._headers = CIMultiDictProxy(CIMultiDict(h)) + response._headers = HeadersDictProxy(CIMultiDict(h)) assert response.get_encoding() == "utf-8" @@ -925,7 +925,7 @@ def test_content_type() -> None: original_url=url, ) h = {"Content-Type": "application/json;charset=cp1251"} - response._headers = CIMultiDictProxy(CIMultiDict(h)) + response._headers = HeadersDictProxy(CIMultiDict(h)) assert "application/json" == response.content_type @@ -944,7 +944,7 @@ def test_content_type_no_header() -> None: request_headers=CIMultiDict[str](), original_url=url, ) - response._headers = CIMultiDictProxy(CIMultiDict({})) + response._headers = HeadersDictProxy(CIMultiDict()) assert "application/octet-stream" == response.content_type @@ -964,7 +964,7 @@ def test_charset() -> None: original_url=url, ) h = {"Content-Type": "application/json;charset=cp1251"} - response._headers = CIMultiDictProxy(CIMultiDict(h)) + response._headers = HeadersDictProxy(CIMultiDict(h)) assert "cp1251" == response.charset @@ -983,7 +983,7 @@ def test_charset_no_header() -> None: request_headers=CIMultiDict[str](), original_url=url, ) - response._headers = CIMultiDictProxy(CIMultiDict({})) + response._headers = HeadersDictProxy(CIMultiDict()) assert response.charset is None @@ -1003,7 +1003,7 @@ def test_charset_no_charset() -> None: original_url=url, ) h = {"Content-Type": "application/json"} - response._headers = CIMultiDictProxy(CIMultiDict(h)) + response._headers = HeadersDictProxy(CIMultiDict(h)) assert response.charset is None @@ -1023,7 +1023,7 @@ def test_content_disposition_full() -> None: original_url=url, ) h = {"Content-Disposition": 'attachment; filename="archive.tar.gz"; foo=bar'} - response._headers = CIMultiDictProxy(CIMultiDict(h)) + response._headers = HeadersDictProxy(CIMultiDict(h)) assert response.content_disposition is not None assert "attachment" == response.content_disposition.type @@ -1048,7 +1048,7 @@ def test_content_disposition_no_parameters() -> None: original_url=url, ) h = {"Content-Disposition": "attachment"} - response._headers = CIMultiDictProxy(CIMultiDict(h)) + response._headers = HeadersDictProxy(CIMultiDict(h)) assert response.content_disposition is not None assert "attachment" == response.content_disposition.type @@ -1078,7 +1078,7 @@ def test_content_disposition_empty_parts(content_disposition: str) -> None: original_url=url, ) h = {"Content-Disposition": content_disposition} - response._headers = CIMultiDictProxy(CIMultiDict(h)) + response._headers = HeadersDictProxy(CIMultiDict(h)) with pytest.warns(BadContentDispositionHeader): assert response.content_disposition is not None @@ -1100,7 +1100,7 @@ def test_content_disposition_no_header() -> None: request_headers=CIMultiDict[str](), original_url=url, ) - response._headers = CIMultiDictProxy(CIMultiDict({})) + response._headers = HeadersDictProxy(CIMultiDict()) assert response.content_disposition is None @@ -1119,7 +1119,7 @@ def test_default_encoding_is_utf8() -> None: request_headers=CIMultiDict[str](), original_url=url, ) - response._headers = CIMultiDictProxy(CIMultiDict({})) + response._headers = HeadersDictProxy(CIMultiDict()) response._body = b"" assert response.get_encoding() == "utf-8" @@ -1227,7 +1227,7 @@ def test_redirect_history_in_exception() -> None: original_url=hist_url, ) - hist_response._headers = CIMultiDictProxy(CIMultiDict(hist_headers)) + hist_response._headers = HeadersDictProxy(CIMultiDict(hist_headers)) hist_response.status = 301 hist_response.reason = "REDIRECT" @@ -1263,7 +1263,7 @@ def side_effect(*args: object, **kwargs: object) -> "asyncio.Future[bytes]": return fut h = {"Content-Type": "application/json;charset=cp1251"} - response._headers = CIMultiDictProxy(CIMultiDict(h)) + response._headers = HeadersDictProxy(CIMultiDict(h)) content = response.content = mock.Mock() content.read.side_effect = side_effect @@ -1343,7 +1343,7 @@ def test_response_links_comma_separated( ), ), ) - response._headers = CIMultiDictProxy(CIMultiDict(h)) + response._headers = HeadersDictProxy(CIMultiDict(h)) assert response.links == { "next": {"url": URL("http://example.com/page/1.html"), "rel": "next"}, "home": {"url": URL("http://example.com/"), "rel": "home"}, @@ -1370,7 +1370,7 @@ def test_response_links_multiple_headers( ("Link", "; rel=next"), ("Link", "; rel=home"), ) - response._headers = CIMultiDictProxy(CIMultiDict(h)) + response._headers = HeadersDictProxy(CIMultiDict(h)) assert response.links == { "next": {"url": URL("http://example.com/page/1.html"), "rel": "next"}, "home": {"url": URL("http://example.com/"), "rel": "home"}, @@ -1394,7 +1394,7 @@ def test_response_links_no_rel( original_url=url, ) h = (("Link", ""),) - response._headers = CIMultiDictProxy(CIMultiDict(h)) + response._headers = HeadersDictProxy(CIMultiDict(h)) assert response.links == { "http://example.com/": {"url": URL("http://example.com/")} } @@ -1417,7 +1417,7 @@ def test_response_links_quoted( original_url=url, ) h = (("Link", '; rel="home-page"'),) - response._headers = CIMultiDictProxy(CIMultiDict(h)) + response._headers = HeadersDictProxy(CIMultiDict(h)) assert response.links == { "home-page": {"url": URL("http://example.com/"), "rel": "home-page"} } @@ -1440,7 +1440,7 @@ def test_response_links_relative( original_url=url, ) h = (("Link", "; rel=rel"),) - response._headers = CIMultiDictProxy(CIMultiDict(h)) + response._headers = HeadersDictProxy(CIMultiDict(h)) assert response.links == { "rel": {"url": URL("http://def-cl-resp.org/relative/path"), "rel": "rel"} } @@ -1462,7 +1462,7 @@ def test_response_links_empty( request_headers=CIMultiDict[str](), original_url=url, ) - response._headers = CIMultiDictProxy(CIMultiDict()) + response._headers = HeadersDictProxy(CIMultiDict()) assert response.links == {} @@ -1529,7 +1529,7 @@ def test_response_duplicate_cookie_names( ("Set-Cookie", "user-pref=light; Domain=api.example.com; Path=/"), ] ) - response._headers = CIMultiDictProxy(headers) + response._headers = HeadersDictProxy(CIMultiDict(headers)) # Set raw cookie headers as done in ClientResponse.start() response._raw_cookie_headers = tuple(headers.getall("Set-Cookie", [])) @@ -1540,9 +1540,7 @@ def test_response_duplicate_cookie_names( assert response.cookies["user-pref"].value == "light" # Last one wins -def test_response_raw_cookie_headers_preserved( - event_loop: asyncio.AbstractEventLoop, session: ClientSession -) -> None: +async def test_response_raw_cookie_headers_preserved(session: ClientSession) -> None: """Test that raw Set-Cookie headers are preserved in _raw_cookie_headers.""" url = URL("http://example.com") response = ClientResponse( @@ -1552,34 +1550,49 @@ def test_response_raw_cookie_headers_preserved( continue100=None, timer=TimerNoop(), traces=[], - loop=event_loop, + loop=asyncio.get_running_loop(), session=session, request_headers=CIMultiDict[str](), original_url=url, ) # Set headers with multiple cookies - cookie_headers = [ + cookie_headers = ( "session-id=123; Domain=.example.com; Path=/; Secure", "session-id=456; Domain=.www.example.com; Path=/", "tracking=xyz; Domain=.example.com; Path=/; HttpOnly", - ] + ) + md = CIMultiDict[str]() + for c in cookie_headers: + md.add("Set-Cookie", c) + raw_hdrs = tuple((k.encode(), v.encode()) for k, v in md.items()) - headers: CIMultiDict[str] = CIMultiDict() - for cookie_hdr in cookie_headers: - headers.add("Set-Cookie", cookie_hdr) + message = http.RawResponseMessage( + version=http.HttpVersion11, + code=200, + reason="OK", + headers=HeadersDictProxy(md), + raw_headers=raw_hdrs, + should_close=False, + compression=None, + upgrade=False, + chunked=False, + ) + payload = mock.create_autospec(aiohttp.StreamReader, spec_set=True, instance=True) - response._headers = CIMultiDictProxy(headers) + connection = mock.create_autospec(Connection, spec_set=True, instance=True) + connection.protocol = aiohttp.DataQueue(asyncio.get_running_loop()) + connection.protocol.feed_data((message, payload)) - # Set raw cookie headers as done in ClientResponse.start() - response._raw_cookie_headers = tuple(response.headers.getall(hdrs.SET_COOKIE, [])) + await response.start(connection) # Verify raw headers are preserved - assert response._raw_cookie_headers == tuple(cookie_headers) - assert len(response._raw_cookie_headers) == 3 + assert response._raw_cookie_headers == cookie_headers # But SimpleCookie only has unique names - assert len(response.cookies) == 2 # 'session-id' and 'tracking' + assert len(response.cookies) == 2 + assert "session-id" in response.cookies + assert "tracking" in response.cookies def test_response_cookies_setter_updates_raw_headers( diff --git a/tests/test_http_parser.py b/tests/test_http_parser.py index 97790951960..fa71d9aa2ed 100644 --- a/tests/test_http_parser.py +++ b/tests/test_http_parser.py @@ -19,7 +19,7 @@ from aiohttp import http_exceptions, streams from aiohttp.base_protocol import BaseProtocol from aiohttp.client_proto import ResponseHandler -from aiohttp.helpers import DEFAULT_CHUNK_SIZE, NO_EXTENSIONS +from aiohttp.helpers import DEFAULT_CHUNK_SIZE, NO_EXTENSIONS, HeadersDictProxy from aiohttp.http_parser import ( DeflateBuffer, HeadersParser, @@ -263,6 +263,72 @@ def test_bad_header_name( parser.feed_data(text) +@pytest.mark.parametrize( + ("hdr_vals", "expected"), + ( + ( + ('"http://example.com/a.html,foo", apples',), + ("http://example.com/a.html,foo", "apples"), + ), + (("bananas, apples",), ("bananas", "apples")), + (("bananas", "apples"), ("bananas", "apples")), + ( + ('"http://example.com/a.html,foo", "apples"',), + ("http://example.com/a.html,foo", "apples"), + ), + ( + ('"Sat, 04 May 1996", "Wed, 14 Sep 2005"',), + ("Sat, 04 May 1996", "Wed, 14 Sep 2005"), + ), + (("foo,bar,baz",), ("foo", "bar", "baz")), + (('"applebanna, this',), ('"applebanna', "this")), + (('fooo", "bar"',), ('fooo"', "bar")), + ((" spam , eggs ",), ("spam", "eggs")), + ((" spam ", " eggs "), ("spam", "eggs")), + ((r'spam"foo\"bar"',), (r'spam"foo\"bar"',)), + # https://www.rfc-editor.org/rfc/rfc9110.html#name-recipient-requirements + (("foo, ,bar,",), ("foo", "bar")), + ((", , ",), ()), + (("",), ()), + # Escaped characters in quoted strings + # https://www.rfc-editor.org/rfc/rfc9110.html#section-5.6.4-3 + ((r'"foo\"bar"',), ('foo"bar',)), + ((r'"foo\\\"bar"',), (r"foo\"bar",)), + ((r'"foo\\", bar',), ("foo\\", "bar")), + # Comments: https://www.rfc-editor.org/rfc/rfc9110.html#section-5.6.5 + ((r"foo (bar\"spam\)eggs,\\baz)",), (r'foo (bar"spam)eggs,\baz)',)), + # Not a comment (requires whitespace) + (("foo(bar,spam)",), ("foo(bar", "spam)")), + # Parameters: https://www.rfc-editor.org/rfc/rfc9110.html#section-5.6.6 + (("text/html;charset=utf-8",), ("text/html;charset=utf-8",)), + (('Text/HTML; Charset="utf-8"',), ('Text/HTML; Charset="utf-8"',)), + ( + ("text/plain; q=0.5, text/html, text/x-dvi; q=0.8; format=flowed, */*",), + ( + "text/plain; q=0.5", + "text/html", + "text/x-dvi; q=0.8; format=flowed", + "*/*", + ), + ), + ((r'foo; bar="spam,\"eggs", baz',), ('foo; bar="spam,"eggs"', "baz")), + ((r'foo;bar="spam\\\",eggs",baz',), ('foo;bar="spam\\",eggs"', "baz")), + # Not valid parameters + (('foo; bar ="spam,eggs"',), ('foo; bar ="spam', 'eggs"')), + (('foo;bar= "spam,eggs"',), ('foo;bar= "spam', 'eggs"')), + ), +) +def test_list_headers( + parser: HttpRequestParser, hdr_vals: tuple[str], expected: tuple[str, ...] +) -> None: + headers = "\r\n".join(f"Foo: {v}" for v in hdr_vals) + text = f"POST / HTTP/1.1\r\nHost: a\r\n{headers}\r\n\r\n".encode() + messages, upgrade, tail = parser.feed_data(text) + msg = messages[0][0] + + assert msg.headers.getall("Foo") == expected + + @pytest.mark.parametrize( "hdr", ( @@ -597,11 +663,10 @@ def test_parse_headers_multi(parser: HttpRequestParser) -> None: assert len(messages) == 1 msg = messages[0][0] - assert list(msg.headers.items()) == [ + assert tuple(msg.headers.items()) == ( ("Host", "a"), - ("Set-Cookie", "c1=cookie1"), - ("Set-Cookie", "c2=cookie2"), - ] + ("Set-Cookie", "c1=cookie1, c2=cookie2"), + ) assert msg.raw_headers == ( (b"Host", b"a"), (b"Set-Cookie", b"c1=cookie1"), @@ -732,6 +797,15 @@ def test_request_te_chunked123(parser: HttpRequestParser) -> None: parser.feed_data(text) +def test_request_te_empty_list_invalid(parser: HttpRequestParser) -> None: + text = b"GET /test HTTP/1.1\r\nHost: a\r\nTransfer-Encoding: , \t ,\r\n\r\n" + with pytest.raises( + http_exceptions.BadHttpMessage, + match="Request has invalid `Transfer-Encoding`", + ): + parser.feed_data(text) + + async def test_request_te_last_chunked(parser: HttpRequestParser) -> None: text = b"GET /test HTTP/1.1\r\nHost: a\r\nTransfer-Encoding: not, chunked\r\n\r\n1\r\nT\r\n3\r\nest\r\n0\r\n\r\n" messages, upgrade, tail = parser.feed_data(text) @@ -1769,6 +1843,16 @@ async def test_http_response_parser_notchunked( assert await messages[0][1].read() == b"1\r\nT\r\n3\r\nest\r\n0\r\n\r\n" +async def test_http_response_parser_empty_list_te_not_chunked( + response: HttpResponseParser, +) -> None: + text = b"HTTP/1.1 200 OK\r\nTransfer-Encoding: , \t ,\r\n\r\nbody" + messages, upgrade, tail = response.feed_data(text) + response.feed_eof() + + assert await messages[0][1].read() == b"body" + + async def test_http_response_parser_last_chunked( response: HttpResponseParser, ) -> None: @@ -1952,11 +2036,7 @@ def test_parse_content_length_payload_multiple(response: HttpResponseParser) -> assert msg.version == HttpVersion(major=1, minor=1) assert msg.code == 200 assert msg.reason == "OK" - assert msg.headers == CIMultiDict( - [ - ("Content-Length", "5"), - ] - ) + assert msg.headers == HeadersDictProxy(CIMultiDict([("Content-Length", "5")])) assert msg.raw_headers == ((b"content-length", b"5"),) assert not msg.should_close assert msg.compression is None @@ -1992,11 +2072,7 @@ def test_parse_content_length_than_chunked_payload( assert msg.version == HttpVersion(major=1, minor=1) assert msg.code == 200 assert msg.reason == "OK" - assert msg.headers == CIMultiDict( - [ - ("Content-Length", "5"), - ] - ) + assert msg.headers == HeadersDictProxy(CIMultiDict([("Content-Length", "5")])) assert msg.raw_headers == ((b"content-length", b"5"),) assert not msg.should_close assert msg.compression is None @@ -2038,10 +2114,8 @@ def test_parse_chunked_payload_empty_body_than_another_chunked( assert msg.version == HttpVersion(major=1, minor=1) assert msg.code == code assert msg.reason == "OK" - assert msg.headers == CIMultiDict( - [ - ("Transfer-Encoding", "chunked"), - ] + assert msg.headers == HeadersDictProxy( + CIMultiDict([("Transfer-Encoding", "chunked")]) ) assert msg.raw_headers == ((b"transfer-encoding", b"chunked"),) assert not msg.should_close diff --git a/tests/test_multipart.py b/tests/test_multipart.py index 83046ccc034..aec0bcae922 100644 --- a/tests/test_multipart.py +++ b/tests/test_multipart.py @@ -8,7 +8,7 @@ from unittest import mock import pytest -from multidict import CIMultiDict, CIMultiDictProxy +from multidict import CIMultiDict import aiohttp from aiohttp import payload @@ -20,7 +20,7 @@ CONTENT_TRANSFER_ENCODING, CONTENT_TYPE, ) -from aiohttp.helpers import DEFAULT_CHUNK_SIZE, parse_mimetype +from aiohttp.helpers import DEFAULT_CHUNK_SIZE, HeadersDictProxy, parse_mimetype from aiohttp.multipart import ( BodyPartReader, BodyPartReaderPayload, @@ -106,7 +106,7 @@ def __exit__( class Response: - def __init__(self, headers: CIMultiDictProxy[str], content: Stream) -> None: + def __init__(self, headers: HeadersDictProxy, content: Stream) -> None: self.headers = headers self.content = content @@ -161,7 +161,7 @@ async def test_release_when_stream_at_eof(self) -> None: class TestPartReader: async def test_next(self) -> None: with Stream(b"Hello, world!\r\n--:") as stream: - d = CIMultiDictProxy[str](CIMultiDict()) + d = HeadersDictProxy(CIMultiDict()) obj = aiohttp.BodyPartReader(BOUNDARY, d, stream) result = await obj.next() assert b"Hello, world!" == result @@ -169,7 +169,7 @@ async def test_next(self) -> None: async def test_next_next(self) -> None: with Stream(b"Hello, world!\r\n--:") as stream: - d = CIMultiDictProxy[str](CIMultiDict()) + d = HeadersDictProxy(CIMultiDict()) obj = aiohttp.BodyPartReader(BOUNDARY, d, stream) result = await obj.next() assert b"Hello, world!" == result @@ -179,7 +179,7 @@ async def test_next_next(self) -> None: async def test_read(self) -> None: with Stream(b"Hello, world!\r\n--:") as stream: - d = CIMultiDictProxy[str](CIMultiDict()) + d = HeadersDictProxy(CIMultiDict()) obj = aiohttp.BodyPartReader(BOUNDARY, d, stream) result = await obj.read() assert b"Hello, world!" == result @@ -187,7 +187,7 @@ async def test_read(self) -> None: async def test_read_chunk_at_eof(self) -> None: with Stream(b"--:") as stream: - d = CIMultiDictProxy[str](CIMultiDict()) + d = HeadersDictProxy(CIMultiDict()) obj = aiohttp.BodyPartReader(BOUNDARY, d, stream) obj._at_eof = True result = await obj.read_chunk() @@ -195,7 +195,7 @@ async def test_read_chunk_at_eof(self) -> None: async def test_read_chunk_without_content_length(self) -> None: with Stream(b"Hello, world!\r\n--:") as stream: - d = CIMultiDictProxy[str](CIMultiDict()) + d = HeadersDictProxy(CIMultiDict()) obj = aiohttp.BodyPartReader(BOUNDARY, d, stream) c1 = await obj.read_chunk(8) c2 = await obj.read_chunk(8) @@ -219,7 +219,7 @@ def prepare(data: bytes) -> bytes: prepare(b""), ], ): - d = CIMultiDictProxy[str](CIMultiDict()) + d = HeadersDictProxy(CIMultiDict()) obj = aiohttp.BodyPartReader(BOUNDARY, d, stream) c1 = await obj.read_chunk(8) assert c1 == b"Hello, " @@ -230,7 +230,7 @@ def prepare(data: bytes) -> bytes: async def test_read_all_at_once(self) -> None: with Stream(b"Hello, World!\r\n--:--\r\n") as stream: - d = CIMultiDictProxy[str](CIMultiDict()) + d = HeadersDictProxy(CIMultiDict()) obj = aiohttp.BodyPartReader(BOUNDARY, d, stream) result = await obj.read_chunk() assert b"Hello, World!" == result @@ -240,7 +240,7 @@ async def test_read_all_at_once(self) -> None: async def test_read_incomplete_body_chunked(self) -> None: with Stream(b"Hello, World!\r\n-") as stream: - d = CIMultiDictProxy[str](CIMultiDict()) + d = HeadersDictProxy(CIMultiDict()) obj = aiohttp.BodyPartReader(BOUNDARY, d, stream) result = b"" with pytest.raises(ValueError): @@ -251,7 +251,7 @@ async def test_read_incomplete_body_chunked(self) -> None: async def test_read_with_content_length_malformed_crlf(self) -> None: # Content-Length is correct but data after content is not \r\n content = b"Hello" - h = CIMultiDictProxy(CIMultiDict({"CONTENT-LENGTH": str(len(content))})) + h = HeadersDictProxy(CIMultiDict({"CONTENT-LENGTH": str(len(content))})) # Malformed: "XX" instead of "\r\n" after content with Stream(content + b"XX--:--") as stream: obj = aiohttp.BodyPartReader(BOUNDARY, h, stream) @@ -274,7 +274,7 @@ def prepare(data: bytes) -> bytes: prepare(b""), ], ): - d = CIMultiDictProxy[str](CIMultiDict()) + d = HeadersDictProxy(CIMultiDict()) obj = aiohttp.BodyPartReader(BOUNDARY, d, stream) c1 = await obj.read_chunk(12) assert c1 == b"Hello, World" @@ -285,7 +285,7 @@ def prepare(data: bytes) -> bytes: async def test_multi_read_chunk(self) -> None: with Stream(b"Hello,\r\n--:\r\n\r\nworld!\r\n--:--") as stream: - d = CIMultiDictProxy[str](CIMultiDict()) + d = HeadersDictProxy(CIMultiDict()) obj = aiohttp.BodyPartReader(BOUNDARY, d, stream) result = await obj.read_chunk(8) assert b"Hello," == result @@ -296,7 +296,7 @@ async def test_multi_read_chunk(self) -> None: async def test_read_chunk_properly_counts_read_bytes(self) -> None: expected = b"." * 10 size = len(expected) - h = CIMultiDictProxy(CIMultiDict({"CONTENT-LENGTH": str(size)})) + h = HeadersDictProxy(CIMultiDict({"CONTENT-LENGTH": str(size)})) with StreamWithShortenRead(expected + b"\r\n--:--") as stream: obj = aiohttp.BodyPartReader(BOUNDARY, h, stream) result = bytearray() @@ -311,7 +311,7 @@ async def test_read_chunk_properly_counts_read_bytes(self) -> None: async def test_read_does_not_read_boundary(self) -> None: with Stream(b"Hello, world!\r\n--:") as stream: - d = CIMultiDictProxy[str](CIMultiDict()) + d = HeadersDictProxy(CIMultiDict()) obj = aiohttp.BodyPartReader(BOUNDARY, d, stream) result = await obj.read() assert b"Hello, world!" == result @@ -319,7 +319,7 @@ async def test_read_does_not_read_boundary(self) -> None: async def test_multiread(self) -> None: with Stream(b"Hello,\r\n--:\r\n\r\nworld!\r\n--:--") as stream: - d = CIMultiDictProxy[str](CIMultiDict()) + d = HeadersDictProxy(CIMultiDict()) obj = aiohttp.BodyPartReader(BOUNDARY, d, stream) result = await obj.read() assert b"Hello," == result @@ -329,7 +329,7 @@ async def test_multiread(self) -> None: async def test_read_multiline(self) -> None: with Stream(b"Hello\n,\r\nworld!\r\n--:--") as stream: - d = CIMultiDictProxy[str](CIMultiDict()) + d = HeadersDictProxy(CIMultiDict()) obj = aiohttp.BodyPartReader(BOUNDARY, d, stream) result = await obj.read() assert b"Hello\n,\r\nworld!" == result @@ -338,7 +338,7 @@ async def test_read_multiline(self) -> None: assert obj.at_eof() async def test_read_respects_content_length(self) -> None: - h = CIMultiDictProxy(CIMultiDict({"CONTENT-LENGTH": "100500"})) + h = HeadersDictProxy(CIMultiDict({"CONTENT-LENGTH": "100500"})) with Stream(b"." * 100500 + b"\r\n--:--") as stream: obj = aiohttp.BodyPartReader(BOUNDARY, h, stream) result = await obj.read() @@ -346,7 +346,7 @@ async def test_read_respects_content_length(self) -> None: assert obj.at_eof() async def test_read_with_content_encoding_gzip(self) -> None: - h = CIMultiDictProxy(CIMultiDict({CONTENT_ENCODING: "gzip"})) + h = HeadersDictProxy(CIMultiDict({CONTENT_ENCODING: "gzip"})) with Stream( b"\x1f\x8b\x08\x00\x00\x00\x00\x00\x00\x03\x0b\xc9\xccMU" b"(\xc9W\x08J\xcdI\xacP\x04\x00$\xfb\x9eV\x0e\x00\x00\x00" @@ -361,7 +361,7 @@ async def test_read_with_content_encoding_deflate(self) -> None: content = b"A" * 1_000_000 # Large enough to exceed max_length. compressed = ZLibBackend.compress(content, wbits=-ZLibBackend.MAX_WBITS) - h = CIMultiDictProxy(CIMultiDict({CONTENT_ENCODING: "deflate"})) + h = HeadersDictProxy(CIMultiDict({CONTENT_ENCODING: "deflate"})) with Stream(compressed + b"\r\n--:--") as stream: obj = aiohttp.BodyPartReader(BOUNDARY, h, stream) result = await obj.read(decode=True) @@ -374,14 +374,14 @@ async def test_read_with_content_encoding_identity(self) -> None: b"(\xc9W\x08J\xcdI\xacP\x04\x00$\xfb\x9eV\x0e\x00\x00\x00" b"\r\n" ) - h = CIMultiDictProxy(CIMultiDict({CONTENT_ENCODING: "identity"})) + h = HeadersDictProxy(CIMultiDict({CONTENT_ENCODING: "identity"})) with Stream(thing + b"--:--") as stream: obj = aiohttp.BodyPartReader(BOUNDARY, h, stream) result = await obj.read(decode=True) assert thing[:-2] == result async def test_read_with_content_encoding_unknown(self) -> None: - h = CIMultiDictProxy(CIMultiDict({CONTENT_ENCODING: "snappy"})) + h = HeadersDictProxy(CIMultiDict({CONTENT_ENCODING: "snappy"})) with Stream(b"\x0e4Time to Relax!\r\n--:--") as stream: obj = aiohttp.BodyPartReader(BOUNDARY, h, stream) with pytest.raises(RuntimeError): @@ -391,7 +391,7 @@ async def test_read_decode_compressed_exceeds_max_size(self) -> None: # Compressed data is small, but decompresses beyond client_max_size. original = b"A" * 1024 compressed = gzip.compress(original) - h = CIMultiDictProxy(CIMultiDict({CONTENT_ENCODING: "gzip"})) + h = HeadersDictProxy(CIMultiDict({CONTENT_ENCODING: "gzip"})) with Stream(compressed + b"\r\n--:--") as stream: obj = aiohttp.BodyPartReader( BOUNDARY, @@ -404,14 +404,14 @@ async def test_read_decode_compressed_exceeds_max_size(self) -> None: await obj.read(decode=True) async def test_read_with_content_transfer_encoding_base64(self) -> None: - h = CIMultiDictProxy(CIMultiDict({CONTENT_TRANSFER_ENCODING: "base64"})) + h = HeadersDictProxy(CIMultiDict({CONTENT_TRANSFER_ENCODING: "base64"})) with Stream(b"VGltZSB0byBSZWxheCE=\r\n--:--") as stream: obj = aiohttp.BodyPartReader(BOUNDARY, h, stream) result = await obj.read(decode=True) assert b"Time to Relax!" == result async def test_decode_with_content_transfer_encoding_base64(self) -> None: - h = CIMultiDictProxy(CIMultiDict({CONTENT_TRANSFER_ENCODING: "base64"})) + h = HeadersDictProxy(CIMultiDict({CONTENT_TRANSFER_ENCODING: "base64"})) with Stream(b"VG\r\r\nltZSB0byBSZ\r\nWxheCE=\r\n--:--") as stream: obj = aiohttp.BodyPartReader(BOUNDARY, h, stream) result = b"" @@ -421,7 +421,7 @@ async def test_decode_with_content_transfer_encoding_base64(self) -> None: assert b"Time to Relax!" == result async def test_decode_iter_with_content_transfer_encoding_base64(self) -> None: - h = CIMultiDictProxy(CIMultiDict({CONTENT_TRANSFER_ENCODING: "base64"})) + h = HeadersDictProxy(CIMultiDict({CONTENT_TRANSFER_ENCODING: "base64"})) with Stream(b"VG\r\r\nltZSB0byBSZ\r\nWxheCE=\r\n--:--") as stream: obj = aiohttp.BodyPartReader(BOUNDARY, h, stream) result = b"" @@ -432,7 +432,7 @@ async def test_decode_iter_with_content_transfer_encoding_base64(self) -> None: assert b"Time to Relax!" == result async def test_decode_with_content_encoding_deflate(self) -> None: - h = CIMultiDictProxy(CIMultiDict({CONTENT_ENCODING: "deflate"})) + h = HeadersDictProxy(CIMultiDict({CONTENT_ENCODING: "deflate"})) data = b"\x0b\xc9\xccMU(\xc9W\x08J\xcdI\xacP\x04\x00" with Stream(data + b"\r\n--:--") as stream: obj = aiohttp.BodyPartReader(BOUNDARY, h, stream) @@ -441,7 +441,7 @@ async def test_decode_with_content_encoding_deflate(self) -> None: assert b"Time to Relax!" == result async def test_decode_with_content_encoding_identity(self) -> None: - h = CIMultiDictProxy(CIMultiDict({CONTENT_ENCODING: "identity"})) + h = HeadersDictProxy(CIMultiDict({CONTENT_ENCODING: "identity"})) data = b"Time to Relax!" with Stream(data + b"\r\n--:--") as stream: obj = aiohttp.BodyPartReader(BOUNDARY, h, stream) @@ -450,7 +450,7 @@ async def test_decode_with_content_encoding_identity(self) -> None: assert data == result async def test_decode_with_content_encoding_unknown(self) -> None: - h = CIMultiDictProxy(CIMultiDict({CONTENT_ENCODING: "snappy"})) + h = HeadersDictProxy(CIMultiDict({CONTENT_ENCODING: "snappy"})) data = b"Time to Relax!" with Stream(data + b"\r\n--:--") as stream: obj = aiohttp.BodyPartReader(BOUNDARY, h, stream) @@ -459,7 +459,7 @@ async def test_decode_with_content_encoding_unknown(self) -> None: obj.decode(chunk) async def test_read_with_content_transfer_encoding_quoted_printable(self) -> None: - h = CIMultiDictProxy( + h = HeadersDictProxy( CIMultiDict({CONTENT_TRANSFER_ENCODING: "quoted-printable"}) ) with Stream( @@ -481,14 +481,14 @@ async def test_read_with_content_transfer_encoding_binary( b"\xd0\x9f\xd1\x80\xd0\xb8\xd0\xb2\xd0\xb5\xd1\x82," b" \xd0\xbc\xd0\xb8\xd1\x80!" ) - h = CIMultiDictProxy(CIMultiDict({CONTENT_TRANSFER_ENCODING: encoding})) + h = HeadersDictProxy(CIMultiDict({CONTENT_TRANSFER_ENCODING: encoding})) with Stream(data + b"\r\n--:--") as stream: obj = aiohttp.BodyPartReader(BOUNDARY, h, stream) result = await obj.read(decode=True) assert data == result async def test_read_with_content_transfer_encoding_unknown(self) -> None: - h = CIMultiDictProxy(CIMultiDict({CONTENT_TRANSFER_ENCODING: "unknown"})) + h = HeadersDictProxy(CIMultiDict({CONTENT_TRANSFER_ENCODING: "unknown"})) with Stream(b"\x0e4Time to Relax!\r\n--:--") as stream: obj = aiohttp.BodyPartReader(BOUNDARY, h, stream) with pytest.raises(RuntimeError): @@ -496,34 +496,34 @@ async def test_read_with_content_transfer_encoding_unknown(self) -> None: async def test_read_text(self) -> None: with Stream(b"Hello, world!\r\n--:--") as stream: - d = CIMultiDictProxy[str](CIMultiDict()) + d = HeadersDictProxy(CIMultiDict()) obj = aiohttp.BodyPartReader(BOUNDARY, d, stream) result = await obj.text() assert "Hello, world!" == result async def test_read_text_default_encoding(self) -> None: with Stream("Привет, Мир!\r\n--:--".encode()) as stream: - d = CIMultiDictProxy[str](CIMultiDict()) + d = HeadersDictProxy(CIMultiDict()) obj = aiohttp.BodyPartReader(BOUNDARY, d, stream) result = await obj.text() assert "Привет, Мир!" == result async def test_read_text_encoding(self) -> None: with Stream("Привет, Мир!\r\n--:--".encode("cp1251")) as stream: - d = CIMultiDictProxy[str](CIMultiDict()) + d = HeadersDictProxy(CIMultiDict()) obj = aiohttp.BodyPartReader(BOUNDARY, d, stream) result = await obj.text(encoding="cp1251") assert "Привет, Мир!" == result async def test_read_text_guess_encoding(self) -> None: - h = CIMultiDictProxy(CIMultiDict({CONTENT_TYPE: "text/plain;charset=cp1251"})) + h = HeadersDictProxy(CIMultiDict({CONTENT_TYPE: "text/plain;charset=cp1251"})) with Stream("Привет, Мир!\r\n--:--".encode("cp1251")) as stream: obj = aiohttp.BodyPartReader(BOUNDARY, h, stream) result = await obj.text() assert "Привет, Мир!" == result async def test_read_text_compressed(self) -> None: - h = CIMultiDictProxy( + h = HeadersDictProxy( CIMultiDict({CONTENT_ENCODING: "deflate", CONTENT_TYPE: "text/plain"}) ) with Stream(b"\x0b\xc9\xccMU(\xc9W\x08J\xcdI\xacP\x04\x00\r\n--:--") as stream: @@ -532,7 +532,7 @@ async def test_read_text_compressed(self) -> None: assert "Time to Relax!" == result async def test_read_text_while_closed(self) -> None: - h = CIMultiDictProxy(CIMultiDict({CONTENT_TYPE: "text/plain"})) + h = HeadersDictProxy(CIMultiDict({CONTENT_TYPE: "text/plain"})) with Stream(b"") as stream: obj = aiohttp.BodyPartReader(BOUNDARY, h, stream) obj._at_eof = True @@ -540,21 +540,21 @@ async def test_read_text_while_closed(self) -> None: assert "" == result async def test_read_json(self) -> None: - h = CIMultiDictProxy(CIMultiDict({CONTENT_TYPE: "application/json"})) + h = HeadersDictProxy(CIMultiDict({CONTENT_TYPE: "application/json"})) with Stream(b'{"test": "passed"}\r\n--:--') as stream: obj = aiohttp.BodyPartReader(BOUNDARY, h, stream) result = await obj.json() assert {"test": "passed"} == result async def test_read_json_encoding(self) -> None: - h = CIMultiDictProxy(CIMultiDict({CONTENT_TYPE: "application/json"})) + h = HeadersDictProxy(CIMultiDict({CONTENT_TYPE: "application/json"})) with Stream('{"тест": "пассед"}\r\n--:--'.encode("cp1251")) as stream: obj = aiohttp.BodyPartReader(BOUNDARY, h, stream) result = await obj.json(encoding="cp1251") assert {"тест": "пассед"} == result async def test_read_json_guess_encoding(self) -> None: - h = CIMultiDictProxy( + h = HeadersDictProxy( CIMultiDict({CONTENT_TYPE: "application/json; charset=cp1251"}) ) with Stream('{"тест": "пассед"}\r\n--:--'.encode("cp1251")) as stream: @@ -563,7 +563,7 @@ async def test_read_json_guess_encoding(self) -> None: assert {"тест": "пассед"} == result async def test_read_json_compressed(self) -> None: - h = CIMultiDictProxy( + h = HeadersDictProxy( CIMultiDict({CONTENT_ENCODING: "deflate", CONTENT_TYPE: "application/json"}) ) with Stream(b"\xabV*I-.Q\xb2RP*H,.NMQ\xaa\x05\x00\r\n--:--") as stream: @@ -572,7 +572,7 @@ async def test_read_json_compressed(self) -> None: assert {"test": "passed"} == result async def test_read_json_while_closed(self) -> None: - h = CIMultiDictProxy(CIMultiDict({CONTENT_TYPE: "application/json"})) + h = HeadersDictProxy(CIMultiDict({CONTENT_TYPE: "application/json"})) with Stream(b"") as stream: obj = aiohttp.BodyPartReader(BOUNDARY, h, stream) obj._at_eof = True @@ -580,7 +580,7 @@ async def test_read_json_while_closed(self) -> None: assert result is None async def test_read_form(self) -> None: - h = CIMultiDictProxy( + h = HeadersDictProxy( CIMultiDict({CONTENT_TYPE: "application/x-www-form-urlencoded"}) ) with Stream(b"foo=bar&foo=baz&boo=\r\n--:--") as stream: @@ -589,7 +589,7 @@ async def test_read_form(self) -> None: assert [("foo", "bar"), ("foo", "baz"), ("boo", "")] == result async def test_read_form_invalid_utf8(self) -> None: - h = CIMultiDictProxy( + h = HeadersDictProxy( CIMultiDict({CONTENT_TYPE: "application/x-www-form-urlencoded"}) ) with Stream(b"\xff\r\n--:--") as stream: @@ -600,7 +600,7 @@ async def test_read_form_invalid_utf8(self) -> None: await obj.form() async def test_read_form_encoding(self) -> None: - h = CIMultiDictProxy( + h = HeadersDictProxy( CIMultiDict({CONTENT_TYPE: "application/x-www-form-urlencoded"}) ) with Stream("foo=bar&foo=baz&boo=\r\n--:--".encode("cp1251")) as stream: @@ -609,7 +609,7 @@ async def test_read_form_encoding(self) -> None: assert [("foo", "bar"), ("foo", "baz"), ("boo", "")] == result async def test_read_form_guess_encoding(self) -> None: - h = CIMultiDictProxy( + h = HeadersDictProxy( CIMultiDict( {CONTENT_TYPE: "application/x-www-form-urlencoded; charset=utf-8"} ) @@ -620,7 +620,7 @@ async def test_read_form_guess_encoding(self) -> None: assert [("foo", "bar"), ("foo", "baz"), ("boo", "")] == result async def test_read_form_while_closed(self) -> None: - h = CIMultiDictProxy( + h = HeadersDictProxy( CIMultiDict({CONTENT_TYPE: "application/x-www-form-urlencoded"}) ) with Stream(b"") as stream: @@ -631,7 +631,7 @@ async def test_read_form_while_closed(self) -> None: async def test_readline(self) -> None: with Stream(b"Hello\n,\r\nworld!\r\n--:--") as stream: - d = CIMultiDictProxy[str](CIMultiDict()) + d = HeadersDictProxy(CIMultiDict()) obj = aiohttp.BodyPartReader(BOUNDARY, d, stream) result = await obj.readline() assert b"Hello\n" == result @@ -645,14 +645,14 @@ async def test_readline(self) -> None: async def test_release(self) -> None: with Stream(b"Hello,\r\n--:\r\n\r\nworld!\r\n--:--") as stream: - d = CIMultiDictProxy[str](CIMultiDict()) + d = HeadersDictProxy(CIMultiDict()) obj = aiohttp.BodyPartReader(BOUNDARY, d, stream) await obj.release() assert obj.at_eof() assert b"--:\r\n\r\nworld!\r\n--:--" == stream.content.read() async def test_release_respects_content_length(self) -> None: - h = CIMultiDictProxy(CIMultiDict({"CONTENT-LENGTH": "100500"})) + h = HeadersDictProxy(CIMultiDict({"CONTENT-LENGTH": "100500"})) with Stream(b"." * 100500 + b"\r\n--:--") as stream: obj = aiohttp.BodyPartReader(BOUNDARY, h, stream) await obj.release() @@ -660,14 +660,14 @@ async def test_release_respects_content_length(self) -> None: async def test_release_release(self) -> None: with Stream(b"Hello,\r\n--:\r\n\r\nworld!\r\n--:--") as stream: - d = CIMultiDictProxy[str](CIMultiDict()) + d = HeadersDictProxy(CIMultiDict()) obj = aiohttp.BodyPartReader(BOUNDARY, d, stream) await obj.release() await obj.release() assert b"--:\r\n\r\nworld!\r\n--:--" == stream.content.read() async def test_filename(self) -> None: - h = CIMultiDictProxy( + h = HeadersDictProxy( CIMultiDict({CONTENT_DISPOSITION: "attachment; filename=foo.html"}) ) part = aiohttp.BodyPartReader(BOUNDARY, h, mock.Mock()) @@ -681,7 +681,7 @@ async def test_reading_long_part(self) -> None: ) stream.feed_data(b"0" * size + b"\r\n--:--") stream.feed_eof() - d = CIMultiDictProxy[str](CIMultiDict()) + d = HeadersDictProxy(CIMultiDict()) obj = aiohttp.BodyPartReader(BOUNDARY, d, stream) data = await obj.read() assert len(data) == size @@ -689,7 +689,7 @@ async def test_reading_long_part(self) -> None: class TestMultipartReader: def test_from_response(self) -> None: - h = CIMultiDictProxy( + h = HeadersDictProxy( CIMultiDict({CONTENT_TYPE: 'multipart/related;boundary=":"'}) ) with Stream(b"--:\r\n\r\nhello\r\n--:--") as stream: @@ -699,7 +699,7 @@ def test_from_response(self) -> None: assert isinstance(res.stream, aiohttp.MultipartReader) def test_bad_boundary(self) -> None: - h = CIMultiDictProxy( + h = HeadersDictProxy( CIMultiDict({CONTENT_TYPE: "multipart/related;boundary=" + "a" * 80}) ) with Stream(b"") as stream: @@ -708,7 +708,7 @@ def test_bad_boundary(self) -> None: aiohttp.MultipartReader.from_response(resp) # type: ignore[arg-type] def test_dispatch(self) -> None: - h = CIMultiDictProxy(CIMultiDict({CONTENT_TYPE: "text/plain"})) + h = HeadersDictProxy(CIMultiDict({CONTENT_TYPE: "text/plain"})) with Stream(b"--:\r\n\r\necho\r\n--:--") as stream: reader = aiohttp.MultipartReader( {CONTENT_TYPE: 'multipart/related;boundary=":"'}, @@ -718,7 +718,7 @@ def test_dispatch(self) -> None: assert isinstance(res, reader.part_reader_cls) def test_dispatch_bodypart(self) -> None: - h = CIMultiDictProxy(CIMultiDict({CONTENT_TYPE: "text/plain"})) + h = HeadersDictProxy(CIMultiDict({CONTENT_TYPE: "text/plain"})) with Stream(b"--:\r\n\r\necho\r\n--:--") as stream: reader = aiohttp.MultipartReader( {CONTENT_TYPE: 'multipart/related;boundary=":"'}, @@ -728,7 +728,7 @@ def test_dispatch_bodypart(self) -> None: assert isinstance(res, reader.part_reader_cls) def test_dispatch_multipart(self) -> None: - h = CIMultiDictProxy( + h = HeadersDictProxy( CIMultiDict({CONTENT_TYPE: "multipart/related;boundary=--:--"}) ) with Stream( @@ -752,7 +752,7 @@ def test_dispatch_custom_multipart_reader(self) -> None: class CustomReader(aiohttp.MultipartReader): pass - h = CIMultiDictProxy( + h = HeadersDictProxy( CIMultiDict({CONTENT_TYPE: "multipart/related;boundary=--:--"}) ) with Stream( @@ -774,7 +774,7 @@ class CustomReader(aiohttp.MultipartReader): assert isinstance(res, CustomReader) async def test_emit_next(self) -> None: - h = CIMultiDictProxy( + h = HeadersDictProxy( CIMultiDict({CONTENT_TYPE: 'multipart/related;boundary=":"'}) ) with Stream(b"--:\r\n\r\necho\r\n--:--") as stream: @@ -1618,7 +1618,7 @@ async def check(reader: aiohttp.MultipartReader) -> None: async def test_async_for_bodypart() -> None: - h = CIMultiDictProxy[str](CIMultiDict()) + h = HeadersDictProxy(CIMultiDict()) with Stream(b"foobarbaz\r\n--:--") as stream: part = aiohttp.BodyPartReader(boundary=b"--:", headers=h, content=stream) async for data in part: @@ -1729,7 +1729,7 @@ async def test_multipart_writer_reusability_with_io_payloads( async def test_body_part_reader_payload_as_bytes() -> None: """Test that BodyPartReaderPayload.as_bytes raises TypeError.""" # Create a mock BodyPartReader - headers = CIMultiDictProxy(CIMultiDict({CONTENT_TYPE: "text/plain"})) + headers = HeadersDictProxy(CIMultiDict({CONTENT_TYPE: "text/plain"})) protocol = mock.Mock(_reading_paused=False) stream = StreamReader(protocol, 2**16, loop=asyncio.get_event_loop()) body_part = BodyPartReader(BOUNDARY, headers, stream) @@ -1756,7 +1756,7 @@ async def write(inp: bytes) -> None: nonlocal output output += inp - h = CIMultiDictProxy(CIMultiDict({CONTENT_ENCODING: "deflate"})) + h = HeadersDictProxy(CIMultiDict({CONTENT_ENCODING: "deflate"})) if sys.version_info >= (3, 12): writer = mock.create_autospec( AbstractStreamWriter, write=write, spec_set=True, instance=True diff --git a/tests/test_test_utils.py b/tests/test_test_utils.py index 13ae3ee97ac..10e20d0b58a 100644 --- a/tests/test_test_utils.py +++ b/tests/test_test_utils.py @@ -7,12 +7,13 @@ from unittest import mock import pytest -from multidict import CIMultiDict, CIMultiDictProxy +from multidict import CIMultiDict from pytest_aiohttp import AiohttpClient from yarl import URL import aiohttp from aiohttp import web +from aiohttp.helpers import HeadersDictProxy from aiohttp.test_utils import ( REUSE_ADDRESS, AioHTTPTestCase, @@ -170,7 +171,7 @@ def test_make_mocked_request(headers: Mapping[str, str]) -> None: assert req.method == "GET" assert req.path == "/" assert isinstance(req, web.Request) - assert isinstance(req.headers, CIMultiDictProxy) + assert isinstance(req.headers, HeadersDictProxy) def test_make_mocked_request_sslcontext() -> None: diff --git a/tests/test_web_functional.py b/tests/test_web_functional.py index 442aeade528..823b3e0f14f 100644 --- a/tests/test_web_functional.py +++ b/tests/test_web_functional.py @@ -9,7 +9,7 @@ from unittest import mock import pytest -from multidict import CIMultiDictProxy, MultiDict +from multidict import MultiDict from pytest_aiohttp import AiohttpClient, AiohttpServer from pytest_mock import MockerFixture from yarl import URL @@ -27,6 +27,7 @@ from aiohttp.abc import AbstractResolver, ResolveResult from aiohttp.compression_utils import ZLibBackend, ZLibCompressObjProtocol from aiohttp.hdrs import CONTENT_LENGTH, CONTENT_TYPE, TRANSFER_ENCODING +from aiohttp.helpers import HeadersDictProxy from aiohttp.typedefs import Handler, Middleware from aiohttp.web_protocol import RequestHandler @@ -2194,7 +2195,7 @@ async def handler(request: web.Request) -> web.Response: async def test_request_headers_type(aiohttp_client: AiohttpClient) -> None: async def handler(request: web.Request) -> web.Response: - assert isinstance(request.headers, CIMultiDictProxy) + assert isinstance(request.headers, HeadersDictProxy) return web.Response() app = web.Application() diff --git a/tests/test_web_request.py b/tests/test_web_request.py index d2bb290e2f5..7c0ab5d9be4 100644 --- a/tests/test_web_request.py +++ b/tests/test_web_request.py @@ -11,13 +11,13 @@ from unittest import mock import pytest -from multidict import CIMultiDict, CIMultiDictProxy, MultiDict +from multidict import CIMultiDict, MultiDict from pytest_aiohttp import AiohttpClient from yarl import URL from aiohttp import ETag, HttpVersion, web from aiohttp.base_protocol import BaseProtocol -from aiohttp.helpers import DEFAULT_CHUNK_SIZE +from aiohttp.helpers import DEFAULT_CHUNK_SIZE, HeadersDictProxy from aiohttp.http_exceptions import BadHttpMessage, LineTooLong from aiohttp.http_parser import RawRequestMessage from aiohttp.streams import StreamReader @@ -35,7 +35,7 @@ def test_base_ctor() -> None: "GET", "/path/to?a=1&b=2", HttpVersion(1, 1), - CIMultiDictProxy(CIMultiDict()), + HeadersDictProxy(CIMultiDict()), (), False, None, @@ -731,14 +731,12 @@ def test_multiple_forwarded_headers_bad_syntax() -> None: headers = CIMultiDict[str]() headers.add("Forwarded", "for=_1;by=_2") headers.add("Forwarded", "invalid value") - headers.add("Forwarded", "") headers.add("Forwarded", "for=_3;by=_4") req = make_mocked_request("GET", "/", headers=headers) - assert len(req.forwarded) == 4 + assert len(req.forwarded) == 3 assert req.forwarded[0]["for"] == "_1" assert "for" not in req.forwarded[1] - assert "for" not in req.forwarded[2] - assert req.forwarded[3]["by"] == "_4" + assert req.forwarded[2]["by"] == "_4" def test_multiple_forwarded_headers_injection() -> None: diff --git a/tests/test_web_response.py b/tests/test_web_response.py index daa9de46bc9..3dee8d3a1b1 100644 --- a/tests/test_web_response.py +++ b/tests/test_web_response.py @@ -16,7 +16,7 @@ from aiohttp import HttpVersion, HttpVersion10, HttpVersion11, hdrs, web from aiohttp.abc import AbstractStreamWriter -from aiohttp.helpers import ETag +from aiohttp.helpers import ETag, HeadersDictProxy from aiohttp.http_writer import StreamWriter, _serialize_headers from aiohttp.multipart import BodyPartReader, MultipartWriter from aiohttp.payload import BytesPayload, StringPayload @@ -1176,7 +1176,7 @@ def read(self, size: int = -1) -> bytes: (io.BytesIO(b"test"), "test"), (io.BufferedReader(io.BytesIO(b"test")), "test"), (async_iter(), None), - (BodyPartReader(b"x", CIMultiDictProxy(CIMultiDict()), mock.Mock()), None), + (BodyPartReader(b"x", HeadersDictProxy(CIMultiDict()), mock.Mock()), None), ( mpwriter, "--x\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Length: 4\r\n\r\ntest",