diff --git a/.github/workflows/test-suite.yml b/.github/workflows/test-suite.yml
index 9ea74686b8..4378c48861 100644
--- a/.github/workflows/test-suite.yml
+++ b/.github/workflows/test-suite.yml
@@ -14,11 +14,11 @@ jobs:
strategy:
matrix:
- python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
+ python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14"]
steps:
- - uses: "actions/checkout@v4"
- - uses: "actions/setup-python@v5"
+ - uses: actions/checkout@v5
+ - uses: actions/setup-python@v6
with:
python-version: "${{ matrix.python-version }}"
allow-prereleases: true
@@ -30,5 +30,6 @@ jobs:
run: "scripts/build"
- name: "Run tests"
run: "scripts/test"
+ timeout-minutes: 10 # TODO(@cclauss): Remove once Python 3.14 tests are passing.
- name: "Enforce coverage"
run: "scripts/coverage"
diff --git a/README.md b/README.md
index 2ccecd578c..ba3bd9ab26 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. *(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..f57e8eb99b 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]`)*
+* `backports.zstd` - Decoding for "zstd" compressed responses. *(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..5b478e3117 100644
--- a/docs/quickstart.md
+++ b/docs/quickstart.md
@@ -100,7 +100,7 @@ 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`
+encoding will be supported. If `backports.zstd` is installed on Python < 3.14, 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..0be48a946b 100644
--- a/httpx/_decoders.py
+++ b/httpx/_decoders.py
@@ -25,12 +25,14 @@
except ImportError:
brotli = None
-
-# Zstandard support is optional
+# Zstandard support is optional on Python < 3.14
try:
- import zstandard
-except ImportError: # pragma: no cover
- zstandard = None # type: ignore
+ from compression import zstd
+except ImportError:
+ try:
+ from backports import zstd
+ except ImportError:
+ zstd = None
class ContentDecoder:
@@ -162,32 +164,35 @@ class ZStandardDecoder(ContentDecoder):
"""
Handle 'zstd' RFC 8878 decoding.
- Requires `pip install zstandard`.
+ Requires `pip install backports.zstd` on Python < 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
+ self.decompressor.eof = None
+ self.decompressor.flush = lambda: None
+ self.decompressor.unused_data = None
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
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()
@@ -389,5 +394,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..e7ca8e5f35 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -52,7 +52,7 @@ socks = [
"socksio==1.*",
]
zstd = [
- "zstandard>=0.18.0",
+ "backports.zstd>=0.5.0; python_version < '3.14'",
]
[project.scripts]
diff --git a/requirements.txt b/requirements.txt
index 08953d828b..830cbdd49f 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -21,7 +21,7 @@ twine==6.1.0
coverage[toml]==7.10.6
cryptography==45.0.7
mypy==1.17.1
-pytest==8.4.1
+pytest==8.4.2
ruff==0.12.11
trio==0.30.0
trio-typing==0.10.0
diff --git a/tests/test_decoders.py b/tests/test_decoders.py
index 9ffaba189d..daeceb621d 100644
--- a/tests/test_decoders.py
+++ b/tests/test_decoders.py
@@ -4,9 +4,13 @@
import typing
import zlib
+try:
+ from compression import zstd
+except ImportError:
+ from backports import zstd
+
import chardet
import pytest
-import zstandard as zstd
import httpx