diff --git a/README.md b/README.md index bdce7968..ff6e11ed 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 diff --git a/docs/01_introduction/index.mdx b/docs/01_introduction/index.mdx index 44b3ac1f..33b060a2 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 79e26b38..b950e68d 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 `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]" +# 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,17 +161,17 @@ 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. +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/pyproject.toml b/pyproject.toml index dd78bac5..0a1d0682 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 the `backports.zstd` package. +zstd = ["backports.zstd>=1.0.0; python_version < '3.14'"] [project.urls] "Apify Homepage" = "https://apify.com" @@ -205,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] diff --git a/src/apify_client/_apify_client.py b/src/apify_client/_apify_client.py index 255c87ea..8034a6c0 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}') diff --git a/src/apify_client/_resource_clients/actor_server.py b/src/apify_client/_resource_clients/actor_server.py index afddb71c..edd84d13 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() diff --git a/src/apify_client/_resource_clients/key_value_store.py b/src/apify_client/_resource_clients/key_value_store.py index 7d3de8b5..1ec6e5fe 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. """ diff --git a/src/apify_client/_utils/http.py b/src/apify_client/_utils/http.py index fab01a78..058080b9 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/_utils/try_import.py b/src/apify_client/_utils/try_import.py index 4f0609db..72ea9e0c 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 0cbc4b4d..26f1fb94 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,19 @@ 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. Older versions need `backports.zstd` from the `zstd` extra. +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 ccd5d77c..1098dabb 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 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() # 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 00000000..eed8d443 --- /dev/null +++ b/src/apify_client/http_compressors/_zstd.py @@ -0,0 +1,63 @@ +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 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 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: + # 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) diff --git a/src/apify_client/types.py b/src/apify_client/types.py index 64e42ac8..d66fa598 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 diff --git a/tests/unit/test_http_compressors.py b/tests/unit/test_http_compressors.py index b1a9c550..1ed71a24 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,25 @@ @contextmanager -def _brotli_unavailable() -> Generator[None]: - """Reimport the `http_compressors` package with the `brotli` 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. +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 == 'brotli' or name.startswith('brotli.'): - raise ModuleNotFoundError(f"No module named '{name}'", name='brotli') + if name == dependency or name.startswith(f'{dependency}.'): + raise error(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 +91,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_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) + + 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 +151,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 +179,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 +196,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 +215,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 +223,43 @@ 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') +@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', 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'): + _ = 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 +277,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 +305,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( diff --git a/tests/unit/test_key_value_store.py b/tests/unit/test_key_value_store.py index e9352ca9..f6e39991 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 diff --git a/tests/unit/test_utils.py b/tests/unit/test_utils.py index 1a2c395a..1ee98973 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' diff --git a/uv.lock b/uv.lock index d4bc7a19..08d2f876 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"