Skip to content

Commit dfc9296

Browse files
steverepbdracowebknjaz
authored
Add server capability to check for Brotli compressed static files (#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 #8062 Co-authored-by: J. Nick Koston <[email protected]> Co-authored-by: Sviatoslav Sydorenko <[email protected]>
1 parent 026e8d9 commit dfc9296

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 (
69
IO,
710
TYPE_CHECKING,
@@ -37,6 +40,14 @@
3740

3841
NOSENDFILE: Final[bool] = bool(os.environ.get("AIOHTTP_NOSENDFILE"))
3942

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

4152
class FileResponse(StreamResponse):
4253
"""A response object can be used to send files."""
@@ -121,34 +132,36 @@ async def _precondition_failed(
121132
self.content_length = 0
122133
return await super().prepare(request)
123134

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

141-
return filepath, filepath.stat(), False
155+
# Fallback to the uncompressed file
156+
return file_path, file_path.stat(), None
142157

143158
async def prepare(self, request: "BaseRequest") -> Optional[AbstractStreamWriter]:
144159
loop = asyncio.get_event_loop()
145160
# Encoding comparisons should be case-insensitive
146161
# https://www.rfc-editor.org/rfc/rfc9110#section-8.4.1
147-
check_for_gzipped_file = (
148-
"gzip" in request.headers.get(hdrs.ACCEPT_ENCODING, "").lower()
149-
)
150-
filepath, st, gzip = await loop.run_in_executor(
151-
None, self._get_file_path_stat_and_gzip, check_for_gzipped_file
162+
accept_encoding = request.headers.get(hdrs.ACCEPT_ENCODING, "").lower()
163+
file_path, st, file_encoding = await loop.run_in_executor(
164+
None, self._get_file_path_stat_encoding, accept_encoding
152165
)
153166

154167
etag_value = f"{st.st_mtime_ns:x}-{st.st_size:x}"
@@ -181,11 +194,11 @@ async def prepare(self, request: "BaseRequest") -> Optional[AbstractStreamWriter
181194

182195
ct = None
183196
if hdrs.CONTENT_TYPE not in self.headers:
184-
ct, encoding = mimetypes.guess_type(str(filepath))
197+
ct, encoding = mimetypes.guess_type(str(file_path))
185198
if not ct:
186199
ct = "application/octet-stream"
187200
else:
188-
encoding = "gzip" if gzip else None
201+
encoding = file_encoding
189202

190203
status = self._status
191204
file_size = st.st_size
@@ -265,7 +278,7 @@ async def prepare(self, request: "BaseRequest") -> Optional[AbstractStreamWriter
265278
self.content_type = ct
266279
if encoding:
267280
self.headers[hdrs.CONTENT_ENCODING] = encoding
268-
if gzip:
281+
if file_encoding:
269282
self.headers[hdrs.VARY] = hdrs.ACCEPT_ENCODING
270283
# Disable compression if we are already sending
271284
# a compressed file since we don't want to double
@@ -289,7 +302,7 @@ async def prepare(self, request: "BaseRequest") -> Optional[AbstractStreamWriter
289302
if count == 0 or must_be_empty_body(request.method, self.status):
290303
return await super().prepare(request)
291304

292-
fobj = await loop.run_in_executor(None, filepath.open, "rb")
305+
fobj = await loop.run_in_executor(None, file_path.open, "rb")
293306
if start: # be aware that start could be None or int=0 here.
294307
offset = start
295308
else:

aiohttp/web_response.py

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

5555

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

docs/web_reference.rst

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

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

17611762
.. warning::
17621763

tests/test_web_sendfile.py

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

2222
filepath = mock.create_autospec(Path, spec_set=True)
2323
filepath.name = "logo.png"
24-
filepath.with_name.return_value = gz_filepath
24+
filepath.with_suffix.return_value = gz_filepath
2525

2626
file_sender = FileResponse(filepath)
2727
file_sender._path = filepath
@@ -42,7 +42,7 @@ def test_gzip_if_header_not_present_and_file_available(loop: Any) -> None:
4242

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

@@ -64,7 +64,7 @@ def test_gzip_if_header_not_present_and_file_not_available(loop: Any) -> None:
6464

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

@@ -88,7 +88,7 @@ def test_gzip_if_header_present_and_file_not_available(loop: Any) -> None:
8888

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

tests/test_web_sendfile_functional.py

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

14+
try:
15+
import brotlicffi as brotli
16+
except ImportError:
17+
import brotli
18+
1419
try:
1520
import ssl
1621
except ImportError:
@@ -28,9 +33,14 @@ def hello_txt(request, tmp_path_factory) -> pathlib.Path:
2833
indirect parameter can be passed with an encoding to get a compressed path.
2934
"""
3035
txt = tmp_path_factory.mktemp("hello-") / "hello.txt"
31-
hello = {None: txt, "gzip": txt.with_suffix(f"{txt.suffix}.gz")}
36+
hello = {
37+
None: txt,
38+
"gzip": txt.with_suffix(f"{txt.suffix}.gz"),
39+
"br": txt.with_suffix(f"{txt.suffix}.br"),
40+
}
3241
hello[None].write_bytes(HELLO_AIOHTTP)
3342
hello["gzip"].write_bytes(gzip.compress(HELLO_AIOHTTP))
43+
hello["br"].write_bytes(brotli.compress(HELLO_AIOHTTP))
3444
encoding = getattr(request, "param", None)
3545
return hello[encoding]
3646

@@ -216,7 +226,7 @@ async def handler(request):
216226
await client.close()
217227

218228

219-
@pytest.mark.parametrize("hello_txt", ["gzip"], indirect=True)
229+
@pytest.mark.parametrize("hello_txt", ["gzip", "br"], indirect=True)
220230
async def test_static_file_custom_content_type(
221231
hello_txt: pathlib.Path, aiohttp_client: Any, sender: Any
222232
) -> None:
@@ -241,8 +251,16 @@ async def handler(request):
241251
await client.close()
242252

243253

254+
@pytest.mark.parametrize(
255+
("accept_encoding", "expect_encoding"),
256+
[("gzip, deflate", "gzip"), ("gzip, deflate, br", "br")],
257+
)
244258
async def test_static_file_custom_content_type_compress(
245-
hello_txt: pathlib.Path, aiohttp_client: Any, sender: Any
259+
hello_txt: pathlib.Path,
260+
aiohttp_client: Any,
261+
sender: Any,
262+
accept_encoding: str,
263+
expect_encoding: str,
246264
):
247265
"""Test that custom type with encoding is returned for unencoded requests."""
248266

@@ -255,21 +273,27 @@ async def handler(request):
255273
app.router.add_get("/", handler)
256274
client = await aiohttp_client(app)
257275

258-
resp = await client.get("/")
276+
resp = await client.get("/", headers={"Accept-Encoding": accept_encoding})
259277
assert resp.status == 200
260-
assert resp.headers.get("Content-Encoding") == "gzip"
278+
assert resp.headers.get("Content-Encoding") == expect_encoding
261279
assert resp.headers["Content-Type"] == "application/pdf"
262280
assert await resp.read() == HELLO_AIOHTTP
263281
resp.close()
264282
await resp.release()
265283
await client.close()
266284

267285

286+
@pytest.mark.parametrize(
287+
("accept_encoding", "expect_encoding"),
288+
[("gzip, deflate", "gzip"), ("gzip, deflate, br", "br")],
289+
)
268290
@pytest.mark.parametrize("forced_compression", [None, web.ContentCoding.gzip])
269291
async def test_static_file_with_encoding_and_enable_compression(
270292
hello_txt: pathlib.Path,
271293
aiohttp_client: Any,
272294
sender: Any,
295+
accept_encoding: str,
296+
expect_encoding: str,
273297
forced_compression: Optional[web.ContentCoding],
274298
):
275299
"""Test that enable_compression does not double compress when an encoded file is also present."""
@@ -283,9 +307,9 @@ async def handler(request):
283307
app.router.add_get("/", handler)
284308
client = await aiohttp_client(app)
285309

286-
resp = await client.get("/")
310+
resp = await client.get("/", headers={"Accept-Encoding": accept_encoding})
287311
assert resp.status == 200
288-
assert resp.headers.get("Content-Encoding") == "gzip"
312+
assert resp.headers.get("Content-Encoding") == expect_encoding
289313
assert resp.headers["Content-Type"] == "text/plain"
290314
assert await resp.read() == HELLO_AIOHTTP
291315
resp.close()
@@ -294,7 +318,7 @@ async def handler(request):
294318

295319

296320
@pytest.mark.parametrize(
297-
("hello_txt", "expect_encoding"), [["gzip"] * 2], indirect=["hello_txt"]
321+
("hello_txt", "expect_encoding"), [["gzip"] * 2, ["br"] * 2], indirect=["hello_txt"]
298322
)
299323
async def test_static_file_with_content_encoding(
300324
hello_txt: pathlib.Path, aiohttp_client: Any, sender: Any, expect_encoding: str

0 commit comments

Comments
 (0)