Skip to content

Commit b0d1539

Browse files
author
kge
committed
Add support for Zstandard compression
1 parent 8795da3 commit b0d1539

12 files changed

+104
-12
lines changed

CHANGES/11161.feature.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Add support for Zstandard (aka Zstd) compression
2+
-- by :user:`KGuillaume-chaps`.

CONTRIBUTORS.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,7 @@ Justin Foo
218218
Justin Turner Arthur
219219
Kay Zheng
220220
Kevin Samuel
221+
Kilian Guillaume
221222
Kimmo Parviainen-Jalanko
222223
Kirill Klenov
223224
Kirill Malovitsa

aiohttp/_http_parser.pyx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -437,7 +437,7 @@ cdef class HttpParser:
437437
if enc is not None:
438438
self._content_encoding = None
439439
enc = enc.lower()
440-
if enc in ('gzip', 'deflate', 'br'):
440+
if enc in ('gzip', 'deflate', 'br', 'zstd'):
441441
encoding = enc
442442

443443
if self._cparser.type == cparser.HTTP_REQUEST:

aiohttp/client_reqrep.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@
4040
InvalidURL,
4141
ServerFingerprintMismatch,
4242
)
43-
from .compression_utils import HAS_BROTLI
43+
from .compression_utils import HAS_BROTLI, HAS_ZSTD
4444
from .formdata import FormData
4545
from .hdrs import CONTENT_TYPE
4646
from .helpers import (
@@ -101,7 +101,15 @@
101101

102102

103103
def _gen_default_accept_encoding() -> str:
104-
return "gzip, deflate, br" if HAS_BROTLI else "gzip, deflate"
104+
encodings = [
105+
"gzip",
106+
"deflate",
107+
]
108+
if HAS_BROTLI:
109+
encodings.append("br")
110+
if HAS_ZSTD:
111+
encodings.append("zstd")
112+
return ", ".join(encodings)
105113

106114

107115
@frozen_dataclass_decorator

aiohttp/compression_utils.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,13 @@
2121
except ImportError:
2222
HAS_BROTLI = False
2323

24+
try:
25+
import zstandard
26+
27+
HAS_ZSTD = True
28+
except ImportError:
29+
HAS_ZSTD = False
30+
2431
MAX_SYNC_CHUNK_SIZE = 1024
2532

2633

@@ -276,3 +283,19 @@ def flush(self) -> bytes:
276283
if hasattr(self._obj, "flush"):
277284
return cast(bytes, self._obj.flush())
278285
return b""
286+
287+
288+
class ZSTDDecompressor:
289+
def __init__(self) -> None:
290+
if not HAS_ZSTD:
291+
raise RuntimeError(
292+
"The zstd decompression is not available. "
293+
"Please install `zstandard` module"
294+
)
295+
self._obj = zstandard.ZstdDecompressor()
296+
297+
def decompress_sync(self, data: bytes) -> bytes:
298+
return cast(bytes, self._obj.decompress(data))
299+
300+
def flush(self) -> bytes:
301+
return b""

aiohttp/http_parser.py

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,13 @@
2626

2727
from . import hdrs
2828
from .base_protocol import BaseProtocol
29-
from .compression_utils import HAS_BROTLI, BrotliDecompressor, ZLibDecompressor
29+
from .compression_utils import (
30+
HAS_BROTLI,
31+
HAS_ZSTD,
32+
BrotliDecompressor,
33+
ZLibDecompressor,
34+
ZSTDDecompressor,
35+
)
3036
from .helpers import (
3137
_EXC_SENTINEL,
3238
DEBUG,
@@ -527,7 +533,7 @@ def parse_headers(
527533
enc = headers.get(hdrs.CONTENT_ENCODING)
528534
if enc:
529535
enc = enc.lower()
530-
if enc in ("gzip", "deflate", "br"):
536+
if enc in ("gzip", "deflate", "br", "zstd"):
531537
encoding = enc
532538

533539
# chunking
@@ -930,14 +936,21 @@ def __init__(self, out: StreamReader, encoding: Optional[str]) -> None:
930936
self.encoding = encoding
931937
self._started_decoding = False
932938

933-
self.decompressor: Union[BrotliDecompressor, ZLibDecompressor]
939+
self.decompressor: Union[BrotliDecompressor, ZLibDecompressor, ZSTDDecompressor]
934940
if encoding == "br":
935941
if not HAS_BROTLI:
936942
raise ContentEncodingError(
937943
"Can not decode content-encoding: brotli (br). "
938944
"Please install `Brotli`"
939945
)
940946
self.decompressor = BrotliDecompressor()
947+
elif encoding == "zstd":
948+
if not HAS_ZSTD:
949+
raise ContentEncodingError(
950+
"Can not decode content-encoding: zstandard (zstd). "
951+
"Please install `zstandard`"
952+
)
953+
self.decompressor = ZSTDDecompressor()
941954
else:
942955
self.decompressor = ZLibDecompressor(encoding=encoding)
943956

docs/client_quickstart.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,9 @@ You can enable ``brotli`` transfer-encodings support,
190190
just install `Brotli <https://pypi.org/project/Brotli/>`_
191191
or `brotlicffi <https://pypi.org/project/brotlicffi/>`_.
192192

193+
You can enable ``zstd`` transfer-encodings support,
194+
install `zstandard <https://pypi.org/project/zstandard/>`_.
195+
193196
JSON Request
194197
============
195198

docs/spelling_wordlist.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -381,3 +381,5 @@ www
381381
xxx
382382
yarl
383383
zlib
384+
zstandard
385+
zstd

requirements/runtime-deps.in

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,4 @@ frozenlist >= 1.1.1
1010
multidict >=4.5, < 7.0
1111
propcache >= 0.2.0
1212
yarl >= 1.17.0, < 2.0
13+
zstandard; platform_python_implementation == 'CPython'

setup.cfg

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ speedups =
6767
aiodns >= 3.3.0
6868
Brotli; platform_python_implementation == 'CPython'
6969
brotlicffi; platform_python_implementation != 'CPython'
70+
zstandard; platform_python_implementation == 'CPython'
7071

7172
[options.packages.find]
7273
exclude =

tests/test_client_request.py

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -358,7 +358,7 @@ def test_headers(make_request: _RequestMaker) -> None:
358358

359359
assert hdrs.CONTENT_TYPE in req.headers
360360
assert req.headers[hdrs.CONTENT_TYPE] == "text/plain"
361-
assert req.headers[hdrs.ACCEPT_ENCODING] == "gzip, deflate, br"
361+
assert req.headers[hdrs.ACCEPT_ENCODING] == "gzip, deflate, br, zstd"
362362

363363

364364
def test_headers_list(make_request: _RequestMaker) -> None:
@@ -1568,15 +1568,20 @@ def test_loose_cookies_types(loop: asyncio.AbstractEventLoop) -> None:
15681568

15691569

15701570
@pytest.mark.parametrize(
1571-
"has_brotli,expected",
1571+
"has_brotli,has_zstd,expected",
15721572
[
1573-
(False, "gzip, deflate"),
1574-
(True, "gzip, deflate, br"),
1573+
(False, False, "gzip, deflate"),
1574+
(True, False, "gzip, deflate, br"),
1575+
(False, True, "gzip, deflate, zstd"),
1576+
(True, True, "gzip, deflate, br, zstd"),
15751577
],
15761578
)
1577-
def test_gen_default_accept_encoding(has_brotli: bool, expected: str) -> None:
1579+
def test_gen_default_accept_encoding(
1580+
has_brotli: bool, has_zstd: bool, expected: str
1581+
) -> None:
15781582
with mock.patch("aiohttp.client_reqrep.HAS_BROTLI", has_brotli):
1579-
assert _gen_default_accept_encoding() == expected
1583+
with mock.patch("aiohttp.client_reqrep.HAS_ZSTD", has_zstd):
1584+
assert _gen_default_accept_encoding() == expected
15801585

15811586

15821587
@pytest.mark.parametrize(

tests/test_http_parser.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,11 @@
3434
except ImportError:
3535
brotli = None
3636

37+
try:
38+
import zstandard
39+
except ImportError:
40+
zstandard = None
41+
3742
REQUEST_PARSERS = [HttpRequestParserPy]
3843
RESPONSE_PARSERS = [HttpResponseParserPy]
3944

@@ -600,6 +605,14 @@ def test_compression_brotli(parser: HttpRequestParser) -> None:
600605
assert msg.compression == "br"
601606

602607

608+
@pytest.mark.skipif(zstandard is None, reason="zstandard is not installed")
609+
def test_compression_zstd(parser: HttpRequestParser) -> None:
610+
text = b"GET /test HTTP/1.1\r\ncontent-encoding: zstd\r\n\r\n"
611+
messages, upgrade, tail = parser.feed_data(text)
612+
msg = messages[0][0]
613+
assert msg.compression == "zstd"
614+
615+
603616
def test_compression_unknown(parser: HttpRequestParser) -> None:
604617
text = b"GET /test HTTP/1.1\r\ncontent-encoding: compress\r\n\r\n"
605618
messages, upgrade, tail = parser.feed_data(text)
@@ -1849,6 +1862,15 @@ async def test_http_payload_brotli(self, protocol: BaseProtocol) -> None:
18491862
assert b"brotli data" == out._buffer[0]
18501863
assert out.is_eof()
18511864

1865+
@pytest.mark.skipif(zstandard is None, reason="zstandard is not installed")
1866+
async def test_http_payload_zstandard(self, protocol: BaseProtocol) -> None:
1867+
compressed = zstandard.compress(b"zstd data")
1868+
out = aiohttp.StreamReader(protocol, 2**16, loop=asyncio.get_running_loop())
1869+
p = HttpPayloadParser(out, length=len(compressed), compression="zstd")
1870+
p.feed_data(compressed)
1871+
assert b"zstd data" == out._buffer[0]
1872+
assert out.is_eof()
1873+
18521874

18531875
class TestDeflateBuffer:
18541876
async def test_feed_data(self, protocol: BaseProtocol) -> None:
@@ -1919,6 +1941,17 @@ async def test_feed_eof_no_err_brotli(self, protocol: BaseProtocol) -> None:
19191941
dbuf.feed_eof()
19201942
assert [b"line"] == list(buf._buffer)
19211943

1944+
async def test_feed_eof_no_err_zstandard(self, protocol: BaseProtocol) -> None:
1945+
buf = aiohttp.StreamReader(protocol, 2**16, loop=asyncio.get_running_loop())
1946+
dbuf = DeflateBuffer(buf, "zstd")
1947+
1948+
dbuf.decompressor = mock.Mock()
1949+
dbuf.decompressor.flush.return_value = b"line"
1950+
dbuf.decompressor.eof = False
1951+
1952+
dbuf.feed_eof()
1953+
assert [b"line"] == list(buf._buffer)
1954+
19221955
async def test_empty_body(self, protocol: BaseProtocol) -> None:
19231956
buf = aiohttp.StreamReader(protocol, 2**16, loop=asyncio.get_running_loop())
19241957
dbuf = DeflateBuffer(buf, "deflate")

0 commit comments

Comments
 (0)