Skip to content

Commit f70f588

Browse files
steverepbdracowebknjaz
committed
[3.10] Add server capability to check for Brotli compressed static files (aio-libs#8063)
Currently server only checks if static routes have a `.gz` extension and serves them with `gzip` encoding. These changes do the same for `.br` files with `br` encoding. Brotli is prioritized over gzip if both exist and are supported by the client, as it should almost always be a smaller content length. I considered making a check for which is smaller if both exist, but figured it wouldn't be worth the extra file system call in the vast majority of cases (at least not for typical web formats). Users should simply use gzip if it's smaller than Brotli for any file. Resolves aio-libs#8062 (cherry picked from commit dfc9296) Co-authored-by: Steve Repsher <[email protected]> Co-authored-by: J. Nick Koston <[email protected]> Co-authored-by: Sviatoslav Sydorenko <[email protected]>
1 parent cda4a8b commit f70f588

File tree

6 files changed

+76
-36
lines changed

6 files changed

+76
-36
lines changed

CHANGES/8062.feature.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Added server capability to check for static files with Brotli compression via a ``.br`` extension -- by :user:`steverep`.

aiohttp/web_fileresponse.py

Lines changed: 35 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22
import mimetypes
33
import os
44
import pathlib
5+
import sys
6+
from contextlib import suppress
7+
from types import MappingProxyType
58
from typing import ( # noqa
69
IO,
710
TYPE_CHECKING,
@@ -40,6 +43,14 @@
4043

4144
NOSENDFILE: Final[bool] = bool(os.environ.get("AIOHTTP_NOSENDFILE"))
4245

46+
if sys.version_info < (3, 9):
47+
mimetypes.encodings_map[".br"] = "br"
48+
49+
# File extension to IANA encodings map that will be checked in the order defined.
50+
ENCODING_EXTENSIONS = MappingProxyType(
51+
{ext: mimetypes.encodings_map[ext] for ext in (".br", ".gz")}
52+
)
53+
4354

4455
class FileResponse(StreamResponse):
4556
"""A response object can be used to send files."""
@@ -124,34 +135,36 @@ async def _precondition_failed(
124135
self.content_length = 0
125136
return await super().prepare(request)
126137

127-
def _get_file_path_stat_and_gzip(
128-
self, check_for_gzipped_file: bool
129-
) -> Tuple[pathlib.Path, os.stat_result, bool]:
130-
"""Return the file path, stat result, and gzip status.
138+
def _get_file_path_stat_encoding(
139+
self, accept_encoding: str
140+
) -> Tuple[pathlib.Path, os.stat_result, Optional[str]]:
141+
"""Return the file path, stat result, and encoding.
142+
143+
If an uncompressed file is returned, the encoding is set to
144+
:py:data:`None`.
131145
132146
This method should be called from a thread executor
133147
since it calls os.stat which may block.
134148
"""
135-
filepath = self._path
136-
if check_for_gzipped_file:
137-
gzip_path = filepath.with_name(filepath.name + ".gz")
138-
try:
139-
return gzip_path, gzip_path.stat(), True
140-
except OSError:
141-
# Fall through and try the non-gzipped file
142-
pass
149+
file_path = self._path
150+
for file_extension, file_encoding in ENCODING_EXTENSIONS.items():
151+
if file_encoding not in accept_encoding:
152+
continue
153+
154+
compressed_path = file_path.with_suffix(file_path.suffix + file_extension)
155+
with suppress(OSError):
156+
return compressed_path, compressed_path.stat(), file_encoding
143157

144-
return filepath, filepath.stat(), False
158+
# Fallback to the uncompressed file
159+
return file_path, file_path.stat(), None
145160

146161
async def prepare(self, request: "BaseRequest") -> Optional[AbstractStreamWriter]:
147162
loop = asyncio.get_event_loop()
148163
# Encoding comparisons should be case-insensitive
149164
# https://www.rfc-editor.org/rfc/rfc9110#section-8.4.1
150-
check_for_gzipped_file = (
151-
"gzip" in request.headers.get(hdrs.ACCEPT_ENCODING, "").lower()
152-
)
153-
filepath, st, gzip = await loop.run_in_executor(
154-
None, self._get_file_path_stat_and_gzip, check_for_gzipped_file
165+
accept_encoding = request.headers.get(hdrs.ACCEPT_ENCODING, "").lower()
166+
file_path, st, file_encoding = await loop.run_in_executor(
167+
None, self._get_file_path_stat_encoding, accept_encoding
155168
)
156169

157170
etag_value = f"{st.st_mtime_ns:x}-{st.st_size:x}"
@@ -183,12 +196,12 @@ async def prepare(self, request: "BaseRequest") -> Optional[AbstractStreamWriter
183196
return await self._not_modified(request, etag_value, last_modified)
184197

185198
if hdrs.CONTENT_TYPE not in self.headers:
186-
ct, encoding = mimetypes.guess_type(str(filepath))
199+
ct, encoding = mimetypes.guess_type(str(file_path))
187200
if not ct:
188201
ct = "application/octet-stream"
189202
should_set_ct = True
190203
else:
191-
encoding = "gzip" if gzip else None
204+
encoding = file_encoding
192205
should_set_ct = False
193206

194207
status = self._status
@@ -269,7 +282,7 @@ async def prepare(self, request: "BaseRequest") -> Optional[AbstractStreamWriter
269282
self.content_type = ct # type: ignore[assignment]
270283
if encoding:
271284
self.headers[hdrs.CONTENT_ENCODING] = encoding
272-
if gzip:
285+
if file_encoding:
273286
self.headers[hdrs.VARY] = hdrs.ACCEPT_ENCODING
274287
# Disable compression if we are already sending
275288
# a compressed file since we don't want to double
@@ -293,7 +306,7 @@ async def prepare(self, request: "BaseRequest") -> Optional[AbstractStreamWriter
293306
if count == 0 or must_be_empty_body(request.method, self.status):
294307
return await super().prepare(request)
295308

296-
fobj = await loop.run_in_executor(None, filepath.open, "rb")
309+
fobj = await loop.run_in_executor(None, file_path.open, "rb")
297310
if start: # be aware that start could be None or int=0 here.
298311
offset = start
299312
else:

aiohttp/web_response.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@
5252
BaseClass = collections.abc.MutableMapping
5353

5454

55+
# TODO(py311): Convert to StrEnum for wider use
5556
class ContentCoding(enum.Enum):
5657
# The content codings that we have support for.
5758
#

docs/web_reference.rst

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1846,8 +1846,9 @@ Application and Router
18461846
system call even if the platform supports it. This can be accomplished by
18471847
by setting environment variable ``AIOHTTP_NOSENDFILE=1``.
18481848

1849-
If a gzip version of the static content exists at file path + ``.gz``, it
1850-
will be used for the response.
1849+
If a Brotli or gzip compressed version of the static content exists at
1850+
the requested path with the ``.br`` or ``.gz`` extension, it will be used
1851+
for the response. Brotli will be preferred over gzip if both files exist.
18511852

18521853
.. warning::
18531854

tests/test_web_sendfile.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ def test_using_gzip_if_header_present_and_file_available(loop) -> None:
2020

2121
filepath = mock.create_autospec(Path, spec_set=True)
2222
filepath.name = "logo.png"
23-
filepath.with_name.return_value = gz_filepath
23+
filepath.with_suffix.return_value = gz_filepath
2424

2525
file_sender = FileResponse(filepath)
2626
file_sender._path = filepath
@@ -41,7 +41,7 @@ def test_gzip_if_header_not_present_and_file_available(loop) -> None:
4141

4242
filepath = mock.create_autospec(Path, spec_set=True)
4343
filepath.name = "logo.png"
44-
filepath.with_name.return_value = gz_filepath
44+
filepath.with_suffix.return_value = gz_filepath
4545
filepath.stat.return_value.st_size = 1024
4646
filepath.stat.return_value.st_mtime_ns = 1603733507222449291
4747

@@ -63,7 +63,7 @@ def test_gzip_if_header_not_present_and_file_not_available(loop) -> None:
6363

6464
filepath = mock.create_autospec(Path, spec_set=True)
6565
filepath.name = "logo.png"
66-
filepath.with_name.return_value = gz_filepath
66+
filepath.with_suffix.return_value = gz_filepath
6767
filepath.stat.return_value.st_size = 1024
6868
filepath.stat.return_value.st_mtime_ns = 1603733507222449291
6969

@@ -87,7 +87,7 @@ def test_gzip_if_header_present_and_file_not_available(loop) -> None:
8787

8888
filepath = mock.create_autospec(Path, spec_set=True)
8989
filepath.name = "logo.png"
90-
filepath.with_name.return_value = gz_filepath
90+
filepath.with_suffix.return_value = gz_filepath
9191
filepath.stat.return_value.st_size = 1024
9292
filepath.stat.return_value.st_mtime_ns = 1603733507222449291
9393

tests/test_web_sendfile_functional.py

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,11 @@
1010
import aiohttp
1111
from aiohttp import web
1212

13+
try:
14+
import brotlicffi as brotli
15+
except ImportError:
16+
import brotli
17+
1318
try:
1419
import ssl
1520
except ImportError:
@@ -27,9 +32,14 @@ def hello_txt(request, tmp_path_factory) -> pathlib.Path:
2732
indirect parameter can be passed with an encoding to get a compressed path.
2833
"""
2934
txt = tmp_path_factory.mktemp("hello-") / "hello.txt"
30-
hello = {None: txt, "gzip": txt.with_suffix(f"{txt.suffix}.gz")}
35+
hello = {
36+
None: txt,
37+
"gzip": txt.with_suffix(f"{txt.suffix}.gz"),
38+
"br": txt.with_suffix(f"{txt.suffix}.br"),
39+
}
3140
hello[None].write_bytes(HELLO_AIOHTTP)
3241
hello["gzip"].write_bytes(gzip.compress(HELLO_AIOHTTP))
42+
hello["br"].write_bytes(brotli.compress(HELLO_AIOHTTP))
3343
encoding = getattr(request, "param", None)
3444
return hello[encoding]
3545

@@ -220,7 +230,7 @@ async def handler(request):
220230
await client.close()
221231

222232

223-
@pytest.mark.parametrize("hello_txt", ["gzip"], indirect=True)
233+
@pytest.mark.parametrize("hello_txt", ["gzip", "br"], indirect=True)
224234
async def test_static_file_custom_content_type(
225235
hello_txt: pathlib.Path, aiohttp_client: Any, sender: Any
226236
) -> None:
@@ -245,8 +255,16 @@ async def handler(request):
245255
await client.close()
246256

247257

258+
@pytest.mark.parametrize(
259+
("accept_encoding", "expect_encoding"),
260+
[("gzip, deflate", "gzip"), ("gzip, deflate, br", "br")],
261+
)
248262
async def test_static_file_custom_content_type_compress(
249-
hello_txt: pathlib.Path, aiohttp_client: Any, sender: Any
263+
hello_txt: pathlib.Path,
264+
aiohttp_client: Any,
265+
sender: Any,
266+
accept_encoding: str,
267+
expect_encoding: str,
250268
):
251269
"""Test that custom type with encoding is returned for unencoded requests."""
252270

@@ -259,21 +277,27 @@ async def handler(request):
259277
app.router.add_get("/", handler)
260278
client = await aiohttp_client(app)
261279

262-
resp = await client.get("/")
280+
resp = await client.get("/", headers={"Accept-Encoding": accept_encoding})
263281
assert resp.status == 200
264-
assert resp.headers.get("Content-Encoding") == "gzip"
282+
assert resp.headers.get("Content-Encoding") == expect_encoding
265283
assert resp.headers["Content-Type"] == "application/pdf"
266284
assert await resp.read() == HELLO_AIOHTTP
267285
resp.close()
268286
await resp.release()
269287
await client.close()
270288

271289

290+
@pytest.mark.parametrize(
291+
("accept_encoding", "expect_encoding"),
292+
[("gzip, deflate", "gzip"), ("gzip, deflate, br", "br")],
293+
)
272294
@pytest.mark.parametrize("forced_compression", [None, web.ContentCoding.gzip])
273295
async def test_static_file_with_encoding_and_enable_compression(
274296
hello_txt: pathlib.Path,
275297
aiohttp_client: Any,
276298
sender: Any,
299+
accept_encoding: str,
300+
expect_encoding: str,
277301
forced_compression: Optional[web.ContentCoding],
278302
):
279303
"""Test that enable_compression does not double compress when an encoded file is also present."""
@@ -287,9 +311,9 @@ async def handler(request):
287311
app.router.add_get("/", handler)
288312
client = await aiohttp_client(app)
289313

290-
resp = await client.get("/")
314+
resp = await client.get("/", headers={"Accept-Encoding": accept_encoding})
291315
assert resp.status == 200
292-
assert resp.headers.get("Content-Encoding") == "gzip"
316+
assert resp.headers.get("Content-Encoding") == expect_encoding
293317
assert resp.headers["Content-Type"] == "text/plain"
294318
assert await resp.read() == HELLO_AIOHTTP
295319
resp.close()
@@ -298,7 +322,7 @@ async def handler(request):
298322

299323

300324
@pytest.mark.parametrize(
301-
("hello_txt", "expect_encoding"), [["gzip"] * 2], indirect=["hello_txt"]
325+
("hello_txt", "expect_encoding"), [["gzip"] * 2, ["br"] * 2], indirect=["hello_txt"]
302326
)
303327
async def test_static_file_with_content_encoding(
304328
hello_txt: pathlib.Path, aiohttp_client: Any, sender: Any, expect_encoding: str

0 commit comments

Comments
 (0)