diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index 876933b3dd3..bfd106104ac 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -1,6 +1,7 @@ name: CI on: + merge_group: push: branches: - 'master' @@ -50,7 +51,7 @@ jobs: with: python-version: 3.9 - name: Cache PyPI - uses: actions/cache@v3.0.4 + uses: actions/cache@v3.3.2 with: key: pip-lint-${{ hashFiles('requirements/*.txt') }} path: ~/.cache/pip @@ -111,14 +112,14 @@ jobs: with: submodules: true - name: Cache llhttp generated files - uses: actions/cache@v3.0.4 + uses: actions/cache@v3.3.2 id: cache with: key: llhttp-${{ hashFiles('vendor/llhttp/package.json', 'vendor/llhttp/src/**/*') }} path: vendor/llhttp/build - name: Setup NodeJS if: steps.cache.outputs.cache-hit != 'true' - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version: '14' - name: Generate llhttp sources @@ -199,7 +200,7 @@ jobs: run: | echo "::set-output name=dir::$(pip cache dir)" # - name: Cache - name: Cache PyPI - uses: actions/cache@v3.0.4 + uses: actions/cache@v3.3.2 with: key: pip-ci-${{ runner.os }}-${{ matrix.pyver }}-${{ matrix.no-extensions }}-${{ hashFiles('requirements/*.txt') }} path: ${{ steps.pip-cache.outputs.dir }} diff --git a/CHANGES/7078.feature b/CHANGES/7078.feature new file mode 100644 index 00000000000..9a58141e200 --- /dev/null +++ b/CHANGES/7078.feature @@ -0,0 +1 @@ +Added ``WebSocketResponse.get_extra_info()`` to access a protocol transport's extra info. diff --git a/CHANGES/7306.bugfix b/CHANGES/7306.bugfix new file mode 100644 index 00000000000..173236d2fd2 --- /dev/null +++ b/CHANGES/7306.bugfix @@ -0,0 +1 @@ +Fixed ``ClientWebSocketResponse.close_code`` being erroneously set to ``None`` when there are concurrent async tasks receiving data and closing the connection. diff --git a/CHANGES/7689.feature b/CHANGES/7689.feature new file mode 100644 index 00000000000..086f33c2388 --- /dev/null +++ b/CHANGES/7689.feature @@ -0,0 +1 @@ +Allow ``link`` argument to be set to None/empty in HTTP 451 exception. diff --git a/CHANGES/7700.bugfix b/CHANGES/7700.bugfix new file mode 100644 index 00000000000..26fdfa9076b --- /dev/null +++ b/CHANGES/7700.bugfix @@ -0,0 +1 @@ +Fix issue with insufficient HTTP method and version validation. diff --git a/CHANGES/7712.bugfix b/CHANGES/7712.bugfix new file mode 100644 index 00000000000..b5304c34ac2 --- /dev/null +++ b/CHANGES/7712.bugfix @@ -0,0 +1 @@ +Add check to validate that absolute URIs have schemes. diff --git a/CHANGES/7715.bugfix b/CHANGES/7715.bugfix new file mode 100644 index 00000000000..863ea25a693 --- /dev/null +++ b/CHANGES/7715.bugfix @@ -0,0 +1 @@ +Fix unhandled exception when Python HTTP parser encounters unpaired Unicode surrogates. diff --git a/CHANGES/7719.bugfix b/CHANGES/7719.bugfix new file mode 100644 index 00000000000..b5474398bc4 --- /dev/null +++ b/CHANGES/7719.bugfix @@ -0,0 +1 @@ +Update parser to disallow invalid characters in header field names and stop accepting LF as a request line separator. diff --git a/CHANGES/7733.doc b/CHANGES/7733.doc new file mode 100644 index 00000000000..12d682a86df --- /dev/null +++ b/CHANGES/7733.doc @@ -0,0 +1 @@ +Fix, update, and improve client exceptions documentation. diff --git a/CONTRIBUTORS.txt b/CONTRIBUTORS.txt index 25ca8cf6249..22756eb9d89 100644 --- a/CONTRIBUTORS.txt +++ b/CONTRIBUTORS.txt @@ -53,6 +53,7 @@ Arthur Darcet Austin Scola Ben Bader Ben Greiner +Ben Kallus Ben Timby Benedikt Reinartz Bob Haddleton @@ -144,6 +145,7 @@ Hrishikesh Paranjape Hu Bo Hugh Young Hugo Herter +Hugo Hromic Hugo van Kemenade Hynek Schlawack Igor Alexandrov @@ -317,6 +319,7 @@ Tolga Tezel Tomasz Trebski Toshiaki Tanaka Trinh Hoang Nhu +Tymofii Tsiapa Vadim Suharnikov Vaibhav Sagar Vamsi Krishna Avula diff --git a/README.rst b/README.rst index 875b8fc7196..2ea1f4e2bb3 100644 --- a/README.rst +++ b/README.rst @@ -44,7 +44,7 @@ Key Features - Supports both client and server side of HTTP protocol. - Supports both client and server Web-Sockets out-of-the-box and avoids Callback Hell. -- Provides Web-server with middlewares and plugable routing. +- Provides Web-server with middleware and pluggable routing. Getting started diff --git a/aiohttp/client_ws.py b/aiohttp/client_ws.py index 0a010fa7920..02d9f6b6de7 100644 --- a/aiohttp/client_ws.py +++ b/aiohttp/client_ws.py @@ -191,7 +191,8 @@ async def send_json( async def close(self, *, code: int = WSCloseCode.OK, message: bytes = b"") -> bool: # we need to break `receive()` cycle first, # `close()` may be called from different task - if self._waiting is not None and not self._closed: + if self._waiting is not None and not self._closing: + self._closing = True self._reader.feed_data(WS_CLOSING_MESSAGE, 0) await self._waiting @@ -210,7 +211,7 @@ async def close(self, *, code: int = WSCloseCode.OK, message: bytes = b"") -> bo self._response.close() return True - if self._closing: + if self._close_code: self._response.close() return True diff --git a/aiohttp/http_exceptions.py b/aiohttp/http_exceptions.py index f1711321f78..610514b5523 100644 --- a/aiohttp/http_exceptions.py +++ b/aiohttp/http_exceptions.py @@ -85,10 +85,9 @@ def __init__( class InvalidHeader(BadHttpMessage): def __init__(self, hdr: Union[bytes, str]) -> None: - if isinstance(hdr, bytes): - hdr = hdr.decode("utf-8", "surrogateescape") - super().__init__(f"Invalid HTTP Header: {hdr}") - self.hdr = hdr + hdr_s = hdr.decode(errors="backslashreplace") if isinstance(hdr, bytes) else hdr + super().__init__(f"Invalid HTTP header: {hdr!r}") + self.hdr = hdr_s self.args = (hdr,) diff --git a/aiohttp/http_parser.py b/aiohttp/http_parser.py index 24be6a28bdd..34f4d040c03 100644 --- a/aiohttp/http_parser.py +++ b/aiohttp/http_parser.py @@ -34,6 +34,7 @@ ContentEncodingError, ContentLengthError, InvalidHeader, + InvalidURLError, LineTooLong, TransferEncodingError, ) @@ -64,7 +65,9 @@ # token = 1*tchar METHRE: Final[Pattern[str]] = re.compile(r"[!#$%&'*+\-.^_`|~0-9A-Za-z]+") VERSRE: Final[Pattern[str]] = re.compile(r"HTTP/(\d).(\d)") -HDRRE: Final[Pattern[bytes]] = re.compile(rb"[\x00-\x1F\x7F()<>@,;:\[\]={} \t\"\\]") +HDRRE: Final[Pattern[bytes]] = re.compile( + rb"[\x00-\x1F\x7F-\xFF()<>@,;:\[\]={} \t\"\\]" +) HEXDIGIT = re.compile(rb"[0-9a-fA-F]+") @@ -539,7 +542,7 @@ def parse_message(self, lines: List[bytes]) -> RawRequestMessage: # request line line = lines[0].decode("utf-8", "surrogateescape") try: - method, path, version = line.split(maxsplit=2) + method, path, version = line.split(" ", maxsplit=2) except ValueError: raise BadStatusLine(line) from None @@ -549,11 +552,11 @@ def parse_message(self, lines: List[bytes]) -> RawRequestMessage: ) # method - if not METHRE.match(method): + if not METHRE.fullmatch(method): raise BadStatusLine(method) # version - match = VERSRE.match(version) + match = VERSRE.fullmatch(version) if match is None: raise BadStatusLine(line) version_o = HttpVersion(int(match.group(1)), int(match.group(2))) @@ -578,10 +581,18 @@ def parse_message(self, lines: List[bytes]) -> RawRequestMessage: fragment=url_fragment, encoded=True, ) + elif path == "*" and method == "OPTIONS": + # asterisk-form, + url = URL(path, encoded=True) else: # absolute-form for proxy maybe, # https://datatracker.ietf.org/doc/html/rfc7230#section-5.3.2 url = URL(path, encoded=True) + if url.scheme == "": + # not absolute-form + raise InvalidURLError( + path.encode(errors="surrogateescape").decode("latin1") + ) # read headers ( @@ -652,7 +663,7 @@ def parse_message(self, lines: List[bytes]) -> RawResponseMessage: ) # version - match = VERSRE.match(version) + match = VERSRE.fullmatch(version) if match is None: raise BadStatusLine(line) version_o = HttpVersion(int(match.group(1)), int(match.group(2))) @@ -966,7 +977,7 @@ def end_http_chunk_receiving(self) -> None: try: if not NO_EXTENSIONS: - from ._http_parser import ( # type: ignore[import,no-redef] + from ._http_parser import ( # type: ignore[import-not-found,no-redef] HttpRequestParser, HttpResponseParser, RawRequestMessage, diff --git a/aiohttp/http_websocket.py b/aiohttp/http_websocket.py index deb8ab9dcc5..ffd882a3128 100644 --- a/aiohttp/http_websocket.py +++ b/aiohttp/http_websocket.py @@ -160,7 +160,7 @@ def _websocket_mask_python(mask: bytes, data: bytearray) -> None: _websocket_mask = _websocket_mask_python else: try: - from ._websocket import _websocket_mask_cython # type: ignore[import] + from ._websocket import _websocket_mask_cython # type: ignore[import-not-found] _websocket_mask = _websocket_mask_cython except ImportError: # pragma: no cover diff --git a/aiohttp/http_writer.py b/aiohttp/http_writer.py index 8f2d9086b92..d6b02e6f566 100644 --- a/aiohttp/http_writer.py +++ b/aiohttp/http_writer.py @@ -189,7 +189,7 @@ def _py_serialize_headers(status_line: str, headers: "CIMultiDict[str]") -> byte _serialize_headers = _py_serialize_headers try: - import aiohttp._http_writer as _http_writer # type: ignore[import] + import aiohttp._http_writer as _http_writer # type: ignore[import-not-found] _c_serialize_headers = _http_writer._serialize_headers if not NO_EXTENSIONS: diff --git a/aiohttp/web_exceptions.py b/aiohttp/web_exceptions.py index 70355aac8b1..a4ba2e99bf2 100644 --- a/aiohttp/web_exceptions.py +++ b/aiohttp/web_exceptions.py @@ -424,7 +424,7 @@ class HTTPUnavailableForLegalReasons(HTTPClientError): def __init__( self, - link: StrOrURL, + link: Optional[StrOrURL], *, headers: Optional[LooseHeaders] = None, reason: Optional[str] = None, @@ -434,11 +434,13 @@ def __init__( super().__init__( headers=headers, reason=reason, text=text, content_type=content_type ) - self.headers["Link"] = f'<{str(link)}>; rel="blocked-by"' - self._link = URL(link) + self._link = None + if link: + self._link = URL(link) + self.headers["Link"] = f'<{str(self._link)}>; rel="blocked-by"' @property - def link(self) -> URL: + def link(self) -> Optional[URL]: return self._link diff --git a/aiohttp/web_ws.py b/aiohttp/web_ws.py index 9fcdc4bdd53..a21443dff1b 100644 --- a/aiohttp/web_ws.py +++ b/aiohttp/web_ws.py @@ -323,6 +323,19 @@ def ws_protocol(self) -> Optional[str]: def compress(self) -> bool: return self._compress + def get_extra_info(self, name: str, default: Any = None) -> Any: + """Get optional transport information. + + If no value associated with ``name`` is found, ``default`` is returned. + """ + writer = self._writer + if writer is None: + return default + transport = writer.transport + if transport is None: + return default + return transport.get_extra_info(name, default) + def exception(self) -> Optional[BaseException]: return self._exception diff --git a/docs/client_reference.rst b/docs/client_reference.rst index 8b1fad334a2..4c89351e841 100644 --- a/docs/client_reference.rst +++ b/docs/client_reference.rst @@ -1508,7 +1508,15 @@ manually. .. method:: get_extra_info(name, default=None) - Reads extra info from connection's transport + Reads optional extra information from the connection's transport. + If no value associated with ``name`` is found, ``default`` is returned. + + See :meth:`asyncio.BaseTransport.get_extra_info` + + :param str name: The key to look up in the transport extra information. + + :param default: Default value to be used when no value for ``name`` is + found (default is ``None``). .. method:: exception() @@ -2130,13 +2138,6 @@ Response errors .. deprecated:: 3.1 -.. class:: WSServerHandshakeError - - Web socket server response error. - - Derived from :exc:`ClientResponseError` - - .. class:: ContentTypeError Invalid content type. @@ -2157,6 +2158,13 @@ Response errors .. versionadded:: 3.2 + +.. class:: WSServerHandshakeError + + Web socket server response error. + + Derived from :exc:`ClientResponseError` + Connection errors ^^^^^^^^^^^^^^^^^ @@ -2183,14 +2191,6 @@ Connection errors Derived from :exc:`ClientConnectorError` -.. class:: UnixClientConnectorError - - Derived from :exc:`ClientConnectorError` - -.. class:: ServerConnectionError - - Derived from :exc:`ClientConnectionError` - .. class:: ClientSSLError Derived from :exc:`ClientConnectorError` @@ -2207,6 +2207,14 @@ Connection errors Derived from :exc:`ClientSSLError` and :exc:`ssl.CertificateError` +.. class:: UnixClientConnectorError + + Derived from :exc:`ClientConnectorError` + +.. class:: ServerConnectionError + + Derived from :exc:`ClientConnectionError` + .. class:: ServerDisconnectedError Server disconnected. @@ -2218,51 +2226,58 @@ Connection errors Partially parsed HTTP message (optional). -.. class:: ServerTimeoutError - - Server operation timeout: read timeout, etc. - - Derived from :exc:`ServerConnectionError` and :exc:`asyncio.TimeoutError` - .. class:: ServerFingerprintMismatch Server fingerprint mismatch. Derived from :exc:`ServerConnectionError` +.. class:: ServerTimeoutError + + Server operation timeout: read timeout, etc. + + Derived from :exc:`ServerConnectionError` and :exc:`asyncio.TimeoutError` + Hierarchy of exceptions ^^^^^^^^^^^^^^^^^^^^^^^ * :exc:`ClientError` - * :exc:`ClientResponseError` - - * :exc:`ContentTypeError` - * :exc:`WSServerHandshakeError` - * :exc:`~aiohttp.ClientHttpProxyError` - * :exc:`ClientConnectionError` * :exc:`ClientOSError` * :exc:`ClientConnectorError` - * :exc:`ClientSSLError` + * :exc:`ClientProxyConnectionError` - * :exc:`ClientConnectorCertificateError` + * :exc:`ClientSSLError` - * :exc:`ClientConnectorSSLError` + * :exc:`ClientConnectorCertificateError` - * :exc:`ClientProxyConnectionError` + * :exc:`ClientConnectorSSLError` - * :exc:`ServerConnectionError` + * :exc:`UnixClientConnectorError` - * :exc:`ServerDisconnectedError` - * :exc:`ServerTimeoutError` + * :exc:`ServerConnectionError` + + * :exc:`ServerDisconnectedError` * :exc:`ServerFingerprintMismatch` + * :exc:`ServerTimeoutError` + * :exc:`ClientPayloadError` + * :exc:`ClientResponseError` + + * :exc:`~aiohttp.ClientHttpProxyError` + + * :exc:`ContentTypeError` + + * :exc:`TooManyRedirects` + + * :exc:`WSServerHandshakeError` + * :exc:`InvalidURL` diff --git a/docs/web_exceptions.rst b/docs/web_exceptions.rst index 989f1d90f52..fd15632fb6a 100644 --- a/docs/web_exceptions.rst +++ b/docs/web_exceptions.rst @@ -85,7 +85,7 @@ HTTP Exception hierarchy chart:: All HTTP exceptions have the same constructor signature:: HTTPNotFound(*, headers=None, reason=None, - body=None, text=None, content_type=None) + text=None, content_type=None) If not directly specified, *headers* will be added to the *default response headers*. @@ -94,8 +94,8 @@ Classes :exc:`HTTPMultipleChoices`, :exc:`HTTPMovedPermanently`, :exc:`HTTPFound`, :exc:`HTTPSeeOther`, :exc:`HTTPUseProxy`, :exc:`HTTPTemporaryRedirect` have the following constructor signature:: - HTTPFound(location, *, headers=None, reason=None, - body=None, text=None, content_type=None) + HTTPFound(location, *,headers=None, reason=None, + text=None, content_type=None) where *location* is value for *Location HTTP header*. @@ -104,7 +104,15 @@ unsupported method and list of allowed methods:: HTTPMethodNotAllowed(method, allowed_methods, *, headers=None, reason=None, - body=None, text=None, content_type=None) + text=None, content_type=None) + +:exc:`HTTPUnavailableForLegalReasons` should be constructed with a ``link`` +to yourself (as the entity implementing the blockage), and an explanation for +the block included in ``text``.:: + + HTTPUnavailableForLegalReasons(link, *, + headers=None, reason=None, + text=None, content_type=None) Base HTTP Exception ------------------- @@ -478,14 +486,15 @@ HTTP exceptions for status code in range 400-499, e.g. ``raise web.HTTPNotFound( An exception for *451 Unavailable For Legal Reasons*, a subclass of :exc:`HTTPClientError`. - :param link: A link to a resource with information for blocking reason, - :class:`str` or :class:`~yarl.URL` + :param link: A link to yourself (as the entity implementing the blockage), + :class:`str`, :class:`~yarl.URL` or ``None``. For other parameters see :exc:`HTTPException` constructor. + A reason for the block should be included in ``text``. .. attribute:: link - A :class:`~yarl.URL` link to a resource with information for blocking reason, + A :class:`~yarl.URL` link to the entity implementing the blockage or ``None``, read-only property. diff --git a/docs/web_reference.rst b/docs/web_reference.rst index 6af022c7f84..6652edb8490 100644 --- a/docs/web_reference.rst +++ b/docs/web_reference.rst @@ -379,6 +379,8 @@ and :ref:`aiohttp-web-signals` handlers. Reads extra information from the protocol's transport. If no value associated with ``name`` is found, ``default`` is returned. + See :meth:`asyncio.BaseTransport.get_extra_info` + :param str name: The key to look up in the transport extra information. :param default: Default value to be used when no value for ``name`` is @@ -1049,6 +1051,18 @@ and :ref:`aiohttp-web-signals` handlers:: May be ``None`` if server and client protocols are not overlapping. + .. method:: get_extra_info(name, default=None) + + Reads optional extra information from the writer's transport. + If no value associated with ``name`` is found, ``default`` is returned. + + See :meth:`asyncio.BaseTransport.get_extra_info` + + :param str name: The key to look up in the transport extra information. + + :param default: Default value to be used when no value for ``name`` is + found (default is ``None``). + .. method:: exception() Returns last occurred exception or None. diff --git a/requirements/base.in b/requirements/base.in index 5404c474f5c..df67f78afde 100644 --- a/requirements/base.in +++ b/requirements/base.in @@ -2,4 +2,4 @@ -r runtime-deps.in gunicorn -uvloop; platform_system != "Windows" and implementation_name == "cpython" and python_version < "3.12" # MagicStack/uvloop#14 # MagicStack/uvloop#547 +uvloop; platform_system != "Windows" and implementation_name == "cpython" # MagicStack/uvloop#14 diff --git a/requirements/base.txt b/requirements/base.txt index bd2e392ca3a..26dd2799b37 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -4,7 +4,7 @@ # # pip-compile --allow-unsafe --output-file=requirements/base.txt --strip-extras requirements/base.in # -aiodns==3.0.0 ; sys_platform == "linux" or sys_platform == "darwin" +aiodns==3.1.1 ; sys_platform == "linux" or sys_platform == "darwin" # via -r requirements/runtime-deps.in aiosignal==1.3.1 # via -r requirements/runtime-deps.in @@ -34,7 +34,7 @@ pycparser==2.21 # via cffi typing-extensions==4.7.1 # via -r requirements/typing-extensions.in -uvloop==0.17.0 ; platform_system != "Windows" and implementation_name == "cpython" and python_version < "3.12" +uvloop==0.19.0 ; platform_system != "Windows" and implementation_name == "cpython" # via -r requirements/base.in yarl==1.9.2 # via -r requirements/runtime-deps.in diff --git a/requirements/constraints.txt b/requirements/constraints.txt index 9d39a8e0e19..042977416b7 100644 --- a/requirements/constraints.txt +++ b/requirements/constraints.txt @@ -4,7 +4,7 @@ # # pip-compile --allow-unsafe --output-file=requirements/constraints.txt --resolver=backtracking --strip-extras requirements/constraints.in # -aiodns==3.0.0 ; sys_platform == "linux" or sys_platform == "darwin" +aiodns==3.1.1 ; sys_platform == "linux" or sys_platform == "darwin" # via -r requirements/runtime-deps.in aiohttp-theme==0.1.6 # via -r requirements/doc.in @@ -36,19 +36,16 @@ cfgv==3.3.1 # via pre-commit charset-normalizer==3.2.0 # via requests -cherry-picker==2.1.0 +cherry-picker==2.2.0 # via -r requirements/dev.in click==8.1.6 # via # cherry-picker - # click-default-group # pip-tools # slotscheck # towncrier # typer # wait-for-it -click-default-group==1.2.2 - # via towncrier coverage==7.3.2 # via # -r requirements/test.in @@ -57,7 +54,7 @@ cryptography==41.0.3 # via # pyjwt # trustme -cython==3.0.3 +cython==3.0.4 # via -r requirements/cython.in distlib==0.3.7 # via virtualenv @@ -67,8 +64,6 @@ exceptiongroup==1.1.2 # via pytest filelock==3.12.2 # via virtualenv -freezegun==1.2.2 - # via -r requirements/test.in frozenlist==1.4.0 # via # -r requirements/runtime-deps.in @@ -103,7 +98,7 @@ multidict==6.0.4 # -r requirements/multidict.in # -r requirements/runtime-deps.in # yarl -mypy==1.5.1 ; implementation_name == "cpython" +mypy==1.6.1 ; implementation_name == "cpython" # via # -r requirements/lint.in # -r requirements/test.in @@ -127,7 +122,7 @@ platformdirs==3.10.0 # via virtualenv pluggy==1.2.0 # via pytest -pre-commit==3.4.0 +pre-commit==3.5.0 # via -r requirements/lint.in proxy-py==2.4.3 # via -r requirements/test.in @@ -145,7 +140,7 @@ pyjwt==2.8.0 # via gidgethub pyproject-hooks==1.0.0 # via build -pytest==7.4.2 +pytest==7.4.3 # via # -r requirements/lint.in # -r requirements/test.in @@ -156,7 +151,7 @@ pytest-cov==4.1.0 pytest-mock==3.11.1 # via -r requirements/test.in python-dateutil==2.8.2 - # via freezegun + # via time-machine python-on-whales==0.65.0 # via -r requirements/test.in pyyaml==6.0.1 @@ -202,11 +197,12 @@ sphinxcontrib-spelling==8.0.0 ; platform_system != "Windows" # via -r requirements/doc-spelling.in sphinxcontrib-towncrier==0.3.2a0 # via -r requirements/doc.in -toml==0.10.2 - # via cherry-picker +time-machine==2.13.0 ; implementation_name == "cpython" + # via -r requirements/test.in tomli==2.0.1 # via # build + # cherry-picker # coverage # mypy # pip-tools @@ -214,7 +210,7 @@ tomli==2.0.1 # pytest # slotscheck # towncrier -towncrier==23.6.0 +towncrier==23.10.0 # via # -r requirements/doc.in # sphinxcontrib-towncrier @@ -236,7 +232,7 @@ uritemplate==4.1.1 # via gidgethub urllib3==2.0.4 # via requests -uvloop==0.17.0 ; platform_system != "Windows" and python_version < "3.12" +uvloop==0.19.0 ; platform_system != "Windows" # via # -r requirements/base.in # -r requirements/lint.in diff --git a/requirements/cython.txt b/requirements/cython.txt index 3182b6251ed..3b920b22401 100644 --- a/requirements/cython.txt +++ b/requirements/cython.txt @@ -4,7 +4,7 @@ # # pip-compile --allow-unsafe --output-file=requirements/cython.txt --resolver=backtracking --strip-extras requirements/cython.in # -cython==3.0.3 +cython==3.0.4 # via -r requirements/cython.in multidict==6.0.4 # via -r requirements/multidict.in diff --git a/requirements/dev.txt b/requirements/dev.txt index 422bc9ac049..a1b0ee2df1d 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -4,7 +4,7 @@ # # pip-compile --allow-unsafe --output-file=requirements/dev.txt --resolver=backtracking --strip-extras requirements/dev.in # -aiodns==3.0.0 ; sys_platform == "linux" or sys_platform == "darwin" +aiodns==3.1.1 ; sys_platform == "linux" or sys_platform == "darwin" # via -r requirements/runtime-deps.in aiohttp-theme==0.1.6 # via -r requirements/doc.in @@ -36,19 +36,16 @@ cfgv==3.3.1 # via pre-commit charset-normalizer==3.2.0 # via requests -cherry-picker==2.1.0 +cherry-picker==2.2.0 # via -r requirements/dev.in click==8.1.6 # via # cherry-picker - # click-default-group # pip-tools # slotscheck # towncrier # typer # wait-for-it -click-default-group==1.2.2 - # via towncrier coverage==7.3.2 # via # -r requirements/test.in @@ -65,8 +62,6 @@ exceptiongroup==1.1.2 # via pytest filelock==3.12.2 # via virtualenv -freezegun==1.2.2 - # via -r requirements/test.in frozenlist==1.4.0 # via # -r requirements/runtime-deps.in @@ -100,7 +95,7 @@ multidict==6.0.4 # via # -r requirements/runtime-deps.in # yarl -mypy==1.5.1 ; implementation_name == "cpython" +mypy==1.6.1 ; implementation_name == "cpython" # via # -r requirements/lint.in # -r requirements/test.in @@ -124,7 +119,7 @@ platformdirs==3.10.0 # via virtualenv pluggy==1.2.0 # via pytest -pre-commit==3.4.0 +pre-commit==3.5.0 # via -r requirements/lint.in proxy-py==2.4.3 # via -r requirements/test.in @@ -140,7 +135,7 @@ pyjwt==2.8.0 # via gidgethub pyproject-hooks==1.0.0 # via build -pytest==7.4.2 +pytest==7.4.3 # via # -r requirements/lint.in # -r requirements/test.in @@ -151,7 +146,7 @@ pytest-cov==4.1.0 pytest-mock==3.11.1 # via -r requirements/test.in python-dateutil==2.8.2 - # via freezegun + # via time-machine python-on-whales==0.65.0 # via -r requirements/test.in pyyaml==6.0.1 @@ -194,11 +189,12 @@ sphinxcontrib-serializinghtml==1.1.5 # via sphinx sphinxcontrib-towncrier==0.3.2a0 # via -r requirements/doc.in -toml==0.10.2 - # via cherry-picker +time-machine==2.13.0 ; implementation_name == "cpython" + # via -r requirements/test.in tomli==2.0.1 # via # build + # cherry-picker # coverage # mypy # pip-tools @@ -206,7 +202,7 @@ tomli==2.0.1 # pytest # slotscheck # towncrier -towncrier==23.6.0 +towncrier==23.10.0 # via # -r requirements/doc.in # sphinxcontrib-towncrier @@ -228,7 +224,7 @@ uritemplate==4.1.1 # via gidgethub urllib3==2.0.4 # via requests -uvloop==0.17.0 ; platform_system != "Windows" and implementation_name == "cpython" and python_version < "3.12" +uvloop==0.19.0 ; platform_system != "Windows" and implementation_name == "cpython" # via # -r requirements/base.in # -r requirements/lint.in diff --git a/requirements/doc-spelling.txt b/requirements/doc-spelling.txt index f9fdecdebd8..e8afc882862 100644 --- a/requirements/doc-spelling.txt +++ b/requirements/doc-spelling.txt @@ -5,7 +5,7 @@ # pip-compile --allow-unsafe --output-file=requirements/doc-spelling.txt --resolver=backtracking --strip-extras requirements/doc-spelling.in # aiohttp-theme==0.1.6 - # via -r doc.in + # via -r requirements/doc.in alabaster==0.7.13 # via sphinx babel==2.12.1 @@ -17,10 +17,6 @@ certifi==2023.7.22 charset-normalizer==3.3.0 # via requests click==8.1.6 - # via - # click-default-group - # towncrier -click-default-group==1.2.2 # via towncrier docutils==0.20.1 # via sphinx @@ -42,7 +38,7 @@ packaging==23.1 # via sphinx pillow==9.5.0 # via - # -c broken-projects.in + # -c requirements/broken-projects.in # blockdiag pyenchant==3.2.2 # via sphinxcontrib-spelling @@ -54,14 +50,14 @@ snowballstemmer==2.2.0 # via sphinx sphinx==7.1.2 # via - # -r doc.in + # -r requirements/doc.in # sphinxcontrib-blockdiag # sphinxcontrib-spelling # sphinxcontrib-towncrier sphinxcontrib-applehelp==1.0.4 # via sphinx sphinxcontrib-blockdiag==3.0.0 - # via -r doc.in + # via -r requirements/doc.in sphinxcontrib-devhelp==1.0.2 # via sphinx sphinxcontrib-htmlhelp==2.0.1 @@ -73,14 +69,16 @@ sphinxcontrib-qthelp==1.0.3 sphinxcontrib-serializinghtml==1.1.5 # via sphinx sphinxcontrib-spelling==8.0.0 ; platform_system != "Windows" - # via -r doc-spelling.in + # via -r requirements/doc-spelling.in sphinxcontrib-towncrier==0.3.2a0 - # via -r doc.in -towncrier==23.6.0 + # via -r requirements/doc.in +tomli==2.0.1 + # via towncrier +towncrier==23.10.0 # via - # -r doc.in + # -r requirements/doc.in # sphinxcontrib-towncrier -urllib3==2.0.6 +urllib3==2.0.7 # via requests webcolors==1.13 # via blockdiag diff --git a/requirements/doc.txt b/requirements/doc.txt index afb156ae5ca..b80760fdd47 100644 --- a/requirements/doc.txt +++ b/requirements/doc.txt @@ -5,7 +5,7 @@ # pip-compile --allow-unsafe --output-file=requirements/doc.txt --resolver=backtracking --strip-extras requirements/doc.in # aiohttp-theme==0.1.6 - # via -r doc.in + # via -r requirements/doc.in alabaster==0.7.13 # via sphinx babel==2.12.1 @@ -17,10 +17,6 @@ certifi==2023.7.22 charset-normalizer==3.3.0 # via requests click==8.1.6 - # via - # click-default-group - # towncrier -click-default-group==1.2.2 # via towncrier docutils==0.20.1 # via sphinx @@ -42,7 +38,7 @@ packaging==23.1 # via sphinx pillow==9.5.0 # via - # -c broken-projects.in + # -c requirements/broken-projects.in # blockdiag pygments==2.15.1 # via sphinx @@ -52,13 +48,13 @@ snowballstemmer==2.2.0 # via sphinx sphinx==7.1.2 # via - # -r doc.in + # -r requirements/doc.in # sphinxcontrib-blockdiag # sphinxcontrib-towncrier sphinxcontrib-applehelp==1.0.4 # via sphinx sphinxcontrib-blockdiag==3.0.0 - # via -r doc.in + # via -r requirements/doc.in sphinxcontrib-devhelp==1.0.2 # via sphinx sphinxcontrib-htmlhelp==2.0.1 @@ -70,12 +66,14 @@ sphinxcontrib-qthelp==1.0.3 sphinxcontrib-serializinghtml==1.1.5 # via sphinx sphinxcontrib-towncrier==0.3.2a0 - # via -r doc.in -towncrier==23.6.0 + # via -r requirements/doc.in +tomli==2.0.1 + # via towncrier +towncrier==23.10.0 # via - # -r doc.in + # -r requirements/doc.in # sphinxcontrib-towncrier -urllib3==2.0.6 +urllib3==2.0.7 # via requests webcolors==1.13 # via blockdiag diff --git a/requirements/lint.in b/requirements/lint.in index 14c0fd84638..34616155912 100644 --- a/requirements/lint.in +++ b/requirements/lint.in @@ -5,4 +5,4 @@ mypy; implementation_name == "cpython" pre-commit pytest slotscheck -uvloop; platform_system != "Windows" and python_version < "3.12" +uvloop; platform_system != "Windows" diff --git a/requirements/lint.txt b/requirements/lint.txt index 1c8dfcbf2db..b10574614ae 100644 --- a/requirements/lint.txt +++ b/requirements/lint.txt @@ -22,7 +22,7 @@ identify==2.5.26 # via pre-commit iniconfig==2.0.0 # via pytest -mypy==1.5.1 ; implementation_name == "cpython" +mypy==1.6.1 ; implementation_name == "cpython" # via -r requirements/lint.in mypy-extensions==1.0.0 # via mypy @@ -34,9 +34,9 @@ platformdirs==3.10.0 # via virtualenv pluggy==1.2.0 # via pytest -pre-commit==3.4.0 +pre-commit==3.5.0 # via -r requirements/lint.in -pytest==7.4.2 +pytest==7.4.3 # via -r requirements/lint.in pyyaml==6.0.1 # via pre-commit @@ -52,7 +52,7 @@ typing-extensions==4.7.1 # -r requirements/typing-extensions.in # aioredis # mypy -uvloop==0.17.0 ; platform_system != "Windows" and python_version < "3.12" +uvloop==0.19.0 ; platform_system != "Windows" # via -r requirements/lint.in virtualenv==20.24.2 # via pre-commit diff --git a/requirements/runtime-deps.txt b/requirements/runtime-deps.txt index 7dcce778e89..d478d05e720 100644 --- a/requirements/runtime-deps.txt +++ b/requirements/runtime-deps.txt @@ -4,7 +4,7 @@ # # pip-compile --allow-unsafe --output-file=requirements/runtime-deps.txt --strip-extras requirements/runtime-deps.in # -aiodns==3.0.0 ; sys_platform == "linux" or sys_platform == "darwin" +aiodns==3.1.1 ; sys_platform == "linux" or sys_platform == "darwin" # via -r requirements/runtime-deps.in aiosignal==1.3.1 # via -r requirements/runtime-deps.in diff --git a/requirements/test.in b/requirements/test.in index 9e9161272bf..417d45959be 100644 --- a/requirements/test.in +++ b/requirements/test.in @@ -2,7 +2,6 @@ -c broken-projects.in coverage -freezegun mypy; implementation_name == "cpython" proxy.py pytest @@ -11,5 +10,6 @@ pytest-mock python-on-whales re-assert setuptools-git +time-machine; implementation_name == "cpython" trustme; platform_machine != "i686" # no 32-bit wheels wait-for-it diff --git a/requirements/test.txt b/requirements/test.txt index 9219ae1f28a..344b3df47b3 100644 --- a/requirements/test.txt +++ b/requirements/test.txt @@ -4,7 +4,7 @@ # # pip-compile --allow-unsafe --output-file=requirements/test.txt --resolver=backtracking --strip-extras requirements/test.in # -aiodns==3.0.0 ; sys_platform == "linux" or sys_platform == "darwin" +aiodns==3.1.1 ; sys_platform == "linux" or sys_platform == "darwin" # via -r requirements/runtime-deps.in aiosignal==1.3.1 # via -r requirements/runtime-deps.in @@ -32,8 +32,6 @@ cryptography==41.0.3 # via trustme exceptiongroup==1.1.2 # via pytest -freezegun==1.2.2 - # via -r requirements/test.in frozenlist==1.4.0 # via # -r requirements/runtime-deps.in @@ -51,7 +49,7 @@ multidict==6.0.4 # via # -r requirements/runtime-deps.in # yarl -mypy==1.5.1 ; implementation_name == "cpython" +mypy==1.6.1 ; implementation_name == "cpython" # via -r requirements/test.in mypy-extensions==1.0.0 # via mypy @@ -69,7 +67,7 @@ pycparser==2.21 # via cffi pydantic==1.10.12 # via python-on-whales -pytest==7.4.2 +pytest==7.4.3 # via # -r requirements/test.in # pytest-cov @@ -79,7 +77,7 @@ pytest-cov==4.1.0 pytest-mock==3.11.1 # via -r requirements/test.in python-dateutil==2.8.2 - # via freezegun + # via time-machine python-on-whales==0.65.0 # via -r requirements/test.in re-assert==1.1.0 @@ -92,6 +90,8 @@ setuptools-git==1.2 # via -r requirements/test.in six==1.16.0 # via python-dateutil +time-machine==2.13.0 ; implementation_name == "cpython" + # via -r requirements/test.in tomli==2.0.1 # via # coverage @@ -112,7 +112,7 @@ typing-extensions==4.7.1 # typer urllib3==2.0.4 # via requests -uvloop==0.17.0 ; platform_system != "Windows" and implementation_name == "cpython" and python_version < "3.12" +uvloop==0.19.0 ; platform_system != "Windows" and implementation_name == "cpython" # via -r requirements/base.in wait-for-it==2.2.2 # via -r requirements/test.in diff --git a/setup.cfg b/setup.cfg index 8fa44a86565..4b63cf039e4 100644 --- a/setup.cfg +++ b/setup.cfg @@ -41,7 +41,7 @@ classifiers = [options] python_requires = >=3.8 -packages = find: +packages = aiohttp # https://setuptools.readthedocs.io/en/latest/setuptools.html#setting-the-zip-safe-flag zip_safe = False include_package_data = True diff --git a/tests/test_client_ws_functional.py b/tests/test_client_ws_functional.py index 205f4d50a6e..5a4b6edbbfe 100644 --- a/tests/test_client_ws_functional.py +++ b/tests/test_client_ws_functional.py @@ -233,6 +233,33 @@ async def handler(request): assert msg.type == aiohttp.WSMsgType.CLOSED +async def test_concurrent_task_close(aiohttp_client: Any) -> None: + async def handler(request): + ws = web.WebSocketResponse() + await ws.prepare(request) + await ws.receive() + return ws + + app = web.Application() + app.router.add_route("GET", "/", handler) + + client = await aiohttp_client(app) + async with client.ws_connect("/") as resp: + # wait for the message in a separate task + task = asyncio.create_task(resp.receive()) + + # Make sure we start to wait on receiving message before closing the connection + await asyncio.sleep(0.1) + + closed = await resp.close() + + await task + + assert closed + assert resp.closed + assert resp.close_code == 1000 + + async def test_concurrent_close(aiohttp_client: Any) -> None: client_ws = None diff --git a/tests/test_cookiejar.py b/tests/test_cookiejar.py index 755838fbeac..00a32708756 100644 --- a/tests/test_cookiejar.py +++ b/tests/test_cookiejar.py @@ -4,17 +4,22 @@ import itertools import pathlib import pickle +import sys import unittest from http.cookies import BaseCookie, Morsel, SimpleCookie from typing import Any from unittest import mock import pytest -from freezegun import freeze_time from yarl import URL from aiohttp import CookieJar, DummyCookieJar +try: + from time_machine import travel +except ImportError: + travel = None # type: ignore[assignment] + def dump_cookiejar() -> bytes: # pragma: no cover """Create pickled data for test_pickle_format().""" @@ -418,10 +423,10 @@ def timed_request(self, url: Any, update_time: Any, send_time: Any): elif isinstance(send_time, float): send_time = datetime.datetime.fromtimestamp(send_time) - with freeze_time(update_time): + with travel(update_time, tick=False): self.jar.update_cookies(self.cookies_to_send) - with freeze_time(send_time): + with travel(send_time, tick=False): cookies_sent = self.jar.filter_cookies(URL(url)) self.jar.clear() @@ -608,6 +613,10 @@ def test_path_value(self) -> None: self.assertEqual(cookies_received["path-cookie"]["path"], "/somepath") self.assertEqual(cookies_received["wrong-path-cookie"]["path"], "/") + @unittest.skipIf( + sys.implementation.name != "cpython", + reason="time_machine leverages CPython specific pointers https://github.com/adamchainz/time-machine/issues/305", + ) def test_expires(self) -> None: ts_before = datetime.datetime( 1975, 1, 1, tzinfo=datetime.timezone.utc @@ -629,6 +638,10 @@ def test_expires(self) -> None: self.assertEqual(set(cookies_sent.keys()), {"shared-cookie"}) + @unittest.skipIf( + sys.implementation.name != "cpython", + reason="time_machine leverages CPython specific pointers https://github.com/adamchainz/time-machine/issues/305", + ) def test_max_age(self) -> None: cookies_sent = self.timed_request("http://maxagetest.com/", 1000, 1000) @@ -776,6 +789,10 @@ async def test_cookie_jar_clear_all() -> None: assert len(sut) == 0 +@pytest.mark.skipif( + sys.implementation.name != "cpython", + reason="time_machine leverages CPython specific pointers https://github.com/adamchainz/time-machine/issues/305", +) async def test_cookie_jar_clear_expired(): sut = CookieJar() @@ -784,11 +801,11 @@ async def test_cookie_jar_clear_expired(): cookie["foo"] = "bar" cookie["foo"]["expires"] = "Tue, 1 Jan 1990 12:00:00 GMT" - with freeze_time("1980-01-01"): + with travel("1980-01-01", tick=False): sut.update_cookies(cookie) sut.clear(lambda x: False) - with freeze_time("1980-01-01"): + with travel("1980-01-01", tick=False): assert len(sut) == 0 diff --git a/tests/test_http_exceptions.py b/tests/test_http_exceptions.py index 28fdcbe0c69..ace35062a0f 100644 --- a/tests/test_http_exceptions.py +++ b/tests/test_http_exceptions.py @@ -103,7 +103,7 @@ class TestInvalidHeader: def test_ctor(self) -> None: err = http_exceptions.InvalidHeader("X-Spam") assert err.code == 400 - assert err.message == "Invalid HTTP Header: X-Spam" + assert err.message == "Invalid HTTP header: 'X-Spam'" assert err.headers is None def test_pickle(self) -> None: @@ -113,17 +113,17 @@ def test_pickle(self) -> None: pickled = pickle.dumps(err, proto) err2 = pickle.loads(pickled) assert err2.code == 400 - assert err2.message == "Invalid HTTP Header: X-Spam" + assert err2.message == "Invalid HTTP header: 'X-Spam'" assert err2.headers is None assert err2.foo == "bar" def test_str(self) -> None: err = http_exceptions.InvalidHeader(hdr="X-Spam") - assert str(err) == "400, message:\n Invalid HTTP Header: X-Spam" + assert str(err) == "400, message:\n Invalid HTTP header: 'X-Spam'" def test_repr(self) -> None: err = http_exceptions.InvalidHeader(hdr="X-Spam") - expected = "" + expected = "" assert repr(err) == expected diff --git a/tests/test_http_parser.py b/tests/test_http_parser.py index 9bbdf255276..8b4121be87d 100644 --- a/tests/test_http_parser.py +++ b/tests/test_http_parser.py @@ -177,6 +177,7 @@ def test_cve_2023_37276(parser: Any) -> None: "Baz: abc\x00def", "Foo : bar", # https://www.rfc-editor.org/rfc/rfc9112.html#section-5.1-2 "Foo\t: bar", + "\xffoo: bar", ), ) def test_bad_headers(parser: Any, hdr: str) -> None: @@ -185,6 +186,23 @@ def test_bad_headers(parser: Any, hdr: str) -> None: parser.feed_data(text) +def test_unpaired_surrogate_in_header_py(loop: Any, protocol: Any) -> None: + parser = HttpRequestParserPy( + protocol, + loop, + 2**16, + max_line_size=8190, + max_field_size=8190, + ) + text = b"POST / HTTP/1.1\r\n\xff\r\n\r\n" + message = None + try: + parser.feed_data(text) + except http_exceptions.InvalidHeader as e: + message = e.message.encode("utf-8") + assert message is not None + + def test_content_length_transfer_encoding(parser: Any) -> None: text = ( b"GET / HTTP/1.1\r\nHost: a\r\nContent-Length: 5\r\nTransfer-Encoding: a\r\n\r\n" @@ -661,6 +679,12 @@ def test_http_request_bad_status_line(parser: Any) -> None: assert r"\n" not in exc_info.value.message +def test_http_request_bad_status_line_whitespace(parser: Any) -> None: + text = b"GET\n/path\fHTTP/1.1\r\n\r\n" + with pytest.raises(http_exceptions.BadStatusLine): + parser.feed_data(text) + + def test_http_request_upgrade(parser: Any) -> None: text = ( b"GET /test HTTP/1.1\r\n" @@ -727,7 +751,7 @@ def test_http_request_parser_two_slashes(parser: Any) -> None: def test_http_request_parser_bad_method(parser: Any) -> None: with pytest.raises(http_exceptions.BadStatusLine): - parser.feed_data(b'=":(e),[T];?" /get HTTP/1.1\r\n\r\n') + parser.feed_data(b'G=":<>(e),[T];?" /get HTTP/1.1\r\n\r\n') def test_http_request_parser_bad_version(parser: Any) -> None: @@ -737,7 +761,17 @@ def test_http_request_parser_bad_version(parser: Any) -> None: def test_http_request_parser_bad_version_number(parser: Any) -> None: with pytest.raises(http_exceptions.BadHttpMessage): - parser.feed_data(b"GET /test HTTP/12.3\r\n\r\n") + parser.feed_data(b"GET /test HTTP/1.32\r\n\r\n") + + +def test_http_request_parser_bad_ascii_uri(parser: Any) -> None: + with pytest.raises(http_exceptions.InvalidURLError): + parser.feed_data(b"GET ! HTTP/1.1\r\n\r\n") + + +def test_http_request_parser_bad_nonascii_uri(parser: Any) -> None: + with pytest.raises(http_exceptions.InvalidURLError): + parser.feed_data(b"GET \xff HTTP/1.1\r\n\r\n") @pytest.mark.parametrize("size", [40965, 8191]) diff --git a/tests/test_web_exceptions.py b/tests/test_web_exceptions.py index 2c9e2d32d2e..1d3262abab6 100644 --- a/tests/test_web_exceptions.py +++ b/tests/test_web_exceptions.py @@ -312,23 +312,51 @@ def test_pickle(self) -> None: class TestHTTPUnavailableForLegalReasons: def test_ctor(self) -> None: - resp = web.HTTPUnavailableForLegalReasons( + exc = web.HTTPUnavailableForLegalReasons( link="http://warning.or.kr/", headers={"X-Custom": "value"}, reason="Zaprescheno", text="text", content_type="custom", ) - assert resp.link == URL("http://warning.or.kr/") - assert resp.text == "text" + assert exc.link == URL("http://warning.or.kr/") + assert exc.text == "text" compare: Mapping[str, str] = { "X-Custom": "value", "Content-Type": "custom", "Link": '; rel="blocked-by"', } - assert resp.headers == compare - assert resp.reason == "Zaprescheno" - assert resp.status == 451 + assert exc.headers == compare + assert exc.reason == "Zaprescheno" + assert exc.status == 451 + + def test_no_link(self) -> None: + with pytest.raises(TypeError): + web.HTTPUnavailableForLegalReasons() # type: ignore[call-arg] + + def test_none_link(self) -> None: + exc = web.HTTPUnavailableForLegalReasons(link=None) + assert exc.link is None + assert "Link" not in exc.headers + + def test_empty_link(self) -> None: + exc = web.HTTPUnavailableForLegalReasons(link="") + assert exc.link is None + assert "Link" not in exc.headers + + def test_link_str(self) -> None: + exc = web.HTTPUnavailableForLegalReasons(link="http://warning.or.kr/") + assert exc.link == URL("http://warning.or.kr/") + assert exc.headers["Link"] == '; rel="blocked-by"' + + def test_link_url(self) -> None: + exc = web.HTTPUnavailableForLegalReasons(link=URL("http://warning.or.kr/")) + assert exc.link == URL("http://warning.or.kr/") + assert exc.headers["Link"] == '; rel="blocked-by"' + + def test_link_CRLF(self) -> None: + exc = web.HTTPUnavailableForLegalReasons(link="http://warning.or.kr/\r\n") + assert "\r\n" not in exc.headers["Link"] def test_pickle(self) -> None: resp = web.HTTPUnavailableForLegalReasons( diff --git a/tests/test_web_websocket.py b/tests/test_web_websocket.py index 093cf549cf6..90e798813f4 100644 --- a/tests/test_web_websocket.py +++ b/tests/test_web_websocket.py @@ -412,3 +412,35 @@ async def test_no_transfer_encoding_header(make_request: Any, mocker: Any) -> No await ws._start(req) assert "Transfer-Encoding" not in ws.headers + + +@pytest.mark.parametrize( + "ws_transport, expected_result", + [ + ( + mock.MagicMock( + transport=mock.MagicMock( + get_extra_info=lambda name, default=None: {"test": "existent"}.get( + name, default + ) + ) + ), + "existent", + ), + (None, "default"), + (mock.MagicMock(transport=None), "default"), + ], +) +async def test_get_extra_info( + make_request: Any, mocker: Any, ws_transport: Any, expected_result: Any +) -> None: + valid_key = "test" + default_value = "default" + + req = make_request("GET", "/") + ws = WebSocketResponse() + + await ws.prepare(req) + ws._writer = ws_transport + + assert ws.get_extra_info(valid_key, default_value) == expected_result