From f94f98cb26274d9501e41d33da1358b4483592fc Mon Sep 17 00:00:00 2001 From: Apify AI Factory Date: Tue, 10 Nov 2026 10:22:29 +0100 Subject: [PATCH 01/21] feat: add zstd request body compression --- src/apify_client/_utils/try_import.py | 20 ++++--- src/apify_client/http_compressors/__init__.py | 21 ++++++- src/apify_client/http_compressors/_resolve.py | 8 ++- src/apify_client/http_compressors/_zstd.py | 60 +++++++++++++++++++ 4 files changed, 97 insertions(+), 12 deletions(-) create mode 100644 src/apify_client/http_compressors/_zstd.py diff --git a/src/apify_client/_utils/try_import.py b/src/apify_client/_utils/try_import.py index 4f0609db7..72ea9e0c8 100644 --- a/src/apify_client/_utils/try_import.py +++ b/src/apify_client/_utils/try_import.py @@ -19,24 +19,28 @@ class ImportState: @contextmanager -def try_import(module_name: str, *symbol_names: str, dependency_name: str, extra_name: str) -> Generator[ImportState]: +def try_import( + module_name: str, *symbol_names: str, dependency_name: str, extra_name: str | None +) -> Generator[ImportState]: """Context manager to attempt importing symbols into a module. If the named optional dependency is missing, the symbols are replaced with `FailedImport` objects whose message - names the extra that installs it. Import errors caused by the importing module itself or by another dependency - are propagated instead of being masked. + names the extra that installs it, or is the import error's own message when `extra_name` is `None` because no extra + can install the dependency. A dotted `dependency_name` also counts as missing when its parent package is missing. + Import errors caused by the importing module itself or by another dependency are propagated instead of being masked. """ state = ImportState() try: yield state except ModuleNotFoundError as exc: - if exc.name != dependency_name: + if exc.name is None or not (dependency_name == exc.name or dependency_name.startswith(f'{exc.name}.')): raise state.available = False - message = ( - f"{exc.args[0]}. Install the optional '{extra_name}' extra to use it: " - f"pip install 'apify-client[{extra_name}]'" - ) + message = exc.args[0] + if extra_name is not None: + message += ( + f". Install the optional '{extra_name}' extra to use it: pip install 'apify-client[{extra_name}]'" + ) for symbol_name in symbol_names: setattr(sys.modules[module_name], symbol_name, FailedImport(message)) diff --git a/src/apify_client/http_compressors/__init__.py b/src/apify_client/http_compressors/__init__.py index 0cbc4b4de..5f11f39ea 100644 --- a/src/apify_client/http_compressors/__init__.py +++ b/src/apify_client/http_compressors/__init__.py @@ -1,3 +1,5 @@ +import sys as _sys + from apify_client._utils.try_import import install_import_hook as _install_import_hook from apify_client._utils.try_import import try_import as _try_import @@ -12,7 +14,20 @@ with _try_import(__name__, 'BrotliHttpCompressor', dependency_name='brotli', extra_name='brotli') as _brotli_import: from apify_client.http_compressors._brotli import BrotliHttpCompressor -if _brotli_import.available: - __all__ = ['BrotliHttpCompressor', 'GzipHttpCompressor', 'HttpCompressor'] +# zstd is in the standard library from Python 3.14, though a Python build can leave it out, which no extra can fix. +# Older versions need the `backports.zstd` package from the `zstd` extra. Either way it's wrapped in try_import. +if _sys.version_info >= (3, 14): + _zstd_dependency, _zstd_extra = '_zstd', None else: - __all__ = ['GzipHttpCompressor', 'HttpCompressor'] + _zstd_dependency, _zstd_extra = 'backports.zstd', 'zstd' + +with _try_import( + __name__, 'ZstdHttpCompressor', dependency_name=_zstd_dependency, extra_name=_zstd_extra +) as _zstd_import: + from apify_client.http_compressors._zstd import ZstdHttpCompressor + +__all__ = ['GzipHttpCompressor', 'HttpCompressor'] +if _brotli_import.available: + __all__ += ['BrotliHttpCompressor'] +if _zstd_import.available: + __all__ += ['ZstdHttpCompressor'] diff --git a/src/apify_client/http_compressors/_resolve.py b/src/apify_client/http_compressors/_resolve.py index ccd5d77cc..810e4d83a 100644 --- a/src/apify_client/http_compressors/_resolve.py +++ b/src/apify_client/http_compressors/_resolve.py @@ -19,7 +19,8 @@ def resolve_compressor(compression: HttpCompressionAlgorithm | HttpCompressor) - A ready-to-use `HttpCompressor`. Raises: - ImportError: If the requested algorithm needs an optional extra that is not installed. + ImportError: If the requested algorithm needs an optional extra that is not installed, or for `'zstd'` on + Python 3.14 and newer, if the Python build lacks zstd support. ValueError: If `compression` is not a recognized compression algorithm. """ if isinstance(compression, HttpCompressor): @@ -32,6 +33,11 @@ def resolve_compressor(compression: HttpCompressionAlgorithm | HttpCompressor) - from apify_client.http_compressors import BrotliHttpCompressor # noqa: PLC0415 return BrotliHttpCompressor() + if compression == 'zstd': + # Imported here for the same reason as brotli: before Python 3.14 zstd needs the `zstd` extra. + from apify_client.http_compressors import ZstdHttpCompressor # noqa: PLC0415 + + return ZstdHttpCompressor() # The backend supports also `deflate` and `identity` (no compression). One can build # a custom compressor if needed. diff --git a/src/apify_client/http_compressors/_zstd.py b/src/apify_client/http_compressors/_zstd.py new file mode 100644 index 000000000..4bc6d334b --- /dev/null +++ b/src/apify_client/http_compressors/_zstd.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +import sys + +from apify_client.http_compressors._base import HttpCompressor + +if sys.version_info >= (3, 14): + try: + from compression import zstd + except ModuleNotFoundError as exc: + # `compression.zstd` is optional when CPython is built, and no extra can add it to a build without it. + if exc.name != '_zstd': + raise + raise ModuleNotFoundError( + 'zstd compression is unavailable: this Python build was compiled without zstd support (`compression.zstd`)', + name='_zstd', + ) from exc +else: + # Not `from backports import zstd`: when another `backports.*` package is installed but this one is not, that + # raises a plain `ImportError` instead of the `ModuleNotFoundError` the optional-extra handling expects. + import backports.zstd as zstd # noqa: PLR0402 + + +class ZstdHttpCompressor(HttpCompressor): + """Compresses request bodies using zstd. + + Uses the standard library `compression.zstd` module on Python 3.14 and newer, provided the Python build includes + it. On older versions it requires the `zstd` extra: `pip install "apify-client[zstd]"`. + """ + + content_encoding = 'zstd' + + _min_quality = 1 + """Lowest valid quality (fastest, least compression).""" + + _max_quality = 19 + """Highest valid quality (slowest, best compression). + + zstd itself goes up to 22, but levels 20 to 22 use a window above the 8 MB that RFC 9659 requires HTTP decoders + to support, so a record stored at those levels could fail to decode in a client that accepts zstd. + """ + + def __init__(self, *, quality: int = 3) -> None: + """Initialize the zstd compressor. + + Args: + quality: Compression level, from the fastest to the best compression. zstd's levels 20 to 22 are not + accepted: their window exceeds the 8 MB that HTTP decoders are required to support. + + Raises: + ValueError: If `quality` is out of the valid range. + """ + if not self._min_quality <= quality <= self._max_quality: + raise ValueError( + f'zstd quality must be between {self._min_quality} and {self._max_quality}, got {quality}.' + ) + self._quality = quality + + def compress(self, data: bytes) -> bytes: + return zstd.compress(data, level=self._quality) From 08a3d8120f656eea8e017b153a9785d394efc22a Mon Sep 17 00:00:00 2001 From: Apify AI Factory Date: Tue, 10 Nov 2026 10:23:55 +0100 Subject: [PATCH 02/21] feat: accept 'zstd' as a compression algorithm and add the zstd extra --- pyproject.toml | 2 ++ src/apify_client/_utils/http.py | 4 ++-- src/apify_client/types.py | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index dd78bac5a..ecac2c2dd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,6 +36,8 @@ dependencies = [ [project.optional-dependencies] brotli = ["brotli>=1.0.9"] httpx2 = ["httpx2>=2.0.0"] +# zstd is in the standard library (`compression.zstd`) from Python 3.14; older versions use its official backport. +zstd = ["backports.zstd>=1.0.0; python_version < '3.14'"] [project.urls] "Apify Homepage" = "https://apify.com" diff --git a/src/apify_client/_utils/http.py b/src/apify_client/_utils/http.py index fab01a785..40e2c1c96 100644 --- a/src/apify_client/_utils/http.py +++ b/src/apify_client/_utils/http.py @@ -76,8 +76,8 @@ def to_path_segment(value: str) -> str: def is_compressible_content_type(content_type: str | None) -> bool: """Decide whether a request body with the given content type is worth compressing. - Images, audio, video and archives already carry their own compression. Running them through gzip or brotli - burns CPU, holds a second full copy of the body in memory, and usually produces output slightly larger than + Images, audio, video and archives already carry their own compression. Running them through gzip, brotli or + zstd burns CPU, holds a second full copy of the body in memory, and usually produces output slightly larger than the input. Formats that are raw despite such a media type, for example `image/bmp` or `audio/wav`, are still compressed. A body with no content type is assumed to be compressible. diff --git a/src/apify_client/types.py b/src/apify_client/types.py index 64e42ac85..d66fa5988 100644 --- a/src/apify_client/types.py +++ b/src/apify_client/types.py @@ -24,7 +24,7 @@ before the request reaches the Actor, so the `Authorization` header stays free for the Actor's own authentication. """ -HttpCompressionAlgorithm = Literal['brotli', 'gzip'] +HttpCompressionAlgorithm = Literal['brotli', 'gzip', 'zstd'] """Accepted string literals for the `compression` parameter on `ApifyClient` and `ApifyClientAsync`.""" QueryParamValue = str | int | float | bool | Decimal | datetime | list[str] | None From 0ec8a9d9b83b3e6c0ed9f1a5dbf1433fcaeb8eb4 Mon Sep 17 00:00:00 2001 From: Apify AI Factory Date: Tue, 10 Nov 2026 10:24:42 +0100 Subject: [PATCH 03/21] docs: document zstd request body compression --- docs/01_introduction/index.mdx | 2 + docs/02_concepts/13_http_compression.mdx | 50 +++++++++++++++++------- 2 files changed, 38 insertions(+), 14 deletions(-) diff --git a/docs/01_introduction/index.mdx b/docs/01_introduction/index.mdx index 44b3ac1fe..92cc77ba0 100644 --- a/docs/01_introduction/index.mdx +++ b/docs/01_introduction/index.mdx @@ -61,6 +61,8 @@ For better request-body compression, opt in to `brotli`, which compresses better +To compress with `zstd` instead, pass `compression='zstd'`. On Python 3.14 and newer it uses the standard library; on older versions install the optional `zstd` extra with `pip install "apify-client[zstd]"`. + For details, see [HTTP compression](../02_concepts/13_http_compression.mdx). The client uses [Impit](https://github.com/apify/impit) as its default HTTP transport. To use the built-in [HTTPX2](https://github.com/pydantic/httpx2) transport, install the optional `httpx2` extra, which provides Pydantic's maintained continuation of HTTPX: diff --git a/docs/02_concepts/13_http_compression.mdx b/docs/02_concepts/13_http_compression.mdx index 79e26b38d..352852b04 100644 --- a/docs/02_concepts/13_http_compression.mdx +++ b/docs/02_concepts/13_http_compression.mdx @@ -1,7 +1,7 @@ --- id: http-compression title: HTTP compression -description: The client compresses request bodies automatically using gzip by default, with optional brotli via an explicit opt-in. +description: The client compresses request bodies automatically using gzip by default, with optional brotli or zstd via an explicit opt-in. --- import Tabs from '@theme/Tabs'; @@ -17,7 +17,7 @@ The Apify client compresses request bodies before sending them to the API. It re ## How it works -The client compresses request bodies using the compressor configured via the `compression` parameter (default `'gzip'`). The server supports both gzip and brotli and decompresses the request body transparently. A body is compressed only when it's large enough to benefit, its content type isn't already compressed, the request carries no `Content-Encoding` of its own, and the body isn't streamed from a file or an iterator. For details, see [Minimum body size](#minimum-body-size), [Already-compressed payloads](#already-compressed-payloads), [Pre-compressed bodies](#pre-compressed-bodies), and [Streaming uploads](./09_streaming.mdx#streaming-uploads). +The client compresses request bodies using the compressor configured via the `compression` parameter (default `'gzip'`). The server supports gzip, brotli, and zstd and decompresses the request body transparently. A body is compressed only when it's large enough to benefit, its content type isn't already compressed, the request carries no `Content-Encoding` of its own, and the body isn't streamed from a file or an iterator. For details, see [Minimum body size](#minimum-body-size), [Already-compressed payloads](#already-compressed-payloads), [Pre-compressed bodies](#pre-compressed-bodies), and [Streaming uploads](./09_streaming.mdx#streaming-uploads). ## Minimum body size @@ -66,7 +66,7 @@ A payload can reach the client already encoded, for example a gzipped file read -The header is forwarded verbatim, so it also covers encodings the client ships no compressor for, such as `deflate`. It's forwarded for a streamed value too, so an open gzipped file can be passed in place of its bytes. The API accepts `gzip`, `br`, `deflate`, and `identity`. Passing `identity` turns compression off for a single request without changing how the client is configured. +The header is forwarded verbatim, so it also covers encodings the client ships no compressor for, such as `deflate`. It's forwarded for a streamed value too, so an open gzipped file can be passed in place of its bytes. The API accepts `gzip`, `br`, `zstd`, `deflate`, and `identity`. Passing `identity` turns compression off for a single request without changing how the client is configured. A value that can't be compressed at all - a string, an object serialized to JSON, or a file-like value opened in text mode - is rejected with a `TypeError` when `content_encoding` names a compression. Beyond that the client can't verify that the bytes match the header, so set `Content-Encoding` only when the payload really is encoded that way. Key-value store records are stored exactly as you upload them, which makes the header part of the stored record rather than a transport detail. @@ -82,6 +82,9 @@ client = ApifyClient(token='MY-APIFY-TOKEN') # Opt in to brotli (requires apify-client[brotli]) client = ApifyClient(token='MY-APIFY-TOKEN', compression='brotli') + +# Opt in to zstd (requires apify-client[zstd] before Python 3.14) +client = ApifyClient(token='MY-APIFY-TOKEN', compression='zstd') ``` ## Enabling brotli @@ -96,19 +99,38 @@ uv add "apify-client[brotli]" Then pass `compression='brotli'` to the client constructor. If you request brotli without installing the extra, the client raises a clear `ImportError`. There is no silent fallback. +## Enabling zstd + +Python 3.14 and newer ship zstd in the standard library as `compression.zstd`, so pass `compression='zstd'` and nothing else is needed, as long as your Python build includes zstd support, which the python.org builds do. On older Python versions, install the optional extra, which brings in the `backports.zstd` package: + +```bash +pip install "apify-client[zstd]" +# or +uv add "apify-client[zstd]" +``` + +As with brotli, requesting zstd without the extra on Python older than 3.14, or on a Python 3.14 build without zstd support, raises a clear `ImportError`. + ## Custom quality and advanced control For fine-grained control over compression quality, inject an `HttpCompressor` instance directly instead of a string literal: ```python from apify_client import ApifyClient -from apify_client.http_compressors import BrotliHttpCompressor, GzipHttpCompressor +from apify_client.http_compressors import ( + BrotliHttpCompressor, + GzipHttpCompressor, + ZstdHttpCompressor, +) # Brotli at maximum quality client = ApifyClient(token='MY-APIFY-TOKEN', compression=BrotliHttpCompressor(quality=11)) # Gzip at maximum quality client = ApifyClient(token='MY-APIFY-TOKEN', compression=GzipHttpCompressor(quality=9)) + +# Zstd at the highest accepted level +client = ApifyClient(token='MY-APIFY-TOKEN', compression=ZstdHttpCompressor(quality=19)) ``` You can also implement a fully custom compressor by subclassing `HttpCompressor`. The client calls it only for bodies that reach the [minimum body size](#minimum-body-size) and aren't [already compressed](#already-compressed-payloads) or [pre-compressed by the caller](#pre-compressed-bodies): @@ -139,16 +161,16 @@ client = ApifyClient(token='MY-APIFY-TOKEN', compression=IdentityCompressor()) ## Comparison -| | Brotli | Gzip | -|-------------------------------|-----------------------------------------|---------------------------------| -| **Compression ratio** | Typically better than gzip | Good | -| **CPU cost** | Moderate, depends on quality | Low | -| **Availability** | Requires the `brotli` extra | Built-in, no extra needed | -| **`Content-Encoding` header** | `br` | `gzip` | -| **Quality range** | `0–11` | `1–9` | -| **Default quality** | `6` | `6` | -| **Enable via config** | `compression='brotli'` | `compression='gzip'` (default) | -| **Best for** | Large payloads where bandwidth matters | Minimal-dependency environments | +| | Brotli | Gzip | Zstd | +|-------------------------------|-----------------------------------------|---------------------------------|-------------------------------------------------------| +| **Compression ratio** | Typically better than gzip | Good | Close to brotli at high levels | +| **CPU cost** | Moderate, depends on quality | Low | Low at the default level, grows with it | +| **Availability** | Requires the `brotli` extra | Built-in, no extra needed | Built-in from Python 3.14, else the `zstd` extra | +| **`Content-Encoding` header** | `br` | `gzip` | `zstd` | +| **Quality range** | `0–11` | `1–9` | `1–19` | +| **Default quality** | `6` | `6` | `3` | +| **Enable via config** | `compression='brotli'` | `compression='gzip'` (default) | `compression='zstd'` | +| **Best for** | Large payloads where bandwidth matters | Minimal-dependency environments | Fast compression at a good ratio | :::tip For most workloads, the bandwidth savings from brotli outweigh the CPU costs. Install the `brotli` extra and pass `compression='brotli'` unless you can't install additional packages in your environment. From 53a447b9dfc485c2d3947f9a877daef23dfb2eae Mon Sep 17 00:00:00 2001 From: Apify AI Factory Date: Tue, 10 Nov 2026 10:25:27 +0100 Subject: [PATCH 04/21] docs: mention the zstd extra in the README --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index bdce7968e..d47cdec37 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,9 @@ uv add "apify-client[brotli]" ``` + To compress with `zstd`, pass `compression='zstd'`. Python 3.14 and newer have it in the standard library; on older + versions install the optional `zstd` extra: `pip install "apify-client[zstd]"`. + [Impit](https://github.com/apify/impit) is the default HTTP client and is installed automatically. To use the built-in [HTTPX2](https://github.com/pydantic/httpx2) client instead, install the optional `httpx2` extra, which provides Pydantic's maintained continuation of HTTPX, and pass `http_client=Httpx2HttpClient()` to From 8edd3bd558e47a06dc7968fe07b6654cfa6a3c3a Mon Sep 17 00:00:00 2001 From: Apify AI Factory Date: Tue, 10 Nov 2026 10:26:12 +0100 Subject: [PATCH 05/21] test: cover the zstd compressor --- tests/unit/test_http_compressors.py | 102 +++++++++++++++++++++++++--- 1 file changed, 92 insertions(+), 10 deletions(-) diff --git a/tests/unit/test_http_compressors.py b/tests/unit/test_http_compressors.py index b1a9c5503..5e853314b 100644 --- a/tests/unit/test_http_compressors.py +++ b/tests/unit/test_http_compressors.py @@ -14,9 +14,14 @@ from apify_client import ApifyClient, ApifyClientAsync from apify_client._consts import MIN_COMPRESSION_SIZE -from apify_client.http_compressors import BrotliHttpCompressor, GzipHttpCompressor +from apify_client.http_compressors import BrotliHttpCompressor, GzipHttpCompressor, ZstdHttpCompressor from apify_client.http_compressors._resolve import resolve_compressor +if sys.version_info >= (3, 14): + from compression import zstd +else: + import backports.zstd as zstd # noqa: PLR0402 - the same import form as `http_compressors/_zstd.py` + if TYPE_CHECKING: from collections.abc import Callable, Generator @@ -29,20 +34,22 @@ @contextmanager -def _brotli_unavailable() -> Generator[None]: - """Reimport the `http_compressors` package with the `brotli` import forced to fail. +def _dependency_unavailable(dependency: str, *importers: str) -> Generator[None]: + """Reimport the `http_compressors` package with the `dependency` import forced to fail. - Simulates an environment where the optional `brotli` extra is not installed, and restores the - original module state afterwards so other tests in the same worker are unaffected. + Simulates an environment where the optional extra providing `dependency` is not installed, and restores the + original module state afterwards so other tests in the same worker are unaffected. `importers` names cached + packages outside `http_compressors` that import `dependency`; they and their submodules are reimported too. """ class _Blocker: def find_spec(self, name: str, *_args: object) -> None: - if name == 'brotli' or name.startswith('brotli.'): - raise ModuleNotFoundError(f"No module named '{name}'", name='brotli') + if name == dependency or name.startswith(f'{dependency}.'): + raise ModuleNotFoundError(f"No module named '{name}'", name=dependency) def _affected(name: str) -> bool: - return name == 'brotli' or name.startswith(('brotli.', 'apify_client.http_compressors')) + prefixes = tuple(f'{package}.' for package in (dependency, *importers)) + return name in (dependency, *importers) or name.startswith((*prefixes, 'apify_client.http_compressors')) saved = {name: mod for name, mod in list(sys.modules.items()) if _affected(name)} blocker = _Blocker() @@ -81,6 +88,27 @@ def test_gzip_compressor_round_trips_at_quality(quality: int) -> None: assert gzip.decompress(GzipHttpCompressor(quality=quality).compress(b'payload')) == b'payload' +def test_zstd_compressor_defaults_to_level_3(monkeypatch: pytest.MonkeyPatch) -> None: + """Zstd compressor uses compression level `3` when no quality is given.""" + compress = Mock(return_value=b'compressed') + monkeypatch.setattr(zstd, 'compress', compress) + + assert ZstdHttpCompressor().compress(b'payload') == b'compressed' + compress.assert_called_once_with(b'payload', level=3) + + +@pytest.mark.parametrize( + 'quality', + [ + pytest.param(1, id='fastest'), + pytest.param(19, id='best'), + ], +) +def test_zstd_compressor_round_trips_at_quality(quality: int) -> None: + """Zstd compressor round-trips data at the boundary quality levels.""" + assert zstd.decompress(ZstdHttpCompressor(quality=quality).compress(b'payload')) == b'payload' + + @pytest.mark.parametrize( 'quality', [ @@ -120,6 +148,20 @@ def test_brotli_compressor_rejects_out_of_range_quality(quality: int) -> None: BrotliHttpCompressor(quality=quality) +@pytest.mark.parametrize( + 'quality', + [ + pytest.param(0, id='below minimum'), + pytest.param(20, id='above maximum'), + pytest.param(-1, id='negative'), + ], +) +def test_zstd_compressor_rejects_out_of_range_quality(quality: int) -> None: + """Zstd compressor raises `ValueError` at construction for a quality not between `1` and `19`.""" + with pytest.raises(ValueError, match='zstd quality must be between 1 and 19'): + ZstdHttpCompressor(quality=quality) + + def test_gzip_compressor_round_trips_and_sets_content_encoding() -> None: """Gzip compressor round-trips data and reports `gzip` as its content encoding.""" compressor = GzipHttpCompressor() @@ -134,6 +176,13 @@ def test_brotli_compressor_round_trips_and_sets_content_encoding() -> None: assert brotli.decompress(compressor.compress(b'hello world')) == b'hello world' +def test_zstd_compressor_round_trips_and_sets_content_encoding() -> None: + """Zstd compressor round-trips data and reports `zstd` as its content encoding.""" + compressor = ZstdHttpCompressor() + assert compressor.content_encoding == 'zstd' + assert zstd.decompress(compressor.compress(b'hello world')) == b'hello world' + + def test_resolve_compressor_gzip() -> None: """The `'gzip'` literal resolves to a `GzipHttpCompressor`.""" assert isinstance(resolve_compressor('gzip'), GzipHttpCompressor) @@ -144,6 +193,11 @@ def test_resolve_compressor_brotli() -> None: assert isinstance(resolve_compressor('brotli'), BrotliHttpCompressor) +def test_resolve_compressor_zstd() -> None: + """The `'zstd'` literal resolves to a `ZstdHttpCompressor`.""" + assert isinstance(resolve_compressor('zstd'), ZstdHttpCompressor) + + def test_resolve_compressor_passes_through_instance() -> None: """An `HttpCompressor` instance is returned unchanged.""" compressor = BrotliHttpCompressor(quality=11) @@ -158,7 +212,7 @@ def test_resolve_compressor_rejects_unknown_algorithm() -> None: def test_brotli_import_hook_raises_clear_error_when_extra_missing() -> None: """Accessing `BrotliHttpCompressor` without the `brotli` extra raises `ImportError`, not `TypeError`.""" - with _brotli_unavailable(): + with _dependency_unavailable('brotli'): module = importlib.import_module('apify_client.http_compressors') with pytest.raises(ImportError): _ = module.BrotliHttpCompressor @@ -166,10 +220,36 @@ def test_brotli_import_hook_raises_clear_error_when_extra_missing() -> None: def test_resolve_compressor_brotli_raises_clear_error_when_extra_missing() -> None: """Resolving `compression='brotli'` without the extra raises `ImportError` at resolution time.""" - with _brotli_unavailable(), pytest.raises(ImportError): + with _dependency_unavailable('brotli'), pytest.raises(ImportError): resolve_compressor('brotli') +@pytest.mark.skipif(sys.version_info >= (3, 14), reason='zstd is in the standard library from Python 3.14') +def test_zstd_import_hook_raises_clear_error_when_extra_missing() -> None: + """Accessing `ZstdHttpCompressor` without the `zstd` extra raises an `ImportError` naming the extra.""" + with _dependency_unavailable('backports.zstd'): + module = importlib.import_module('apify_client.http_compressors') + with pytest.raises(ImportError, match=r'apify-client\[zstd\]'): + _ = module.ZstdHttpCompressor + + +@pytest.mark.skipif(sys.version_info >= (3, 14), reason='zstd is in the standard library from Python 3.14') +def test_resolve_compressor_zstd_raises_clear_error_when_extra_missing() -> None: + """Resolving `compression='zstd'` without the extra raises `ImportError` at resolution time.""" + with _dependency_unavailable('backports.zstd'), pytest.raises(ImportError): + resolve_compressor('zstd') + + +@pytest.mark.skipif(sys.version_info < (3, 14), reason='zstd is in the standard library only from Python 3.14') +def test_zstd_import_hook_raises_clear_error_when_python_lacks_zstd() -> None: + """On a Python 3.14+ build without zstd, the package still imports and `ZstdHttpCompressor` raises `ImportError`.""" + with _dependency_unavailable('_zstd', 'compression'): + module = importlib.import_module('apify_client.http_compressors') + assert module.GzipHttpCompressor is not None + with pytest.raises(ImportError, match='compiled without zstd support'): + _ = module.ZstdHttpCompressor + + _ITEMS_PATH = '/v2/datasets/test_dataset_id/items' _LARGE_BODY = [{'index': index, 'url': f'https://example.com/item/{index}'} for index in range(MIN_COMPRESSION_SIZE)] @@ -187,6 +267,7 @@ def handler(request: Request) -> Response: [ pytest.param('gzip', 'gzip', gzip.decompress, id='gzip'), pytest.param('brotli', 'br', brotli.decompress, id='brotli'), + pytest.param('zstd', 'zstd', zstd.decompress, id='zstd'), ], ) def test_configured_compression_reaches_the_wire_sync( @@ -214,6 +295,7 @@ def test_configured_compression_reaches_the_wire_sync( [ pytest.param('gzip', 'gzip', gzip.decompress, id='gzip'), pytest.param('brotli', 'br', brotli.decompress, id='brotli'), + pytest.param('zstd', 'zstd', zstd.decompress, id='zstd'), ], ) async def test_configured_compression_reaches_the_wire_async( From 01a571f3cf9d9e41111b76e2378e9181a1850922 Mon Sep 17 00:00:00 2001 From: Apify AI Factory Date: Tue, 10 Nov 2026 10:27:32 +0100 Subject: [PATCH 06/21] test: cover the optional-import handling of dotted and extra-less dependencies --- tests/unit/test_utils.py | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/tests/unit/test_utils.py b/tests/unit/test_utils.py index 1a2c395a4..1ee98973d 100644 --- a/tests/unit/test_utils.py +++ b/tests/unit/test_utils.py @@ -71,6 +71,40 @@ def test_try_import_records_the_named_missing_dependency() -> None: del sys.modules[module_name] +def test_try_import_without_extra_keeps_the_import_error_message() -> None: + """With no extra to install, the failed-import placeholder carries the import error's own message.""" + module_name = 'test_missing_dependency_without_extra_module' + sys.modules[module_name] = ModuleType(module_name) + try: + with try_import(module_name, 'OptionalSymbol', dependency_name='_optional', extra_name=None) as state: + raise ModuleNotFoundError('This Python build has no _optional', name='_optional') + assert state.available is False + assert sys.modules[module_name].OptionalSymbol.message == 'This Python build has no _optional' + finally: + del sys.modules[module_name] + + +def test_try_import_treats_a_missing_parent_package_as_the_missing_dependency() -> None: + """A dotted dependency counts as missing when its parent package is missing, but not a sibling of it.""" + module_name = 'test_missing_parent_package_module' + sys.modules[module_name] = ModuleType(module_name) + try: + with try_import( + module_name, 'OptionalSymbol', dependency_name='backports.zstd', extra_name='optional-extra' + ) as state: + raise ModuleNotFoundError("No module named 'backports'", name='backports') + assert state.available is False + assert isinstance(sys.modules[module_name].OptionalSymbol, FailedImport) + + with ( + pytest.raises(ModuleNotFoundError, match=r'backports\.other'), + try_import(module_name, 'OtherSymbol', dependency_name='backports.zstd', extra_name='optional-extra'), + ): + raise ModuleNotFoundError("No module named 'backports.other'", name='backports.other') + finally: + del sys.modules[module_name] + + def test_to_safe_id() -> None: assert to_safe_id('abc') == 'abc' assert to_safe_id('abc/def') == 'abc~def' From bc033f7b4a4ec1c0f977cea9ed7729ceeec491d4 Mon Sep 17 00:00:00 2001 From: Apify AI Factory Date: Tue, 10 Nov 2026 10:28:28 +0100 Subject: [PATCH 07/21] docs: note the zstd build requirement in the Actor server Raises section --- src/apify_client/_resource_clients/actor_server.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/apify_client/_resource_clients/actor_server.py b/src/apify_client/_resource_clients/actor_server.py index afddb71cf..ddde00d80 100644 --- a/src/apify_client/_resource_clients/actor_server.py +++ b/src/apify_client/_resource_clients/actor_server.py @@ -203,7 +203,8 @@ def request( Raises: ApifyClientError: If the Actor is not a Server Actor. NotFoundError: If the Actor does not exist. - ImportError: If `compression` needs an optional extra that is not installed. + ImportError: If `compression` needs an optional extra that is not installed, or for `'zstd'` on Python 3.14 + and newer, if the Python build lacks zstd support. ValueError: If both `data` and `json` are passed, or `compression` is not a supported algorithm. """ server_url = self.get_url() @@ -399,7 +400,8 @@ async def request( Raises: ApifyClientError: If the Actor is not a Server Actor. NotFoundError: If the Actor does not exist. - ImportError: If `compression` needs an optional extra that is not installed. + ImportError: If `compression` needs an optional extra that is not installed, or for `'zstd'` on Python 3.14 + and newer, if the Python build lacks zstd support. ValueError: If both `data` and `json` are passed, or `compression` is not a supported algorithm. """ server_url = await self.get_url() From 214c2fdad8ce8f1a2db34e95c76f621b7caa1909 Mon Sep 17 00:00:00 2001 From: Apify AI Factory Date: Tue, 10 Nov 2026 10:30:14 +0100 Subject: [PATCH 08/21] docs: document the zstd compression option on the client constructors --- src/apify_client/_apify_client.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/apify_client/_apify_client.py b/src/apify_client/_apify_client.py index 255c87ea5..ec6518683 100644 --- a/src/apify_client/_apify_client.py +++ b/src/apify_client/_apify_client.py @@ -157,11 +157,14 @@ def __init__( compression: Compression algorithm for request bodies. Defaults to `'gzip'`. Pass a string literal to select an algorithm, or an `HttpCompressor` instance for finer-grained control. `'brotli'` needs the `brotli` extra (`pip install "apify-client[brotli]"`). It is not the default because the `brotli` package is a C - extension, while gzip support is part of the Python standard library. + extension, while gzip support is part of the Python standard library. `'zstd'` uses the standard + library on Python 3.14 and newer when the Python build includes zstd, and needs the `zstd` extra + (`pip install "apify-client[zstd]"`) before that. Raises: ValueError: If `max_retries` is negative. - ImportError: If `compression` is `'brotli'` and the `brotli` extra is not installed. + ImportError: If `compression` is `'brotli'` or `'zstd'` and the extra it needs is not installed, or for + `'zstd'` on Python 3.14 and newer, if the Python build lacks zstd support. """ if max_retries < 0: raise ValueError(f'max_retries must be a non-negative integer, got {max_retries}') @@ -540,11 +543,14 @@ def __init__( compression: Compression algorithm for request bodies. Defaults to `'gzip'`. Pass a string literal to select an algorithm, or an `HttpCompressor` instance for finer-grained control. `'brotli'` needs the `brotli` extra (`pip install "apify-client[brotli]"`). It is not the default because the `brotli` package is a C - extension, while gzip support is part of the Python standard library. + extension, while gzip support is part of the Python standard library. `'zstd'` uses the standard + library on Python 3.14 and newer when the Python build includes zstd, and needs the `zstd` extra + (`pip install "apify-client[zstd]"`) before that. Raises: ValueError: If `max_retries` is negative. - ImportError: If `compression` is `'brotli'` and the `brotli` extra is not installed. + ImportError: If `compression` is `'brotli'` or `'zstd'` and the extra it needs is not installed, or for + `'zstd'` on Python 3.14 and newer, if the Python build lacks zstd support. """ if max_retries < 0: raise ValueError(f'max_retries must be a non-negative integer, got {max_retries}') From 212ce68807163f5949bc933e1f4c0c28985dc858 Mon Sep 17 00:00:00 2001 From: Apify AI Factory Date: Tue, 10 Nov 2026 10:31:48 +0100 Subject: [PATCH 09/21] docs: list zstd among the encodings set_record accepts --- .../_resource_clients/key_value_store.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/apify_client/_resource_clients/key_value_store.py b/src/apify_client/_resource_clients/key_value_store.py index 7d3de8b5c..1ec6e5fe3 100644 --- a/src/apify_client/_resource_clients/key_value_store.py +++ b/src/apify_client/_resource_clients/key_value_store.py @@ -384,9 +384,9 @@ def set_record( content_type: The content type of the saved value. content_encoding: The encoding already applied to `value`, sent as the `Content-Encoding` header. Pass it to upload a pre-compressed value - the client then forwards the bytes as they are instead of - compressing them itself. The API accepts `gzip`, `br`, `deflate`, and `identity`, and stores the - record exactly as uploaded, so this also becomes the encoding the record is served with. A `str`, a - JSON-serializable object, or a text-mode file cannot be carrying a compression and raises + compressing them itself. The API accepts `gzip`, `br`, `zstd`, `deflate`, and `identity`, and stores + the record exactly as uploaded, so this also becomes the encoding the record is served with. A `str`, + a JSON-serializable object, or a text-mode file cannot be carrying a compression and raises `TypeError`; a streamed value is taken at its word, since its bytes are only seen as they are sent. timeout: Timeout for the API HTTP request. """ @@ -828,9 +828,9 @@ async def set_record( content_type: The content type of the saved value. content_encoding: The encoding already applied to `value`, sent as the `Content-Encoding` header. Pass it to upload a pre-compressed value - the client then forwards the bytes as they are instead of - compressing them itself. The API accepts `gzip`, `br`, `deflate`, and `identity`, and stores the - record exactly as uploaded, so this also becomes the encoding the record is served with. A `str`, a - JSON-serializable object, or a text-mode file cannot be carrying a compression and raises + compressing them itself. The API accepts `gzip`, `br`, `zstd`, `deflate`, and `identity`, and stores + the record exactly as uploaded, so this also becomes the encoding the record is served with. A `str`, + a JSON-serializable object, or a text-mode file cannot be carrying a compression and raises `TypeError`; a streamed value is taken at its word, since its bytes are only seen as they are sent. timeout: Timeout for the API HTTP request. """ From ced1d0ea88b72d4b7101cca46bf4076db59221b6 Mon Sep 17 00:00:00 2001 From: Apify AI Factory Date: Tue, 10 Nov 2026 10:34:44 +0100 Subject: [PATCH 10/21] build: let ty resolve without backports.zstd on Python 3.14+ --- pyproject.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index ecac2c2dd..5bd5f1220 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -207,6 +207,10 @@ exclude = ["website/versioned_docs"] [tool.ty.rules] unused-ignore-comment = "error" +[tool.ty.analysis] +# Installed only below Python 3.14 (the `zstd` extra), so it cannot resolve when type checking under 3.14 and newer. +allowed-unresolved-imports = ["backports.zstd"] + [[tool.ty.overrides]] include = ["docs/**/*.py", "website/**/*.py"] [tool.ty.overrides.rules] From d68f6a0fa24f47ac9a954a96f054ed37aac5398f Mon Sep 17 00:00:00 2001 From: Apify AI Factory Date: Tue, 10 Nov 2026 14:18:04 +0100 Subject: [PATCH 11/21] feat: tolerate a zstd module that fails to load on Python 3.14+ --- docs/02_concepts/13_http_compression.mdx | 4 ++-- src/apify_client/_utils/http.py | 2 +- src/apify_client/http_compressors/_resolve.py | 2 +- src/apify_client/http_compressors/_zstd.py | 9 ++++++--- tests/unit/test_http_compressors.py | 20 ++++++++++++++----- tests/unit/test_key_value_store.py | 12 ++++++++++- 6 files changed, 36 insertions(+), 13 deletions(-) diff --git a/docs/02_concepts/13_http_compression.mdx b/docs/02_concepts/13_http_compression.mdx index 352852b04..cb4b18c82 100644 --- a/docs/02_concepts/13_http_compression.mdx +++ b/docs/02_concepts/13_http_compression.mdx @@ -101,7 +101,7 @@ Then pass `compression='brotli'` to the client constructor. If you request brotl ## Enabling zstd -Python 3.14 and newer ship zstd in the standard library as `compression.zstd`, so pass `compression='zstd'` and nothing else is needed, as long as your Python build includes zstd support, which the python.org builds do. On older Python versions, install the optional extra, which brings in the `backports.zstd` package: +Python 3.14 and newer ship zstd in the standard library as `compression.zstd`, so `compression='zstd'` works there without an extra. On a Python build compiled without zstd support, or whose zstd library can't be loaded, `compression.zstd` can't be imported. On older Python versions, install the optional extra, which brings in the `backports.zstd` package: ```bash pip install "apify-client[zstd]" @@ -173,5 +173,5 @@ client = ApifyClient(token='MY-APIFY-TOKEN', compression=IdentityCompressor()) | **Best for** | Large payloads where bandwidth matters | Minimal-dependency environments | Fast compression at a good ratio | :::tip -For most workloads, the bandwidth savings from brotli outweigh the CPU costs. Install the `brotli` extra and pass `compression='brotli'` unless you can't install additional packages in your environment. +For most workloads, the bandwidth savings from brotli outweigh the CPU costs. Install the `brotli` extra and pass `compression='brotli'` unless you can't install additional packages in your environment. On Python 3.14 and newer, `compression='zstd'` is an alternative that needs no extra package. ::: diff --git a/src/apify_client/_utils/http.py b/src/apify_client/_utils/http.py index 40e2c1c96..058080b91 100644 --- a/src/apify_client/_utils/http.py +++ b/src/apify_client/_utils/http.py @@ -76,7 +76,7 @@ def to_path_segment(value: str) -> str: def is_compressible_content_type(content_type: str | None) -> bool: """Decide whether a request body with the given content type is worth compressing. - Images, audio, video and archives already carry their own compression. Running them through gzip, brotli or + Images, audio, video and archives already carry their own compression. Running them through gzip, brotli, or zstd burns CPU, holds a second full copy of the body in memory, and usually produces output slightly larger than the input. Formats that are raw despite such a media type, for example `image/bmp` or `audio/wav`, are still compressed. A body with no content type is assumed to be compressible. diff --git a/src/apify_client/http_compressors/_resolve.py b/src/apify_client/http_compressors/_resolve.py index 810e4d83a..afb456f6c 100644 --- a/src/apify_client/http_compressors/_resolve.py +++ b/src/apify_client/http_compressors/_resolve.py @@ -34,7 +34,7 @@ def resolve_compressor(compression: HttpCompressionAlgorithm | HttpCompressor) - return BrotliHttpCompressor() if compression == 'zstd': - # Imported here for the same reason as brotli: before Python 3.14 zstd needs the `zstd` extra. + # Imported here so a missing `zstd` extra, or a Python build without zstd, raises `ImportError` at call time. from apify_client.http_compressors import ZstdHttpCompressor # noqa: PLC0415 return ZstdHttpCompressor() diff --git a/src/apify_client/http_compressors/_zstd.py b/src/apify_client/http_compressors/_zstd.py index 4bc6d334b..072690dc9 100644 --- a/src/apify_client/http_compressors/_zstd.py +++ b/src/apify_client/http_compressors/_zstd.py @@ -7,12 +7,15 @@ if sys.version_info >= (3, 14): try: from compression import zstd - except ModuleNotFoundError as exc: - # `compression.zstd` is optional when CPython is built, and no extra can add it to a build without it. + except ImportError as exc: + # `compression.zstd` is optional when CPython is built, and no extra can add it to a build without it. A plain + # `ImportError` means `_zstd` exists but can't load, for example when the zstd shared library is missing. if exc.name != '_zstd': raise + # The name must match the `dependency_name` that `http_compressors/__init__.py` passes to `try_import`. raise ModuleNotFoundError( - 'zstd compression is unavailable: this Python build was compiled without zstd support (`compression.zstd`)', + 'zstd compression is unavailable: this Python build was compiled without zstd support, or its zstd library ' + f'cannot be loaded (`compression.zstd`): {exc}', name='_zstd', ) from exc else: diff --git a/tests/unit/test_http_compressors.py b/tests/unit/test_http_compressors.py index 5e853314b..1ed71a243 100644 --- a/tests/unit/test_http_compressors.py +++ b/tests/unit/test_http_compressors.py @@ -34,18 +34,21 @@ @contextmanager -def _dependency_unavailable(dependency: str, *importers: str) -> Generator[None]: +def _dependency_unavailable( + dependency: str, *importers: str, error: type[ImportError] = ModuleNotFoundError +) -> Generator[None]: """Reimport the `http_compressors` package with the `dependency` import forced to fail. Simulates an environment where the optional extra providing `dependency` is not installed, and restores the original module state afterwards so other tests in the same worker are unaffected. `importers` names cached packages outside `http_compressors` that import `dependency`; they and their submodules are reimported too. + `error` is the exception the import raises: `ImportError` simulates a module that exists but can't load. """ class _Blocker: def find_spec(self, name: str, *_args: object) -> None: if name == dependency or name.startswith(f'{dependency}.'): - raise ModuleNotFoundError(f"No module named '{name}'", name=dependency) + raise error(f"No module named '{name}'", name=dependency) def _affected(name: str) -> bool: prefixes = tuple(f'{package}.' for package in (dependency, *importers)) @@ -88,7 +91,7 @@ def test_gzip_compressor_round_trips_at_quality(quality: int) -> None: assert gzip.decompress(GzipHttpCompressor(quality=quality).compress(b'payload')) == b'payload' -def test_zstd_compressor_defaults_to_level_3(monkeypatch: pytest.MonkeyPatch) -> None: +def test_zstd_compressor_defaults_to_quality_3(monkeypatch: pytest.MonkeyPatch) -> None: """Zstd compressor uses compression level `3` when no quality is given.""" compress = Mock(return_value=b'compressed') monkeypatch.setattr(zstd, 'compress', compress) @@ -241,9 +244,16 @@ def test_resolve_compressor_zstd_raises_clear_error_when_extra_missing() -> None @pytest.mark.skipif(sys.version_info < (3, 14), reason='zstd is in the standard library only from Python 3.14') -def test_zstd_import_hook_raises_clear_error_when_python_lacks_zstd() -> None: +@pytest.mark.parametrize( + 'error', + [ + pytest.param(ModuleNotFoundError, id='module missing'), + pytest.param(ImportError, id='module fails to load'), + ], +) +def test_zstd_import_hook_raises_clear_error_when_python_lacks_zstd(error: type[ImportError]) -> None: """On a Python 3.14+ build without zstd, the package still imports and `ZstdHttpCompressor` raises `ImportError`.""" - with _dependency_unavailable('_zstd', 'compression'): + with _dependency_unavailable('_zstd', 'compression', error=error): module = importlib.import_module('apify_client.http_compressors') assert module.GzipHttpCompressor is not None with pytest.raises(ImportError, match='compiled without zstd support'): diff --git a/tests/unit/test_key_value_store.py b/tests/unit/test_key_value_store.py index e9352ca9c..f6e399913 100644 --- a/tests/unit/test_key_value_store.py +++ b/tests/unit/test_key_value_store.py @@ -2,6 +2,7 @@ import gzip import io +import sys import zlib from datetime import timedelta from typing import TYPE_CHECKING, Any @@ -16,6 +17,11 @@ from apify_client.errors import ApifyApiError from apify_client.http_clients import StreamedRequestBody +if sys.version_info >= (3, 14): + from compression import zstd +else: + import backports.zstd as zstd # noqa: PLR0402 - the same import form as `http_compressors/_zstd.py` + if TYPE_CHECKING: from collections.abc import AsyncIterator, Callable, Iterator @@ -113,6 +119,7 @@ async def async_bytes_chunks() -> AsyncIterator[bytes]: _PRE_ENCODED_VALUE_CASES = [ pytest.param('gzip', gzip.compress(_BYTES_VALUE), id='gzip'), pytest.param('br', brotli.compress(_BYTES_VALUE), id='brotli'), + pytest.param('zstd', zstd.compress(_BYTES_VALUE), id='zstd'), pytest.param('deflate', zlib.compress(_BYTES_VALUE), id='encoding the client has no compressor for'), pytest.param('identity', _BYTES_VALUE, id='identity opt-out'), ] @@ -130,10 +137,11 @@ async def async_bytes_chunks() -> AsyncIterator[bytes]: params=[ pytest.param(('gzip', 'gzip'), id='gzip'), pytest.param(('brotli', 'br'), id='brotli'), + pytest.param(('zstd', 'zstd'), id='zstd'), ] ) def compression_case(request: pytest.FixtureRequest) -> tuple[HttpCompressionAlgorithm, str]: - """Run each test over both supported request-body compression algorithms, as (algorithm, content-encoding).""" + """Run each test over every supported request-body compression algorithm, as (algorithm, content-encoding).""" return request.param @@ -164,6 +172,8 @@ def decode_body(request: Request) -> bytes: return gzip.decompress(raw) if encoding == 'br': return brotli.decompress(raw) + if encoding == 'zstd': + return zstd.decompress(raw) return raw From 19d5957a9f0ac9139c9c00b5d5267f04bbc1017d Mon Sep 17 00:00:00 2001 From: apify-factory <299481685+apify-factory@users.noreply.github.com> Date: Tue, 10 Nov 2026 13:52:35 +0000 Subject: [PATCH 12/21] chore: lock backports.zstd for the zstd extra --- uv.lock | 82 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 81 insertions(+), 1 deletion(-) diff --git a/uv.lock b/uv.lock index d4bc7a190..08d2f8768 100644 --- a/uv.lock +++ b/uv.lock @@ -63,6 +63,9 @@ brotli = [ httpx2 = [ { name = "httpx2" }, ] +zstd = [ + { name = "backports-zstd", marker = "python_full_version < '3.14'" }, +] [package.dev-dependencies] dev = [ @@ -87,6 +90,7 @@ dev = [ [package.metadata] requires-dist = [ + { name = "backports-zstd", marker = "python_full_version < '3.14' and extra == 'zstd'", specifier = ">=1.0.0" }, { name = "brotli", marker = "extra == 'brotli'", specifier = ">=1.0.9" }, { name = "colorama", specifier = ">=0.4.0" }, { name = "httpx2", marker = "extra == 'httpx2'", specifier = ">=2.0.0" }, @@ -95,7 +99,7 @@ requires-dist = [ { name = "pydantic", extras = ["email"], specifier = ">=2.11.0" }, { name = "typing-extensions", specifier = ">=4.6.0" }, ] -provides-extras = ["brotli", "httpx2"] +provides-extras = ["brotli", "httpx2", "zstd"] [package.metadata.requires-dev] dev = [ @@ -127,6 +131,82 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/46/bd/551ee6af426af84ca33e02622be722925c196608e9127d731ef17c47f06e/argcomplete-3.7.2-py3-none-any.whl", hash = "sha256:6029205678bdd9c1c728a155f5f9ecf5812393f969eef58807641a2bc2aa5b19", size = 43294, upload-time = "2026-08-06T04:53:20.246Z" }, ] +[[package]] +name = "backports-zstd" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ff/9c/13569626440e88f09d16f43ec1c2aa0d10a523be2811414580d1cfb7c9f3/backports_zstd-1.8.0.tar.gz", hash = "sha256:9dae4f4c481716e3db473d667457b4f508ff7459c0931b567a5c9677fb3db316", size = 1006566, upload-time = "2026-10-10T16:36:40.642Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/b2/43853a0c366f26b140c272adce74b3c280a2e28ee023c53af53ddd6d9d93/backports_zstd-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4c4af1b9542bc6420d55ff47d7efe13c19f56a80cbdd1ffd0a29767801dab886", size = 439457, upload-time = "2026-10-10T16:34:28.048Z" }, + { url = "https://files.pythonhosted.org/packages/20/6d/ab02ba30a51fa9ec452ee0aaccee7e9c3feda8b3a1b0f7e6aeac0a8a5259/backports_zstd-1.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8efdb220f34418cef987da10d857cf95cdcffe431cc0e536efc25d7279abf118", size = 368264, upload-time = "2026-10-10T16:34:29.599Z" }, + { url = "https://files.pythonhosted.org/packages/cd/71/7632053324885d43fe9ad376607885462386a1de6ec6daad3eee291c6ac8/backports_zstd-1.8.0-cp311-cp311-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:e70eefb72358ae3c94eac62cf7fa3c392cc21f0a8221d6cdaf3d74aedb9775bf", size = 508606, upload-time = "2026-10-10T16:34:31.201Z" }, + { url = "https://files.pythonhosted.org/packages/34/68/7743d8b0c0b28696b2b4757d90afe2844e8a91121d63951829ad9d27edb2/backports_zstd-1.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c6f9ecc5a251fd9495ee717daa0dc87c195f50d6d3679ddb430eb58256a0ca53", size = 478535, upload-time = "2026-10-10T16:34:32.859Z" }, + { url = "https://files.pythonhosted.org/packages/ef/a2/99a32b753e233f501287ee7df2011a9828242c9f0d1c6a5045a4fd587f2e/backports_zstd-1.8.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:84d7c45f063ee8cce1dc14cf382511554b0db19234094fa91214be68d185a5a8", size = 583901, upload-time = "2026-10-10T16:34:34.625Z" }, + { url = "https://files.pythonhosted.org/packages/5e/fd/1812a60ed4943049accfd820d18eeca8ad79461eea9b0be6f52b29614851/backports_zstd-1.8.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:117e1ebc7224ea328c7fba82dfe6b76cead2a2b1f427dabcd8a5fa87c47abd15", size = 643571, upload-time = "2026-10-10T16:34:36.43Z" }, + { url = "https://files.pythonhosted.org/packages/cf/c9/3eb6466013bbee7f12cf442507ca80d3e31ec1fd68156c57647518a47d27/backports_zstd-1.8.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7fe40a58dbe1fd358e0ceb5b6b3f50a9b328f8fff42dcb3bdaeb9a022c2506", size = 493818, upload-time = "2026-10-10T16:34:38.185Z" }, + { url = "https://files.pythonhosted.org/packages/66/c7/1c8fb5b9e97aa172d68e4bbfb808962a32e9c89b7f25f81cec47c16b5d6d/backports_zstd-1.8.0-cp311-cp311-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ba1f16c4196b8392e0adc1f201d0d1aadcc0b78dbe9049fc3d98633cbce565d9", size = 567704, upload-time = "2026-10-10T16:34:40.111Z" }, + { url = "https://files.pythonhosted.org/packages/ab/46/8ff2cca539dc1bc35e85c75772ce901ccaa4696cc0c32f8bd00f426595f9/backports_zstd-1.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3568397b72546bab27054fb7526f90b2842a6978cda1224f37c061087ea15bb1", size = 484551, upload-time = "2026-10-10T16:34:41.776Z" }, + { url = "https://files.pythonhosted.org/packages/a4/8a/2324e68cb8404b95bdd292575f52c8dd6567a23a4985e6e0322260ea6747/backports_zstd-1.8.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:d0a6cafbc18dd32832bd4c22a40348634d191afadf3e0b82fc5df225dfb94e3b", size = 512127, upload-time = "2026-10-10T16:34:43.418Z" }, + { url = "https://files.pythonhosted.org/packages/b7/06/a18156cd52d65f8186a4ee72ce6fe200a23dc3d366f43097d30d77b2cb5d/backports_zstd-1.8.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e67b330874664e41cb03216e4e33fe79b91304269b329fca82f5bd9e0501a48d", size = 588258, upload-time = "2026-10-10T16:34:45.029Z" }, + { url = "https://files.pythonhosted.org/packages/de/ee/e70d81890364b508fde19979a728161ed836795eab83753c1fdd4e41b395/backports_zstd-1.8.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:290b41aa11285c8e1eeba7450afb7e9fd61572373410110a2a06a23ae97937f9", size = 565565, upload-time = "2026-10-10T16:34:46.632Z" }, + { url = "https://files.pythonhosted.org/packages/31/72/843335eba25b83c6e1c4febca74cf0e8a80c1108876fef2fe2ebce80bc79/backports_zstd-1.8.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:13c00e1c66c78a0d1e1c60d0806e9bd430d4c5c92cdce3fa8d087aea436bf449", size = 634218, upload-time = "2026-10-10T16:34:48.272Z" }, + { url = "https://files.pythonhosted.org/packages/90/24/86a428aed44e8389e4436f9e913ba90563efd61779ad5caa360822154fe5/backports_zstd-1.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0f722107de223fe68efa83b1cc3a11d67d1888441073732f0d350ff8111d23df", size = 497825, upload-time = "2026-10-10T16:34:50.146Z" }, + { url = "https://files.pythonhosted.org/packages/bb/0e/a8e246b4ef0e992cd764f7bc898de2878380c3af4b85d5c0e2bd6d22d0fe/backports_zstd-1.8.0-cp311-cp311-win32.whl", hash = "sha256:6b6c46d5d5932b7ad24f42069104919fa806fac0a02144aa8af0f9bb96705274", size = 292853, upload-time = "2026-10-10T16:34:51.927Z" }, + { url = "https://files.pythonhosted.org/packages/50/53/4e36af749d8c115659acfee2bcc6ebbf5cc34fdd30b467c205eae4925c6d/backports_zstd-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:a11422c67c6295d36a7a30bac5df82e8a4fc82539d8def0d082ecf15cb24f538", size = 330411, upload-time = "2026-10-10T16:34:53.439Z" }, + { url = "https://files.pythonhosted.org/packages/43/13/9a027f33f95d2d4ab565e9d3655cb8f71e2a1e32e86a57195a787e00483b/backports_zstd-1.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:0a77b019b80038b1426a74849b0fb8f9b46f876cee74f6d59f26acd1559d4c01", size = 321855, upload-time = "2026-10-10T16:34:54.865Z" }, + { url = "https://files.pythonhosted.org/packages/d3/03/3c303d6f3066f84f2c52acfc38852546a836596dd9a2bc7add83bd96b527/backports_zstd-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6e024aee6bfd04094fce60133b0e6bd0f8027cdb2823157880bc87f1ffdfee21", size = 439718, upload-time = "2026-10-10T16:34:56.573Z" }, + { url = "https://files.pythonhosted.org/packages/92/31/1e73b2835c78a9067ecba390b0eea032f827fc0b2f8bf2c8656992c30dc8/backports_zstd-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d810d83c8a703f424ed2a49aa271078c91b530da2d8c104bd88207e68d116de8", size = 368337, upload-time = "2026-10-10T16:34:58.287Z" }, + { url = "https://files.pythonhosted.org/packages/85/43/b0cc88c7d13a544f6d38f288fd96e1595395dad31f49fad2619f06b96d95/backports_zstd-1.8.0-cp312-cp312-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:d057948e8cffa19f0cc8668e06fd502ad8a69f398e91a426b39dcc5eeb197c2f", size = 509148, upload-time = "2026-10-10T16:34:59.951Z" }, + { url = "https://files.pythonhosted.org/packages/ed/29/81cc731a0408c3cba05a44ece00476305dbe1a52e27a4c323c98685f7015/backports_zstd-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6aa762cf369d9bfca1e013eaad562f8e129d71b7a82f0c459870d6d21651bcb3", size = 478911, upload-time = "2026-10-10T16:35:01.791Z" }, + { url = "https://files.pythonhosted.org/packages/df/63/dc62779cabb725a8974a2d303bfe0d7cd5b8987fab79ab445c48efcfb2e4/backports_zstd-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0b9d6c4ca7d927fd094badcf9174ee5c82ddb4855fe14658806c8c8a07d4a165", size = 584283, upload-time = "2026-10-10T16:35:03.666Z" }, + { url = "https://files.pythonhosted.org/packages/e5/12/5e8ce29119d78845cd3351bcd79baa16a30aa8c19f8c359a1719a15d97b3/backports_zstd-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:74d85b8ce50aea247289be183f853e67c106959c4048ce286b26c4663b06bb6d", size = 643167, upload-time = "2026-10-10T16:35:05.342Z" }, + { url = "https://files.pythonhosted.org/packages/3f/08/a9d59fb9e20215ede0c8ea4d729373dc0592aee45776cdd86c92c3c6242c/backports_zstd-1.8.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f9e9aa28a44db1897fb637f037175566f3b75890d4bae6cae7ba34f1df1e0804", size = 496867, upload-time = "2026-10-10T16:35:07.118Z" }, + { url = "https://files.pythonhosted.org/packages/e8/b8/abcd2be476a47dd236500c405df32aa81902c54750b26c626f190bbef6b9/backports_zstd-1.8.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2c431f3cdc7eb663a42574e27a8604a18181ea4e193504f222d8e61c6f5f8b78", size = 571623, upload-time = "2026-10-10T16:35:09.014Z" }, + { url = "https://files.pythonhosted.org/packages/03/ce/31e668dcdfe017b3240f49c3ef67b108224d3f66d90e9f26caecafc3c29c/backports_zstd-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e0431230a67e8f07210efe654abda9844a55c3bf57d74e60425d9d65770b1de4", size = 484948, upload-time = "2026-10-10T16:35:10.974Z" }, + { url = "https://files.pythonhosted.org/packages/5a/98/d9122b7531830ceb0f62adb88694bb8cc414a27d1d03539c44dd96fa7a63/backports_zstd-1.8.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:9b62b6c8c5a43b294d4358c2016bfbc507cc574315ffa75346ccf0b621746461", size = 512635, upload-time = "2026-10-10T16:35:12.658Z" }, + { url = "https://files.pythonhosted.org/packages/6e/f0/168c6d0c93a3ad6568d0b0ac2f732efc9132b2839d4e6759e61f5239107d/backports_zstd-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:869ab7e5421873dfbdbf646d52b4e8d711093972819c06c6daf3249a1ec6e0e7", size = 588696, upload-time = "2026-10-10T16:35:14.595Z" }, + { url = "https://files.pythonhosted.org/packages/22/32/b8eacce542dae88df98f923e81c079a01b66b7fbdf103e319f6fb1df2dfa/backports_zstd-1.8.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:ec1a796429674ebc0e2d48feb3b6658bf49d3ae840b0c0e14ad50c4d6b7341fe", size = 568895, upload-time = "2026-10-10T16:35:16.287Z" }, + { url = "https://files.pythonhosted.org/packages/dd/16/8abede9513ec8fd584e36159b1dce82042a97214e69f53f08605b245999f/backports_zstd-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:775b701a576769df053cfb7d9456b06223b40e329c010be6cc178fe9e404a3d2", size = 633610, upload-time = "2026-10-10T16:35:18.014Z" }, + { url = "https://files.pythonhosted.org/packages/6d/74/4e82ed15ae212b0fc0cd8f82c5bbf6a9dd584b6b37df0c3485663c6ad105/backports_zstd-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ab77a2e6e21c57e8341bb7656c71d1a1653151ebe787b3f092ce86a02543eb52", size = 501407, upload-time = "2026-10-10T16:35:19.688Z" }, + { url = "https://files.pythonhosted.org/packages/bd/02/7e86774e0a3c2457d23939acbb32bdb019e6bdec48892986255faa262c3d/backports_zstd-1.8.0-cp312-cp312-win32.whl", hash = "sha256:f99b44c2c13fc60f65ad568bf7401d9540370f996b1040793a34988324e3b712", size = 293065, upload-time = "2026-10-10T16:35:21.309Z" }, + { url = "https://files.pythonhosted.org/packages/a5/78/2f497fd2bbf46099e46650f75467967d21f25bb921c894d28d493bbfb7e4/backports_zstd-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:1eddf59fedaf19dd3a8e9c597add7eb6f0d51d4467a0924b2dcd2c118ed18ff5", size = 330563, upload-time = "2026-10-10T16:35:22.968Z" }, + { url = "https://files.pythonhosted.org/packages/ba/2c/3a1a91cea5b98e24cb54ecf142a72246d2e1efa5efe41504388188598951/backports_zstd-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:2b3247a7a916b90f155b4133eedaceadd0c37b4149ee32e4d74fe512a14be89b", size = 322189, upload-time = "2026-10-10T16:35:24.494Z" }, + { url = "https://files.pythonhosted.org/packages/66/a8/7a04f1daaa42936ec3d98f213b4698b18053d1154f2aee1d067c4121fe3a/backports_zstd-1.8.0-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:4e92ff4ce96b3c61d25900875b6cf1ee249349b8e419abd80893ec9b8026444e", size = 401586, upload-time = "2026-10-10T16:35:26.263Z" }, + { url = "https://files.pythonhosted.org/packages/ef/c2/d26216501b3e13583084e11106ade1779b280f3304c75d84d2dfb9e5d609/backports_zstd-1.8.0-cp313-cp313-android_24_x86_64.whl", hash = "sha256:0c2e652b4fbc2e6b7bd05a09b6eab3a51bfaed9e7fca1bc81d763dc47361e2ff", size = 455589, upload-time = "2026-10-10T16:35:28.174Z" }, + { url = "https://files.pythonhosted.org/packages/df/66/372b138fa7e7be4d6aff343a55dd77e492867cb5de701899b5aa01722836/backports_zstd-1.8.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:915d3e7e57194b5cee33f10cf2d9f5c4f7658c8a167236f9ba5501520cf133e8", size = 358662, upload-time = "2026-10-10T16:35:29.819Z" }, + { url = "https://files.pythonhosted.org/packages/7a/26/0b89de2f83088f89e10ea3f4a5badef9bc95098bdd39a3031362da48dc60/backports_zstd-1.8.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4e6f8483b795a09c0e0fbacca4fa844242bc6d5fc64b8a6ee99f88ad8af27b08", size = 367357, upload-time = "2026-10-10T16:35:31.649Z" }, + { url = "https://files.pythonhosted.org/packages/74/01/5239b39d3f65ba80e2129b9273bf736245e4a1c03b8a317ed399c4fe10dd/backports_zstd-1.8.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:1fe4b06a019aa4cdf87af320eef56a4bdbdb924ead36a7a918645d72edece966", size = 447892, upload-time = "2026-10-10T16:35:33.534Z" }, + { url = "https://files.pythonhosted.org/packages/b5/13/e4eceee62d144f68944addb0179368d626f96d3644d965620774f1f5e463/backports_zstd-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:49c4006cdf41c15ffcc74f10d9a6485be841106cd4d5aa7ea7bf1075cc37fb83", size = 439240, upload-time = "2026-10-10T16:35:35.351Z" }, + { url = "https://files.pythonhosted.org/packages/1f/5f/996aceebbbc4eebc05d99fe1714b1b0930260eac5171e8ebc3a952390c0d/backports_zstd-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4fa862d24b7fb392279a95bc9acc1f0ede8a25de9efbed03fb305ceac2f6abb0", size = 367710, upload-time = "2026-10-10T16:35:37.004Z" }, + { url = "https://files.pythonhosted.org/packages/93/0b/c373a7f92df9df1f9e0657ea0dd86c45444b8414db616b3d38b62f90075c/backports_zstd-1.8.0-cp313-cp313-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:9af83a6d7dc67896fd91bcd4c2cd182ba97d7cca2b09a94373a5fef154001d98", size = 508347, upload-time = "2026-10-10T16:35:38.683Z" }, + { url = "https://files.pythonhosted.org/packages/b4/36/07dca77032300047efd09808d49ab9d1fff8657553adbc8e0e6405aba864/backports_zstd-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1a808ba1371231c00a2b71f03840a727088e287d0ee1dfb3230958950f21f421", size = 478416, upload-time = "2026-10-10T16:35:40.504Z" }, + { url = "https://files.pythonhosted.org/packages/ee/a9/bb96724619a1dcc3a9e3138d15a6f7a2fc40b581926db4ac00e424af79c1/backports_zstd-1.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6cc15051c282ac2585a2425d22f416ae2deb5afb441b22831b349b02fd58a782", size = 583888, upload-time = "2026-10-10T16:35:42.159Z" }, + { url = "https://files.pythonhosted.org/packages/cd/6d/65e6e437eb54b5be2ce7248ac236d82a771a672457c950e7f96849699274/backports_zstd-1.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7a23d38d7b9ca93403acd3c2c306af6e547a24d150c25ac2d7a8acd751fbd968", size = 644796, upload-time = "2026-10-10T16:35:43.882Z" }, + { url = "https://files.pythonhosted.org/packages/5d/6d/3c422b33d40aaca6e9d9fdd47f1a047ac499de749c887ab3dab62f731fb2/backports_zstd-1.8.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44a9004f9e809ea56910d326d21946650369db59eb86edc0c76840f21530704c", size = 493385, upload-time = "2026-10-10T16:35:45.576Z" }, + { url = "https://files.pythonhosted.org/packages/ba/b9/ea08e2c2b8a7bfabff359852e4d7a9cbc2cde09715907250c0e53432fbe9/backports_zstd-1.8.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5ff307f3f0ef3b7f40ccfce42c0704fddc99cd30bca451330f42466db1981be9", size = 568613, upload-time = "2026-10-10T16:35:47.394Z" }, + { url = "https://files.pythonhosted.org/packages/b2/6e/775cb7317f1f693c7f3e96fa5cf5426b461616b52730a72f978f31b334b0/backports_zstd-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6c8572e27c5f0b9d11020d3f597bf3c35fe0f5ae6f99156dc52b0bd937ba8908", size = 484237, upload-time = "2026-10-10T16:35:49.496Z" }, + { url = "https://files.pythonhosted.org/packages/fc/f8/c31798a8911390fb0d4f058f65cba2e54141d6394c35430b1d495d121667/backports_zstd-1.8.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cc1d9d3660c40abe4095de80f43ce4c955d08f7d9803d3da97176aa61b76d923", size = 511865, upload-time = "2026-10-10T16:35:51.223Z" }, + { url = "https://files.pythonhosted.org/packages/68/df/0ff79b6a2d7f5c10d3ebc7e23b5281f51130feb4db8afadac98ba5131c18/backports_zstd-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:83cea5cdd70e1d74382be6deeeda1db79aedd1a06af4f8a8fbafba9eedae5230", size = 588422, upload-time = "2026-10-10T16:35:53.371Z" }, + { url = "https://files.pythonhosted.org/packages/19/a7/d5dbad63911fc3040253dc209a7aac8921e928fe64f3fcde051066aa5a75/backports_zstd-1.8.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e74eb204b9d7798fc57393202c443fc2ec84283d82387168baeb763f8beb224d", size = 566480, upload-time = "2026-10-10T16:35:55.459Z" }, + { url = "https://files.pythonhosted.org/packages/d8/b9/621e734eb144d56c7632b763c0ce3fa196839fc0f82830244206a9d37d8d/backports_zstd-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:515497b3d49dd6d7a84fb16a0a0007bc460b4a7e1f55e70f33315c66d3844e8e", size = 635191, upload-time = "2026-10-10T16:35:57.307Z" }, + { url = "https://files.pythonhosted.org/packages/af/72/1b6709f13f2a22a1d72e15f114ab62e852db33ba0f8840c7d102523bcdb6/backports_zstd-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6283c90997038abf46c8a0bb75afb4dc6cbf061421802fda0afc382fe4b348b3", size = 497769, upload-time = "2026-10-10T16:35:59.395Z" }, + { url = "https://files.pythonhosted.org/packages/de/52/cd0a82fd52ae159a0316d2257156968c356cab81062d6050af48a4e8a3d6/backports_zstd-1.8.0-cp313-cp313-win32.whl", hash = "sha256:9d76a3193a3a4a6b1249021e7ecf72e4cabc1dca611c6fb41db1c0b5d2faf741", size = 292645, upload-time = "2026-10-10T16:36:01.439Z" }, + { url = "https://files.pythonhosted.org/packages/12/0e/5c5a916cea73b455850083ccf76078de655face3dfe4126848570c57a6dd/backports_zstd-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:b583990d554cc6f6141c5c43b6db3c7da87a214253e08339d917ee3baa3021b6", size = 330247, upload-time = "2026-10-10T16:36:03.058Z" }, + { url = "https://files.pythonhosted.org/packages/86/3c/7297d87eed9254f6b4823c05b37aa07ec2a99bc5f195760dc574e925eecf/backports_zstd-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:0600e166cb00739a26de74ee1696221a53a4d5dc1f96a0bdeb6b307c1626c15c", size = 322066, upload-time = "2026-10-10T16:36:04.932Z" }, + { url = "https://files.pythonhosted.org/packages/42/1c/74a4b8310af405f477b5278ae652d35f0609acae3f23c9fc472f79d11600/backports_zstd-1.8.0-pp311-pypy311_pp80-macosx_10_15_x86_64.whl", hash = "sha256:900b357bbae805bb98672471ede748c80ccfc1212be0b4ef52a102750ef742a7", size = 413974, upload-time = "2026-10-10T16:36:17.615Z" }, + { url = "https://files.pythonhosted.org/packages/30/1c/3bb324f70aac60a4c5aad60b9d365af2dac81205b20ecf66e04947381228/backports_zstd-1.8.0-pp311-pypy311_pp80-macosx_11_0_arm64.whl", hash = "sha256:1eae18c682f7daf8d7b39c988516d7a123ec446beb77f709d0cb1475ab57f0cc", size = 344649, upload-time = "2026-10-10T16:36:19.602Z" }, + { url = "https://files.pythonhosted.org/packages/95/fc/a62c13e0498fb951a65caf8c979624fddd1085e388b067ec7b225b59c1e9/backports_zstd-1.8.0-pp311-pypy311_pp80-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:59d29e16273a440af6beb11965cfa84cd19207b38fb5302b2430bc8eabef4812", size = 422892, upload-time = "2026-10-10T16:36:21.375Z" }, + { url = "https://files.pythonhosted.org/packages/6c/9b/6d8e6044eb6a829c075f2f1e59dc6a9789de606c4ef95fb66095efb3a47f/backports_zstd-1.8.0-pp311-pypy311_pp80-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:307badd18496d7c7c6adb91b524b120b4fd3ab5609ec794c36953b9a5f4f4728", size = 396431, upload-time = "2026-10-10T16:36:23.436Z" }, + { url = "https://files.pythonhosted.org/packages/db/50/c5dd607ca0281509ce22b683d43ad801b68b36b9dd0429e5d34c50886f6f/backports_zstd-1.8.0-pp311-pypy311_pp80-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:40966dc0a3d08d56f83a6b79239d3f294896c9aee453449064fc3627058448fb", size = 416398, upload-time = "2026-10-10T16:36:25.197Z" }, + { url = "https://files.pythonhosted.org/packages/24/9c/0210e539a290f64d1303afeae4f79f94ed97e8cf7171bd385fc373a4c414/backports_zstd-1.8.0-pp311-pypy311_pp80-win_amd64.whl", hash = "sha256:029bca2385ebb4355135bdb8559792d2768ae19707705eea84e68c42a30a0276", size = 404276, upload-time = "2026-10-10T16:36:27.003Z" }, + { url = "https://files.pythonhosted.org/packages/1f/c8/dba9e5905e83ac955c1c19b797f59f5335a351664a7b25a709929d63dfbc/backports_zstd-1.8.0-pp312-pypy312_pp80-macosx_10_15_x86_64.whl", hash = "sha256:f710d03f84d74f11737735f846b44ef1545cadb73ef47bcd3d0e124f253dd763", size = 413972, upload-time = "2026-10-10T16:36:28.92Z" }, + { url = "https://files.pythonhosted.org/packages/93/11/8ee691bfd2c8292a573a0378a616372aa01ed9e6001d5778ae666a239265/backports_zstd-1.8.0-pp312-pypy312_pp80-macosx_11_0_arm64.whl", hash = "sha256:2b11fb8b9c798657c97ad3165893f146c300e2f7f800e9c54c0d2143052c1486", size = 344652, upload-time = "2026-10-10T16:36:30.853Z" }, + { url = "https://files.pythonhosted.org/packages/19/33/86bb2cd5c6e827adba98fb091ccecb29dae3bb33e0406f8e08be7bdbe70b/backports_zstd-1.8.0-pp312-pypy312_pp80-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:ec7351d3e6ea92338dc4e0e53c876d2e2092e07ad3a2083088e0160200efdd15", size = 422892, upload-time = "2026-10-10T16:36:32.708Z" }, + { url = "https://files.pythonhosted.org/packages/42/a2/629f5e9c3edd2a31f7dd65b8097241b5036f98105efac251a12c1a8f7cb5/backports_zstd-1.8.0-pp312-pypy312_pp80-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:63ae348b629121eeb967244fecd254f41b4b3a63d074c252f4d7777f5d17c71c", size = 396431, upload-time = "2026-10-10T16:36:34.842Z" }, + { url = "https://files.pythonhosted.org/packages/9e/f6/9c223e9cccc5a797c17475fde1a8a78ada0dcdd39be2302f4605e565c0ce/backports_zstd-1.8.0-pp312-pypy312_pp80-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:163b5c36321bf5652b6e4aeb04d3644ddbf9c1881a82322e376e5be3532af26b", size = 416398, upload-time = "2026-10-10T16:36:36.706Z" }, + { url = "https://files.pythonhosted.org/packages/8f/e3/2eb6f517c9a6746a735b49ba4ab3ed3df6c4ec9072169805547ae590e296/backports_zstd-1.8.0-pp312-pypy312_pp80-win_amd64.whl", hash = "sha256:3f0288db18a64f4f4146f4526456ff62b2edb625b2d43956e764885edd3f1da2", size = 404272, upload-time = "2026-10-10T16:36:38.766Z" }, +] + [[package]] name = "black" version = "26.10.1" From a155d9a6ecf49d7108b9225946c3a829229d93ea Mon Sep 17 00:00:00 2001 From: apify-factory <299481685+apify-factory@users.noreply.github.com> Date: Tue, 10 Nov 2026 13:57:27 +0000 Subject: [PATCH 13/21] feat: suggest gzip or brotli when the Python build lacks zstd --- src/apify_client/http_compressors/_zstd.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/apify_client/http_compressors/_zstd.py b/src/apify_client/http_compressors/_zstd.py index 072690dc9..eed8d4435 100644 --- a/src/apify_client/http_compressors/_zstd.py +++ b/src/apify_client/http_compressors/_zstd.py @@ -14,8 +14,8 @@ raise # The name must match the `dependency_name` that `http_compressors/__init__.py` passes to `try_import`. raise ModuleNotFoundError( - 'zstd compression is unavailable: this Python build was compiled without zstd support, or its zstd library ' - f'cannot be loaded (`compression.zstd`): {exc}', + 'zstd compression is unavailable because this Python build was compiled without zstd support, or its zstd ' + f"library cannot be loaded ({exc}). Use compression='gzip' or compression='brotli' instead.", name='_zstd', ) from exc else: From 7f623d368ef514fd1f3db27edb0aab36c6022161 Mon Sep 17 00:00:00 2001 From: apify-factory <299481685+apify-factory@users.noreply.github.com> Date: Tue, 10 Nov 2026 13:57:27 +0000 Subject: [PATCH 14/21] docs: tighten the zstd availability notes --- docs/02_concepts/13_http_compression.mdx | 2 +- pyproject.toml | 2 +- src/apify_client/_apify_client.py | 4 ++-- src/apify_client/_resource_clients/actor_server.py | 4 ++-- src/apify_client/http_compressors/_resolve.py | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/02_concepts/13_http_compression.mdx b/docs/02_concepts/13_http_compression.mdx index cb4b18c82..b950e68db 100644 --- a/docs/02_concepts/13_http_compression.mdx +++ b/docs/02_concepts/13_http_compression.mdx @@ -101,7 +101,7 @@ Then pass `compression='brotli'` to the client constructor. If you request brotl ## Enabling zstd -Python 3.14 and newer ship zstd in the standard library as `compression.zstd`, so `compression='zstd'` works there without an extra. On a Python build compiled without zstd support, or whose zstd library can't be loaded, `compression.zstd` can't be imported. On older Python versions, install the optional extra, which brings in the `backports.zstd` package: +Python 3.14 and newer ship zstd in the standard library as `compression.zstd`, so `compression='zstd'` works there without an extra. On older Python versions, install the optional extra, which brings in the `backports.zstd` package: ```bash pip install "apify-client[zstd]" diff --git a/pyproject.toml b/pyproject.toml index 5bd5f1220..0a1d0682e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,7 +36,7 @@ dependencies = [ [project.optional-dependencies] brotli = ["brotli>=1.0.9"] httpx2 = ["httpx2>=2.0.0"] -# zstd is in the standard library (`compression.zstd`) from Python 3.14; older versions use its official backport. +# zstd is in the standard library (`compression.zstd`) from Python 3.14; older versions use the `backports.zstd` package. zstd = ["backports.zstd>=1.0.0; python_version < '3.14'"] [project.urls] diff --git a/src/apify_client/_apify_client.py b/src/apify_client/_apify_client.py index ec6518683..8034a6c0c 100644 --- a/src/apify_client/_apify_client.py +++ b/src/apify_client/_apify_client.py @@ -163,7 +163,7 @@ def __init__( Raises: ValueError: If `max_retries` is negative. - ImportError: If `compression` is `'brotli'` or `'zstd'` and the extra it needs is not installed, or for + ImportError: If `compression` is `'brotli'` or `'zstd'` and the extra it needs is not installed, or, for `'zstd'` on Python 3.14 and newer, if the Python build lacks zstd support. """ if max_retries < 0: @@ -549,7 +549,7 @@ def __init__( Raises: ValueError: If `max_retries` is negative. - ImportError: If `compression` is `'brotli'` or `'zstd'` and the extra it needs is not installed, or for + ImportError: If `compression` is `'brotli'` or `'zstd'` and the extra it needs is not installed, or, for `'zstd'` on Python 3.14 and newer, if the Python build lacks zstd support. """ if max_retries < 0: diff --git a/src/apify_client/_resource_clients/actor_server.py b/src/apify_client/_resource_clients/actor_server.py index ddde00d80..edd84d135 100644 --- a/src/apify_client/_resource_clients/actor_server.py +++ b/src/apify_client/_resource_clients/actor_server.py @@ -203,7 +203,7 @@ def request( Raises: ApifyClientError: If the Actor is not a Server Actor. NotFoundError: If the Actor does not exist. - ImportError: If `compression` needs an optional extra that is not installed, or for `'zstd'` on Python 3.14 + ImportError: If `compression` needs an optional extra that is not installed, or, for `'zstd'` on Python 3.14 and newer, if the Python build lacks zstd support. ValueError: If both `data` and `json` are passed, or `compression` is not a supported algorithm. """ @@ -400,7 +400,7 @@ async def request( Raises: ApifyClientError: If the Actor is not a Server Actor. NotFoundError: If the Actor does not exist. - ImportError: If `compression` needs an optional extra that is not installed, or for `'zstd'` on Python 3.14 + ImportError: If `compression` needs an optional extra that is not installed, or, for `'zstd'` on Python 3.14 and newer, if the Python build lacks zstd support. ValueError: If both `data` and `json` are passed, or `compression` is not a supported algorithm. """ diff --git a/src/apify_client/http_compressors/_resolve.py b/src/apify_client/http_compressors/_resolve.py index afb456f6c..1098dabb2 100644 --- a/src/apify_client/http_compressors/_resolve.py +++ b/src/apify_client/http_compressors/_resolve.py @@ -19,7 +19,7 @@ def resolve_compressor(compression: HttpCompressionAlgorithm | HttpCompressor) - A ready-to-use `HttpCompressor`. Raises: - ImportError: If the requested algorithm needs an optional extra that is not installed, or for `'zstd'` on + ImportError: If the requested algorithm needs an optional extra that is not installed, or, for `'zstd'` on Python 3.14 and newer, if the Python build lacks zstd support. ValueError: If `compression` is not a recognized compression algorithm. """ From 845edaf055392fdffe0a677a0ea75e4b7eb2f6aa Mon Sep 17 00:00:00 2001 From: apify-factory <299481685+apify-factory@users.noreply.github.com> Date: Tue, 10 Nov 2026 14:54:51 +0000 Subject: [PATCH 15/21] docs: split the zstd install notes and shorten the zstd import comment --- README.md | 4 ++-- docs/01_introduction/index.mdx | 2 +- src/apify_client/http_compressors/__init__.py | 3 +-- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index d47cdec37..ff6e11ed4 100644 --- a/README.md +++ b/README.md @@ -57,8 +57,8 @@ uv add "apify-client[brotli]" ``` - To compress with `zstd`, pass `compression='zstd'`. Python 3.14 and newer have it in the standard library; on older - versions install the optional `zstd` extra: `pip install "apify-client[zstd]"`. + To compress with `zstd`, pass `compression='zstd'`. Python 3.14 and newer have it in the standard library. On older + versions, install the optional `zstd` extra: `pip install "apify-client[zstd]"`. [Impit](https://github.com/apify/impit) is the default HTTP client and is installed automatically. To use the built-in [HTTPX2](https://github.com/pydantic/httpx2) client instead, install the optional `httpx2` extra, which diff --git a/docs/01_introduction/index.mdx b/docs/01_introduction/index.mdx index 92cc77ba0..33b060a28 100644 --- a/docs/01_introduction/index.mdx +++ b/docs/01_introduction/index.mdx @@ -61,7 +61,7 @@ For better request-body compression, opt in to `brotli`, which compresses better -To compress with `zstd` instead, pass `compression='zstd'`. On Python 3.14 and newer it uses the standard library; on older versions install the optional `zstd` extra with `pip install "apify-client[zstd]"`. +To compress with `zstd` instead, pass `compression='zstd'`. On Python 3.14 and newer it uses the standard library. On older versions, install the optional `zstd` extra with `pip install "apify-client[zstd]"`. For details, see [HTTP compression](../02_concepts/13_http_compression.mdx). diff --git a/src/apify_client/http_compressors/__init__.py b/src/apify_client/http_compressors/__init__.py index 5f11f39ea..26f1fb94c 100644 --- a/src/apify_client/http_compressors/__init__.py +++ b/src/apify_client/http_compressors/__init__.py @@ -14,8 +14,7 @@ with _try_import(__name__, 'BrotliHttpCompressor', dependency_name='brotli', extra_name='brotli') as _brotli_import: from apify_client.http_compressors._brotli import BrotliHttpCompressor -# zstd is in the standard library from Python 3.14, though a Python build can leave it out, which no extra can fix. -# Older versions need the `backports.zstd` package from the `zstd` extra. Either way it's wrapped in try_import. +# zstd is in the standard library from Python 3.14. Older versions need `backports.zstd` from the `zstd` extra. if _sys.version_info >= (3, 14): _zstd_dependency, _zstd_extra = '_zstd', None else: From b114508547a4d9ee3469a5bb6d4ec53ee20b70f0 Mon Sep 17 00:00:00 2001 From: apify-factory <299481685+apify-factory@users.noreply.github.com> Date: Wed, 11 Nov 2026 08:55:33 +0000 Subject: [PATCH 16/21] fix: keep the client importable when backports.zstd fails to load --- src/apify_client/http_compressors/_zstd.py | 12 +++++++++++- tests/unit/test_http_compressors.py | 12 +++++++++++- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/src/apify_client/http_compressors/_zstd.py b/src/apify_client/http_compressors/_zstd.py index eed8d4435..f9b201e4c 100644 --- a/src/apify_client/http_compressors/_zstd.py +++ b/src/apify_client/http_compressors/_zstd.py @@ -21,7 +21,17 @@ else: # Not `from backports import zstd`: when another `backports.*` package is installed but this one is not, that # raises a plain `ImportError` instead of the `ModuleNotFoundError` the optional-extra handling expects. - import backports.zstd as zstd # noqa: PLR0402 + try: + import backports.zstd as zstd # noqa: PLR0402 + except ModuleNotFoundError: + raise + except ImportError as exc: + # The package is installed but its C extension can't load. Without this, `import apify_client` would fail for + # every user, not only for those who pick zstd. + raise ModuleNotFoundError( + f'`backports.zstd` is installed but cannot be loaded ({exc})', + name='backports.zstd', + ) from exc class ZstdHttpCompressor(HttpCompressor): diff --git a/tests/unit/test_http_compressors.py b/tests/unit/test_http_compressors.py index 1ed71a243..be08f61b9 100644 --- a/tests/unit/test_http_compressors.py +++ b/tests/unit/test_http_compressors.py @@ -20,7 +20,7 @@ if sys.version_info >= (3, 14): from compression import zstd else: - import backports.zstd as zstd # noqa: PLR0402 - the same import form as `http_compressors/_zstd.py` + from backports import zstd if TYPE_CHECKING: from collections.abc import Callable, Generator @@ -243,6 +243,16 @@ def test_resolve_compressor_zstd_raises_clear_error_when_extra_missing() -> None resolve_compressor('zstd') +@pytest.mark.skipif(sys.version_info >= (3, 14), reason='zstd is in the standard library from Python 3.14') +def test_zstd_import_hook_raises_clear_error_when_backport_fails_to_load() -> None: + """If `backports.zstd` is installed but fails to load, the package still imports and `ZstdHttpCompressor` raises.""" + with _dependency_unavailable('backports.zstd', error=ImportError): + module = importlib.import_module('apify_client.http_compressors') + assert module.GzipHttpCompressor is not None + with pytest.raises(ImportError, match='installed but cannot be loaded'): + _ = module.ZstdHttpCompressor + + @pytest.mark.skipif(sys.version_info < (3, 14), reason='zstd is in the standard library only from Python 3.14') @pytest.mark.parametrize( 'error', From 345e0924d445c4fb875eee2404deac4f1cf2d122 Mon Sep 17 00:00:00 2001 From: apify-factory <299481685+apify-factory@users.noreply.github.com> Date: Wed, 11 Nov 2026 08:55:33 +0000 Subject: [PATCH 17/21] test: cover zstd in the compressor fixture and use the plain backports import --- tests/unit/test_http_clients.py | 8 ++++++++ tests/unit/test_key_value_store.py | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/tests/unit/test_http_clients.py b/tests/unit/test_http_clients.py index 7d7d0161f..ac15b92ff 100644 --- a/tests/unit/test_http_clients.py +++ b/tests/unit/test_http_clients.py @@ -4,6 +4,7 @@ import gzip import json import logging +import sys import threading import time from collections.abc import AsyncIterator, Iterator @@ -35,6 +36,12 @@ from apify_client.http_compressors._base import HttpCompressor from apify_client.http_compressors._brotli import BrotliHttpCompressor from apify_client.http_compressors._gzip import GzipHttpCompressor +from apify_client.http_compressors._zstd import ZstdHttpCompressor + +if sys.version_info >= (3, 14): + from compression import zstd +else: + from backports import zstd if TYPE_CHECKING: from collections.abc import Callable @@ -782,6 +789,7 @@ def test_call_retries_only_the_statuses_in_retry_status_codes(monkeypatch: pytes params=[ pytest.param((GzipHttpCompressor(), 'gzip', gzip.decompress), id='gzip'), pytest.param((BrotliHttpCompressor(), 'br', brotli.decompress), id='brotli'), + pytest.param((ZstdHttpCompressor(), 'zstd', zstd.decompress), id='zstd'), ] ) def compressor_case(request: pytest.FixtureRequest) -> tuple: diff --git a/tests/unit/test_key_value_store.py b/tests/unit/test_key_value_store.py index f6e399913..ba6e14081 100644 --- a/tests/unit/test_key_value_store.py +++ b/tests/unit/test_key_value_store.py @@ -20,7 +20,7 @@ if sys.version_info >= (3, 14): from compression import zstd else: - import backports.zstd as zstd # noqa: PLR0402 - the same import form as `http_compressors/_zstd.py` + from backports import zstd if TYPE_CHECKING: from collections.abc import AsyncIterator, Callable, Iterator From 7fe7fe1a76f997eca85cec065aa27cec40b94f6b Mon Sep 17 00:00:00 2001 From: apify-factory <299481685+apify-factory@users.noreply.github.com> Date: Wed, 11 Nov 2026 08:55:33 +0000 Subject: [PATCH 18/21] docs: show uv add for the zstd extra --- README.md | 3 ++- docs/01_introduction/index.mdx | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index ff6e11ed4..d56f24d26 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,8 @@ ``` To compress with `zstd`, pass `compression='zstd'`. Python 3.14 and newer have it in the standard library. On older - versions, install the optional `zstd` extra: `pip install "apify-client[zstd]"`. + versions, install the optional `zstd` extra: `pip install "apify-client[zstd]"` or + `uv add "apify-client[zstd]"`. [Impit](https://github.com/apify/impit) is the default HTTP client and is installed automatically. To use the built-in [HTTPX2](https://github.com/pydantic/httpx2) client instead, install the optional `httpx2` extra, which diff --git a/docs/01_introduction/index.mdx b/docs/01_introduction/index.mdx index 33b060a28..4d5c44bb1 100644 --- a/docs/01_introduction/index.mdx +++ b/docs/01_introduction/index.mdx @@ -61,7 +61,7 @@ For better request-body compression, opt in to `brotli`, which compresses better -To compress with `zstd` instead, pass `compression='zstd'`. On Python 3.14 and newer it uses the standard library. On older versions, install the optional `zstd` extra with `pip install "apify-client[zstd]"`. +To compress with `zstd` instead, pass `compression='zstd'`. On Python 3.14 and newer it uses the standard library. On older versions, install the optional `zstd` extra with `pip install "apify-client[zstd]"` or `uv add "apify-client[zstd]"`. For details, see [HTTP compression](../02_concepts/13_http_compression.mdx). From 7964ffa6bf6fb0eaaeb1a2cc3d68c0805e50cfb3 Mon Sep 17 00:00:00 2001 From: apify-factory <299481685+apify-factory@users.noreply.github.com> Date: Wed, 11 Nov 2026 08:59:09 +0000 Subject: [PATCH 19/21] fix: keep backports.zstd imports type-checkable and the load error consistent --- src/apify_client/http_compressors/__init__.py | 3 ++- src/apify_client/http_compressors/_zstd.py | 2 +- tests/unit/test_http_clients.py | 2 +- tests/unit/test_http_compressors.py | 4 ++-- tests/unit/test_key_value_store.py | 2 +- 5 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/apify_client/http_compressors/__init__.py b/src/apify_client/http_compressors/__init__.py index 26f1fb94c..407c4027f 100644 --- a/src/apify_client/http_compressors/__init__.py +++ b/src/apify_client/http_compressors/__init__.py @@ -14,7 +14,8 @@ with _try_import(__name__, 'BrotliHttpCompressor', dependency_name='brotli', extra_name='brotli') as _brotli_import: from apify_client.http_compressors._brotli import BrotliHttpCompressor -# zstd is in the standard library from Python 3.14. Older versions need `backports.zstd` from the `zstd` extra. +# zstd is in the standard library from Python 3.14, so no extra applies there: a build without it can't be fixed by +# one. Older versions need `backports.zstd` from the `zstd` extra. if _sys.version_info >= (3, 14): _zstd_dependency, _zstd_extra = '_zstd', None else: diff --git a/src/apify_client/http_compressors/_zstd.py b/src/apify_client/http_compressors/_zstd.py index f9b201e4c..eea5a8b17 100644 --- a/src/apify_client/http_compressors/_zstd.py +++ b/src/apify_client/http_compressors/_zstd.py @@ -29,7 +29,7 @@ # The package is installed but its C extension can't load. Without this, `import apify_client` would fail for # every user, not only for those who pick zstd. raise ModuleNotFoundError( - f'`backports.zstd` is installed but cannot be loaded ({exc})', + f'`backports.zstd` failed to load ({exc})', name='backports.zstd', ) from exc diff --git a/tests/unit/test_http_clients.py b/tests/unit/test_http_clients.py index ac15b92ff..b2bd50d4b 100644 --- a/tests/unit/test_http_clients.py +++ b/tests/unit/test_http_clients.py @@ -41,7 +41,7 @@ if sys.version_info >= (3, 14): from compression import zstd else: - from backports import zstd + import backports.zstd as zstd # noqa: PLR0402 - `ty` allows only `backports.zstd` to be unresolved on 3.14+ if TYPE_CHECKING: from collections.abc import Callable diff --git a/tests/unit/test_http_compressors.py b/tests/unit/test_http_compressors.py index be08f61b9..a3ca8ee6d 100644 --- a/tests/unit/test_http_compressors.py +++ b/tests/unit/test_http_compressors.py @@ -20,7 +20,7 @@ if sys.version_info >= (3, 14): from compression import zstd else: - from backports import zstd + import backports.zstd as zstd # noqa: PLR0402 - `ty` allows only `backports.zstd` to be unresolved on 3.14+ if TYPE_CHECKING: from collections.abc import Callable, Generator @@ -249,7 +249,7 @@ def test_zstd_import_hook_raises_clear_error_when_backport_fails_to_load() -> No with _dependency_unavailable('backports.zstd', error=ImportError): module = importlib.import_module('apify_client.http_compressors') assert module.GzipHttpCompressor is not None - with pytest.raises(ImportError, match='installed but cannot be loaded'): + with pytest.raises(ImportError, match=r'failed to load .*apify-client\[zstd\]'): _ = module.ZstdHttpCompressor diff --git a/tests/unit/test_key_value_store.py b/tests/unit/test_key_value_store.py index ba6e14081..c4609c3c4 100644 --- a/tests/unit/test_key_value_store.py +++ b/tests/unit/test_key_value_store.py @@ -20,7 +20,7 @@ if sys.version_info >= (3, 14): from compression import zstd else: - from backports import zstd + import backports.zstd as zstd # noqa: PLR0402 - `ty` allows only `backports.zstd` to be unresolved on 3.14+ if TYPE_CHECKING: from collections.abc import AsyncIterator, Callable, Iterator From db6ed4e871a905f67ae67f2cd3be5532d2aee501 Mon Sep 17 00:00:00 2001 From: apify-factory <299481685+apify-factory@users.noreply.github.com> Date: Wed, 11 Nov 2026 09:02:01 +0000 Subject: [PATCH 20/21] fix: give a broken backports.zstd install its own error without the install hint --- src/apify_client/_utils/try_import.py | 10 ++++++---- src/apify_client/http_compressors/__init__.py | 4 ++-- src/apify_client/http_compressors/_zstd.py | 4 +++- tests/unit/test_http_compressors.py | 3 ++- tests/unit/test_utils.py | 15 +++++++++++++++ 5 files changed, 28 insertions(+), 8 deletions(-) diff --git a/src/apify_client/_utils/try_import.py b/src/apify_client/_utils/try_import.py index 72ea9e0c8..b365ddb8e 100644 --- a/src/apify_client/_utils/try_import.py +++ b/src/apify_client/_utils/try_import.py @@ -25,9 +25,11 @@ def try_import( """Context manager to attempt importing symbols into a module. If the named optional dependency is missing, the symbols are replaced with `FailedImport` objects whose message - names the extra that installs it, or is the import error's own message when `extra_name` is `None` because no extra - can install the dependency. A dotted `dependency_name` also counts as missing when its parent package is missing. - Import errors caused by the importing module itself or by another dependency are propagated instead of being masked. + names the extra that installs it. The message is the import error's own when `extra_name` is `None` because no extra + can install the dependency, or when the error was raised from another one because the dependency is installed but + can't load, which installing the extra doesn't fix. A dotted `dependency_name` also counts as missing when its + parent package is missing. Import errors caused by the importing module itself or by another dependency are + propagated instead of being masked. """ state = ImportState() try: @@ -37,7 +39,7 @@ def try_import( raise state.available = False message = exc.args[0] - if extra_name is not None: + if extra_name is not None and exc.__cause__ is None: message += ( f". Install the optional '{extra_name}' extra to use it: pip install 'apify-client[{extra_name}]'" ) diff --git a/src/apify_client/http_compressors/__init__.py b/src/apify_client/http_compressors/__init__.py index 407c4027f..52c12088f 100644 --- a/src/apify_client/http_compressors/__init__.py +++ b/src/apify_client/http_compressors/__init__.py @@ -14,8 +14,8 @@ with _try_import(__name__, 'BrotliHttpCompressor', dependency_name='brotli', extra_name='brotli') as _brotli_import: from apify_client.http_compressors._brotli import BrotliHttpCompressor -# zstd is in the standard library from Python 3.14, so no extra applies there: a build without it can't be fixed by -# one. Older versions need `backports.zstd` from the `zstd` extra. +# zstd is in the standard library from Python 3.14, and no extra can add it to a build without it. Older versions need +# `backports.zstd` from the `zstd` extra. if _sys.version_info >= (3, 14): _zstd_dependency, _zstd_extra = '_zstd', None else: diff --git a/src/apify_client/http_compressors/_zstd.py b/src/apify_client/http_compressors/_zstd.py index eea5a8b17..dd6eeefdc 100644 --- a/src/apify_client/http_compressors/_zstd.py +++ b/src/apify_client/http_compressors/_zstd.py @@ -29,7 +29,9 @@ # The package is installed but its C extension can't load. Without this, `import apify_client` would fail for # every user, not only for those who pick zstd. raise ModuleNotFoundError( - f'`backports.zstd` failed to load ({exc})', + f'`backports.zstd` is installed but cannot be loaded ({exc}). Reinstall it with ' + "`pip install --force-reinstall backports.zstd`, or use compression='gzip' or compression='brotli' " + 'instead.', name='backports.zstd', ) from exc diff --git a/tests/unit/test_http_compressors.py b/tests/unit/test_http_compressors.py index a3ca8ee6d..1ce7a8992 100644 --- a/tests/unit/test_http_compressors.py +++ b/tests/unit/test_http_compressors.py @@ -249,8 +249,9 @@ def test_zstd_import_hook_raises_clear_error_when_backport_fails_to_load() -> No with _dependency_unavailable('backports.zstd', error=ImportError): module = importlib.import_module('apify_client.http_compressors') assert module.GzipHttpCompressor is not None - with pytest.raises(ImportError, match=r'failed to load .*apify-client\[zstd\]'): + with pytest.raises(ImportError, match='installed but cannot be loaded') as exc_info: _ = module.ZstdHttpCompressor + assert 'apify-client[zstd]' not in str(exc_info.value) @pytest.mark.skipif(sys.version_info < (3, 14), reason='zstd is in the standard library only from Python 3.14') diff --git a/tests/unit/test_utils.py b/tests/unit/test_utils.py index 1ee98973d..670b0bf34 100644 --- a/tests/unit/test_utils.py +++ b/tests/unit/test_utils.py @@ -84,6 +84,21 @@ def test_try_import_without_extra_keeps_the_import_error_message() -> None: del sys.modules[module_name] +def test_try_import_keeps_the_message_of_a_dependency_that_fails_to_load() -> None: + """An error raised from another one, for an installed dependency that can't load, gets no install hint.""" + module_name = 'test_dependency_fails_to_load_module' + sys.modules[module_name] = ModuleType(module_name) + try: + with try_import( + module_name, 'OptionalSymbol', dependency_name='_optional', extra_name='optional-extra' + ) as state: + raise ModuleNotFoundError('_optional cannot be loaded', name='_optional') from ImportError('broken') + assert state.available is False + assert sys.modules[module_name].OptionalSymbol.message == '_optional cannot be loaded' + finally: + del sys.modules[module_name] + + def test_try_import_treats_a_missing_parent_package_as_the_missing_dependency() -> None: """A dotted dependency counts as missing when its parent package is missing, but not a sibling of it.""" module_name = 'test_missing_parent_package_module' From fd96b8860789e591fe3f9608ffc5a345451328c7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 09:22:17 +0000 Subject: [PATCH 21/21] test: add integration tests sending zstd bodies to the live API Cover a key-value store record and a dataset push through a client that compresses request bodies with zstd, for both transports and both the sync and async client. Co-Authored-By: Claude Fable 5.2 Claude-Session: https://claude.ai/code/session_017ocuAK2fAevKdoyPCB2HvC --- tests/README.md | 1 + tests/integration/conftest.py | 28 ++++++++++++++++++++ tests/integration/test_dataset.py | 32 +++++++++++++++++++++++ tests/integration/test_key_value_store.py | 32 +++++++++++++++++++++++ 4 files changed, 93 insertions(+) diff --git a/tests/README.md b/tests/README.md index 760b8184d..f6e7974d5 100644 --- a/tests/README.md +++ b/tests/README.md @@ -61,6 +61,7 @@ To run them against a different Apify API environment, also set `APIFY_INTEGRATI ### Integration test fixtures - `client`: an `ApifyClient` or `ApifyClientAsync` for the main test user. Each test that uses it runs four times, once for each combination of sync or async client and `impit` or `httpx2` transport. +- `zstd_client`: the same four combinations as `client`, with request bodies compressed with zstd instead of the default gzip. - `is_async`: `True` when the test runs with the async client. - `test_dataset_of_another_user` and `test_kvs_of_another_user`: a dataset and a key-value store owned by the second user, with known content and the signatures for accessing it. diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index b57a8647c..25678e29e 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -24,6 +24,7 @@ ImpitHttpClient, ImpitHttpClientAsync, ) +from apify_client.http_compressors import ZstdHttpCompressor if TYPE_CHECKING: from collections.abc import AsyncGenerator, Generator @@ -152,6 +153,33 @@ async def client( await http_client_async.aclose() +@pytest.fixture +async def zstd_client( + client_type: str, + api_token: str, + http_client_classes: HttpClientClasses, +) -> AsyncGenerator[ApifyClient | ApifyClientAsync]: + """Return each sync/async and HTTP client implementation combination, compressing request bodies with zstd.""" + api_url = os.getenv(API_URL_ENV_VAR) or DEFAULT_API_URL + if client_type == 'sync': + http_client = http_client_classes.sync(http_compressor=ZstdHttpCompressor()) + yield ApifyClient.with_custom_http_client( + api_token, + api_url=api_url, + http_client=http_client, + ) + http_client.close() + return + + http_client_async = http_client_classes.async_(http_compressor=ZstdHttpCompressor()) + yield ApifyClientAsync.with_custom_http_client( + api_token, + api_url=api_url, + http_client=http_client_async, + ) + await http_client_async.aclose() + + @pytest.fixture def is_async(client_type: str) -> bool: """True if current test is using async client.""" diff --git a/tests/integration/test_dataset.py b/tests/integration/test_dataset.py index e370d0c9f..8bd333fd3 100644 --- a/tests/integration/test_dataset.py +++ b/tests/integration/test_dataset.py @@ -7,6 +7,7 @@ from contextlib import AbstractAsyncContextManager, AbstractContextManager from datetime import timedelta from typing import TYPE_CHECKING +from unittest.mock import Mock import impit import pytest @@ -22,6 +23,7 @@ from apify_client._resource_clients.dataset import DatasetItemsPage from apify_client.errors import ApifyApiError from apify_client.http_clients import HttpResponse +from apify_client.http_compressors import ZstdHttpCompressor if TYPE_CHECKING: from apify_client import ApifyClient, ApifyClientAsync @@ -276,6 +278,36 @@ async def get_items() -> DatasetItemsPage: await maybe_await(dataset_client.delete()) +async def test_dataset_push_zstd_compressed_items( + zstd_client: ApifyClient | ApifyClientAsync, monkeypatch: pytest.MonkeyPatch +) -> None: + """Items the client compresses with zstd are accepted by the API and listed back unchanged.""" + compress_spy = Mock(wraps=ZstdHttpCompressor().compress) + monkeypatch.setattr(ZstdHttpCompressor, 'compress', compress_spy) + + dataset_name = get_random_resource_name('dataset') + created_dataset = await maybe_await(zstd_client.datasets().get_or_create(name=dataset_name)) + assert isinstance(created_dataset, DatasetResource) + dataset_client = zstd_client.dataset(created_dataset.id) + + try: + # Enough items to get the JSON body past the size threshold, so the client compresses it. + items_to_push = [{'id': i, 'name': f'Item {i}', 'text': 'compressible payload'} for i in range(100)] + await maybe_await(dataset_client.push_items(items_to_push)) + compress_spy.assert_called_once() + + # Poll until all items are visible (eventual consistency) + async def get_items() -> DatasetItemsPage: + page = await maybe_await(dataset_client.list_items()) + assert isinstance(page, DatasetItemsPage) + return page + + items_page = await poll_until_condition(get_items, lambda page: len(page.items) == len(items_to_push)) + assert items_page.items == items_to_push + finally: + await maybe_await(dataset_client.delete()) + + async def test_dataset_list_items_with_pagination(client: ApifyClient | ApifyClientAsync) -> None: """Test listing items with pagination parameters.""" dataset_name = get_random_resource_name('dataset') diff --git a/tests/integration/test_key_value_store.py b/tests/integration/test_key_value_store.py index db8a64f97..2d642269d 100644 --- a/tests/integration/test_key_value_store.py +++ b/tests/integration/test_key_value_store.py @@ -8,6 +8,7 @@ from collections.abc import AsyncIterator, Iterator from datetime import timedelta from typing import TYPE_CHECKING +from unittest.mock import Mock import impit import pytest @@ -29,6 +30,7 @@ ) from apify_client.errors import ApifyApiError from apify_client.http_clients import HttpResponse +from apify_client.http_compressors import ZstdHttpCompressor if TYPE_CHECKING: from apify_client import ApifyClient, ApifyClientAsync @@ -646,6 +648,36 @@ async def get_record() -> dict | None: await maybe_await(store_client.delete()) +async def test_key_value_store_set_and_get_zstd_compressed_record( + zstd_client: ApifyClient | ApifyClientAsync, monkeypatch: pytest.MonkeyPatch +) -> None: + """A record the client compresses with zstd is accepted by the API and read back as the original value.""" + compress_spy = Mock(wraps=ZstdHttpCompressor().compress) + monkeypatch.setattr(ZstdHttpCompressor, 'compress', compress_spy) + + store_name = get_random_resource_name('kvs') + created_store = await maybe_await(zstd_client.key_value_stores().get_or_create(name=store_name)) + assert isinstance(created_store, KeyValueStoreResource) + store_client = zstd_client.key_value_store(created_store.id) + + try: + # Past the size threshold and of a compressible content type, so the client compresses the body. + payload = 'compressible payload ' * 500 + await maybe_await(store_client.set_record('zstd-compressed', payload, content_type='text/plain')) + compress_spy.assert_called_once_with(payload.encode()) + + # Poll until the record is visible (eventual consistency) + async def get_record() -> dict | None: + return await maybe_await(store_client.get_record('zstd-compressed')) + + record = await poll_until_condition(get_record, lambda record: record is not None) + assert isinstance(record, dict) + assert record['value'] == payload + assert 'text/plain' in record['content_type'] + finally: + await maybe_await(store_client.delete()) + + async def test_key_value_store_get_record_public_url(client: ApifyClient | ApifyClientAsync) -> None: """Test get_record_public_url returns a working signed URL.""" store_name = get_random_resource_name('kvs')