From b312e2080ce0cd7a73ddefec940504938d70939b Mon Sep 17 00:00:00 2001 From: Molly Rose Date: Fri, 25 Jul 2025 14:34:49 -0400 Subject: [PATCH 1/2] feat: Use standard library zstd (3.14+) if available --- httpx/_decoders.py | 28 ++++++++++++++++++---------- pyproject.toml | 2 +- tests/test_decoders.py | 6 +++++- 3 files changed, 24 insertions(+), 12 deletions(-) diff --git a/httpx/_decoders.py b/httpx/_decoders.py index 899dfada87..e5f1556aef 100644 --- a/httpx/_decoders.py +++ b/httpx/_decoders.py @@ -26,11 +26,15 @@ brotli = None -# Zstandard support is optional +# Zstandard support is avaible in the standard library from 3.14 and later, +# or by an optional dependency on `zstandard` try: - import zstandard + import compression.zstd as zstandard except ImportError: # pragma: no cover - zstandard = None # type: ignore + try: + import zstandard + except ImportError: # pragma: no cover + zstandard = None class ContentDecoder: @@ -174,9 +178,16 @@ def __init__(self) -> None: "Make sure to install httpx using `pip install httpx[zstd]`." ) from None - self.decompressor = zstandard.ZstdDecompressor().decompressobj() + self._new_decompressor() self.seen_data = False + def _new_decompressor(self) -> None: + decompressor = zstandard.ZstdDecompressor() + if hasattr(decompressor, "decompressobj"): + self.decompressor = decompressor.decompressobj() # prgama: no cover + else: + self.decompressor = decompressor # pragma: no cover + def decode(self, data: bytes) -> bytes: assert zstandard is not None self.seen_data = True @@ -185,19 +196,16 @@ def decode(self, data: bytes) -> bytes: 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._new_decompressor() output.write(self.decompressor.decompress(unused_data)) except zstandard.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: + if self.seen_data and not self.decompressor.eof: raise DecodingError("Zstandard data is incomplete") # pragma: no cover - return bytes(ret) + return b"" class MultiDecoder(ContentDecoder): diff --git a/pyproject.toml b/pyproject.toml index eb9e5c9a3d..5445002707 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,7 +52,7 @@ socks = [ "socksio==1.*", ] zstd = [ - "zstandard>=0.18.0", + "zstandard>=0.18.0; python_version < \"3.14\"", ] [project.scripts] diff --git a/tests/test_decoders.py b/tests/test_decoders.py index 9ffaba189d..66d70b24d9 100644 --- a/tests/test_decoders.py +++ b/tests/test_decoders.py @@ -6,7 +6,11 @@ import chardet import pytest -import zstandard as zstd + +try: + from compression import zstd +except ImportError: + import zstandard as zstd import httpx From 824f3f54de3ed844ea50ad3550448d0e0de6a2ec Mon Sep 17 00:00:00 2001 From: Molly Rose Date: Fri, 25 Jul 2025 14:45:52 -0400 Subject: [PATCH 2/2] docs: Default zstd support for Python 3.14+ --- README.md | 2 +- docs/index.md | 2 +- docs/quickstart.md | 5 +++-- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 2ccecd578c..d3271aad93 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]`)* +* `zstandard` - Decoding for "zstd" compressed responses before Python 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/index.md b/docs/index.md index 90a4f6b6f7..d5546d68a3 100644 --- a/docs/index.md +++ b/docs/index.md @@ -119,7 +119,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]`)* +* `zstandard` - Decoding for "zstd" compressed responses before Python 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 38da2fec36..a226bf17a2 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -100,8 +100,9 @@ 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. The `zstd` response encoding is supported by +default on Python 3.14 and later, and optionally available on earlier Python +versions with `zstandard` installed. For example, to create an image from binary data returned by a request, you can use the following code: