From 4d05db33ceaa977e93dc07c12cbf8ea091df12a3 Mon Sep 17 00:00:00 2001 From: Rogdham Date: Sat, 13 Dec 2025 16:41:26 +0100 Subject: [PATCH] Use zstandard implementation from stdlib (PEP-784) --- README.md | 2 +- docs/quickstart.md | 4 ++-- httpx/_decoders.py | 25 ++++++++++++++----------- pyproject.toml | 2 +- tests/test_decoders.py | 7 ++++++- 5 files changed, 24 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 2ccecd578c..96042e4c41 100644 --- a/README.md +++ b/README.md @@ -136,7 +136,7 @@ As well as these optional installs: * `rich` - Rich terminal support. *(Optional, with `httpx[cli]`)* * `click` - Command line client support. *(Optional, with `httpx[cli]`)* * `brotli` or `brotlicffi` - Decoding for "brotli" compressed responses. *(Optional, with `httpx[brotli]`)* -* `zstandard` - Decoding for "zstd" compressed responses. *(Optional, with `httpx[zstd]`)* +* `backports.zstd` - Decoding for "zstd" compressed responses on Python before 3.14. *(Optional, with `httpx[zstd]`)* 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 diff --git a/docs/quickstart.md b/docs/quickstart.md index e140b53cd7..49e2eb4de3 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -100,8 +100,8 @@ b'\n\n\nExample Domain...' Any `gzip` and `deflate` HTTP response encodings will automatically be decoded for you. If `brotlipy` is installed, then the `brotli` response -encoding will be supported. If `zstandard` is installed, then `zstd` -response encodings will also be supported. +encoding will be supported. If the Python version used is 3.14 or higher or +if `backports.zstd` is installed, then `zstd` response encodings will also be supported. For example, to create an image from binary data returned by a request, you can use the following code: diff --git a/httpx/_decoders.py b/httpx/_decoders.py index 899dfada87..f35de81fb9 100644 --- a/httpx/_decoders.py +++ b/httpx/_decoders.py @@ -8,6 +8,7 @@ import codecs import io +import sys import typing import zlib @@ -28,9 +29,12 @@ # Zstandard support is optional try: - import zstandard + if sys.version_info >= (3, 14): + from compression import zstd # pragma: no cover + else: + from backports import zstd # pragma: no cover except ImportError: # pragma: no cover - zstandard = None # type: ignore + zstd = None # type: ignore class ContentDecoder: @@ -162,42 +166,41 @@ class ZStandardDecoder(ContentDecoder): """ Handle 'zstd' RFC 8878 decoding. - Requires `pip install zstandard`. + Requires `pip install backports.zstd` for Python before 3.14. Can be installed as a dependency of httpx using `pip install httpx[zstd]`. """ # inspired by the ZstdDecoder implementation in urllib3 def __init__(self) -> None: - if zstandard is None: # pragma: no cover + if zstd is None: # pragma: no cover raise ImportError( "Using 'ZStandardDecoder', ..." "Make sure to install httpx using `pip install httpx[zstd]`." ) from None - self.decompressor = zstandard.ZstdDecompressor().decompressobj() + self.decompressor = zstd.ZstdDecompressor() self.seen_data = False def decode(self, data: bytes) -> bytes: - assert zstandard is not None + assert zstd is not None self.seen_data = True output = io.BytesIO() try: output.write(self.decompressor.decompress(data)) while self.decompressor.eof and self.decompressor.unused_data: unused_data = self.decompressor.unused_data - self.decompressor = zstandard.ZstdDecompressor().decompressobj() + self.decompressor = zstd.ZstdDecompressor() output.write(self.decompressor.decompress(unused_data)) - except zstandard.ZstdError as exc: + except zstd.ZstdError as exc: raise DecodingError(str(exc)) from exc return output.getvalue() def flush(self) -> bytes: if not self.seen_data: return b"" - ret = self.decompressor.flush() # note: this is a no-op if not self.decompressor.eof: raise DecodingError("Zstandard data is incomplete") # pragma: no cover - return bytes(ret) + return b"" class MultiDecoder(ContentDecoder): @@ -389,5 +392,5 @@ def flush(self) -> list[str]: if brotli is None: SUPPORTED_DECODERS.pop("br") # pragma: no cover -if zstandard is None: +if zstd is None: SUPPORTED_DECODERS.pop("zstd") # pragma: no cover diff --git a/pyproject.toml b/pyproject.toml index fc3e95ea74..48bd19629e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,7 +52,7 @@ socks = [ "socksio==1.*", ] zstd = [ - "zstandard>=0.18.0", + "backports.zstd>=1.0.0 ; python_version < '3.14'", ] [project.scripts] diff --git a/tests/test_decoders.py b/tests/test_decoders.py index 9ffaba189d..e1192ac004 100644 --- a/tests/test_decoders.py +++ b/tests/test_decoders.py @@ -1,15 +1,20 @@ from __future__ import annotations import io +import sys import typing import zlib import chardet import pytest -import zstandard as zstd import httpx +if sys.version_info >= (3, 14): + from compression import zstd # pragma: no cover +else: + from backports import zstd # pragma: no cover + def test_deflate(): """