From ec0da453cb341a93c10d925b513c9465bd8fa3bd Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 2 Sep 2026 02:01:55 +0000 Subject: [PATCH 1/7] Bump github/codeql-action from 4.37.8 to 4.37.9 (#13594) Bumps [github/codeql-action](https://github.com/github/codeql-action) from 4.37.8 to 4.37.9.
Release notes

Sourced from github/codeql-action's releases.

v4.37.9

Changelog

Sourced from github/codeql-action's changelog.

4.37.9 - 26 Aug 2026

Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=github/codeql-action&package-manager=github_actions&previous-version=4.37.8&new-version=4.37.9)](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 7f313586aff..cf6fbc17ef3 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.8 + uses: github/codeql-action/init@v4.37.9 with: languages: ${{ matrix.language }} config-file: ./.github/codeql.yml queries: +security-and-quality - name: Autobuild - uses: github/codeql-action/autobuild@v4.37.8 + uses: github/codeql-action/autobuild@v4.37.9 if: ${{ matrix.language == 'python' || matrix.language == 'javascript' }} - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v4.37.8 + uses: github/codeql-action/analyze@v4.37.9 with: category: "/language:${{ matrix.language }}" From 16a713c9d644be25360bf8fba6368b38cc025a60 Mon Sep 17 00:00:00 2001 From: Sam Bull Date: Wed, 2 Sep 2026 03:10:15 +0100 Subject: [PATCH 2/7] Add text_charset parameter (#13590) Fixes #4559. --- CHANGES/4559.feature.rst | 1 + aiohttp/web_fileresponse.py | 9 +- aiohttp/web_urldispatcher.py | 10 ++- docs/web_reference.rst | 26 +++++- tests/test_web_sendfile_functional.py | 116 ++++++++++++++++++++++++++ 5 files changed, 159 insertions(+), 3 deletions(-) create mode 100644 CHANGES/4559.feature.rst diff --git a/CHANGES/4559.feature.rst b/CHANGES/4559.feature.rst new file mode 100644 index 00000000000..ac11a5c44ba --- /dev/null +++ b/CHANGES/4559.feature.rst @@ -0,0 +1 @@ +Added a ``text_charset`` parameter to ``FileResponse`` and ``UrlDispatcher.add_static`` -- by :user:`Dreamsorcerer`. diff --git a/aiohttp/web_fileresponse.py b/aiohttp/web_fileresponse.py index b09bfe109d4..a6d764d9d2a 100644 --- a/aiohttp/web_fileresponse.py +++ b/aiohttp/web_fileresponse.py @@ -91,11 +91,15 @@ def __init__( status: int = 200, reason: str | None = None, headers: LooseHeaders | None = None, + text_charset: str | None = None, ) -> None: super().__init__(status=status, reason=reason, headers=headers) self._path = pathlib.Path(path) self._chunk_size = chunk_size + if text_charset == "": + raise ValueError("text_charset must not be an empty string") + self._text_charset = text_charset def _seek_and_read(self, fobj: BinaryIO, offset: int, chunk_size: int) -> bytes: fobj.seek(offset) @@ -381,7 +385,10 @@ async def _prepare_open_file( guesser = CONTENT_TYPES.guess_file_type else: guesser = CONTENT_TYPES.guess_type - self.content_type = guesser(self._path)[0] or FALLBACK_CONTENT_TYPE + content_type = guesser(self._path)[0] or FALLBACK_CONTENT_TYPE + self.content_type = content_type + if self._text_charset is not None and content_type.startswith("text/"): + self.charset = self._text_charset if file_encoding: self._headers[hdrs.CONTENT_ENCODING] = file_encoding diff --git a/aiohttp/web_urldispatcher.py b/aiohttp/web_urldispatcher.py index 2e20708a9f0..bcef8ef72d4 100644 --- a/aiohttp/web_urldispatcher.py +++ b/aiohttp/web_urldispatcher.py @@ -508,6 +508,7 @@ def __init__( name: str | None = None, expect_handler: _ExpectHandler | None = None, chunk_size: int = DEFAULT_CHUNK_SIZE, + text_charset: str | None = None, show_index: bool = False, break_symlink_sandbox: bool = False, append_version: bool = False, @@ -522,6 +523,9 @@ def __init__( self._directory = directory self._show_index = show_index self._chunk_size = chunk_size + if text_charset == "": + raise ValueError("text_charset must not be an empty string") + self._text_charset = text_charset self._break_symlink_sandbox = break_symlink_sandbox self._expect_handler = expect_handler self._append_version = append_version @@ -667,7 +671,9 @@ def _resolve_path_to_response(self, unresolved_path: Path) -> StreamResponse: raise HTTPForbidden() from error # Return the file response, which handles all other checks. - return FileResponse(file_path, chunk_size=self._chunk_size) + return FileResponse( + file_path, chunk_size=self._chunk_size, text_charset=self._text_charset + ) def _directory_as_html(self, dir_path: Path) -> str: """returns directory's index as html.""" @@ -1136,6 +1142,7 @@ def add_static( name: str | None = None, expect_handler: _ExpectHandler | None = None, chunk_size: int = DEFAULT_CHUNK_SIZE, + text_charset: str | None = None, show_index: bool = False, break_symlink_sandbox: bool = False, append_version: bool = False, @@ -1155,6 +1162,7 @@ def add_static( name=name, expect_handler=expect_handler, chunk_size=chunk_size, + text_charset=text_charset, show_index=show_index, break_symlink_sandbox=break_symlink_sandbox, append_version=append_version, diff --git a/docs/web_reference.rst b/docs/web_reference.rst index d8913bbe18f..07dd06d8b38 100644 --- a/docs/web_reference.rst +++ b/docs/web_reference.rst @@ -941,7 +941,7 @@ and :ref:`aiohttp-web-signals` handlers:: :attr:`~aiohttp.StreamResponse.body`, represented as :class:`str`. -.. class:: FileResponse(*, path, chunk_size=256*1024, status=200, reason=None, headers=None) +.. class:: FileResponse(*, path, chunk_size=256*1024, status=200, reason=None, headers=None, text_charset=None) :canonical: aiohttp.web_fileresponse.FileResponse The response class used to send files, inherited from :class:`StreamResponse`. @@ -966,6 +966,19 @@ and :ref:`aiohttp-web-signals` handlers:: response's ones. The ``Content-Type`` response header will be overridden if provided. + :param str text_charset: charset to advertise for text files, + e.g. ``"utf-8"``. When the ``Content-Type`` + header is guessed from the file extension and + the guessed type is ``text/*``, a ``charset`` + parameter with this value is appended to it + (e.g. ``text/plain; charset=utf-8``). Other + media types never take the charset, and an + explicit ``Content-Type`` supplied via *headers* + is never modified. By default (``None``) no + charset is added. + + .. versionadded:: 3.15 + .. class:: WebSocketResponse(*, timeout=10.0, receive_timeout=None, \ autoclose=True, autoping=True, heartbeat=None, \ @@ -1895,6 +1908,7 @@ Application and Router .. method:: add_static(prefix, path, *, name=None, expect_handler=None, \ chunk_size=256*1024, \ + text_charset=None, \ show_index=False, \ break_symlink_sandbox=False, \ append_version=False) @@ -1939,6 +1953,14 @@ Application and Router say, 1Mb may increase file downloading speed but consumes more memory. + :param str text_charset: charset appended to the guessed + ``Content-Type`` of ``text/*`` files, + e.g. ``"utf-8"``; passed to + :class:`~aiohttp.web.FileResponse`. By + default (``None``) no charset is added. + + .. versionadded:: 3.15 + :param bool show_index: flag for allowing to show indexes of a directory, by default it's not allowed and HTTP/403 will be returned on directory access. @@ -2538,6 +2560,7 @@ The definition is created by functions like :func:`get` or .. function:: static(prefix, path, *, name=None, expect_handler=None, \ chunk_size=256*1024, \ + text_charset=None, \ show_index=False, break_symlink_sandbox=False, \ append_version=False) :canonical: aiohttp.web_routedef.static @@ -2651,6 +2674,7 @@ A routes table definition used for describing routes by decorators .. method:: static(prefix, path, *, name=None, expect_handler=None, \ chunk_size=256*1024, \ + text_charset=None, \ show_index=False, break_symlink_sandbox=False, \ append_version=False) diff --git a/tests/test_web_sendfile_functional.py b/tests/test_web_sendfile_functional.py index 93d505720c7..ec325b3b55d 100644 --- a/tests/test_web_sendfile_functional.py +++ b/tests/test_web_sendfile_functional.py @@ -261,6 +261,122 @@ async def handler(request: web.Request) -> web.FileResponse: await client.close() +@pytest.mark.parametrize( + ("filename", "charset", "expected_type"), + ( + ("hello.txt", "utf-8", "text/plain; charset=utf-8"), + ("hello.html", "UTF-8", "text/html; charset=utf-8"), + ("hello.txt", "koi8-r", "text/plain; charset=koi8-r"), + ), +) +async def test_static_file_charset( + aiohttp_client: AiohttpClient, + tmp_path: pathlib.Path, + filename: str, + charset: str, + expected_type: str, +) -> None: + """Test that the charset is appended to guessed text/* content types.""" + file_path = tmp_path / filename + file_path.write_bytes(b"Hello") + + async def handler(request: web.Request) -> web.FileResponse: + return web.FileResponse(file_path, text_charset=charset) + + app = web.Application() + app.router.add_get("/", handler) + client = await aiohttp_client(app) + + async with client.get("/") as resp: + assert resp.status == 200 + assert resp.headers["Content-Type"] == expected_type + + +@pytest.mark.parametrize( + ("filename", "expected_type"), + ( + ("data.bin", "application/octet-stream"), + ("data.json", "application/json"), + ), +) +async def test_static_file_charset_not_applied_to_non_text( + aiohttp_client: AiohttpClient, + tmp_path: pathlib.Path, + filename: str, + expected_type: str, +) -> None: + """Test that the charset is not appended to non-text content types.""" + file_path = tmp_path / filename + file_path.write_bytes(b"data") + + async def handler(request: web.Request) -> web.FileResponse: + return web.FileResponse(file_path, text_charset="utf-8") + + app = web.Application() + app.router.add_get("/", handler) + client = await aiohttp_client(app) + + async with client.get("/") as resp: + assert resp.status == 200 + assert resp.headers["Content-Type"] == expected_type + + +async def test_static_file_charset_keeps_explicit_content_type( + aiohttp_client: AiohttpClient, tmp_path: pathlib.Path +) -> None: + """Test that the charset never modifies a user-supplied Content-Type header.""" + file_path = tmp_path / "hello.txt" + file_path.write_bytes(b"Hello") + + async def handler(request: web.Request) -> web.FileResponse: + return web.FileResponse( + file_path, + headers={"Content-Type": "text/plain; charset=latin-1"}, + text_charset="utf-8", + ) + + app = web.Application() + app.router.add_get("/", handler) + client = await aiohttp_client(app) + + async with client.get("/") as resp: + assert resp.status == 200 + assert resp.headers["Content-Type"] == "text/plain; charset=latin-1" + + +async def test_static_route_charset( + aiohttp_client: AiohttpClient, tmp_path: pathlib.Path +) -> None: + """Test that add_static passes the charset through to file responses.""" + (tmp_path / "hello.txt").write_bytes(b"Hello") + (tmp_path / "data.bin").write_bytes(b"\x00\x01\x02") + + app = web.Application() + app.router.add_static("/static", tmp_path, text_charset="utf-8") + client = await aiohttp_client(app) + + async with client.get("/static/hello.txt") as resp: + assert resp.status == 200 + assert resp.headers["Content-Type"] == "text/plain; charset=utf-8" + + async with client.get("/static/data.bin") as resp: + assert resp.status == 200 + assert resp.headers["Content-Type"] == "application/octet-stream" + + +def test_static_file_text_charset_empty() -> None: + """Test that an empty text_charset is rejected at construction time.""" + with pytest.raises(ValueError, match="text_charset"): + web.FileResponse(pathlib.Path("hello.txt"), text_charset="") + + +def test_static_route_text_charset_empty(tmp_path: pathlib.Path) -> None: + """Test that an empty text_charset is rejected when the route is set up.""" + app = web.Application() + with pytest.raises(ValueError, match="text_charset"): + app.router.add_static("/static", tmp_path, text_charset="") + + @pytest.mark.parametrize("hello_txt", ["gzip", "br"], indirect=True) async def test_static_file_custom_content_type( hello_txt: pathlib.Path, aiohttp_client: AiohttpClient, sender: _Sender From bc4d08903bca356b9156b058537dc76ef5466a29 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 2 Sep 2026 02:26:13 +0000 Subject: [PATCH 3/7] Bump platformdirs from 4.11.4 to 4.11.5 (#13599) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [platformdirs](https://github.com/tox-dev/platformdirs) from 4.11.4 to 4.11.5.
Release notes

Sourced from platformdirs's releases.

4.11.5

What's Changed

New Contributors

Full Changelog: https://github.com/tox-dev/platformdirs/compare/4.11.4...4.11.5

Changelog

Sourced from platformdirs's changelog.

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

.. towncrier-draft-entries:: Unreleased

.. towncrier release notes start


4.11.5 (2026-08-27)


  • Give :func:~platformdirs.user_preference_dir and :func:~platformdirs.user_preference_path the same arguments as :func:~platformdirs.user_config_dir. Added without arguments in :pr:491, they could only return the unscoped base directory even though the property they wrap appends the app name and version. :pr:531
  • Make :func:~platformdirs.site_applications_path return the first entry when multipath=True, matching :func:~platformdirs.site_data_path. On Unix and macOS it passed the whole $XDG_DATA_DIRS list to :class:~pathlib.Path, giving one unusable path such as /first/applications:/second/applications. :pr:532
  • Give :func:~platformdirs.user_applications_dir, :func:~platformdirs.user_applications_path, :func:~platformdirs.site_applications_dir and :func:~platformdirs.site_applications_path the app arguments. Android scopes both applications directories to the app, so without them the functions could only return the unscoped base directory there. On the two site functions they are keyword-only, keeping multipath first positional as it has been since 4.9.0; the two user functions take their boolean options keyword-only. :pr:534
  • Correct the ordering note on the iterator methods. use_site_for_root drops the user directory entirely, so the iterators are documented as yielding the most specific directory first rather than always yielding the user one. :pr:533

4.11.4 (2026-08-24)


  • Stop the iter_*_dirs methods yielding the same directory twice when a site directory resolves to its user equivalent - :pr:520 covered only Unix with use_site_for_root. It also hit :meth:~platformdirs.PlatformDirs.iter_runtime_dirs on Unix with $XDG_RUNTIME_DIR set, on Windows and macOS, and all six iterators on Android. :pr:524
  • Fix the config merging example in the how-to guide. iter_config_paths yields the user directory first, so the config.update loop let the site defaults override the user's config instead of the other way round. :pr:529

4.11.3 (2026-08-13)


  • python -m platformdirs now lists :func:~platformdirs.user_desktop_dir, which was missing from the properties it prints. :pr:523
  • Stop :func:~platformdirs.site_data_dir, :func:~platformdirs.site_config_dir and :func:~platformdirs.site_applications_dir raising IndexError on Unix and macOS when $XDG_DATA_DIRS or $XDG_CONFIG_DIRS holds only separators and whitespace, such as ":". These values now fall back to the platform defaults, and each entry is stripped of surrounding whitespace. :pr:523

4.11.2 (2026-08-10)

... (truncated)

Commits
  • bd77b0d Release 4.11.5
  • 2a3ab96 fix: accept app arguments in user_preference_dir (#531)
  • 56a9bc7 refactor: keyword-only booleans on the applications functions (#535)
  • 8147f77 fix: accept app arguments in the applications functions (#534)
  • 8ea7338 fix: return one site applications path for multipath (#532)
  • 4d4279f docs: fix the iterator order claim in api.rst (#533)
  • babf239 [pre-commit.ci] pre-commit autoupdate (#530)
  • See full diff in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=platformdirs&package-manager=pip&previous-version=4.11.4&new-version=4.11.5)](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 346df184588..0957231e6d0 100644 --- a/requirements/constraints.txt +++ b/requirements/constraints.txt @@ -166,7 +166,7 @@ pip-tools==7.6.1 # via -r requirements/dev.in pkgconfig==1.6.0 # via -r requirements/test-common-base.in -platformdirs==4.11.4 +platformdirs==4.11.5 # via virtualenv pluggy==1.6.0 # via diff --git a/requirements/dev.txt b/requirements/dev.txt index ba443755289..9e77cb10212 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -163,7 +163,7 @@ pip-tools==7.6.1 # via -r requirements/dev.in pkgconfig==1.6.0 # via -r requirements/test-common-base.in -platformdirs==4.11.4 +platformdirs==4.11.5 # via virtualenv pluggy==1.6.0 # via diff --git a/requirements/lint.txt b/requirements/lint.txt index 9aedc9f8871..06a73dda21e 100644 --- a/requirements/lint.txt +++ b/requirements/lint.txt @@ -84,7 +84,7 @@ packaging==26.3 # via pytest pathspec==1.1.1 # via mypy -platformdirs==4.11.4 +platformdirs==4.11.5 # via virtualenv pluggy==1.6.0 # via pytest From c74d7e989dc24d428a4801e95144dcf61a9b8ecf Mon Sep 17 00:00:00 2001 From: Sam Bull Date: Wed, 2 Sep 2026 03:45:49 +0100 Subject: [PATCH 4/7] Fix some flaky tests (#13626) --- tests/test_client_functional.py | 12 ++++++---- tests/test_connector.py | 41 ++++++++++++++++++--------------- 2 files changed, 30 insertions(+), 23 deletions(-) diff --git a/tests/test_client_functional.py b/tests/test_client_functional.py index f0dbaa5da5b..8ec172152e7 100644 --- a/tests/test_client_functional.py +++ b/tests/test_client_functional.py @@ -4010,7 +4010,7 @@ async def handler(request: web.Request) -> web.Response: assert 1 == len(client.session.connector._conns) -async def test_server_close_keepalive_connection(unused_tcp_port: int) -> None: +async def test_server_close_keepalive_connection() -> None: loop = asyncio.get_running_loop() class Proto(asyncio.Protocol): @@ -4035,7 +4035,7 @@ def data_received(self, data: bytes) -> None: def connection_lost(self, exc: BaseException | None) -> None: self.transp = None - server = await loop.create_server(Proto, "127.0.0.1", unused_tcp_port) + server = await loop.create_server(Proto, "127.0.0.1", 0) addr = server.sockets[0].getsockname() @@ -4051,7 +4051,7 @@ def connection_lost(self, exc: BaseException | None) -> None: await server.wait_closed() -async def test_handle_keepalive_on_closed_connection(unused_tcp_port: int) -> None: +async def test_handle_keepalive_on_closed_connection() -> None: loop = asyncio.get_running_loop() class Proto(asyncio.Protocol): @@ -4070,7 +4070,7 @@ def data_received(self, data: bytes) -> None: def connection_lost(self, exc: BaseException | None) -> None: self.transp = None - server = await loop.create_server(Proto, "127.0.0.1", unused_tcp_port) + server = await loop.create_server(Proto, "127.0.0.1", 0) addr = server.sockets[0].getsockname() @@ -4352,7 +4352,9 @@ async def handler(request: web.Request) -> web.Response: # Make sure its really closed assert not client.session.connector._conns - async with client.get("/") as result: + # This request works (handler responds instantly); override the tight + # session timeout so slow CI can't flake the round trip on a new connection. + async with client.get("/", timeout=aiohttp.ClientTimeout(total=10)) as result: assert await result.read() == b"request:3" # Make sure its not closed diff --git a/tests/test_connector.py b/tests/test_connector.py index bca03287f55..2db6bd569c6 100644 --- a/tests/test_connector.py +++ b/tests/test_connector.py @@ -4015,20 +4015,26 @@ async def test_default_use_dns_cache() -> None: async def test_resolver_not_called_with_address_is_ip( - unused_tcp_port: int, make_client_request: _RequestMaker + make_client_request: _RequestMaker, ) -> None: resolver = mock.MagicMock() connector = aiohttp.TCPConnector(resolver=resolver) - req = make_client_request( - "GET", - URL(f"http://127.0.0.1:{unused_tcp_port}"), - loop=asyncio.get_running_loop(), - response_class=mock.Mock(), - ) + # A held, bound, non-listening socket refuses connections deterministically + # and keeps the port from being taken by a listener in the meantime. + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(("127.0.0.1", 0)) + port = s.getsockname()[1] - with pytest.raises(OSError): - await connector.connect(req, [], ClientTimeout()) + req = make_client_request( + "GET", + URL(f"http://127.0.0.1:{port}"), + loop=asyncio.get_running_loop(), + response_class=mock.Mock(), + ) + + with pytest.raises(OSError): + await connector.connect(req, [], ClientTimeout()) resolver.resolve.assert_not_called() @@ -4036,7 +4042,7 @@ async def test_resolver_not_called_with_address_is_ip( async def test_tcp_connector_raise_connector_ssl_error( - aiohttp_server: AiohttpServer, ssl_ctx: ssl.SSLContext, unused_tcp_port: int + aiohttp_server: AiohttpServer, ssl_ctx: ssl.SSLContext ) -> None: async def handler(request: web.Request) -> NoReturn: assert False @@ -4046,7 +4052,7 @@ async def handler(request: web.Request) -> NoReturn: srv = await aiohttp_server(app, ssl=ssl_ctx) - conn = aiohttp.TCPConnector(local_addr=("127.0.0.1", unused_tcp_port)) + conn = aiohttp.TCPConnector() session = aiohttp.ClientSession(connector=conn) url = srv.make_url("/") @@ -4080,7 +4086,6 @@ async def test_tcp_connector_do_not_raise_connector_ssl_error( ssl_ctx: ssl.SSLContext, client_ssl_ctx: ssl.SSLContext, host: str, - unused_tcp_port: int, ) -> None: async def handler(request: web.Request) -> web.Response: return web.Response() @@ -4089,7 +4094,7 @@ async def handler(request: web.Request) -> web.Response: app.router.add_get("/", handler) srv = await aiohttp_server(app, ssl=ssl_ctx) - conn = aiohttp.TCPConnector(local_addr=("127.0.0.1", unused_tcp_port)) + conn = aiohttp.TCPConnector() # resolving something.localhost with the real DNS resolver does not work on macOS, so we have a stub. async def _resolve_host( @@ -4137,7 +4142,7 @@ async def _resolve_host( async def test_tcp_connector_uses_provided_local_addr( aiohttp_server: AiohttpServer, - unused_tcp_port: int, + unused_tcp_port_factory: Callable[[], int], ) -> None: async def handler(request: web.Request) -> web.Response: return web.Response() @@ -4145,19 +4150,19 @@ async def handler(request: web.Request) -> web.Response: app = web.Application() app.router.add_get("/", handler) srv = await aiohttp_server(app) + url = srv.make_url("/") - conn = aiohttp.TCPConnector(local_addr=("127.0.0.1", unused_tcp_port)) + port = unused_tcp_port_factory() + conn = aiohttp.TCPConnector(local_addr=("127.0.0.1", port)) session = aiohttp.ClientSession(connector=conn) - url = srv.make_url("/") - r = await session.get(url) r.release() first_conn = next(iter(conn._conns.values()))[0][0] assert first_conn.transport is not None sockname = first_conn.transport.get_extra_info("sockname") - assert sockname == ("127.0.0.1", unused_tcp_port) + assert sockname == ("127.0.0.1", port) r.close() await session.close() await conn.close() From b1b1ac1cf1ea01f220da76be2b80cdb587206df9 Mon Sep 17 00:00:00 2001 From: Marco Edward Gorelli <33491632+MarcoGorelli@users.noreply.github.com> Date: Wed, 2 Sep 2026 04:07:11 +0100 Subject: [PATCH 5/7] Make Python code more typing-spec compliant (#13604) --- CHANGES/13604.misc.rst | 1 + CONTRIBUTORS.txt | 1 + aiohttp/test_utils.py | 5 +++-- tests/conftest.py | 14 ++++++++++---- tests/test_benchmarks_client_request.py | 9 ++++++--- tests/test_client_request.py | 8 ++++++-- tests/test_connector.py | 8 ++++++-- tests/test_proxy.py | 8 ++++++-- 8 files changed, 39 insertions(+), 15 deletions(-) create mode 100644 CHANGES/13604.misc.rst diff --git a/CHANGES/13604.misc.rst b/CHANGES/13604.misc.rst new file mode 100644 index 00000000000..a02ba340a61 --- /dev/null +++ b/CHANGES/13604.misc.rst @@ -0,0 +1 @@ +Made Python code more typing-spec compliant -- by :user:`MarcoGorelli`. diff --git a/CONTRIBUTORS.txt b/CONTRIBUTORS.txt index 5e8f0e74486..f655270a3aa 100644 --- a/CONTRIBUTORS.txt +++ b/CONTRIBUTORS.txt @@ -262,6 +262,7 @@ Manny7717 Manuel Miranda Marat Sharafutdinov Marc Mueller +Marco Gorelli Marco Paolini Marcus Campbell Marcus Stojcevich diff --git a/aiohttp/test_utils.py b/aiohttp/test_utils.py index a550b369288..b2686b24eab 100644 --- a/aiohttp/test_utils.py +++ b/aiohttp/test_utils.py @@ -60,6 +60,7 @@ _ApplicationNone = TypeVar("_ApplicationNone", Application, None) _Request = TypeVar("_Request", bound=BaseRequest) +_ServerRequest = TypeVar("_ServerRequest", bound=BaseRequest) REUSE_ADDRESS = os.name == "posix" and sys.platform != "cygwin" @@ -237,8 +238,8 @@ def __init__( ) -> None: ... @overload def __init__( - self: "TestClient[_Request, None]", - server: BaseTestServer[_Request], + self: "TestClient[_ServerRequest, None]", + server: BaseTestServer[_ServerRequest], *, cookie_jar: AbstractCookieJar | None = None, **kwargs: Any, diff --git a/tests/conftest.py b/tests/conftest.py index 6e18199bed6..71310bd904e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -14,7 +14,7 @@ from http.cookies import BaseCookie from pathlib import Path from tempfile import TemporaryDirectory -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Protocol from unittest import mock from uuid import uuid4 @@ -63,9 +63,17 @@ def pytest_configure(config: pytest.Config) -> None: if sys.version_info >= (3, 11): from typing import Unpack + + class _RequestMaker(Protocol): + def __call__( + self, method: str, url: URL, **kwargs: Unpack[ClientRequestArgs] + ) -> ClientRequest: ... + else: from typing import Any as Unpack + _RequestMaker = Any + # We require pytest-aiohttp to avoid confusing debugging if it's not installed. pytest_plugins = ("pytest_aiohttp.plugin", "pytester") @@ -444,9 +452,7 @@ def parametrize_zlib_backend( @pytest.fixture -async def make_client_request() -> ( - AsyncIterator[Callable[[str, URL, Unpack[ClientRequestArgs]], ClientRequest]] -): +async def make_client_request() -> AsyncIterator[_RequestMaker]: """Fixture to help creating test ClientRequest objects with defaults.""" requests: list[ClientRequest] = [] sessions: list[ClientSession] = [] diff --git a/tests/test_benchmarks_client_request.py b/tests/test_benchmarks_client_request.py index cbe7ec309b5..cb8e92e1f8b 100644 --- a/tests/test_benchmarks_client_request.py +++ b/tests/test_benchmarks_client_request.py @@ -2,9 +2,8 @@ import asyncio import sys -from collections.abc import Callable from http.cookies import BaseCookie -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Protocol import pytest from multidict import CIMultiDict @@ -25,7 +24,11 @@ if sys.version_info >= (3, 11): from typing import Unpack - _RequestMaker = Callable[[str, URL, Unpack[ClientRequestArgs]], ClientRequest] + class _RequestMaker(Protocol): + def __call__( + self, method: str, url: URL, **kwargs: Unpack[ClientRequestArgs] + ) -> ClientRequest: ... + else: _RequestMaker = Any if TYPE_CHECKING: diff --git a/tests/test_client_request.py b/tests/test_client_request.py index 78c36d26eca..224b7fd570c 100644 --- a/tests/test_client_request.py +++ b/tests/test_client_request.py @@ -5,7 +5,7 @@ import sys from collections.abc import AsyncIterator, Callable, Iterable from http.cookies import BaseCookie, SimpleCookie -from typing import Any +from typing import Any, Protocol from unittest import mock import pytest @@ -35,7 +35,11 @@ if sys.version_info >= (3, 11): from typing import Unpack - _RequestMaker = Callable[[str, URL, Unpack[ClientRequestArgs]], ClientRequest] + class _RequestMaker(Protocol): + def __call__( + self, method: str, url: URL, **kwargs: Unpack[ClientRequestArgs] + ) -> ClientRequest: ... + else: _RequestMaker = Any diff --git a/tests/test_connector.py b/tests/test_connector.py index 2db6bd569c6..f36619a668c 100644 --- a/tests/test_connector.py +++ b/tests/test_connector.py @@ -13,7 +13,7 @@ from collections.abc import AsyncIterator, Awaitable, Callable, Iterator, Sequence from concurrent import futures from contextlib import closing, suppress -from typing import Any, Literal, NoReturn +from typing import Any, Literal, NoReturn, Protocol from unittest import mock import pytest @@ -50,7 +50,11 @@ if sys.version_info >= (3, 11): from typing import Unpack - _RequestMaker = Callable[[str, URL, Unpack[ClientRequestArgs]], ClientRequest] + class _RequestMaker(Protocol): + def __call__( + self, method: str, url: URL, **kwargs: Unpack[ClientRequestArgs] + ) -> ClientRequest: ... + else: _RequestMaker = Any diff --git a/tests/test_proxy.py b/tests/test_proxy.py index 71e25aef48b..39e5ee561ce 100644 --- a/tests/test_proxy.py +++ b/tests/test_proxy.py @@ -2,7 +2,7 @@ import socket import ssl import sys -from typing import Callable +from typing import Protocol from unittest import mock import pytest @@ -25,7 +25,11 @@ if sys.version_info >= (3, 11): from typing import Unpack - _RequestMaker = Callable[[str, URL, Unpack[ClientRequestArgs]], ClientRequest] + class _RequestMaker(Protocol): + def __call__( + self, method: str, url: URL, **kwargs: Unpack[ClientRequestArgs] + ) -> ClientRequest: ... + else: from typing import Any From c25e8dd1afec38d349df61c96ff07f108d854d32 Mon Sep 17 00:00:00 2001 From: Arockia Rajamanickam Date: Wed, 2 Sep 2026 08:39:12 +0530 Subject: [PATCH 6/7] Make `ClientConnectorCertificateError.ssl` mean the same as on the base class (#13583) --- CHANGES/4099.breaking.rst | 4 ++++ CONTRIBUTORS.txt | 1 + aiohttp/client_exceptions.py | 7 ++----- docs/client_reference.rst | 6 ++++++ tests/test_client_exceptions.py | 22 +++++++++++++++++++--- 5 files changed, 32 insertions(+), 8 deletions(-) create mode 100644 CHANGES/4099.breaking.rst diff --git a/CHANGES/4099.breaking.rst b/CHANGES/4099.breaking.rst new file mode 100644 index 00000000000..7fed36e732f --- /dev/null +++ b/CHANGES/4099.breaking.rst @@ -0,0 +1,4 @@ +``ClientConnectorCertificateError.ssl`` now returns the value passed to the +``ssl`` parameter, matching +:attr:`ClientConnectorError.ssl `. +-- by :user:`ArockiaRajamanickam`. diff --git a/CONTRIBUTORS.txt b/CONTRIBUTORS.txt index f655270a3aa..39d50e07da3 100644 --- a/CONTRIBUTORS.txt +++ b/CONTRIBUTORS.txt @@ -54,6 +54,7 @@ Anton Kasyanov Anton Zhdan-Pushkin Arcadiy Ivanov Arie Bovenberg +Arockia Rajamanickam Arseny Timoniq Arsh Smith Arshiya Tabasum diff --git a/aiohttp/client_exceptions.py b/aiohttp/client_exceptions.py index 826533af81a..63dac84261d 100644 --- a/aiohttp/client_exceptions.py +++ b/aiohttp/client_exceptions.py @@ -382,13 +382,10 @@ def host(self) -> str: def port(self) -> int | None: return self._conn_key.port - @property - def ssl(self) -> bool: - return self._conn_key.is_ssl - def __str__(self) -> str: return ( - f"Cannot connect to host {self.host}:{self.port} ssl:{self.ssl} " + f"Cannot connect to host {self.host}:{self.port} " + f"ssl:{'default' if self.ssl is True else self.ssl} " f"[{self.certificate_error.__class__.__name__}: " f"{self.certificate_error.args}]" ) diff --git a/docs/client_reference.rst b/docs/client_reference.rst index 72a68670a5c..76d3a3334d3 100644 --- a/docs/client_reference.rst +++ b/docs/client_reference.rst @@ -2887,6 +2887,12 @@ Connection errors Derived from :exc:`ClientOSError` + .. attribute:: ssl + + The value passed as the ``ssl`` parameter of the request: an + :class:`ssl.SSLContext`, a :class:`bool`, or a + :class:`~aiohttp.Fingerprint`. + .. class:: ClientConnectorDNSError :canonical: aiohttp.client_exceptions.ClientConnectorDNSError diff --git a/tests/test_client_exceptions.py b/tests/test_client_exceptions.py index 164bbf58219..730df58b75e 100644 --- a/tests/test_client_exceptions.py +++ b/tests/test_client_exceptions.py @@ -1,5 +1,6 @@ import errno import pickle +import ssl import sys import pytest @@ -176,7 +177,7 @@ def test_ctor(self) -> None: assert err.certificate_error == certificate_error assert err.host == "example.com" assert err.port == 8080 - assert err.ssl is False + assert err.ssl is True if sys.version_info >= (3, 11): assert_type(err.args, tuple[client_reqrep.ConnectionKey, Exception]) @@ -192,7 +193,7 @@ def test_pickle(self) -> None: assert err2.certificate_error.args == ("Bad certificate",) assert err2.host == "example.com" assert err2.port == 8080 - assert err2.ssl is False + assert err2.ssl is True assert err2.foo == "bar" def test_repr(self) -> None: @@ -211,7 +212,7 @@ def test_str(self) -> None: connection_key=self.connection_key, certificate_error=certificate_error ) assert str(err) == ( - "Cannot connect to host example.com:8080 ssl:False" + "Cannot connect to host example.com:8080 ssl:default" " [Exception: ('Bad certificate',)]" ) @@ -224,6 +225,21 @@ def test_oserror(self) -> None: assert err.errno == 1 assert err.strerror == "Bad certificate" + def test_ssl_is_the_same_as_on_the_base_class(self) -> None: + context = ssl.create_default_context() + connection_key = self.connection_key._replace(is_ssl=True, ssl=context) + certificate_error = Exception("Bad certificate") + + err = client.ClientConnectorCertificateError( + connection_key=connection_key, certificate_error=certificate_error + ) + base_err = client.ClientConnectorError( + connection_key=connection_key, os_error=OSError(1, "Bad certificate") + ) + + assert err.ssl is context + assert err.ssl is base_err.ssl + class TestServerDisconnectedError: def test_ctor(self) -> None: From bffb2f1c14e177f8660226f7b07bc10c3917a607 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 2 Sep 2026 04:10:25 +0100 Subject: [PATCH 7/7] [pre-commit.ci] pre-commit autoupdate (#13605) --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f2279aba030..3962f573c2e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -60,7 +60,7 @@ repos: - flake8-no-implicit-concat==0.3.4 - flake8-requirements==1.7.8 - repo: https://github.com/PyCQA/isort - rev: '9.0.0b5' + rev: '9.0.1' hooks: - id: isort - repo: https://github.com/psf/black-pre-commit-mirror