From e401437694e26b96fd6e879135e318d092d6b2b5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 20 Jul 2026 11:35:13 +0000 Subject: [PATCH 01/10] Bump filelock from 3.29.7 to 3.30.2 (#13185) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [filelock](https://github.com/tox-dev/py-filelock) from 3.29.7 to 3.30.2.
Release notes

Sourced from filelock's releases.

3.30.2

What's Changed

Full Changelog: https://github.com/tox-dev/filelock/compare/3.30.1...3.30.2

3.30.1

What's Changed

Full Changelog: https://github.com/tox-dev/filelock/compare/3.30.0...3.30.1

3.30.0

What's Changed

... (truncated)

Changelog

Sourced from filelock's changelog.

########### Changelog ###########

.. towncrier-draft-entries:: Unreleased

.. towncrier release notes start


3.31.1 (2026-07-20)



3.31.0 (2026-07-18)



3.30.3 (2026-07-17)



3.30.2 (2026-07-16)



3.30.1 (2026-07-16)


... (truncated)

Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=filelock&package-manager=pip&previous-version=3.29.7&new-version=3.30.2)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
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 | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/requirements/constraints.txt b/requirements/constraints.txt index 5ba2fa07929..c8cd4ec901a 100644 --- a/requirements/constraints.txt +++ b/requirements/constraints.txt @@ -85,7 +85,7 @@ exceptiongroup==1.3.1 # pytest execnet==2.1.2 # via pytest-xdist -filelock==3.29.7 +filelock==3.30.2 # via # python-discovery # virtualenv diff --git a/requirements/dev.txt b/requirements/dev.txt index f25ae78fc3c..62aa539a80c 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -83,7 +83,7 @@ exceptiongroup==1.3.1 # pytest execnet==2.1.2 # via pytest-xdist -filelock==3.29.7 +filelock==3.30.2 # via # python-discovery # virtualenv diff --git a/requirements/lint.txt b/requirements/lint.txt index 6abeb16e52d..7281cc528ef 100644 --- a/requirements/lint.txt +++ b/requirements/lint.txt @@ -42,7 +42,7 @@ exceptiongroup==1.3.1 # via # aiofastnet # pytest -filelock==3.29.7 +filelock==3.30.2 # via # python-discovery # virtualenv From a57747ed361c7d16b72053f168808d6e62b7929a Mon Sep 17 00:00:00 2001 From: giulio d'erme Date: Mon, 20 Jul 2026 13:48:09 +0200 Subject: [PATCH 02/10] Fix C parser folding fragment into query_string for empty-query origin-form target (#13172) --- CHANGES/13171.bugfix.rst | 3 +++ aiohttp/_http_parser.pyx | 2 +- tests/test_http_parser.py | 13 +++++++++++++ 3 files changed, 17 insertions(+), 1 deletion(-) create mode 100644 CHANGES/13171.bugfix.rst diff --git a/CHANGES/13171.bugfix.rst b/CHANGES/13171.bugfix.rst new file mode 100644 index 00000000000..fead0685a8e --- /dev/null +++ b/CHANGES/13171.bugfix.rst @@ -0,0 +1,3 @@ +Fixed the C HTTP parser folding the fragment into the query string for an +origin-form request target with an empty query (e.g. ``/path?#frag``), +which diverged from the pure-Python parser -- by :user:`GiulioDER`. diff --git a/aiohttp/_http_parser.pyx b/aiohttp/_http_parser.pyx index 475b3263764..a02754822c0 100644 --- a/aiohttp/_http_parser.pyx +++ b/aiohttp/_http_parser.pyx @@ -736,7 +736,7 @@ cdef class HttpRequestParser(HttpParser): else: path = self._path[0:idx1] idx1 += 1 - idx2 = self._path.find("#", idx1+1) + idx2 = self._path.find("#", idx1) if idx2 == -1: query = self._path[idx1:] fragment = "" diff --git a/tests/test_http_parser.py b/tests/test_http_parser.py index 15d3338bbc6..0cc7f32c507 100644 --- a/tests/test_http_parser.py +++ b/tests/test_http_parser.py @@ -2542,6 +2542,19 @@ def test_parse_uri_utf8_percent_encoded(parser: HttpRequestParser) -> None: assert msg.url.fragment == "фраг" +def test_parse_uri_empty_query_with_fragment(parser: HttpRequestParser) -> None: + # Origin-form target with an empty query but a fragment: the ``#`` sits + # immediately after ``?``. Regression for the C parser folding ``#frag`` + # into the query string instead of the fragment. + text = b"GET /path?#frag HTTP/1.1\r\nHost: a\r\n\r\n" + messages, upgrade, tail = parser.feed_data(text) + msg = messages[0][0] + + assert msg.url.path == "/path" + assert msg.url.query == {} + assert msg.url.fragment == "frag" + + @pytest.mark.skipif( "HttpRequestParserC" not in dir(aiohttp.http_parser), reason="C based HTTP parser not available", From 0ed510d8dc9f6397878fe775ac7e2fbd0b13641f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 20 Jul 2026 11:48:25 +0000 Subject: [PATCH 03/10] Bump coverage from 7.15.1 to 7.15.2 (#13187) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [coverage](https://github.com/coveragepy/coveragepy) from 7.15.1 to 7.15.2.
Release notes

Sourced from coverage's releases.

7.15.2

Version 7.15.2 — 2026-07-15

  • Fix: one of the performance improvements in 7.15.1 (pull 2215) dramatically increased memory use during reporting for large projects. Now we use a different approach that is both faster and slimmer than 7.15.0. Fixes issue 2229.

:arrow_right:  PyPI page: coverage 7.15.2. :arrow_right:  To install: python3 -m pip install coverage==7.15.2

Changelog

Sourced from coverage's changelog.

Version 7.15.2 — 2026-07-15

  • Fix: one of the performance improvements in 7.15.1 (pull 2215) dramatically increased memory use during reporting for large projects. Now we use a different approach that is both faster and slimmer than 7.15.0. Fixes issue 2229_.

.. _issue 2229: coveragepy/coveragepy#2229

.. _changes_7-15-1:

Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=coverage&package-manager=pip&previous-version=7.15.1&new-version=7.15.2)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
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/test-common-base.txt | 2 +- requirements/test-common.txt | 2 +- requirements/test-ft.txt | 2 +- requirements/test-mobile.txt | 2 +- requirements/test.txt | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/requirements/constraints.txt b/requirements/constraints.txt index c8cd4ec901a..e6bd40845de 100644 --- a/requirements/constraints.txt +++ b/requirements/constraints.txt @@ -65,7 +65,7 @@ click==8.4.2 # via # pip-tools # towncrier -coverage==7.15.1 +coverage==7.15.2 # via # -r requirements/test-common.in # pytest-cov diff --git a/requirements/dev.txt b/requirements/dev.txt index 62aa539a80c..be38ff875a9 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -65,7 +65,7 @@ click==8.4.2 # via # pip-tools # towncrier -coverage==7.15.1 +coverage==7.15.2 # via # -r requirements/test-common.in # pytest-cov diff --git a/requirements/test-common-base.txt b/requirements/test-common-base.txt index e0c459cc5fe..3b4d891f50c 100644 --- a/requirements/test-common-base.txt +++ b/requirements/test-common-base.txt @@ -14,7 +14,7 @@ attrs==26.1.0 # via aiohttp backports-asyncio-runner==1.2.0 # via pytest-asyncio -coverage==7.15.1 +coverage==7.15.2 # via pytest-cov exceptiongroup==1.3.1 # via pytest diff --git a/requirements/test-common.txt b/requirements/test-common.txt index 1fe9c9f37a0..8a9ec2f2900 100644 --- a/requirements/test-common.txt +++ b/requirements/test-common.txt @@ -22,7 +22,7 @@ blockbuster==1.5.26 # via -r requirements/test-common.in cffi==2.1.0 # via cryptography -coverage==7.15.1 +coverage==7.15.2 # via # -r requirements/test-common.in # pytest-cov diff --git a/requirements/test-ft.txt b/requirements/test-ft.txt index 1c94a68019c..ee8bdad86f9 100644 --- a/requirements/test-ft.txt +++ b/requirements/test-ft.txt @@ -38,7 +38,7 @@ cffi==2.1.0 # via # cryptography # pycares -coverage==7.15.1 +coverage==7.15.2 # via # -r requirements/test-common.in # pytest-cov diff --git a/requirements/test-mobile.txt b/requirements/test-mobile.txt index 870c0227097..ad8817265fe 100644 --- a/requirements/test-mobile.txt +++ b/requirements/test-mobile.txt @@ -34,7 +34,7 @@ cffi==2.1.0 ; sys_platform != "android" and sys_platform != "ios" # via # -r requirements/test-mobile.in # pycares -coverage==7.15.1 +coverage==7.15.2 # via pytest-cov exceptiongroup==1.3.1 # via diff --git a/requirements/test.txt b/requirements/test.txt index 2d1125835ff..84b05d7bba9 100644 --- a/requirements/test.txt +++ b/requirements/test.txt @@ -38,7 +38,7 @@ cffi==2.1.0 # via # cryptography # pycares -coverage==7.15.1 +coverage==7.15.2 # via # -r requirements/test-common.in # pytest-cov From 2b9068690af7f868ccd01552c58cf045afc6d9f6 Mon Sep 17 00:00:00 2001 From: agu2347 <94227848+agu2347@users.noreply.github.com> Date: Mon, 20 Jul 2026 17:24:32 +0530 Subject: [PATCH 04/10] Fix StreamResponse.last_modified rounding inconsistency between datetime and timestamp (#13170) --- CHANGES/5303.bugfix.rst | 7 +++++++ aiohttp/web_response.py | 2 ++ tests/test_web_response.py | 17 +++++++++++++++++ 3 files changed, 26 insertions(+) create mode 100644 CHANGES/5303.bugfix.rst diff --git a/CHANGES/5303.bugfix.rst b/CHANGES/5303.bugfix.rst new file mode 100644 index 00000000000..fcb1e7c8ae9 --- /dev/null +++ b/CHANGES/5303.bugfix.rst @@ -0,0 +1,7 @@ +Fixed :py:attr:`~aiohttp.web.StreamResponse.last_modified` rounding a +:class:`datetime.datetime` with a fractional second down while rounding an +equivalent :class:`float` timestamp up, so the two produced different +``Last-Modified`` headers for the same instant. Both now round up to the +next whole second, consistent with how :py:class:`~aiohttp.web.FileResponse` +relies on this rounding to avoid falsely reporting a resource as +unmodified. diff --git a/aiohttp/web_response.py b/aiohttp/web_response.py index c9fa02f2524..8b0e6ed1c52 100644 --- a/aiohttp/web_response.py +++ b/aiohttp/web_response.py @@ -267,6 +267,8 @@ def last_modified( "%a, %d %b %Y %H:%M:%S GMT", time.gmtime(math.ceil(value)) ) elif isinstance(value, datetime.datetime): + if value.microsecond: + value = value.replace(microsecond=0) + datetime.timedelta(seconds=1) self._headers[hdrs.LAST_MODIFIED] = time.strftime( "%a, %d %b %Y %H:%M:%S GMT", value.utctimetuple() ) diff --git a/tests/test_web_response.py b/tests/test_web_response.py index cc564cf832f..d4fb92d3cf4 100644 --- a/tests/test_web_response.py +++ b/tests/test_web_response.py @@ -302,6 +302,23 @@ def test_last_modified_datetime() -> None: assert resp.last_modified == dt +def test_last_modified_datetime_and_timestamp_round_consistently() -> None: + dt = datetime.datetime(2020, 12, 2, 9, 51, 2, 500000, datetime.timezone.utc) + + resp_dt = web.StreamResponse() + resp_dt.last_modified = dt + + resp_ts = web.StreamResponse() + resp_ts.last_modified = dt.timestamp() + + assert resp_dt.headers["Last-Modified"] == resp_ts.headers["Last-Modified"] + # Both should round up to the next whole second, so that Last-Modified + # is never earlier than the file's true st_mtime, invalidating the caching. + assert resp_dt.last_modified == datetime.datetime( + 2020, 12, 2, 9, 51, 3, 0, datetime.timezone.utc + ) + + def test_last_modified_reset() -> None: resp = web.StreamResponse() From bcfe363831f3360e2dbb59bffbd2721f2dbe063e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 20 Jul 2026 11:58:06 +0000 Subject: [PATCH 05/10] Bump github/codeql-action from 4.37.0 to 4.37.1 (#13182) Bumps [github/codeql-action](https://github.com/github/codeql-action) from 4.37.0 to 4.37.1.
Release notes

Sourced from github/codeql-action's releases.

v4.37.1

  • Upcoming breaking change: Add a deprecation warning for customers using CodeQL version 2.20.6 and earlier. These versions of CodeQL were discontinued on 1 July 2026 alongside GitHub Enterprise Server 3.16, and will be unsupported by the next minor release of the CodeQL Action. #3956
  • Update default CodeQL bundle version to 2.26.1. #4019
Changelog

Sourced from github/codeql-action's changelog.

4.37.1 - 16 Jul 2026

  • Upcoming breaking change: Add a deprecation warning for customers using CodeQL version 2.20.6 and earlier. These versions of CodeQL were discontinued on 1 July 2026 alongside GitHub Enterprise Server 3.16, and will be unsupported by the next minor release of the CodeQL Action. #3956
  • Update default CodeQL bundle version to 2.26.1. #4019
Commits
  • 7188fc3 Merge pull request #4020 from github/update-v4.37.1-9e7c07009
  • c8b5f69 Update changelog for v4.37.1
  • 9e7c070 Merge pull request #4014 from github/mbg/explicit-remote-prefix
  • 3492b7e Change REMOTE_PATH_PREFIX to remote=
  • 3654baa Merge remote-tracking branch 'origin/main' into mbg/explicit-remote-prefix
  • 2d682ac Merge pull request #4017 from github/dependabot/github_actions/dot-github/wor...
  • 23f6a50 Merge pull request #4009 from github/mbg/action-state/additions
  • 1ee3c75 Merge pull request #4018 from github/dependabot/github_actions/dot-github/wor...
  • e053684 Merge pull request #4015 from github/dependabot/npm_and_yarn/npm-minor-fd2e83...
  • 6803c56 Merge pull request #4019 from github/update-bundle/codeql-bundle-v2.26.1
  • Additional commits viewable in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=github/codeql-action&package-manager=github_actions&previous-version=4.37.0&new-version=4.37.1)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/codeql.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index bb4c76fb366..6d4bf162d59 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -29,17 +29,17 @@ jobs: uses: actions/checkout@v7 - name: Initialize CodeQL - uses: github/codeql-action/init@v4.37.0 + uses: github/codeql-action/init@v4.37.1 with: languages: ${{ matrix.language }} config-file: ./.github/codeql.yml queries: +security-and-quality - name: Autobuild - uses: github/codeql-action/autobuild@v4.37.0 + uses: github/codeql-action/autobuild@v4.37.1 if: ${{ matrix.language == 'python' || matrix.language == 'javascript' }} - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v4.37.0 + uses: github/codeql-action/analyze@v4.37.1 with: category: "/language:${{ matrix.language }}" From cd0556b23ade56c51b1214cddcf5b5e6661a9796 Mon Sep 17 00:00:00 2001 From: Sam Bull Date: Mon, 20 Jul 2026 14:20:08 +0100 Subject: [PATCH 06/10] Fix a flaky test on Windows (#13194) --- tests/test_helpers.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/test_helpers.py b/tests/test_helpers.py index deaea0f9fa0..9e2092fc36b 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -4,6 +4,7 @@ import ipaddress import itertools import sys +import time import weakref from collections.abc import Iterator from math import ceil, modf @@ -352,6 +353,10 @@ def test_timeout_handle(event_loop: asyncio.AbstractEventLoop) -> None: assert not handle._callbacks +@pytest.mark.skipif( + time.get_clock_info("monotonic").resolution > 0.001, + reason="loop.time() resolution is coarser than the test's 1ms tolerance", +) def test_when_timeout_smaller_second(event_loop: asyncio.AbstractEventLoop) -> None: timeout = 0.1 From fcd7280b45193faf6f57d83407fed41c61181ed4 Mon Sep 17 00:00:00 2001 From: Sam Bull Date: Mon, 20 Jul 2026 14:20:53 +0100 Subject: [PATCH 07/10] Fix a flaky WS test (#13179) --- tests/test_client_ws_functional.py | 61 ++++++++++++++++++------------ 1 file changed, 36 insertions(+), 25 deletions(-) diff --git a/tests/test_client_ws_functional.py b/tests/test_client_ws_functional.py index ef69bdaffb6..8eccc163150 100644 --- a/tests/test_client_ws_functional.py +++ b/tests/test_client_ws_functional.py @@ -3,7 +3,6 @@ import struct import sys import zlib -from contextlib import suppress from typing import Literal, NoReturn from unittest import mock @@ -898,24 +897,8 @@ async def handler(request: web.Request) -> web.WebSocketResponse: # surface as a timeout/closure on the client side. ws = web.WebSocketResponse(autoping=False) await ws.prepare(request) - - assert ws._writer is not None - transport = ws._writer.transport - - # Server-to-client frames are not masked. - length = len(payload) # payload is fixed length of 2048 bytes - header = bytes((0x82, 126)) + struct.pack("!H", length) - - frame = header + payload - for i in range(0, len(frame), chunk_size): - transport.write(frame[i : i + chunk_size]) - await asyncio.sleep(delay) - - # Ensure the server side is cleaned up. - with suppress(asyncio.TimeoutError): - await ws.receive(timeout=1.0) - with suppress(Exception): - await ws.close() + # Keep the connection open until the client tears it down. + await ws.receive() return ws app = web.Application() @@ -923,15 +906,43 @@ async def handler(request: web.Request) -> web.WebSocketResponse: client = await aiohttp_client(app) async with client.ws_connect("/", heartbeat=heartbeat) as resp: - # If heartbeat were not reset on incoming bytes, the client would send - # a PING while this frame is still being streamed. - with mock.patch.object( - resp._writer, "send_frame", wraps=resp._writer.send_frame - ) as sf: - msg = await resp.receive() + assert resp._conn is not None + protocol = resp._conn.protocol + assert protocol is not None + + # A server->client BINARY frame (unmasked, 16-bit length form). + header = bytes((0x82, 126)) + struct.pack("!H", len(payload)) + frame = header + payload + + loop = asyncio.get_running_loop() + now = loop.time() + # Drive the clock so the result cannot depend on wall-clock scheduling. + with ( + mock.patch.object(loop, "time") as loop_time, + mock.patch.object( + resp._writer, "send_frame", wraps=resp._writer.send_frame + ) as sf, + ): + loop_time.return_value = now + # If heartbeat were not reset on incoming bytes, the client would + # send a PING while this frame is still being streamed. + for i in range(0, len(frame), chunk_size): + # Deliver a chunk via the real receive path, then advance the + # clock by less than the heartbeat so the reset stays ahead. + protocol.data_received(frame[i : i + chunk_size]) + now += delay + loop_time.return_value = now + # Two ticks: run the coalesced reset, then let the heartbeat + # timer re-evaluate its deadline against the advanced clock. + await asyncio.sleep(0) + await asyncio.sleep(0) + assert ( sf.call_args_list.count(mock.call(b"", WSMsgType.PING)) == 0 ), "Heartbeat PING sent while data was still being received" + + # Clock restored: the fully-received frame arrives as one BINARY message. + msg = await resp.receive() assert msg.type is WSMsgType.BINARY assert msg.data == payload assert not resp.closed From b3a1c675ba3795c8b32d3f1e8eaa6f200ab5f465 Mon Sep 17 00:00:00 2001 From: Sam Bull Date: Mon, 20 Jul 2026 14:22:45 +0100 Subject: [PATCH 08/10] Make llhttp method array size dynamic (#13174) --- CHANGES/13174.bugfix.rst | 1 + aiohttp/_cparser.pxd | 82 +++------------------------------------ aiohttp/_http_parser.pyx | 29 +++++++++----- tests/test_http_parser.py | 15 +++++++ 4 files changed, 41 insertions(+), 86 deletions(-) create mode 100644 CHANGES/13174.bugfix.rst diff --git a/CHANGES/13174.bugfix.rst b/CHANGES/13174.bugfix.rst new file mode 100644 index 00000000000..56cf18eea32 --- /dev/null +++ b/CHANGES/13174.bugfix.rst @@ -0,0 +1 @@ +Fixed the C parser reporting newer HTTP methods such as ``QUERY`` as ````; the method table is now derived from the vendored llhttp instead of a hand-maintained count -- by :user:`Dreamsorcerer`. diff --git a/aiohttp/_cparser.pxd b/aiohttp/_cparser.pxd index cc7ef58d664..903f35c4590 100644 --- a/aiohttp/_cparser.pxd +++ b/aiohttp/_cparser.pxd @@ -1,29 +1,20 @@ -from libc.stdint cimport int32_t, uint8_t, uint16_t, uint64_t +# This file contains just the definitions needed in our Cython code. + +from libc.stdint cimport uint8_t, uint16_t, uint64_t cdef extern from "llhttp.h": struct llhttp__internal_s: - int32_t _index - void* _span_pos0 - void* _span_cb0 - int32_t error - const char* reason - const char* error_pos void* data - void* _current uint64_t content_length uint8_t type uint8_t method uint8_t http_major uint8_t http_minor - uint8_t header_state - uint8_t lenient_flags uint8_t upgrade - uint8_t finish uint16_t flags uint16_t status_code - void* settings ctypedef llhttp__internal_s llhttp__internal_t ctypedef llhttp__internal_t llhttp_t @@ -43,20 +34,10 @@ cdef extern from "llhttp.h": llhttp_cb on_chunk_header llhttp_cb on_chunk_complete - llhttp_cb on_url_complete - llhttp_cb on_status_complete - llhttp_cb on_header_field_complete - llhttp_cb on_header_value_complete - ctypedef llhttp_settings_s llhttp_settings_t enum llhttp_errno: HPE_OK, - HPE_INTERNAL, - HPE_STRICT, - HPE_LF_EXPECTED, - HPE_UNEXPECTED_CONTENT_LENGTH, - HPE_CLOSED_CONNECTION, HPE_INVALID_METHOD, HPE_INVALID_URL, HPE_INVALID_CONSTANT, @@ -73,8 +54,7 @@ cdef extern from "llhttp.h": HPE_CB_CHUNK_HEADER, HPE_CB_CHUNK_COMPLETE, HPE_PAUSED, - HPE_PAUSED_UPGRADE, - HPE_USER + HPE_PAUSED_UPGRADE ctypedef llhttp_errno llhttp_errno_t @@ -84,58 +64,10 @@ cdef extern from "llhttp.h": enum llhttp_type: HTTP_REQUEST, - HTTP_RESPONSE, - HTTP_BOTH + HTTP_RESPONSE enum llhttp_method: - HTTP_DELETE, - HTTP_GET, - HTTP_HEAD, - HTTP_POST, - HTTP_PUT, - HTTP_CONNECT, - HTTP_OPTIONS, - HTTP_TRACE, - HTTP_COPY, - HTTP_LOCK, - HTTP_MKCOL, - HTTP_MOVE, - HTTP_PROPFIND, - HTTP_PROPPATCH, - HTTP_SEARCH, - HTTP_UNLOCK, - HTTP_BIND, - HTTP_REBIND, - HTTP_UNBIND, - HTTP_ACL, - HTTP_REPORT, - HTTP_MKACTIVITY, - HTTP_CHECKOUT, - HTTP_MERGE, - HTTP_MSEARCH, - HTTP_NOTIFY, - HTTP_SUBSCRIBE, - HTTP_UNSUBSCRIBE, - HTTP_PATCH, - HTTP_PURGE, - HTTP_MKCALENDAR, - HTTP_LINK, - HTTP_UNLINK, - HTTP_SOURCE, - HTTP_PRI, - HTTP_DESCRIBE, - HTTP_ANNOUNCE, - HTTP_SETUP, - HTTP_PLAY, - HTTP_PAUSE, - HTTP_TEARDOWN, - HTTP_GET_PARAMETER, - HTTP_SET_PARAMETER, - HTTP_REDIRECT, - HTTP_RECORD, - HTTP_FLUSH - - ctypedef llhttp_method llhttp_method_t; + HTTP_CONNECT void llhttp_settings_init(llhttp_settings_t* settings) void llhttp_init(llhttp_t* parser, llhttp_type type, @@ -152,8 +84,6 @@ cdef extern from "llhttp.h": const char* llhttp_get_error_reason(const llhttp_t* parser) const char* llhttp_get_error_pos(const llhttp_t* parser) - const char* llhttp_method_name(llhttp_method_t method) - void llhttp_set_lenient_headers(llhttp_t* parser, int enabled) void llhttp_set_lenient_optional_cr_before_lf(llhttp_t* parser, int enabled) void llhttp_set_lenient_spaces_after_chunk_size(llhttp_t* parser, int enabled) diff --git a/aiohttp/_http_parser.pyx b/aiohttp/_http_parser.pyx index a02754822c0..19273ac31f7 100644 --- a/aiohttp/_http_parser.pyx +++ b/aiohttp/_http_parser.pyx @@ -100,20 +100,29 @@ cdef inline object extend(object buf, const char* at, size_t length): memcpy(ptr + s, at, length) -DEF METHODS_COUNT = 46; +# The method-name table and its length come straight from llhttp's canonical +# HTTP_ALL_METHOD_MAP, so they track the vendored llhttp version automatically +# instead of relying on a hand-maintained method count. +cdef extern from *: + """ + #include "llhttp.h" + + #define _AIOHTTP_METHOD_NAME(NUM, NAME, STRING) [NUM] = #STRING, + static const char* const _aiohttp_method_names[] = { + HTTP_ALL_METHOD_MAP(_AIOHTTP_METHOD_NAME) + }; + #undef _AIOHTTP_METHOD_NAME + """ + const char* _aiohttp_method_names[] + const int METHODS_COUNT "((int)(sizeof(_aiohttp_method_names) / sizeof(_aiohttp_method_names[0])))" + cdef list _http_method = [] for i in range(METHODS_COUNT): - _http_method.append( - cparser.llhttp_method_name( i).decode('ascii')) - + assert _aiohttp_method_names[i] is not NULL + _http_method.append(_aiohttp_method_names[i].decode('ascii')) -cdef inline str http_method_str(int i): - if i < METHODS_COUNT: - return _http_method[i] - else: - return "" cdef inline object find_header(bytes raw_header): cdef Py_ssize_t size @@ -506,7 +515,7 @@ cdef class HttpParser: encoding = enc if self._cparser.type == cparser.HTTP_REQUEST: - method = http_method_str(self._cparser.method) + method = _http_method[self._cparser.method] msg = _new_request_message( method, self._path, http_version, headers, raw_headers, diff --git a/tests/test_http_parser.py b/tests/test_http_parser.py index 0cc7f32c507..b37c71f397f 100644 --- a/tests/test_http_parser.py +++ b/tests/test_http_parser.py @@ -706,6 +706,21 @@ def test_parse(parser: HttpRequestParser) -> None: assert msg.headers["Host"] == "a" +def test_parse_query_method(parser: HttpRequestParser) -> None: + """QUERY has the highest llhttp method id; both parsers must decode its name. + + Regression test for a stale hand-maintained method count in the C parser + that mapped the last method(s) to "". + """ + text = b"QUERY /test HTTP/1.1\r\nHost: a\r\n\r\n" + messages, upgrade, tail = parser.feed_data(text) + assert len(messages) == 1 + msg = messages[0][0] + assert msg.method == "QUERY" + assert msg.path == "/test" + assert msg.version == (1, 1) + + async def test_parse_body(parser: HttpRequestParser) -> None: text = b"GET /test HTTP/1.1\r\nHost: a\r\nContent-Length: 4\r\n\r\nbody" messages, upgrade, tail = parser.feed_data(text) From 6fa8f394544550124df0b3304b37988e17ede1a7 Mon Sep 17 00:00:00 2001 From: Rodrigo Nogueira Date: Mon, 20 Jul 2026 12:47:16 -0300 Subject: [PATCH 09/10] Expose the request timeout to client middlewares (#13176) --- CHANGES/13176.feature.rst | 3 +++ aiohttp/client_reqrep.py | 5 +++++ docs/client_reference.rst | 10 ++++++++++ tests/test_client_request.py | 6 ++++++ 4 files changed, 24 insertions(+) create mode 100644 CHANGES/13176.feature.rst diff --git a/CHANGES/13176.feature.rst b/CHANGES/13176.feature.rst new file mode 100644 index 00000000000..db505da1a88 --- /dev/null +++ b/CHANGES/13176.feature.rst @@ -0,0 +1,3 @@ +Exposed the request's timeout configuration to client middlewares as the +read-only :attr:`ClientRequest.timeout ` +property -- by :user:`rodrigobnogueira`. diff --git a/aiohttp/client_reqrep.py b/aiohttp/client_reqrep.py index 51da8711c58..88934197978 100644 --- a/aiohttp/client_reqrep.py +++ b/aiohttp/client_reqrep.py @@ -1126,6 +1126,11 @@ def body(self) -> payload.Payload: def skip_auto_headers(self) -> CIMultiDict[None]: return self._skip_auto_headers or CIMultiDict() + @property + def timeout(self) -> ClientTimeout: + """The timeout configuration this request runs under (read-only).""" + return self._timeout + @property def connection_key(self) -> ConnectionKey: if proxy_headers := self.proxy_headers: diff --git a/docs/client_reference.rst b/docs/client_reference.rst index 5fd965acc12..72a68670a5c 100644 --- a/docs/client_reference.rst +++ b/docs/client_reference.rst @@ -2106,6 +2106,16 @@ ClientRequest - :class:`ssl.SSLContext`: Custom SSL context - :class:`Fingerprint`: Verify specific certificate fingerprint + .. attribute:: timeout + :type: ClientTimeout + + The timeout configuration this request runs under (read-only): the + per-request timeout when one was passed to the request method, the + session's default otherwise. Useful in middleware to bound waits or + retries by the caller's time budget. + + .. versionadded:: 3.15 + .. attribute:: url :type: yarl.URL diff --git a/tests/test_client_request.py b/tests/test_client_request.py index 5124fadbe54..0c7fca410f4 100644 --- a/tests/test_client_request.py +++ b/tests/test_client_request.py @@ -122,6 +122,12 @@ async def test_version_default(make_client_request: _RequestMaker) -> None: assert req.version == (1, 1) +async def test_timeout_property(make_client_request: _RequestMaker) -> None: + timeout = ClientTimeout(total=42) + req = make_client_request("get", URL("http://python.org/"), timeout=timeout) + assert req.timeout is timeout + + async def test_request_info(make_client_request: _RequestMaker) -> None: req = make_client_request("get", URL("http://python.org/")) url = URL("http://python.org/") From ed8b040c81dfdce5793271c7d6c3aa36c97a2ba3 Mon Sep 17 00:00:00 2001 From: Javid Khan Date: Mon, 20 Jul 2026 21:29:03 +0530 Subject: [PATCH 10/10] escape backslashes in digest auth quoted-string values (#13054) --- CHANGES/13054.bugfix.rst | 4 +++ CONTRIBUTORS.txt | 1 + aiohttp/client_middleware_digest_auth.py | 8 +++--- tests/test_client_middleware_digest_auth.py | 30 ++++++++++++++++++++- 4 files changed, 38 insertions(+), 5 deletions(-) create mode 100644 CHANGES/13054.bugfix.rst diff --git a/CHANGES/13054.bugfix.rst b/CHANGES/13054.bugfix.rst new file mode 100644 index 00000000000..75196b9bfe6 --- /dev/null +++ b/CHANGES/13054.bugfix.rst @@ -0,0 +1,4 @@ +Fixed ``escape_quotes`` in the Digest authentication middleware not escaping +backslashes, so a ``WWW-Authenticate`` challenge value containing a backslash +could break out of its quoted-string in the generated ``Authorization`` header +-- by :user:`dxbjavid`. diff --git a/CONTRIBUTORS.txt b/CONTRIBUTORS.txt index 724ee94965c..624b9400fbd 100644 --- a/CONTRIBUTORS.txt +++ b/CONTRIBUTORS.txt @@ -198,6 +198,7 @@ Jan Buchar Jan Gosmann Jarno Elonen Jashandeep Sohi +Javid Khan Javier Torres Jean-Baptiste Estival Jens Steinhauser diff --git a/aiohttp/client_middleware_digest_auth.py b/aiohttp/client_middleware_digest_auth.py index f4904e363d8..fbcdf549b4a 100644 --- a/aiohttp/client_middleware_digest_auth.py +++ b/aiohttp/client_middleware_digest_auth.py @@ -102,13 +102,13 @@ class DigestAuthChallenge(TypedDict, total=False): def escape_quotes(value: str) -> str: - """Escape double quotes for HTTP header values.""" - return value.replace('"', '\\"') + """Escape backslashes and double quotes for HTTP quoted-strings.""" + return value.replace("\\", "\\\\").replace('"', '\\"') def unescape_quotes(value: str) -> str: - """Unescape double quotes in HTTP header values.""" - return value.replace('\\"', '"') + """Unescape backslashes and double quotes in HTTP quoted-strings.""" + return value.replace('\\"', '"').replace("\\\\", "\\") def parse_header_pairs(header: str) -> dict[str, str]: diff --git a/tests/test_client_middleware_digest_auth.py b/tests/test_client_middleware_digest_auth.py index fb0cd0ec876..793afed88aa 100644 --- a/tests/test_client_middleware_digest_auth.py +++ b/tests/test_client_middleware_digest_auth.py @@ -551,6 +551,32 @@ async def test_escaping_quotes_in_auth_header() -> None: assert 'opaque="opaque\\"with\\"quotes"' in header +async def test_backslash_in_challenge_cannot_break_quoting() -> None: + """A backslash in a server-supplied value must not escape the closing quote.""" + auth = DigestAuthMiddleware("bob", "secret") + # A malicious/compromised server could return directives ending in a + # backslash; without escaping it the trailing quote of the field would be + # turned into an escaped quote, letting the value run into later directives. + auth._challenge = DigestAuthChallenge( + realm="realm\\", + nonce="n0", + qop="auth", + algorithm="MD5", + opaque="op\\", + ) + + header = await auth._encode("GET", URL("http://example.com/path"), b"") + + assert 'realm="realm\\\\"' in header + assert 'opaque="op\\\\"' in header + # Re-parsing the header must recover the exact realm value, i.e. the + # backslash did not consume the closing quote and swallow ``nonce``. + params = parse_header_pairs(header[len("Digest ") :]) + assert params["realm"] == "realm\\" + assert params["nonce"] == "n0" + assert params["opaque"] == "op\\" + + async def test_template_based_header_construction( auth_mw_with_challenge: DigestAuthMiddleware, mock_sha1_digest: mock.MagicMock, @@ -624,7 +650,9 @@ async def test_template_based_header_construction( ), ('""', '\\"\\"', "Just double quotes"), ('"', '\\"', "Single double quote"), - ('already\\"escaped', 'already\\\\"escaped', "Already escaped quotes"), + ('already\\"escaped', 'already\\\\\\"escaped', "Already escaped quotes"), + ("back\\slash", "back\\\\slash", "Embedded backslash"), + ("trailing\\", "trailing\\\\", "Trailing backslash"), ], ) def test_quote_escaping_functions(