From 89973955afa627c995785677c207a83243bcf708 Mon Sep 17 00:00:00 2001 From: pctablet505 Date: Wed, 15 Jul 2026 16:47:26 +0000 Subject: [PATCH] Add optional charset parameter to web.FileResponse Allows callers to set an explicit charset on the Content-Type header for text-like MIME types (e.g. text/plain, text/html). Fixes #4559 --- CHANGES/4559.feature.rst | 1 + aiohttp/web_fileresponse.py | 5 +++ docs/web_reference.rst | 5 ++- tests/test_web_sendfile_functional.py | 55 +++++++++++++++++++++++++++ 4 files changed, 65 insertions(+), 1 deletion(-) 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..02e04efcf6b --- /dev/null +++ b/CHANGES/4559.feature.rst @@ -0,0 +1 @@ +Added an optional ``charset`` parameter to :class:`~aiohttp.web.FileResponse` so callers can set an explicit charset on the response content-type for text-like MIME types. diff --git a/aiohttp/web_fileresponse.py b/aiohttp/web_fileresponse.py index b09bfe109d4..3c227d9794a 100644 --- a/aiohttp/web_fileresponse.py +++ b/aiohttp/web_fileresponse.py @@ -91,11 +91,13 @@ def __init__( status: int = 200, reason: str | None = None, headers: LooseHeaders | None = None, + charset: str | None = None, ) -> None: super().__init__(status=status, reason=reason, headers=headers) self._path = pathlib.Path(path) self._chunk_size = chunk_size + self._charset = charset def _seek_and_read(self, fobj: BinaryIO, offset: int, chunk_size: int) -> bytes: fobj.seek(offset) @@ -383,6 +385,9 @@ async def _prepare_open_file( guesser = CONTENT_TYPES.guess_type self.content_type = guesser(self._path)[0] or FALLBACK_CONTENT_TYPE + if self._charset is not None and self.content_type.startswith("text/"): + self.charset = self._charset + if file_encoding: self._headers[hdrs.CONTENT_ENCODING] = file_encoding self._headers[hdrs.VARY] = hdrs.ACCEPT_ENCODING diff --git a/docs/web_reference.rst b/docs/web_reference.rst index 6b6d6aa06c3..aafbb5c51b4 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, charset=None) :canonical: aiohttp.web_fileresponse.FileResponse The response class used to send files, inherited from :class:`StreamResponse`. @@ -966,6 +966,9 @@ and :ref:`aiohttp-web-signals` handlers:: response's ones. The ``Content-Type`` response header will be overridden if provided. + :param str charset: Charset to append to the ``Content-Type`` header for + text-like MIME types (e.g. ``text/plain``). + .. class:: WebSocketResponse(*, timeout=10.0, receive_timeout=None, \ autoclose=True, autoping=True, heartbeat=None, \ diff --git a/tests/test_web_sendfile_functional.py b/tests/test_web_sendfile_functional.py index 93d505720c7..8a5c25b4795 100644 --- a/tests/test_web_sendfile_functional.py +++ b/tests/test_web_sendfile_functional.py @@ -261,6 +261,61 @@ async def handler(request: web.Request) -> web.FileResponse: await client.close() +@pytest.mark.parametrize( + ("filename", "content", "expected_type"), + [ + ("hello.txt", b"Hello", "text/plain"), + ("hello.html", b"

Hi

", "text/html"), + ], +) +async def test_static_file_charset( + aiohttp_client: AiohttpClient, + tmp_path: pathlib.Path, + filename: str, + content: bytes, + expected_type: str, +) -> None: + """Test that charset is appended to the content-type for text-like files.""" + file_path = tmp_path / filename + file_path.write_bytes(content) + + async def handler(request: web.Request) -> web.FileResponse: + return web.FileResponse(file_path, charset="utf-8") + + app = web.Application() + app.router.add_get("/", handler) + client = await aiohttp_client(app) + + resp = await client.get("/") + assert resp.status == 200 + assert resp.headers["Content-Type"] == f"{expected_type}; charset=utf-8" + assert await resp.read() == content + resp.close() + resp.release() + await client.close() + + +async def test_static_file_charset_ignored_for_non_text( + aiohttp_client: AiohttpClient, tmp_path: pathlib.Path +) -> None: + """Test that charset is not appended to non-text content types.""" + file_path = tmp_path / "data.bin" + file_path.write_bytes(b"\x00\x01\x02") + + async def handler(request: web.Request) -> web.FileResponse: + return web.FileResponse(file_path, charset="utf-8") + + app = web.Application() + app.router.add_get("/", handler) + client = await aiohttp_client(app) + + resp = await client.get("/") + assert resp.status == 200 + assert resp.headers["Content-Type"] == "application/octet-stream" + resp.release() + await client.close() + + @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