Use either brotli or brotlicffi. (#1618)

* Use either brotli (recommended for CPython) or brotlicffi (Recommended for PyPy and others)

* Add comments in places where we switch behaviour depending on brotli/brotlicffi

Co-authored-by: Florimond Manca <florimond.manca@gmail.com>
This commit is contained in:
Tom Christie 2021-08-13 11:52:45 +01:00 committed by GitHub
parent acb5e6ac50
commit d5143120d1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 42 additions and 25 deletions

View File

@ -124,7 +124,7 @@ The HTTPX project relies on these excellent libraries:
* `idna` - Internationalized domain name support.
* `sniffio` - Async library autodetection.
* `async_generator` - Backport support for `contextlib.asynccontextmanager`. *(Only required for Python 3.6)*
* `brotlicffi` - Decoding for "brotli" compressed responses. *(Optional)*
* `brotli` or `brotlicffi` - Decoding for "brotli" compressed responses. *(Optional)*
A huge amount of credit is due to `requests` for the API layout that
much of this work follows, as well as to `urllib3` for plenty of design

View File

@ -116,7 +116,7 @@ The HTTPX project relies on these excellent libraries:
* `idna` - Internationalized domain name support.
* `sniffio` - Async library autodetection.
* `async_generator` - Backport support for `contextlib.asynccontextmanager`. *(Only required for Python 3.6)*
* `brotlicffi` - Decoding for "brotli" compressed responses. *(Optional)*
* `brotli` or `brotlicffi` - Decoding for "brotli" compressed responses. *(Optional)*
A huge amount of credit is due to `requests` for the API layout that
much of this work follows, as well as to `urllib3` for plenty of design

View File

@ -12,6 +12,17 @@ try:
except ImportError:
from async_generator import asynccontextmanager # type: ignore # noqa
# Brotli support is optional
# The C bindings in `brotli` are recommended for CPython.
# The CFFI bindings in `brotlicffi` are recommended for PyPy and everything else.
try:
import brotlicffi as brotli
except ImportError: # pragma: nocover
try:
import brotli
except ImportError:
brotli = None
if sys.version_info >= (3, 10) or (
sys.version_info >= (3, 7) and ssl.OPENSSL_VERSION_INFO >= (1, 1, 0, 7)
):

View File

@ -8,13 +8,9 @@ import io
import typing
import zlib
from ._compat import brotli
from ._exceptions import DecodingError
try:
import brotlicffi
except ImportError: # pragma: nocover
brotlicffi = None
class ContentDecoder:
def decode(self, data: bytes) -> bytes:
@ -99,18 +95,20 @@ class BrotliDecoder(ContentDecoder):
"""
def __init__(self) -> None:
if brotlicffi is None: # pragma: nocover
if brotli is None: # pragma: nocover
raise ImportError(
"Using 'BrotliDecoder', but the 'brotlicffi' library "
"is not installed."
"Using 'BrotliDecoder', but neither of the 'brotlicffi' or 'brotli' "
"packages have been installed. "
"Make sure to install httpx using `pip install httpx[brotli]`."
) from None
self.decompressor = brotlicffi.Decompressor()
self.decompressor = brotli.Decompressor()
self.seen_data = False
if hasattr(self.decompressor, "decompress"):
self._decompress = self.decompressor.decompress
# The 'brotlicffi' package.
self._decompress = self.decompressor.decompress # pragma: nocover
else:
# The 'brotli' package.
self._decompress = self.decompressor.process # pragma: nocover
def decode(self, data: bytes) -> bytes:
@ -118,8 +116,8 @@ class BrotliDecoder(ContentDecoder):
return b""
self.seen_data = True
try:
return self.decompressor.decompress(data)
except brotlicffi.Error as exc:
return self._decompress(data)
except brotli.error as exc:
raise DecodingError(str(exc)) from exc
def flush(self) -> bytes:
@ -127,9 +125,14 @@ class BrotliDecoder(ContentDecoder):
return b""
try:
if hasattr(self.decompressor, "finish"):
self.decompressor.finish()
# Only available in the 'brotlicffi' package.
# As the decompressor decompresses eagerly, this
# will never actually emit any data. However, it will potentially throw
# errors if a truncated or damaged data stream has been used.
self.decompressor.finish() # pragma: nocover
return b""
except brotlicffi.Error as exc: # pragma: nocover
except brotli.error as exc: # pragma: nocover
raise DecodingError(str(exc)) from exc
@ -326,5 +329,5 @@ SUPPORTED_DECODERS = {
}
if brotlicffi is None:
if brotli is None:
SUPPORTED_DECODERS.pop("br") # pragma: nocover

View File

@ -65,7 +65,10 @@ setup(
],
extras_require={
"http2": "h2>=3,<5",
"brotli": "brotlicffi==1.*",
"brotli": [
"brotli; platform_python_implementation == 'CPython'",
"brotlicffi; platform_python_implementation != 'CPython'"
],
},
classifiers=[
"Development Status :: 4 - Beta",

View File

@ -1,10 +1,10 @@
import json
import pickle
import brotlicffi
import pytest
import httpx
from httpx._compat import brotli
class StreamingBody:
@ -798,7 +798,7 @@ def test_link_headers(headers, expected):
def test_decode_error_with_request(header_value):
headers = [(b"Content-Encoding", header_value)]
body = b"test 123"
compressed_body = brotlicffi.compress(body)[3:]
compressed_body = brotli.compress(body)[3:]
with pytest.raises(httpx.DecodingError):
httpx.Response(
200,
@ -819,7 +819,7 @@ def test_decode_error_with_request(header_value):
def test_value_error_without_request(header_value):
headers = [(b"Content-Encoding", header_value)]
body = b"test 123"
compressed_body = brotlicffi.compress(body)[3:]
compressed_body = brotli.compress(body)[3:]
with pytest.raises(httpx.DecodingError):
httpx.Response(200, headers=headers, content=compressed_body)

View File

@ -1,9 +1,9 @@
import zlib
import brotlicffi
import pytest
import httpx
from httpx._compat import brotli
from httpx._decoders import (
BrotliDecoder,
ByteChunker,
@ -69,7 +69,7 @@ def test_gzip():
def test_brotli():
body = b"test 123"
compressed_body = brotlicffi.compress(body)
compressed_body = brotli.compress(body)
headers = [(b"Content-Encoding", b"br")]
response = httpx.Response(
@ -102,7 +102,7 @@ def test_multi():
def test_multi_with_identity():
body = b"test 123"
compressed_body = brotlicffi.compress(body)
compressed_body = brotli.compress(body)
headers = [(b"Content-Encoding", b"br, identity")]
response = httpx.Response(
@ -165,7 +165,7 @@ def test_decoders_empty_cases(decoder):
def test_decoding_errors(header_value):
headers = [(b"Content-Encoding", header_value)]
body = b"test 123"
compressed_body = brotlicffi.compress(body)[3:]
compressed_body = brotli.compress(body)[3:]
with pytest.raises(httpx.DecodingError):
request = httpx.Request("GET", "https://example.org")
httpx.Response(200, headers=headers, content=compressed_body, request=request)