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 }}" 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 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/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/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/CONTRIBUTORS.txt b/CONTRIBUTORS.txt index 5e8f0e74486..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 @@ -262,6 +263,7 @@ Manny7717 Manuel Miranda Marat Sharafutdinov Marc Mueller +Marco Gorelli Marco Paolini Marcus Campbell Marcus Stojcevich 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/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/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/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/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/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 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_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: 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_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 bca03287f55..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 @@ -4015,20 +4019,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 +4046,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 +4056,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 +4090,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 +4098,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 +4146,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 +4154,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() 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 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