From ae603602d358d8239039fe22d8fab4087ebc6183 Mon Sep 17 00:00:00 2001 From: Christian Clauss Date: Sun, 31 Aug 2025 17:01:07 +0200 Subject: [PATCH] Use backports.zstd instead of zstandard https://www.python.org/download/pre-releases https://www.python.org/downloads/release/python-3140rc2 --- .github/workflows/test-suite.yml | 7 ++++--- README.md | 2 +- docs/index.md | 2 +- docs/quickstart.md | 2 +- httpx/_decoders.py | 29 +++++++++++++++++------------ pyproject.toml | 2 +- requirements.txt | 2 +- tests/test_decoders.py | 6 +++++- 8 files changed, 31 insertions(+), 21 deletions(-) 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