From fd3174f338a4dedf9db55be210d19454566a7544 Mon Sep 17 00:00:00 2001 From: Dmitry Erlikh Date: Mon, 23 Nov 2020 09:21:44 +0100 Subject: [PATCH] Add client Brotli support (#5227) --- CHANGES/5219.feature | 1 + aiohttp/client_reqrep.py | 7 ++++- docs/glossary.rst | 13 +++++++++ docs/index.rst | 6 +++++ docs/spelling_wordlist.txt | 2 ++ requirements/test.txt | 1 + tests/test_client_request.py | 21 +++++++++++++-- tests/test_web_functional.py | 52 +++++++++++++++--------------------- 8 files changed, 69 insertions(+), 34 deletions(-) create mode 100644 CHANGES/5219.feature diff --git a/CHANGES/5219.feature b/CHANGES/5219.feature new file mode 100644 index 00000000000..1e674586e0b --- /dev/null +++ b/CHANGES/5219.feature @@ -0,0 +1 @@ +Add client brotli compression support (optional with runtime check) diff --git a/aiohttp/client_reqrep.py b/aiohttp/client_reqrep.py index aa5c2de6686..ac8a3c13629 100644 --- a/aiohttp/client_reqrep.py +++ b/aiohttp/client_reqrep.py @@ -49,6 +49,7 @@ set_result, ) from .http import SERVER_SOFTWARE, HttpVersion10, HttpVersion11, StreamWriter +from .http_parser import HAS_BROTLI from .log import client_logger from .streams import StreamReader from .typedefs import ( @@ -81,6 +82,10 @@ from .tracing import Trace +def _gen_default_accept_encoding() -> str: + return "gzip, deflate, br" if HAS_BROTLI else "gzip, deflate" + + @attr.s(auto_attribs=True, frozen=True, slots=True) class ContentDisposition: type: Optional[str] @@ -165,7 +170,7 @@ class ClientRequest: DEFAULT_HEADERS = { hdrs.ACCEPT: "*/*", - hdrs.ACCEPT_ENCODING: "gzip, deflate", + hdrs.ACCEPT_ENCODING: _gen_default_accept_encoding(), } body = b"" diff --git a/docs/glossary.rst b/docs/glossary.rst index bc5e1169c33..c2da11817af 100644 --- a/docs/glossary.rst +++ b/docs/glossary.rst @@ -27,6 +27,19 @@ https://pypi.python.org/pypi/asyncio/ + Brotli + + Brotli is a generic-purpose lossless compression algorithm that + compresses data using a combination of a modern variant + of the LZ77 algorithm, Huffman coding and second order context modeling, + with a compression ratio comparable to the best currently available + general-purpose compression methods. It is similar in speed with deflate + but offers more dense compression. + + The specification of the Brotli Compressed Data Format is defined :rfc:`7932` + + https://pypi.org/project/Brotli/ + callable Any object that can be called. Use :func:`callable` to check diff --git a/docs/index.rst b/docs/index.rst index 3e6554fe029..d287741dea0 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -168,6 +168,12 @@ Dependencies $ pip install aiodns +- *Optional* :term:`Brotli` for brotli (:rfc:`7932`) client compression support. + + .. code-block:: bash + + $ pip install Brotli + Communication channels ====================== diff --git a/docs/spelling_wordlist.txt b/docs/spelling_wordlist.txt index 0fe42e59764..859ca7b10b4 100644 --- a/docs/spelling_wordlist.txt +++ b/docs/spelling_wordlist.txt @@ -5,6 +5,7 @@ Backporting BaseEventLoop BasicAuth BodyPartReader +Brotli Bugfixes BytesIO brotli @@ -111,6 +112,7 @@ backports basename boolean botocore +brotli bugfix builtin cChardet diff --git a/requirements/test.txt b/requirements/test.txt index 3085dd5881f..2804688b764 100644 --- a/requirements/test.txt +++ b/requirements/test.txt @@ -1,5 +1,6 @@ -r base.txt +Brotli==1.0.9 coverage==5.3 cryptography==3.2.1; platform_machine!="i686" and python_version<"3.9" # no 32-bit wheels; no python 3.9 wheels yet freezegun==1.0.0 diff --git a/tests/test_client_request.py b/tests/test_client_request.py index b88bb09ff52..e9f8ca95301 100644 --- a/tests/test_client_request.py +++ b/tests/test_client_request.py @@ -12,7 +12,12 @@ import aiohttp from aiohttp import BaseConnector, hdrs, payload -from aiohttp.client_reqrep import ClientRequest, ClientResponse, Fingerprint +from aiohttp.client_reqrep import ( + ClientRequest, + ClientResponse, + Fingerprint, + _gen_default_accept_encoding, +) from aiohttp.test_utils import make_mocked_coro @@ -304,7 +309,7 @@ def test_headers(make_request) -> None: assert "CONTENT-TYPE" in req.headers assert req.headers["CONTENT-TYPE"] == "text/plain" - assert req.headers["ACCEPT-ENCODING"] == "gzip, deflate" + assert req.headers["ACCEPT-ENCODING"] == "gzip, deflate, br" def test_headers_list(make_request) -> None: @@ -1188,3 +1193,15 @@ def test_loose_cookies_types(loop) -> None: for loose_cookies_type in accepted_types: req.update_cookies(cookies=loose_cookies_type) + + +@pytest.mark.parametrize( + "has_brotli,expected", + [ + (False, "gzip, deflate"), + (True, "gzip, deflate, br"), + ], +) +def test_gen_default_accept_encoding(has_brotli, expected) -> None: + with mock.patch("aiohttp.client_reqrep.HAS_BROTLI", has_brotli): + assert _gen_default_accept_encoding() == expected diff --git a/tests/test_web_functional.py b/tests/test_web_functional.py index 754b5d31b49..aee03af2a84 100644 --- a/tests/test_web_functional.py +++ b/tests/test_web_functional.py @@ -6,6 +6,7 @@ import zlib from unittest import mock +import brotli import pytest from multidict import CIMultiDictProxy, MultiDict from yarl import URL @@ -897,29 +898,22 @@ async def handler(request): assert resp_data == b"test" -async def test_response_with_precompressed_body_gzip(aiohttp_client) -> None: - async def handler(request): - headers = {"Content-Encoding": "gzip"} - zcomp = zlib.compressobj(wbits=16 + zlib.MAX_WBITS) - data = zcomp.compress(b"mydata") + zcomp.flush() - return web.Response(body=data, headers=headers) - - app = web.Application() - app.router.add_get("/", handler) - client = await aiohttp_client(app) - - resp = await client.get("/") - assert 200 == resp.status - data = await resp.read() - assert b"mydata" == data - assert resp.headers.get("Content-Encoding") == "gzip" - - -async def test_response_with_precompressed_body_deflate(aiohttp_client) -> None: +@pytest.mark.parametrize( + "compressor,encoding", + [ + (zlib.compressobj(wbits=16 + zlib.MAX_WBITS), "gzip"), + (zlib.compressobj(wbits=zlib.MAX_WBITS), "deflate"), + # Actually, wrong compression format, but + # should be supported for some legacy cases. + (zlib.compressobj(wbits=-zlib.MAX_WBITS), "deflate"), + ], +) +async def test_response_with_precompressed_body( + aiohttp_client, compressor, encoding +) -> None: async def handler(request): - headers = {"Content-Encoding": "deflate"} - zcomp = zlib.compressobj(wbits=zlib.MAX_WBITS) - data = zcomp.compress(b"mydata") + zcomp.flush() + headers = {"Content-Encoding": encoding} + data = compressor.compress(b"mydata") + compressor.flush() return web.Response(body=data, headers=headers) app = web.Application() @@ -930,17 +924,13 @@ async def handler(request): assert 200 == resp.status data = await resp.read() assert b"mydata" == data - assert resp.headers.get("Content-Encoding") == "deflate" + assert resp.headers.get("Content-Encoding") == encoding -async def test_response_with_precompressed_body_deflate_no_hdrs(aiohttp_client) -> None: +async def test_response_with_precompressed_body_brotli(aiohttp_client) -> None: async def handler(request): - headers = {"Content-Encoding": "deflate"} - # Actually, wrong compression format, but - # should be supported for some legacy cases. - zcomp = zlib.compressobj(wbits=-zlib.MAX_WBITS) - data = zcomp.compress(b"mydata") + zcomp.flush() - return web.Response(body=data, headers=headers) + headers = {"Content-Encoding": "br"} + return web.Response(body=brotli.compress(b"mydata"), headers=headers) app = web.Application() app.router.add_get("/", handler) @@ -950,7 +940,7 @@ async def handler(request): assert 200 == resp.status data = await resp.read() assert b"mydata" == data - assert resp.headers.get("Content-Encoding") == "deflate" + assert resp.headers.get("Content-Encoding") == "br" async def test_bad_request_payload(aiohttp_client) -> None: