Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add server capability to check for Brotli compressed static files #8063

Merged
merged 21 commits into from
Feb 14, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
63c1d61
Add server capability to check for Brotli compressed static files
steverep Jan 26, 2024
50fd1bb
Remove use of StrEnum for compat < 3.11
steverep Jan 27, 2024
9a6ddb0
Remove Literal key type for extensions dict
steverep Jan 27, 2024
26815a0
Use typing.Dict for 3.8 compatibility
steverep Jan 27, 2024
b0d58f7
Test with default accept-encoding header
steverep Jan 27, 2024
ecc4ae1
Defer fix for lower() to separate change
steverep Jan 27, 2024
0397a41
Temporarily remove CI fail-fast
steverep Jan 27, 2024
652a848
Revert "Temporarily remove CI fail-fast"
steverep Jan 28, 2024
517c9d7
Assert headers first and remove gzip from test name
steverep Jan 28, 2024
634bf94
Assert response headers and body as tuple for easier debugging
steverep Jan 28, 2024
54979c5
Add Brotli to mimetypes for < 3.9 and use same map as library
steverep Jan 28, 2024
d5fd592
Revert "Test with default accept-encoding header"
steverep Jan 30, 2024
e7de51d
Merge master
steverep Jan 30, 2024
80caa3c
Apply review suggestions and other renames
steverep Jan 30, 2024
361f531
Fix mock tests to use with_suffix
steverep Jan 30, 2024
bf37160
More formatting from review
steverep Jan 31, 2024
c8f2b28
Revert tuples and assert in proper order
steverep Feb 1, 2024
e6ce7d5
Merge branch 'master' into serve-brotli-files
bdraco Feb 2, 2024
b636172
Make extensions map immutable
steverep Feb 5, 2024
48bc773
Parametrize 4th test using hello.txt
steverep Feb 6, 2024
b451966
Merge master for hello_txt fixture and create Brotli version accordingly
steverep Feb 14, 2024
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/8062.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Added server capability to check for static files with Brotli compression via a ``.br`` extension -- by :user:`steverep`.
57 changes: 35 additions & 22 deletions aiohttp/web_fileresponse.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@
import mimetypes
import os
import pathlib
import sys
from contextlib import suppress
from types import MappingProxyType
from typing import (
IO,
TYPE_CHECKING,
Expand Down Expand Up @@ -37,6 +40,14 @@

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

if sys.version_info < (3, 9):
steverep marked this conversation as resolved.
Show resolved Hide resolved
mimetypes.encodings_map[".br"] = "br"

# File extension to IANA encodings map that will be checked in the order defined.
ENCODING_EXTENSIONS = MappingProxyType(
{ext: mimetypes.encodings_map[ext] for ext in (".br", ".gz")}
)


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

def _get_file_path_stat_and_gzip(
self, check_for_gzipped_file: bool
) -> Tuple[pathlib.Path, os.stat_result, bool]:
"""Return the file path, stat result, and gzip status.
def _get_file_path_stat_encoding(
self, accept_encoding: str
bdraco marked this conversation as resolved.
Show resolved Hide resolved
) -> Tuple[pathlib.Path, os.stat_result, Optional[str]]:
steverep marked this conversation as resolved.
Show resolved Hide resolved
"""Return the file path, stat result, and encoding.

If an uncompressed file is returned, the encoding is set to
:py:data:`None`.

This method should be called from a thread executor
since it calls os.stat which may block.
"""
filepath = self._path
if check_for_gzipped_file:
bdraco marked this conversation as resolved.
Show resolved Hide resolved
gzip_path = filepath.with_name(filepath.name + ".gz")
try:
return gzip_path, gzip_path.stat(), True
except OSError:
# Fall through and try the non-gzipped file
pass
file_path = self._path
for file_extension, file_encoding in ENCODING_EXTENSIONS.items():
if file_encoding not in accept_encoding:
continue

compressed_path = file_path.with_suffix(file_path.suffix + file_extension)
with suppress(OSError):
return compressed_path, compressed_path.stat(), file_encoding

return filepath, filepath.stat(), False
# Fallback to the uncompressed file
return file_path, file_path.stat(), None

async def prepare(self, request: "BaseRequest") -> Optional[AbstractStreamWriter]:
loop = asyncio.get_event_loop()
# Encoding comparisons should be case-insensitive
# https://www.rfc-editor.org/rfc/rfc9110#section-8.4.1
check_for_gzipped_file = (
"gzip" in request.headers.get(hdrs.ACCEPT_ENCODING, "").lower()
)
filepath, st, gzip = await loop.run_in_executor(
None, self._get_file_path_stat_and_gzip, check_for_gzipped_file
accept_encoding = request.headers.get(hdrs.ACCEPT_ENCODING, "").lower()
bdraco marked this conversation as resolved.
Show resolved Hide resolved
steverep marked this conversation as resolved.
Show resolved Hide resolved
file_path, st, file_encoding = await loop.run_in_executor(
None, self._get_file_path_stat_encoding, accept_encoding
)

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

ct = None
if hdrs.CONTENT_TYPE not in self.headers:
ct, encoding = mimetypes.guess_type(str(filepath))
ct, encoding = mimetypes.guess_type(str(file_path))
if not ct:
ct = "application/octet-stream"
else:
encoding = "gzip" if gzip else None
encoding = file_encoding

status = self._status
file_size = st.st_size
Expand Down Expand Up @@ -265,7 +278,7 @@ async def prepare(self, request: "BaseRequest") -> Optional[AbstractStreamWriter
self.content_type = ct
if encoding:
self.headers[hdrs.CONTENT_ENCODING] = encoding
if gzip:
if file_encoding:
self.headers[hdrs.VARY] = hdrs.ACCEPT_ENCODING
# Disable compression if we are already sending
# a compressed file since we don't want to double
Expand All @@ -289,7 +302,7 @@ async def prepare(self, request: "BaseRequest") -> Optional[AbstractStreamWriter
if count == 0 or must_be_empty_body(request.method, self.status):
return await super().prepare(request)

fobj = await loop.run_in_executor(None, filepath.open, "rb")
fobj = await loop.run_in_executor(None, file_path.open, "rb")
if start: # be aware that start could be None or int=0 here.
offset = start
else:
Expand Down
1 change: 1 addition & 0 deletions aiohttp/web_response.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
BaseClass = collections.abc.MutableMapping


# TODO(py311): Convert to StrEnum for wider use
class ContentCoding(enum.Enum):
# The content codings that we have support for.
#
Expand Down
5 changes: 3 additions & 2 deletions docs/web_reference.rst
Original file line number Diff line number Diff line change
Expand Up @@ -1755,8 +1755,9 @@ Application and Router
system call even if the platform supports it. This can be accomplished by
by setting environment variable ``AIOHTTP_NOSENDFILE=1``.

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

.. warning::

Expand Down
8 changes: 4 additions & 4 deletions tests/test_web_sendfile.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ def test_using_gzip_if_header_present_and_file_available(loop: Any) -> None:

filepath = mock.create_autospec(Path, spec_set=True)
filepath.name = "logo.png"
filepath.with_name.return_value = gz_filepath
filepath.with_suffix.return_value = gz_filepath

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

filepath = mock.create_autospec(Path, spec_set=True)
filepath.name = "logo.png"
filepath.with_name.return_value = gz_filepath
filepath.with_suffix.return_value = gz_filepath
filepath.stat.return_value.st_size = 1024
filepath.stat.return_value.st_mtime_ns = 1603733507222449291

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

filepath = mock.create_autospec(Path, spec_set=True)
filepath.name = "logo.png"
filepath.with_name.return_value = gz_filepath
filepath.with_suffix.return_value = gz_filepath
filepath.stat.return_value.st_size = 1024
filepath.stat.return_value.st_mtime_ns = 1603733507222449291

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

filepath = mock.create_autospec(Path, spec_set=True)
filepath.name = "logo.png"
filepath.with_name.return_value = gz_filepath
filepath.with_suffix.return_value = gz_filepath
filepath.stat.return_value.st_size = 1024
filepath.stat.return_value.st_mtime_ns = 1603733507222449291

Expand Down
40 changes: 32 additions & 8 deletions tests/test_web_sendfile_functional.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@
import aiohttp
from aiohttp import web

try:
import brotlicffi as brotli
except ImportError:
import brotli

try:
import ssl
except ImportError:
Expand All @@ -28,9 +33,14 @@ def hello_txt(request, tmp_path_factory) -> pathlib.Path:
indirect parameter can be passed with an encoding to get a compressed path.
"""
txt = tmp_path_factory.mktemp("hello-") / "hello.txt"
hello = {None: txt, "gzip": txt.with_suffix(f"{txt.suffix}.gz")}
hello = {
None: txt,
"gzip": txt.with_suffix(f"{txt.suffix}.gz"),
"br": txt.with_suffix(f"{txt.suffix}.br"),
}
hello[None].write_bytes(HELLO_AIOHTTP)
hello["gzip"].write_bytes(gzip.compress(HELLO_AIOHTTP))
hello["br"].write_bytes(brotli.compress(HELLO_AIOHTTP))
encoding = getattr(request, "param", None)
return hello[encoding]

Expand Down Expand Up @@ -216,7 +226,7 @@ async def handler(request):
await client.close()


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


@pytest.mark.parametrize(
("accept_encoding", "expect_encoding"),
[("gzip, deflate", "gzip"), ("gzip, deflate, br", "br")],
)
async def test_static_file_custom_content_type_compress(
hello_txt: pathlib.Path, aiohttp_client: Any, sender: Any
hello_txt: pathlib.Path,
aiohttp_client: Any,
sender: Any,
accept_encoding: str,
expect_encoding: str,
):
"""Test that custom type with encoding is returned for unencoded requests."""

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

resp = await client.get("/")
resp = await client.get("/", headers={"Accept-Encoding": accept_encoding})
steverep marked this conversation as resolved.
Show resolved Hide resolved
assert resp.status == 200
assert resp.headers.get("Content-Encoding") == "gzip"
assert resp.headers.get("Content-Encoding") == expect_encoding
assert resp.headers["Content-Type"] == "application/pdf"
assert await resp.read() == HELLO_AIOHTTP
resp.close()
await resp.release()
await client.close()


@pytest.mark.parametrize(
("accept_encoding", "expect_encoding"),
[("gzip, deflate", "gzip"), ("gzip, deflate, br", "br")],
)
@pytest.mark.parametrize("forced_compression", [None, web.ContentCoding.gzip])
async def test_static_file_with_encoding_and_enable_compression(
hello_txt: pathlib.Path,
aiohttp_client: Any,
sender: Any,
accept_encoding: str,
expect_encoding: str,
forced_compression: Optional[web.ContentCoding],
webknjaz marked this conversation as resolved.
Show resolved Hide resolved
):
"""Test that enable_compression does not double compress when an encoded file is also present."""
Expand All @@ -283,9 +307,9 @@ async def handler(request):
app.router.add_get("/", handler)
client = await aiohttp_client(app)

resp = await client.get("/")
resp = await client.get("/", headers={"Accept-Encoding": accept_encoding})
assert resp.status == 200
assert resp.headers.get("Content-Encoding") == "gzip"
assert resp.headers.get("Content-Encoding") == expect_encoding
assert resp.headers["Content-Type"] == "text/plain"
assert await resp.read() == HELLO_AIOHTTP
resp.close()
Expand All @@ -294,7 +318,7 @@ async def handler(request):


@pytest.mark.parametrize(
("hello_txt", "expect_encoding"), [["gzip"] * 2], indirect=["hello_txt"]
("hello_txt", "expect_encoding"), [["gzip"] * 2, ["br"] * 2], indirect=["hello_txt"]
)
async def test_static_file_with_content_encoding(
hello_txt: pathlib.Path, aiohttp_client: Any, sender: Any, expect_encoding: str
Expand Down
Loading