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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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: