Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGES/4559.feature.rst
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions aiohttp/web_fileresponse.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,11 +91,13 @@ def __init__(
status: int = 200,
reason: str | None = None,
headers: LooseHeaders | None = None,
charset: str | None = None,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Complete public API metadata

Adding charset to the public FileResponse constructor requires the corresponding THREAT_MODEL.md update, while the feature fragment also needs the repository-required -- by :user: attribution; leaving both out makes the security documentation and release metadata incomplete for this API.

Context Used: AGENTS.md (source)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

) -> 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)
Expand Down Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion docs/web_reference.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand All @@ -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, \
Expand Down
55 changes: 55 additions & 0 deletions tests/test_web_sendfile_functional.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"<h1>Hi</h1>", "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
Expand Down
Loading