From f26780a9e497350bcb7caa8045df9f86dbb5911f Mon Sep 17 00:00:00 2001 From: Michal Turek Date: Wed, 8 Jul 2026 11:36:05 +0200 Subject: [PATCH 01/30] feat: Compress requests using brotli algo - https://github.com/apify/apify-core/pull/28971 added support for brotli compression to BE. - Pros: higher compression, cons: more CPU intensive. - The brotli dependencies are defined as optional, one has to explicitly enable it and choose one. If none is available, the code falls back to gzip. - JS client doesn't compress requests that are too small, this Python client compresses just everything. No change done, I only noticed and stating it. --- README.md | 9 ++ pyproject.toml | 8 +- src/apify_client/http_clients/_base.py | 33 ++++- tests/unit/test_http_clients.py | 97 ++++++++++--- tests/unit/test_run_charge.py | 24 +++- uv.lock | 192 ++++++++++++++++++++++++- 6 files changed, 332 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index 8b2d9d63..e7cc4fd5 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,15 @@ or any other Python package manager that consumes PyPI. + To enable brotli request-body compression (better than gzip, especially for large payloads), install the optional extra: + + ```bash + pip install "apify-client[brotli]" + # or + uv add "apify-client[brotli]" + ``` + + Without this extra the client falls back to gzip automatically — no code changes needed. - From [conda-forge](https://anaconda.org/conda-forge/apify-client), it can be installed with [conda](https://docs.conda.io/en/latest/): diff --git a/pyproject.toml b/pyproject.toml index 6753ec8c..b4f5823f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,6 +30,12 @@ dependencies = [ "pydantic[email]>=2.11.0", ] +[project.optional-dependencies] +brotli = [ + "brotli>=1.0.9; platform_python_implementation == 'CPython'", + "brotlicffi>=1.0.9; platform_python_implementation != 'CPython'", +] + [project.urls] "Apify Homepage" = "https://apify.com" "Homepage" = "https://docs.apify.com/api/client/python/" @@ -203,7 +209,7 @@ exclude = ["website/versioned_docs"] unused-ignore-comment = "error" [[tool.ty.overrides]] -include = ["docs/**/*.py", "website/**/*.py"] +include = ["docs/**/*.py", "website/**/*.py", "tests/unit/test_http_clients.py", "tests/unit/test_run_charge.py"] [tool.ty.overrides.rules] unresolved-import = "ignore" diff --git a/src/apify_client/http_clients/_base.py b/src/apify_client/http_clients/_base.py index 6f97f16e..c423e96e 100644 --- a/src/apify_client/http_clients/_base.py +++ b/src/apify_client/http_clients/_base.py @@ -23,10 +23,25 @@ from apify_client._utils import to_seconds if TYPE_CHECKING: - from collections.abc import AsyncIterator, Iterator, Mapping + from collections.abc import AsyncIterator, Callable, Iterator, Mapping from apify_client.types import JsonSerializable, Timeout +_brotli_compress: Callable[[bytes | bytearray], bytes] | None = None + +if not TYPE_CHECKING: + try: + import brotli + + _brotli_compress = brotli.compress + except ImportError: + try: + import brotlicffi + + _brotli_compress = brotlicffi.compress + except ImportError: + pass + @docs_group('HTTP clients') @runtime_checkable @@ -203,13 +218,17 @@ def _prepare_request_call( data: str | bytes | bytearray | None = None, json: JsonSerializable | None = None, ) -> tuple[dict[str, str], dict[str, Any] | None, bytes | None]: - """Prepare headers, params, and body for an HTTP request. Serializes JSON and applies gzip compression.""" + """Prepare headers, params, and body for an HTTP request. Serializes JSON and compresses the body. + + Uses brotli compression (`Content-Encoding: br`) when `brotli` or `brotlicffi` is installed, + otherwise falls back to gzip (`Content-Encoding: gzip`). + """ if json is not None and data is not None: raise ValueError('Cannot pass both "json" and "data" parameters at the same time!') headers = dict(headers) if headers else {} - # Dump JSON data to string so it can be gzipped. + # Dump JSON data to string so it can be compressed. if json is not None: data = jsonlib.dumps(json, ensure_ascii=False, allow_nan=False, default=str).encode('utf-8') headers['Content-Type'] = 'application/json' @@ -217,8 +236,12 @@ def _prepare_request_call( if isinstance(data, (str, bytes, bytearray)): if isinstance(data, str): data = data.encode('utf-8') - data = gzip.compress(data) - headers['Content-Encoding'] = 'gzip' + if _brotli_compress is not None: + data = _brotli_compress(data) + headers['Content-Encoding'] = 'br' + else: + data = gzip.compress(data) + headers['Content-Encoding'] = 'gzip' return (headers, self._parse_params(params), data) diff --git a/tests/unit/test_http_clients.py b/tests/unit/test_http_clients.py index b54c9fee..e4183667 100644 --- a/tests/unit/test_http_clients.py +++ b/tests/unit/test_http_clients.py @@ -13,6 +13,29 @@ from apify_client.http_clients import HttpClient, HttpClientAsync, HttpResponse, ImpitHttpClient, ImpitHttpClientAsync from apify_client.http_clients._impit import _is_retryable_error +try: + import brotli as _brotli_mod + + _EXPECTED_ENCODING = 'br' + + def _decompress(data: bytes) -> bytes: + return _brotli_mod.decompress(data) + +except ImportError: + try: + import brotlicffi as _brotli_mod + + _EXPECTED_ENCODING = 'br' + + def _decompress(data: bytes) -> bytes: + return _brotli_mod.decompress(data) + + except ImportError: + _EXPECTED_ENCODING = 'gzip' + + def _decompress(data: bytes) -> bytes: + return gzip.decompress(data) + class _ConcreteHttpClient(HttpClient): """Minimal concrete HttpClient for testing base class helpers.""" @@ -278,7 +301,7 @@ def test_prepare_request_call_with_json() -> None: headers, _params, data = client._prepare_request_call(json=json_data) assert headers['Content-Type'] == 'application/json' - assert headers['Content-Encoding'] == 'gzip' + assert headers['Content-Encoding'] == _EXPECTED_ENCODING assert data is not None assert isinstance(data, bytes) @@ -290,12 +313,10 @@ def test_prepare_request_call_with_empty_dict_json() -> None: headers, _params, data = client._prepare_request_call(json={}) assert headers['Content-Type'] == 'application/json' - assert headers['Content-Encoding'] == 'gzip' + assert headers['Content-Encoding'] == _EXPECTED_ENCODING assert data is not None assert isinstance(data, bytes) - # Verify the gzipped data contains the JSON - decompressed = gzip.decompress(data) - assert decompressed == b'{}' + assert _decompress(data) == b'{}' def test_prepare_request_call_with_empty_list_json() -> None: @@ -305,12 +326,10 @@ def test_prepare_request_call_with_empty_list_json() -> None: headers, _params, data = client._prepare_request_call(json=[]) assert headers['Content-Type'] == 'application/json' - assert headers['Content-Encoding'] == 'gzip' + assert headers['Content-Encoding'] == _EXPECTED_ENCODING assert data is not None assert isinstance(data, bytes) - # Verify the gzipped data contains the JSON - decompressed = gzip.decompress(data) - assert decompressed == b'[]' + assert _decompress(data) == b'[]' def test_prepare_request_call_with_zero_json() -> None: @@ -320,12 +339,10 @@ def test_prepare_request_call_with_zero_json() -> None: headers, _params, data = client._prepare_request_call(json=0) assert headers['Content-Type'] == 'application/json' - assert headers['Content-Encoding'] == 'gzip' + assert headers['Content-Encoding'] == _EXPECTED_ENCODING assert data is not None assert isinstance(data, bytes) - # Verify the gzipped data contains the JSON - decompressed = gzip.decompress(data) - assert decompressed == b'0' + assert _decompress(data) == b'0' def test_prepare_request_call_with_false_json() -> None: @@ -335,12 +352,10 @@ def test_prepare_request_call_with_false_json() -> None: headers, _params, data = client._prepare_request_call(json=False) assert headers['Content-Type'] == 'application/json' - assert headers['Content-Encoding'] == 'gzip' + assert headers['Content-Encoding'] == _EXPECTED_ENCODING assert data is not None assert isinstance(data, bytes) - # Verify the gzipped data contains the JSON - decompressed = gzip.decompress(data) - assert decompressed == b'false' + assert _decompress(data) == b'false' def test_prepare_request_call_with_empty_string_json() -> None: @@ -350,12 +365,10 @@ def test_prepare_request_call_with_empty_string_json() -> None: headers, _params, data = client._prepare_request_call(json='') assert headers['Content-Type'] == 'application/json' - assert headers['Content-Encoding'] == 'gzip' + assert headers['Content-Encoding'] == _EXPECTED_ENCODING assert data is not None assert isinstance(data, bytes) - # Verify the gzipped data contains the JSON - decompressed = gzip.decompress(data) - assert decompressed == b'""' + assert _decompress(data) == b'""' def test_prepare_request_call_with_string_data() -> None: @@ -364,7 +377,7 @@ def test_prepare_request_call_with_string_data() -> None: headers, _params, data = client._prepare_request_call(data='test string') - assert headers['Content-Encoding'] == 'gzip' + assert headers['Content-Encoding'] == _EXPECTED_ENCODING assert isinstance(data, bytes) @@ -374,7 +387,7 @@ def test_prepare_request_call_with_bytes_data() -> None: headers, _params, data = client._prepare_request_call(data=b'test bytes') - assert headers['Content-Encoding'] == 'gzip' + assert headers['Content-Encoding'] == _EXPECTED_ENCODING assert isinstance(data, bytes) @@ -452,3 +465,41 @@ def test_build_url_with_params_mixed() -> None: assert 'tags=a' in url assert 'tags=b' in url assert 'name=test' in url + + +def test_prepare_request_call_brotli_compression(monkeypatch: pytest.MonkeyPatch) -> None: + """When a brotli compressor is available, request body uses brotli (Content-Encoding: br).""" + brotlicffi = pytest.importorskip('brotlicffi') + monkeypatch.setattr('apify_client.http_clients._base._brotli_compress', brotlicffi.compress) + + client = _ConcreteHttpClient() + headers, _, data = client._prepare_request_call(json={'k': 'v'}) + + assert headers['Content-Encoding'] == 'br' + assert data is not None + assert brotlicffi.decompress(data) == b'{"k": "v"}' + + +def test_prepare_request_call_brotli_library_compression(monkeypatch: pytest.MonkeyPatch) -> None: + """When the brotli (C extension) library is available, request body uses brotli (Content-Encoding: br).""" + brotli = pytest.importorskip('brotli') + monkeypatch.setattr('apify_client.http_clients._base._brotli_compress', brotli.compress) + + client = _ConcreteHttpClient() + headers, _, data = client._prepare_request_call(json={'k': 'v'}) + + assert headers['Content-Encoding'] == 'br' + assert data is not None + assert brotli.decompress(data) == b'{"k": "v"}' + + +def test_prepare_request_call_gzip_fallback_without_brotli(monkeypatch: pytest.MonkeyPatch) -> None: + """When no brotli library is available, request body falls back to gzip (Content-Encoding: gzip).""" + monkeypatch.setattr('apify_client.http_clients._base._brotli_compress', None) + + client = _ConcreteHttpClient() + headers, _, data = client._prepare_request_call(json={'k': 'v'}) + + assert headers['Content-Encoding'] == 'gzip' + assert data is not None + assert gzip.decompress(data) == b'{"k": "v"}' diff --git a/tests/unit/test_run_charge.py b/tests/unit/test_run_charge.py index 6b030651..f3a313b9 100644 --- a/tests/unit/test_run_charge.py +++ b/tests/unit/test_run_charge.py @@ -4,6 +4,25 @@ import json from typing import TYPE_CHECKING +try: + import brotlicffi as _brotli_mod + + def _decompress(data: bytes) -> bytes: + return _brotli_mod.decompress(data) + +except ImportError: + try: + import brotli as _brotli_mod # type: ignore[no-redef] + + def _decompress(data: bytes) -> bytes: + return _brotli_mod.decompress(data) + + except ImportError: + + def _decompress(data: bytes) -> bytes: + return gzip.decompress(data) + + import pytest from werkzeug import Request, Response @@ -18,7 +37,10 @@ def _decode_body(request: Request) -> dict: raw = request.get_data() - if request.headers.get('Content-Encoding') == 'gzip': + encoding = request.headers.get('Content-Encoding') + if encoding == 'br': + raw = _decompress(raw) + elif encoding == 'gzip': raw = gzip.decompress(raw) return json.loads(raw) diff --git a/uv.lock b/uv.lock index ea6ec2b7..d988ded9 100644 --- a/uv.lock +++ b/uv.lock @@ -3,7 +3,8 @@ revision = 3 requires-python = ">=3.11" resolution-markers = [ "python_full_version >= '3.14'", - "python_full_version < '3.14'", + "python_full_version == '3.13.*'", + "python_full_version < '3.13'", ] [options] @@ -49,6 +50,12 @@ dependencies = [ { name = "pydantic", extra = ["email"] }, ] +[package.optional-dependencies] +brotli = [ + { name = "brotli", marker = "platform_python_implementation == 'CPython'" }, + { name = "brotlicffi", marker = "platform_python_implementation != 'CPython'" }, +] + [package.dev-dependencies] dev = [ { name = "black" }, @@ -73,11 +80,14 @@ dev = [ [package.metadata] requires-dist = [ + { name = "brotli", marker = "platform_python_implementation == 'CPython' and extra == 'brotli'", specifier = ">=1.0.9" }, + { name = "brotlicffi", marker = "platform_python_implementation != 'CPython' and extra == 'brotli'", specifier = ">=1.0.9" }, { name = "colorama", specifier = ">=0.4.0" }, { name = "impit", specifier = "~=0.13.0" }, { name = "more-itertools", specifier = ">=10.0.0" }, { name = "pydantic", extras = ["email"], specifier = ">=2.11.0" }, ] +provides-extras = ["brotli"] [package.metadata.requires-dev] dev = [ @@ -147,6 +157,79 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/94/51/f975cae76d44274cc2868dc9040ac5d58d464784610234455b4e7b19c6ef/black-26.5.1-py3-none-any.whl", hash = "sha256:4ed7f7da04046d2e488437170797d3b4a4ad83906683bcb7dfc68b673bbce5e2", size = 213693, upload-time = "2026-05-18T16:53:33.964Z" }, ] +[[package]] +name = "brotli" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f7/16/c92ca344d646e71a43b8bb353f0a6490d7f6e06210f8554c8f874e454285/brotli-1.2.0.tar.gz", hash = "sha256:e310f77e41941c13340a95976fe66a8a95b01e783d430eeaf7a2f87e0a57dd0a", size = 7388632, upload-time = "2025-11-05T18:39:42.86Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/ef/f285668811a9e1ddb47a18cb0b437d5fc2760d537a2fe8a57875ad6f8448/brotli-1.2.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:15b33fe93cedc4caaff8a0bd1eb7e3dab1c61bb22a0bf5bdfdfd97cd7da79744", size = 863110, upload-time = "2025-11-05T18:38:12.978Z" }, + { url = "https://files.pythonhosted.org/packages/50/62/a3b77593587010c789a9d6eaa527c79e0848b7b860402cc64bc0bc28a86c/brotli-1.2.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:898be2be399c221d2671d29eed26b6b2713a02c2119168ed914e7d00ceadb56f", size = 445438, upload-time = "2025-11-05T18:38:14.208Z" }, + { url = "https://files.pythonhosted.org/packages/cd/e1/7fadd47f40ce5549dc44493877db40292277db373da5053aff181656e16e/brotli-1.2.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:350c8348f0e76fff0a0fd6c26755d2653863279d086d3aa2c290a6a7251135dd", size = 1534420, upload-time = "2025-11-05T18:38:15.111Z" }, + { url = "https://files.pythonhosted.org/packages/12/8b/1ed2f64054a5a008a4ccd2f271dbba7a5fb1a3067a99f5ceadedd4c1d5a7/brotli-1.2.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e1ad3fda65ae0d93fec742a128d72e145c9c7a99ee2fcd667785d99eb25a7fe", size = 1632619, upload-time = "2025-11-05T18:38:16.094Z" }, + { url = "https://files.pythonhosted.org/packages/89/5a/7071a621eb2d052d64efd5da2ef55ecdac7c3b0c6e4f9d519e9c66d987ef/brotli-1.2.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:40d918bce2b427a0c4ba189df7a006ac0c7277c180aee4617d99e9ccaaf59e6a", size = 1426014, upload-time = "2025-11-05T18:38:17.177Z" }, + { url = "https://files.pythonhosted.org/packages/26/6d/0971a8ea435af5156acaaccec1a505f981c9c80227633851f2810abd252a/brotli-1.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2a7f1d03727130fc875448b65b127a9ec5d06d19d0148e7554384229706f9d1b", size = 1489661, upload-time = "2025-11-05T18:38:18.41Z" }, + { url = "https://files.pythonhosted.org/packages/f3/75/c1baca8b4ec6c96a03ef8230fab2a785e35297632f402ebb1e78a1e39116/brotli-1.2.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:9c79f57faa25d97900bfb119480806d783fba83cd09ee0b33c17623935b05fa3", size = 1599150, upload-time = "2025-11-05T18:38:19.792Z" }, + { url = "https://files.pythonhosted.org/packages/0d/1a/23fcfee1c324fd48a63d7ebf4bac3a4115bdb1b00e600f80f727d850b1ae/brotli-1.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:844a8ceb8483fefafc412f85c14f2aae2fb69567bf2a0de53cdb88b73e7c43ae", size = 1493505, upload-time = "2025-11-05T18:38:20.913Z" }, + { url = "https://files.pythonhosted.org/packages/36/e5/12904bbd36afeef53d45a84881a4810ae8810ad7e328a971ebbfd760a0b3/brotli-1.2.0-cp311-cp311-win32.whl", hash = "sha256:aa47441fa3026543513139cb8926a92a8e305ee9c71a6209ef7a97d91640ea03", size = 334451, upload-time = "2025-11-05T18:38:21.94Z" }, + { url = "https://files.pythonhosted.org/packages/02/8b/ecb5761b989629a4758c394b9301607a5880de61ee2ee5fe104b87149ebc/brotli-1.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:022426c9e99fd65d9475dce5c195526f04bb8be8907607e27e747893f6ee3e24", size = 369035, upload-time = "2025-11-05T18:38:22.941Z" }, + { url = "https://files.pythonhosted.org/packages/11/ee/b0a11ab2315c69bb9b45a2aaed022499c9c24a205c3a49c3513b541a7967/brotli-1.2.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:35d382625778834a7f3061b15423919aa03e4f5da34ac8e02c074e4b75ab4f84", size = 861543, upload-time = "2025-11-05T18:38:24.183Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2f/29c1459513cd35828e25531ebfcbf3e92a5e49f560b1777a9af7203eb46e/brotli-1.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7a61c06b334bd99bc5ae84f1eeb36bfe01400264b3c352f968c6e30a10f9d08b", size = 444288, upload-time = "2025-11-05T18:38:25.139Z" }, + { url = "https://files.pythonhosted.org/packages/3d/6f/feba03130d5fceadfa3a1bb102cb14650798c848b1df2a808356f939bb16/brotli-1.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:acec55bb7c90f1dfc476126f9711a8e81c9af7fb617409a9ee2953115343f08d", size = 1528071, upload-time = "2025-11-05T18:38:26.081Z" }, + { url = "https://files.pythonhosted.org/packages/2b/38/f3abb554eee089bd15471057ba85f47e53a44a462cfce265d9bf7088eb09/brotli-1.2.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:260d3692396e1895c5034f204f0db022c056f9e2ac841593a4cf9426e2a3faca", size = 1626913, upload-time = "2025-11-05T18:38:27.284Z" }, + { url = "https://files.pythonhosted.org/packages/03/a7/03aa61fbc3c5cbf99b44d158665f9b0dd3d8059be16c460208d9e385c837/brotli-1.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:072e7624b1fc4d601036ab3f4f27942ef772887e876beff0301d261210bca97f", size = 1419762, upload-time = "2025-11-05T18:38:28.295Z" }, + { url = "https://files.pythonhosted.org/packages/21/1b/0374a89ee27d152a5069c356c96b93afd1b94eae83f1e004b57eb6ce2f10/brotli-1.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adedc4a67e15327dfdd04884873c6d5a01d3e3b6f61406f99b1ed4865a2f6d28", size = 1484494, upload-time = "2025-11-05T18:38:29.29Z" }, + { url = "https://files.pythonhosted.org/packages/cf/57/69d4fe84a67aef4f524dcd075c6eee868d7850e85bf01d778a857d8dbe0a/brotli-1.2.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:7a47ce5c2288702e09dc22a44d0ee6152f2c7eda97b3c8482d826a1f3cfc7da7", size = 1593302, upload-time = "2025-11-05T18:38:30.639Z" }, + { url = "https://files.pythonhosted.org/packages/d5/3b/39e13ce78a8e9a621c5df3aeb5fd181fcc8caba8c48a194cd629771f6828/brotli-1.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:af43b8711a8264bb4e7d6d9a6d004c3a2019c04c01127a868709ec29962b6036", size = 1487913, upload-time = "2025-11-05T18:38:31.618Z" }, + { url = "https://files.pythonhosted.org/packages/62/28/4d00cb9bd76a6357a66fcd54b4b6d70288385584063f4b07884c1e7286ac/brotli-1.2.0-cp312-cp312-win32.whl", hash = "sha256:e99befa0b48f3cd293dafeacdd0d191804d105d279e0b387a32054c1180f3161", size = 334362, upload-time = "2025-11-05T18:38:32.939Z" }, + { url = "https://files.pythonhosted.org/packages/1c/4e/bc1dcac9498859d5e353c9b153627a3752868a9d5f05ce8dedd81a2354ab/brotli-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:b35c13ce241abdd44cb8ca70683f20c0c079728a36a996297adb5334adfc1c44", size = 369115, upload-time = "2025-11-05T18:38:33.765Z" }, + { url = "https://files.pythonhosted.org/packages/6c/d4/4ad5432ac98c73096159d9ce7ffeb82d151c2ac84adcc6168e476bb54674/brotli-1.2.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9e5825ba2c9998375530504578fd4d5d1059d09621a02065d1b6bfc41a8e05ab", size = 861523, upload-time = "2025-11-05T18:38:34.67Z" }, + { url = "https://files.pythonhosted.org/packages/91/9f/9cc5bd03ee68a85dc4bc89114f7067c056a3c14b3d95f171918c088bf88d/brotli-1.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0cf8c3b8ba93d496b2fae778039e2f5ecc7cff99df84df337ca31d8f2252896c", size = 444289, upload-time = "2025-11-05T18:38:35.6Z" }, + { url = "https://files.pythonhosted.org/packages/2e/b6/fe84227c56a865d16a6614e2c4722864b380cb14b13f3e6bef441e73a85a/brotli-1.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8565e3cdc1808b1a34714b553b262c5de5fbda202285782173ec137fd13709f", size = 1528076, upload-time = "2025-11-05T18:38:36.639Z" }, + { url = "https://files.pythonhosted.org/packages/55/de/de4ae0aaca06c790371cf6e7ee93a024f6b4bb0568727da8c3de112e726c/brotli-1.2.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:26e8d3ecb0ee458a9804f47f21b74845cc823fd1bb19f02272be70774f56e2a6", size = 1626880, upload-time = "2025-11-05T18:38:37.623Z" }, + { url = "https://files.pythonhosted.org/packages/5f/16/a1b22cbea436642e071adcaf8d4b350a2ad02f5e0ad0da879a1be16188a0/brotli-1.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:67a91c5187e1eec76a61625c77a6c8c785650f5b576ca732bd33ef58b0dff49c", size = 1419737, upload-time = "2025-11-05T18:38:38.729Z" }, + { url = "https://files.pythonhosted.org/packages/46/63/c968a97cbb3bdbf7f974ef5a6ab467a2879b82afbc5ffb65b8acbb744f95/brotli-1.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4ecdb3b6dc36e6d6e14d3a1bdc6c1057c8cbf80db04031d566eb6080ce283a48", size = 1484440, upload-time = "2025-11-05T18:38:39.916Z" }, + { url = "https://files.pythonhosted.org/packages/06/9d/102c67ea5c9fc171f423e8399e585dabea29b5bc79b05572891e70013cdd/brotli-1.2.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3e1b35d56856f3ed326b140d3c6d9db91740f22e14b06e840fe4bb1923439a18", size = 1593313, upload-time = "2025-11-05T18:38:41.24Z" }, + { url = "https://files.pythonhosted.org/packages/9e/4a/9526d14fa6b87bc827ba1755a8440e214ff90de03095cacd78a64abe2b7d/brotli-1.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:54a50a9dad16b32136b2241ddea9e4df159b41247b2ce6aac0b3276a66a8f1e5", size = 1487945, upload-time = "2025-11-05T18:38:42.277Z" }, + { url = "https://files.pythonhosted.org/packages/5b/e8/3fe1ffed70cbef83c5236166acaed7bb9c766509b157854c80e2f766b38c/brotli-1.2.0-cp313-cp313-win32.whl", hash = "sha256:1b1d6a4efedd53671c793be6dd760fcf2107da3a52331ad9ea429edf0902f27a", size = 334368, upload-time = "2025-11-05T18:38:43.345Z" }, + { url = "https://files.pythonhosted.org/packages/ff/91/e739587be970a113b37b821eae8097aac5a48e5f0eca438c22e4c7dd8648/brotli-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:b63daa43d82f0cdabf98dee215b375b4058cce72871fd07934f179885aad16e8", size = 369116, upload-time = "2025-11-05T18:38:44.609Z" }, + { url = "https://files.pythonhosted.org/packages/17/e1/298c2ddf786bb7347a1cd71d63a347a79e5712a7c0cba9e3c3458ebd976f/brotli-1.2.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:6c12dad5cd04530323e723787ff762bac749a7b256a5bece32b2243dd5c27b21", size = 863080, upload-time = "2025-11-05T18:38:45.503Z" }, + { url = "https://files.pythonhosted.org/packages/84/0c/aac98e286ba66868b2b3b50338ffbd85a35c7122e9531a73a37a29763d38/brotli-1.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3219bd9e69868e57183316ee19c84e03e8f8b5a1d1f2667e1aa8c2f91cb061ac", size = 445453, upload-time = "2025-11-05T18:38:46.433Z" }, + { url = "https://files.pythonhosted.org/packages/ec/f1/0ca1f3f99ae300372635ab3fe2f7a79fa335fee3d874fa7f9e68575e0e62/brotli-1.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:963a08f3bebd8b75ac57661045402da15991468a621f014be54e50f53a58d19e", size = 1528168, upload-time = "2025-11-05T18:38:47.371Z" }, + { url = "https://files.pythonhosted.org/packages/d6/a6/2ebfc8f766d46df8d3e65b880a2e220732395e6d7dc312c1e1244b0f074a/brotli-1.2.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9322b9f8656782414b37e6af884146869d46ab85158201d82bab9abbcb971dc7", size = 1627098, upload-time = "2025-11-05T18:38:48.385Z" }, + { url = "https://files.pythonhosted.org/packages/f3/2f/0976d5b097ff8a22163b10617f76b2557f15f0f39d6a0fe1f02b1a53e92b/brotli-1.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cf9cba6f5b78a2071ec6fb1e7bd39acf35071d90a81231d67e92d637776a6a63", size = 1419861, upload-time = "2025-11-05T18:38:49.372Z" }, + { url = "https://files.pythonhosted.org/packages/9c/97/d76df7176a2ce7616ff94c1fb72d307c9a30d2189fe877f3dd99af00ea5a/brotli-1.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7547369c4392b47d30a3467fe8c3330b4f2e0f7730e45e3103d7d636678a808b", size = 1484594, upload-time = "2025-11-05T18:38:50.655Z" }, + { url = "https://files.pythonhosted.org/packages/d3/93/14cf0b1216f43df5609f5b272050b0abd219e0b54ea80b47cef9867b45e7/brotli-1.2.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:fc1530af5c3c275b8524f2e24841cbe2599d74462455e9bae5109e9ff42e9361", size = 1593455, upload-time = "2025-11-05T18:38:51.624Z" }, + { url = "https://files.pythonhosted.org/packages/b3/73/3183c9e41ca755713bdf2cc1d0810df742c09484e2e1ddd693bee53877c1/brotli-1.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d2d085ded05278d1c7f65560aae97b3160aeb2ea2c0b3e26204856beccb60888", size = 1488164, upload-time = "2025-11-05T18:38:53.079Z" }, + { url = "https://files.pythonhosted.org/packages/64/6a/0c78d8f3a582859236482fd9fa86a65a60328a00983006bcf6d83b7b2253/brotli-1.2.0-cp314-cp314-win32.whl", hash = "sha256:832c115a020e463c2f67664560449a7bea26b0c1fdd690352addad6d0a08714d", size = 339280, upload-time = "2025-11-05T18:38:54.02Z" }, + { url = "https://files.pythonhosted.org/packages/f5/10/56978295c14794b2c12007b07f3e41ba26acda9257457d7085b0bb3bb90c/brotli-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:e7c0af964e0b4e3412a0ebf341ea26ec767fa0b4cf81abb5e897c9338b5ad6a3", size = 375639, upload-time = "2025-11-05T18:38:55.67Z" }, +] + +[[package]] +name = "brotlicffi" +version = "1.2.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8a/b6/017dc5f852ed9b8735af77774509271acbf1de02d238377667145fcee01d/brotlicffi-1.2.0.1.tar.gz", hash = "sha256:c20d5c596278307ad06414a6d95a892377ea274a5c6b790c2548c009385d621c", size = 478156, upload-time = "2026-03-05T19:54:11.547Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/f9/dfa56316837fa798eac19358351e974de8e1e2ca9475af4cb90293cd6576/brotlicffi-1.2.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c85e65913cf2b79c57a3fdd05b98d9731d9255dc0cb696b09376cc091b9cddd", size = 433046, upload-time = "2026-03-05T19:53:46.209Z" }, + { url = "https://files.pythonhosted.org/packages/4a/f5/f8f492158c76b0d940388801f04f747028971ad5774287bded5f1e53f08d/brotlicffi-1.2.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:535f2d05d0273408abc13fc0eebb467afac17b0ad85090c8913690d40207dac5", size = 1541126, upload-time = "2026-03-05T19:53:48.248Z" }, + { url = "https://files.pythonhosted.org/packages/3b/e1/ff87af10ac419600c63e9287a0649c673673ae6b4f2bcf48e96cb2f89f60/brotlicffi-1.2.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ce17eb798ca59ecec67a9bb3fd7a4304e120d1cd02953ce522d959b9a84d58ac", size = 1541983, upload-time = "2026-03-05T19:53:50.317Z" }, + { url = "https://files.pythonhosted.org/packages/47/c0/80ecd9bd45776109fab14040e478bf63e456967c9ddee2353d8330ed8de1/brotlicffi-1.2.0.1-cp314-cp314t-win32.whl", hash = "sha256:3c9544f83cb715d95d7eab3af4adbbef8b2093ad6382288a83b3a25feb1a57ec", size = 349047, upload-time = "2026-03-05T19:53:52.215Z" }, + { url = "https://files.pythonhosted.org/packages/ab/98/13e5b250236a281b6cd9e92a01ee1ae231029fa78faee932ef3766e1cb24/brotlicffi-1.2.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:625f8115d32ae9c0740d01ea51518437c3fbaa3e78d41cb18459f6f7ac326000", size = 385652, upload-time = "2026-03-05T19:53:53.892Z" }, + { url = "https://files.pythonhosted.org/packages/9a/9f/b98dcd4af47994cee97aebac866996a006a2e5fc1fd1e2b82a8ad95cf09c/brotlicffi-1.2.0.1-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:91ba5f0ccc040f6ff8f7efaf839f797723d03ed46acb8ae9408f99ffd2572cf4", size = 432608, upload-time = "2026-03-05T19:53:56.736Z" }, + { url = "https://files.pythonhosted.org/packages/b1/7a/ac4ee56595a061e3718a6d1ea7e921f4df156894acffb28ed88a1fd52022/brotlicffi-1.2.0.1-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be9a670c6811af30a4bd42d7116dc5895d3b41beaa8ed8a89050447a0181f5ce", size = 1534257, upload-time = "2026-03-05T19:53:58.667Z" }, + { url = "https://files.pythonhosted.org/packages/99/39/e7410db7f6f56de57744ea52a115084ceb2735f4d44973f349bb92136586/brotlicffi-1.2.0.1-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f3314a3476f59e5443f9f72a6dff16edc0c3463c9b318feaef04ae3e4683f5a", size = 1536838, upload-time = "2026-03-05T19:54:00.705Z" }, + { url = "https://files.pythonhosted.org/packages/a6/75/6e7977d1935fc3fbb201cbd619be8f2c7aea25d40a096967132854b34708/brotlicffi-1.2.0.1-cp38-abi3-win32.whl", hash = "sha256:82ea52e2b5d3145b6c406ebd3efb0d55db718b7ad996bd70c62cec0439de1187", size = 343337, upload-time = "2026-03-05T19:54:02.446Z" }, + { url = "https://files.pythonhosted.org/packages/d8/ef/e7e485ce5e4ba3843a0a92feb767c7b6098fd6e65ce752918074d175ae71/brotlicffi-1.2.0.1-cp38-abi3-win_amd64.whl", hash = "sha256:da2e82a08e7778b8bc539d27ca03cdd684113e81394bfaaad8d0dfc6a17ddede", size = 379026, upload-time = "2026-03-05T19:54:04.322Z" }, + { url = "https://files.pythonhosted.org/packages/7f/53/6262c2256513e6f530d81642477cb19367270922063eaa2d7b781d8c723d/brotlicffi-1.2.0.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:e015af99584c6db1490a69a210c765953e473e63adc2d891ac3062a737c9e851", size = 402265, upload-time = "2026-03-05T19:54:05.858Z" }, + { url = "https://files.pythonhosted.org/packages/1f/d9/d5340b43cf5fbe7fe5a083d237e5338cc1caa73bea523be1c5e452c26290/brotlicffi-1.2.0.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:37cb587d32bf7168e2218c455e22e409ad1f3157c6c71945879a311f3e6b6abf", size = 406710, upload-time = "2026-03-05T19:54:07.272Z" }, + { url = "https://files.pythonhosted.org/packages/a3/82/dbced4c1e0792efdf23fd90ff6d2a320c64ff4dfef7aacc85c04fde9ddd2/brotlicffi-1.2.0.1-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d6ba65dd528892b4d9960beba2ae011a753620bcfc66cf6fa3cee18d7b0baa4", size = 402787, upload-time = "2026-03-05T19:54:08.73Z" }, + { url = "https://files.pythonhosted.org/packages/ef/6f/534205ba7590c9a8716a614f270c5c2ec419b5b7079b3f9cd31b7b5580de/brotlicffi-1.2.0.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:f2a5575653b0672638ba039b82fda56854934d7a6a24d4b8b5033f73ab43cbc1", size = 375108, upload-time = "2026-03-05T19:54:10.079Z" }, +] + [[package]] name = "certifi" version = "2026.6.17" @@ -156,6 +239,104 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" }, ] +[[package]] +name = "cffi" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/57/5f/ff100cae70ebe9d8df1c01a00e510e45d9adb5c1fdda84791b199141de97/cffi-2.1.0.tar.gz", hash = "sha256:efc1cdd798b1aaf39b4610bba7aad28c9bea9b910f25c784ccf9ec1fa719d1f9", size = 531036, upload-time = "2026-07-06T21:34:30.382Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d3/67/85c89a59ba36a671e79638f44d466749f08179266a57e4f2ffdf92174072/cffi-2.1.0-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:02cb7ff33ded4f1532476731f89ede53e2e488a8e6205515a82144246ffa7dcc", size = 183845, upload-time = "2026-07-06T21:32:26.32Z" }, + { url = "https://files.pythonhosted.org/packages/ea/dd/e3b0baa2d3d6a857ac72b7efbf18e32e487c9cdafcc13049ad765495b15e/cffi-2.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f5bce581e6b8c235e566a14768a943b172ada3ed73537bb0c0be1edee312d4e7", size = 184186, upload-time = "2026-07-06T21:32:28.025Z" }, + { url = "https://files.pythonhosted.org/packages/65/68/9f3ef890cf3c6ab97bd531c5677f67613d302165d16f8142b2811782a614/cffi-2.1.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:30b65779d598c370374fefabf138d456fd6f3216bfa7bedfab1ba82025b0cd93", size = 211892, upload-time = "2026-07-06T21:32:29.565Z" }, + { url = "https://files.pythonhosted.org/packages/22/d7/1a74539db16d8bfd839ff1515948948efbb162e574650fd3d846896eea95/cffi-2.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88023dfe18799507b73f1dbb0d14326a17465de1bc9c9c7655c22845e9ddc3a2", size = 218793, upload-time = "2026-07-06T21:32:30.951Z" }, + { url = "https://files.pythonhosted.org/packages/ec/d1/9a5b7169499e8e8d8e636de70b97ac7c9447104d2ff1a2cd94790cea5162/cffi-2.1.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:0a96b74cda968eebbad56d973efe5098974f0a9fb323865bf99ea1fd24e3e64c", size = 205737, upload-time = "2026-07-06T21:32:32.216Z" }, + { url = "https://files.pythonhosted.org/packages/ba/b0/e131a9c41f10607926278453d9596163594fe1c4ebc46efe3b5e5b34eb84/cffi-2.1.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a5781494d4d400a3f47f8f1da94b324f6e6b440a53387774002890a2a2f4b50f", size = 204909, upload-time = "2026-07-06T21:32:33.655Z" }, + { url = "https://files.pythonhosted.org/packages/fb/d2/4398416cd699b35167947c6e22aca52c47e69ad5695073c9f1f2c52e04aa/cffi-2.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aa7a1b53a2a4452ada2d1b5dade9960b2522f1e61293a811a077439e39029565", size = 217883, upload-time = "2026-07-06T21:32:35.173Z" }, + { url = "https://files.pythonhosted.org/packages/a2/a5/d4fe77b589e5e82d43ebc809bf2e6474afe8e48e32ea050b9357645b6471/cffi-2.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9d8272c0e483b024e1b9ad029821470ed8ec65631dbd90217469da0e7cd89f1c", size = 221251, upload-time = "2026-07-06T21:32:36.527Z" }, + { url = "https://files.pythonhosted.org/packages/22/f0/a2fc43084c0433caf7f461bccc013e28f848d04ee1c5ed7fce71423cf4d9/cffi-2.1.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7762faa47e8ff7eb80bd261d9a7d8eea2d8baa69de5e95b70c1f338bbe712f02", size = 214250, upload-time = "2026-07-06T21:32:37.852Z" }, + { url = "https://files.pythonhosted.org/packages/04/8c/b925975448cf20634a9fbd5efceb807219db452653648d2897c0989cab2d/cffi-2.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:89095c1968b4ba8285840e131bf2891b09ae137fe2146905acae0354fbce1b5e", size = 219441, upload-time = "2026-07-06T21:32:39.146Z" }, + { url = "https://files.pythonhosted.org/packages/eb/da/5c4918a2d61d86fa927d716cb3d8e4626ef8dc8f605a599d32f33897f59a/cffi-2.1.0-cp311-cp311-win32.whl", hash = "sha256:64c753a0f87a256020004f37a1c8c02c480e725f910f0b2a0f3f07debd1b2479", size = 174496, upload-time = "2026-07-06T21:32:40.467Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c8/6c2de1d55cf35ef8b92885d5ef280790f0fb9634d87ea1cc315176aecd61/cffi-2.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:4f26194e3d95e06501b942642855aed4f953d55e95d7d01b7c4483db3ecff458", size = 185113, upload-time = "2026-07-06T21:32:41.761Z" }, + { url = "https://files.pythonhosted.org/packages/9e/4e/e8d7cb5783f1841a3c8fb3a7735838d7484d08ec08c9f984b14cac1ac0e9/cffi-2.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:35aaea0c7ee0e58a5cd8c2fd1a48fdf7ece0d2699b7ecdda08194e9ce5dd9b3d", size = 179927, upload-time = "2026-07-06T21:32:42.961Z" }, + { url = "https://files.pythonhosted.org/packages/1e/85/990925db5df586ec90beb97529c853497e7f85ba0234830447faf41c3057/cffi-2.1.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:df2b82571a1b30f58a87bf4e5a9e78d2b1eff6c6ce8fd3aa3757221f93f0863f", size = 184829, upload-time = "2026-07-06T21:32:44.324Z" }, + { url = "https://files.pythonhosted.org/packages/4b/92/e7bb136ad6b5352603732cf907ef862ca103f20f2031c1735a46300c20c9/cffi-2.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:78474632761faa0fb96f30b1c928c84ebcf68713cbb80d15bab09dfe61640fde", size = 184728, upload-time = "2026-07-06T21:32:45.683Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c0/d1ec30ffb370f748f2fb54425972bfef9871e0132e82fb589c46b6676049/cffi-2.1.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:5972433ad71a9e46516584ef60a0fda12d9dc459938d1539c3ddecf9bdc1368d", size = 214815, upload-time = "2026-07-06T21:32:48.557Z" }, + { url = "https://files.pythonhosted.org/packages/1b/dc/5620cf930688be01f2d673804291de757a934c90b946dbdc3d84130c2ea4/cffi-2.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b6422532152adf4e59b110cb2808cee7a033800952f5c036b4af047ee43199e7", size = 222429, upload-time = "2026-07-06T21:32:49.848Z" }, + { url = "https://files.pythonhosted.org/packages/4b/a4/77b53abbf7a1e0beb9637edbef2a94d15f9c822f591e85d439ffd91519a6/cffi-2.1.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:46b1c8db8f6122420f32d02fffb924c2fe9bc772d228c7c711748fff56aabb2b", size = 210315, upload-time = "2026-07-06T21:32:51.221Z" }, + { url = "https://files.pythonhosted.org/packages/58/0c/f528df19cc94b675087324d4760d9e6d5bfae97d6217aa4fac43de4f5fcc/cffi-2.1.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9fafc5aa2e2a39aaf7f8cc0c1f044a9b07fca12e558dca53a3cc5c654ad67a7", size = 208859, upload-time = "2026-07-06T21:32:52.512Z" }, + { url = "https://files.pythonhosted.org/packages/62/f2/c9522a81c32132799a1972c39f5c5f8b4c8b9f00488a23feaa6c06f07741/cffi-2.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1e9f50d192a3e525b15a75ab5114e442d83d657b7ec29182a991bc9a88fd3a66", size = 221844, upload-time = "2026-07-06T21:32:53.704Z" }, + { url = "https://files.pythonhosted.org/packages/6e/28/bd53988b9833e8f8ad539d26f4c07a6b3f6bcb1e9e02e7ca038250b3428d/cffi-2.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:98fff996e983a36d3aa2eca83af40c5821202e7e6f32d13ae94e3d2286f10cfe", size = 225287, upload-time = "2026-07-06T21:32:54.907Z" }, + { url = "https://files.pythonhosted.org/packages/79/99/0d0fd37f055224085f42bbb2c022d002e17dde4a97972822327b07d84101/cffi-2.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:379de10ce1ba048b1448599d1b37b24caee16309d1ac98d3982fc997f768700b", size = 223681, upload-time = "2026-07-06T21:32:56.329Z" }, + { url = "https://files.pythonhosted.org/packages/b0/80/c138990aa2a70b1a269f6e06348729836d733d6f970867943f61d367f8cc/cffi-2.1.0-cp312-cp312-win32.whl", hash = "sha256:9b8f0f26ca4e7513c534d351eca551947d053fac438f2a04ac96d882909b0d3a", size = 175269, upload-time = "2026-07-06T21:32:57.777Z" }, + { url = "https://files.pythonhosted.org/packages/a8/eb/f636456ff21a83fc13c032b58cc5dde061691546ac79efa284b2989b7982/cffi-2.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:c97f080ea627e2863524c5af3836e2270b5f5dfff1f104392b959f8df0c5d384", size = 185881, upload-time = "2026-07-06T21:32:59.253Z" }, + { url = "https://files.pythonhosted.org/packages/dd/2c/400ea43e721727dca8a65c4521390e9196757caba4a45643acb2b63271b8/cffi-2.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:6d194185eabd279f1c05ebe3504265ddfc5ad2b58d0714f7db9f01da592e9eb6", size = 180088, upload-time = "2026-07-06T21:33:02.278Z" }, + { url = "https://files.pythonhosted.org/packages/96/88/a996879e2eeccb815f6e3a5967b12a308257412acec882039d386bd2aa7b/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:10537b1df4967ca26d21e5072d7d54188354483b91dc75058968d3f0cf13fbda", size = 194331, upload-time = "2026-07-06T21:33:03.697Z" }, + { url = "https://files.pythonhosted.org/packages/58/85/7ae00d5c8dd6266f4e944c3db630f3c5c9a98b61d469c714d848b1d8138a/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a95b05f9baf29b91171b3a8bd2020b028835243e7b0ff6bb23e2a3c228518b1b", size = 196966, upload-time = "2026-07-06T21:33:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/8c/e9/45c3a76ad8d43ad9261f4c95436da61128d3ca545d72b9612c0ab5be0b1c/cffi-2.1.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:15faec4adfff450819f3aee0e2e02c812de6edb88203aa58807955db2003472a", size = 184795, upload-time = "2026-07-06T21:33:06.699Z" }, + { url = "https://files.pythonhosted.org/packages/84/4c/82f132cb4418ee6d953d982b19191e87e2a6372c8a4ce36e50b69d6ade4a/cffi-2.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:716ff8ec22f20b4d988b12884086bcef0fc99737043e503f7a3935a6be99b1ea", size = 184746, upload-time = "2026-07-06T21:33:08.071Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1c/4ed5a0e5bdca6cbc275556de3328dd1b76fd0c11cc13c88fe66d1d8715f2/cffi-2.1.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:63960549e4f8dc41e31accb97b975abaecfc44c03e396c093a6436763c2ea7db", size = 214747, upload-time = "2026-07-06T21:33:09.671Z" }, + { url = "https://files.pythonhosted.org/packages/3a/a6/e879bb68cc23a2bc9ba8f4b7d8019f0c2694bad2ab6c4a3701d429439f58/cffi-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ff067a8d8d880e7809e4ac88eb009bb848870115317b306666502ccad30b147f", size = 222392, upload-time = "2026-07-06T21:33:10.896Z" }, + { url = "https://files.pythonhosted.org/packages/88/f6/01890cfd63c08f8eb96a8319b0443690197d240a8bd6346048cf7bde9190/cffi-2.1.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3b926723c13eba9f81d2ef3820d63aeceec3b2d4639906047bf675cb8a7a500d", size = 210285, upload-time = "2026-07-06T21:33:12.251Z" }, + { url = "https://files.pythonhosted.org/packages/a6/cf/2b684132056f438567b61e19d690dd31cd0921ace051e0a458be6074369e/cffi-2.1.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:47ff3a8bfd8cb9da1af7524b965127095055654c177fcfc7578debcb015eecd0", size = 208801, upload-time = "2026-07-06T21:33:13.617Z" }, + { url = "https://files.pythonhosted.org/packages/6f/08/f2e7d62c460faae0926f2d6e423694aa409ced3bc1fe2927a0a6e5f05416/cffi-2.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:799416bae98336e400981ff6e532d67d5c709cfb30afb79865a1315f94b0e224", size = 221808, upload-time = "2026-07-06T21:33:15.466Z" }, + { url = "https://files.pythonhosted.org/packages/38/37/04f54b8e63a02f3d908332c9effbf8c366167c6f733ed8a3d4f79b7e2a1e/cffi-2.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:961be50688f7fba2fa65f63712d3b9b341a22311f5253460ce933f52f0de1c8c", size = 225241, upload-time = "2026-07-06T21:33:16.869Z" }, + { url = "https://files.pythonhosted.org/packages/a9/d6/c72eecca433cd3e681c65ed313ab4835d9d4a379704d0f628a6a05f51c2e/cffi-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bf5c6cf48238b0eb4c086978c492ad1cbc22373fc5b2d7353b3a598ce6db887a", size = 223588, upload-time = "2026-07-06T21:33:18.239Z" }, + { url = "https://files.pythonhosted.org/packages/c6/4b/e706f67279140f92939da3475ad610df18bfd52d50f14953a8e5fede71d5/cffi-2.1.0-cp313-cp313-win32.whl", hash = "sha256:db3eb7d46527159a878ec3460e9d40615bc25ba337d477db681aea6e4f05c5d2", size = 175248, upload-time = "2026-07-06T21:33:19.799Z" }, + { url = "https://files.pythonhosted.org/packages/5a/47/59eb7975cb0e4ef0afa764ea945b29a5bb4537a9f771cb7d6c8a5dd74c95/cffi-2.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:8e74a6135550c4748af665b1b1118b6aab33b1fc6a16f9aff630af107c3b4512", size = 185717, upload-time = "2026-07-06T21:33:21.47Z" }, + { url = "https://files.pythonhosted.org/packages/5a/af/34fee85c48f8d94efc8597bc09470c9dd274c145f1c12e0fbc6ab6d38d74/cffi-2.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:2282cd5e38aa8accd03e99d1256af8411c84cdbee6a89d841b563fdbd1f3e50f", size = 180114, upload-time = "2026-07-06T21:33:22.515Z" }, + { url = "https://files.pythonhosted.org/packages/d8/f0/81478e482afa03f6d18dc8f2afb5edc45b3080853b634b5ed91961be0998/cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d2117334c3af3bdcb9a88522b844a2bdb5efdc4f71c6c822df55486ae1c3347a", size = 194142, upload-time = "2026-07-06T21:33:23.657Z" }, + { url = "https://files.pythonhosted.org/packages/7d/95/8de304305cd9204974b0ca051b86d307cafca13aa575a0ef1b44d92c0d8c/cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:702c436735fbe99d59ada02a1f65cfc0d31c0ee8b7290912f8fbc5cd1e4b16c3", size = 196819, upload-time = "2026-07-06T21:33:25.007Z" }, + { url = "https://files.pythonhosted.org/packages/20/71/7c8372d30e42415602ed9f268f7cfd66f1b855fed881ecd168bcb45dbc0b/cffi-2.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1ff3456eab0d889592d1936d6125bbfbc7ae4d3354a700f8bd80450a66445d4d", size = 184965, upload-time = "2026-07-06T21:33:26.605Z" }, + { url = "https://files.pythonhosted.org/packages/d6/5c/584e626835f0375c928176c04137c96927165cb8733cdb3150ec04e5ee5e/cffi-2.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c4165821e131d6d4ca444347c2b694e2311bcfa3fe5a861cc72968f28867beac", size = 184952, upload-time = "2026-07-06T21:33:27.823Z" }, + { url = "https://files.pythonhosted.org/packages/2e/d2/065fcae1c73979fac8e054462478d0ff8a29c40cdc2ed7ea5676a061df53/cffi-2.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:276f20fffd7b396e12516ba8edf9509210ac248cbbc5acbc39cd512f9f59ebe6", size = 222353, upload-time = "2026-07-06T21:33:29.178Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a5/e8bbb1ce5b3ac2f53ad6a10bde44318a5a8d99d4f4a000d44a6e39aeb3e4/cffi-2.1.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7d5980a3433d4b71a5e120f9dd551403d7824e31e2e67124fe2769c404c06913", size = 210051, upload-time = "2026-07-06T21:33:30.534Z" }, + { url = "https://files.pythonhosted.org/packages/28/ed/c127d3ac36e899c965e3361357c3befacd6578c03f40125183e41c3b219e/cffi-2.1.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6ca4919c6e4f89aa99c42510b42cf54596892c00b3f9077f6bdd1505e24b9c8d", size = 208630, upload-time = "2026-07-06T21:33:31.753Z" }, + { url = "https://files.pythonhosted.org/packages/cc/d7/97d3136f81db489ec8d1d67748c110d6c994268fd7528014aa9f2b085e4e/cffi-2.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d53d10f7da99ae46f7373b9150393e9c5eab9b224909982b43832668de4779f5", size = 221593, upload-time = "2026-07-06T21:33:33.044Z" }, + { url = "https://files.pythonhosted.org/packages/d3/27/93195977168ee63aed233a1a0993a2178798654d1f4bddcdd321d6fd3b21/cffi-2.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c351efb95e832a853a29361675f33a7ce53de1a109cd73fd47af0712213aa4ce", size = 225146, upload-time = "2026-07-06T21:33:34.224Z" }, + { url = "https://files.pythonhosted.org/packages/b3/c1/6dbd291ee2ae5a50a034aa057207081f545923bbf15dad4511e985aafff5/cffi-2.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:dbf7c7a88e2bac086f06d14577332760bdeecc42bdec8ac4077f6260557d9326", size = 223240, upload-time = "2026-07-06T21:33:35.57Z" }, + { url = "https://files.pythonhosted.org/packages/0f/6f/ade5ce9863a57992a6ea3d0d10d7e29b8749fc127204b3d493d667b2815f/cffi-2.1.0-cp314-cp314-win32.whl", hash = "sha256:1854b724d00f6654c742097d5387569021be12d3a0f770eae1df8f8acfcc6acd", size = 177723, upload-time = "2026-07-06T21:33:51.626Z" }, + { url = "https://files.pythonhosted.org/packages/41/de/92b9eeed4ae4a21d6fd9b2a2c8505cbed573299902ea73981cc13f7ff62c/cffi-2.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:1b96bfe2c4bd825681b7d311ad6d9b7280a091f43e8f63da5729638083cd3bfb", size = 187937, upload-time = "2026-07-06T21:33:53.403Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1a/cc6ae6c2913a03aab8898eee57963cf1035b8df5872ed8b9115fcc7e2be8/cffi-2.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:7d28dff1db6764108bc30788d85d61c876beff416d9a49cb9dd7c5a9f34f5804", size = 183001, upload-time = "2026-07-06T21:33:54.74Z" }, + { url = "https://files.pythonhosted.org/packages/14/f0/134c00ce0779ec86dea2aa1aac69339c2741a8045072676763512363a2ea/cffi-2.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7ea6b3e2c4250ff1de21c630fe72d0f63eb95c2c32ffbf64a358cf4a8836d714", size = 188538, upload-time = "2026-07-06T21:33:36.792Z" }, + { url = "https://files.pythonhosted.org/packages/50/d8/3b86aba791cb610d24e8a3e1b2cd529e71fa15096b04e4d4e360049d4a4c/cffi-2.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6af371f3767faeffc6ac1ef57cdfd25844403e9d3f476c5537caee499de96376", size = 188230, upload-time = "2026-07-06T21:33:38.011Z" }, + { url = "https://files.pythonhosted.org/packages/14/d0/117dcd9209255ad8571fbc8c92ef32593a1d294dcec91ddc4e4db50606f2/cffi-2.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb4e8997a49aa2c08a3e43c9045d224448b8941d88e7ac163c7d383e560cbf98", size = 223899, upload-time = "2026-07-06T21:33:39.514Z" }, + { url = "https://files.pythonhosted.org/packages/b6/3d/f20f8b886b254e3ad10e15cd4186d3aed49f3e6a35ab37aab9f8f25f7c03/cffi-2.1.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:bf01d8c84cbea96b944c73b22182e6c7c432b3475632b8111dbfdc95ddad6e13", size = 211652, upload-time = "2026-07-06T21:33:40.851Z" }, + { url = "https://files.pythonhosted.org/packages/28/3b/fad54de07260b93ddeef4b96d0131d57ea900675df1d410ae1deee52d7a6/cffi-2.1.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:33eb1ad83ebe8f313e0df035c406227d55a79456704a863fad9842136af5ad7d", size = 210755, upload-time = "2026-07-06T21:33:42.183Z" }, + { url = "https://files.pythonhosted.org/packages/cc/82/3d5c705acb7abbba9bbd7d79b8e62e0f25b6120eb7ae6ac49f1b721722fe/cffi-2.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ac0f1a2d0cfa7eea3f2aaf006ab6e70e8feeb16b75d65b7e5939982ca2f11056", size = 223933, upload-time = "2026-07-06T21:33:43.603Z" }, + { url = "https://files.pythonhosted.org/packages/6c/d0/47e338384ab6b1004241002fa616301020cea4fc95f283506565d252f276/cffi-2.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c16914df9fb7f500e440e6875fa23ff5e0b31db01fa9c06af98d59a91f0dc2e4", size = 226749, upload-time = "2026-07-06T21:33:45.046Z" }, + { url = "https://files.pythonhosted.org/packages/70/25/65bd5b58ea4bfdfc15cde02cb5365f89ef8ab8b2adfb8fe5c4bd4233382f/cffi-2.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5ecbd0499275d57506d397eebe1981cee87b47fcd9ef5c22cab7ed7644a39a94", size = 225703, upload-time = "2026-07-06T21:33:46.374Z" }, + { url = "https://files.pythonhosted.org/packages/dc/78/aa01ac599a8a4322533d45a1f9bc93b338276d2d59dabbe7c6d92a775c81/cffi-2.1.0-cp314-cp314t-win32.whl", hash = "sha256:7d034dcffa09e9a46c93fa3a3be402096cb5354ac6e41ab8e5cc9cd8b642ad76", size = 182857, upload-time = "2026-07-06T21:33:47.696Z" }, + { url = "https://files.pythonhosted.org/packages/b9/26/d00496b22de4d4228f32dde94ad996f350c8aad676d63bcca0743c8dea4d/cffi-2.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0582a58f3051372229ca8e7f5f589f9e5632678208d8636fea3676711fdf7fe5", size = 194065, upload-time = "2026-07-06T21:33:48.953Z" }, + { url = "https://files.pythonhosted.org/packages/d5/dd/0c7dbf815a579ff005008a2d815a55d6bb047c349eef536d9dc53d3f0a8d/cffi-2.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:510aeeeac94811b138077451da1fb18b308a5feab47dd2b603af55804155e1c8", size = 186404, upload-time = "2026-07-06T21:33:50.309Z" }, + { url = "https://files.pythonhosted.org/packages/55/c7/8c8c50cb11c6750051daf12164098a9a6f027ac4356967fd4d800a07f242/cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:2e9dabb9abcb7ad15938c7196ad5c1718a4e6d33cc79b4c0209bdb64c4a54a5c", size = 194121, upload-time = "2026-07-06T21:33:56.109Z" }, + { url = "https://files.pythonhosted.org/packages/99/e2/67680bf19a6b60d2bb7ff83baefa2a4c3d2d7dc0f3277034b802e1fc504c/cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:37f525a7e7e50c017fdebe58b787be310ad59357ae43a053943a6e1a6c526001", size = 196820, upload-time = "2026-07-06T21:33:57.288Z" }, + { url = "https://files.pythonhosted.org/packages/ed/da/4bbe583a3b3a5c8c60892124fe17f3fa3656523faf0d3484eae90f091853/cffi-2.1.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:95f2954c2c9473d892eca6e0409f3568b37ab62a8eedb122461f73cc273476e3", size = 184936, upload-time = "2026-07-06T21:33:58.765Z" }, + { url = "https://files.pythonhosted.org/packages/e5/4b/1f4c36ab273980d7aa75bb126ea4f8971f24a96108acad3a0a084028c57b/cffi-2.1.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:cdf2448aab5f661c9315308ec8b93f4e8a1a67a3c733f8631067a2b67d5913dc", size = 185045, upload-time = "2026-07-06T21:34:00.085Z" }, + { url = "https://files.pythonhosted.org/packages/ef/c3/ad299dc38f3583f8d916b299f028af418a9ec98bc695fcbebeae7420691c/cffi-2.1.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:90bec57cf82089383bd06a605b3eb8daebf7e5a668520beaf6e327a83a947699", size = 222342, upload-time = "2026-07-06T21:34:01.814Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d8/df4543cc087245044ed02ef3ad8e0a26619d0075ac7a77a12dc81177851b/cffi-2.1.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6274dcb2d15cef48daa73ed1be5a40d501d74dccd0cd6db364776d12cb6ba022", size = 210073, upload-time = "2026-07-06T21:34:03.255Z" }, + { url = "https://files.pythonhosted.org/packages/2c/0e/fac738d73728c6cea2a88a2883dca54892496cbba88a1dc1f2909cb8a6f5/cffi-2.1.0-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:2b71d409cccee78310ab5dec549aed052aaea483346e282c7b02362596e01bb0", size = 208551, upload-time = "2026-07-06T21:34:04.433Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3f/0b04a700dd64f465c93020253a793a82c9b4dff9961f48facd0df945d9b8/cffi-2.1.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7d3538f9c0e50670f4deb93dbb696576e60590369cae2faf7de681e597a8a1f1", size = 221649, upload-time = "2026-07-06T21:34:06.157Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7c/b7379a5704c79eda57ce075869ba70a0368d1c850f803b3c0d078d39dcaf/cffi-2.1.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:8f9ec95b8a043d3dfbc74d9abc6f7baf524dd27a8dc160b0a32ff9cdab650c28", size = 225203, upload-time = "2026-07-06T21:34:07.489Z" }, + { url = "https://files.pythonhosted.org/packages/5a/02/d5e6c43ea85c41bda2a184a3418f195fe7cf602967a8d2b94e085b83deef/cffi-2.1.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:af5e2915d41fe6c961694d7bfdc8562942638200f3ce2765dfb8b745cf997629", size = 223263, upload-time = "2026-07-06T21:34:08.712Z" }, + { url = "https://files.pythonhosted.org/packages/2c/d8/772b8259bf75749adffb1c546828978381fb516f60cf701f6c83daf60c85/cffi-2.1.0-cp315-cp315-win32.whl", hash = "sha256:0a42c688d19fca6e095a53c6a6e2295a5b050a8b289f109adab02a9e61a25de6", size = 177696, upload-time = "2026-07-06T21:34:26.355Z" }, + { url = "https://files.pythonhosted.org/packages/2f/dd/afa2191fc6d57fedd26e5844a2fe2fcc0bbfa00961bbaa5a41e4921e7cca/cffi-2.1.0-cp315-cp315-win_amd64.whl", hash = "sha256:bccbbb5ee76a61f9d99b5bf3846a51d7fca4b6a732fe46f89295610edaf41853", size = 187914, upload-time = "2026-07-06T21:34:27.58Z" }, + { url = "https://files.pythonhosted.org/packages/05/ef/6cd4f8c671517162379dc79cfae5aea9106bc38abb89628d5c16adf6a838/cffi-2.1.0-cp315-cp315-win_arm64.whl", hash = "sha256:8d35c139744adb3e727cd51b1a18324bbe44b8bd41bf8322bca4d41289f48eda", size = 183004, upload-time = "2026-07-06T21:34:28.905Z" }, + { url = "https://files.pythonhosted.org/packages/11/b6/12fc55092817a5faa26fb8c40c7f9d662e11a46ee248c137aafc42517d92/cffi-2.1.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:f9912624a0c0b834b7520d7769b3644453aabc0a7e1c839da7359f050750e9bc", size = 188378, upload-time = "2026-07-06T21:34:09.926Z" }, + { url = "https://files.pythonhosted.org/packages/8d/2e/cdac88979f295fde5daa69622c7d2111e56e7ceb94f211357fbe452339e4/cffi-2.1.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:df92f2aba50eb4d96718b68ef76f2e57a57b54f2fa62333496d16c6d585a85ca", size = 188319, upload-time = "2026-07-06T21:34:11.101Z" }, + { url = "https://files.pythonhosted.org/packages/e0/27/1d0b408497e41a74795af122d7b603c418c5fed0171450f899afd04e594f/cffi-2.1.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0520e1f4c35f44e209cbbb421b67eec42e6a157f59444dfb6058874ff3610e5d", size = 223904, upload-time = "2026-07-06T21:34:12.606Z" }, + { url = "https://files.pythonhosted.org/packages/8b/31/e115c985105dd7ffb32444505f18ceb874bb42d992af05d5dced7ecf1980/cffi-2.1.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3681e031db29958a7502f5c0c9d6bbc4c36cb20f7b104086fa642d1799631ff8", size = 211554, upload-time = "2026-07-06T21:34:13.987Z" }, + { url = "https://files.pythonhosted.org/packages/5a/67/9e6e09409336d9e515c58367e7cfcf4f89df06ad25252675595a58eb59d5/cffi-2.1.0-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:762f99479dcb369f60ab9017ad4ab97a36a1dd7c1ee5a3b15db0f4b8659120cd", size = 210795, upload-time = "2026-07-06T21:34:15.972Z" }, + { url = "https://files.pythonhosted.org/packages/19/e5/d3cc82a4a0be7902af279c04181ad038449c096734464a5ae1de3e1401bd/cffi-2.1.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0611e7ebf90573a535ebdc33ae9da222d037853983e13359f580fab781ca017f", size = 223843, upload-time = "2026-07-06T21:34:17.509Z" }, + { url = "https://files.pythonhosted.org/packages/b9/65/b434abc97ce7cecc2c640fde160507c0ecc7e21544b483ba3325d2e2ea17/cffi-2.1.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:86cf8755a791f72c85dc287128cc62d4f24d392e3f1e15837245623f4a33cccc", size = 226773, upload-time = "2026-07-06T21:34:19.05Z" }, + { url = "https://files.pythonhosted.org/packages/b5/9f/d4dc66ca651eb1145a133314cda721abf13cfac3d28c4a0402263ae6ad75/cffi-2.1.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:ba00f661f8ba35d075c937174e27c2c421cec3942fd2e0ea3e66996757c0fdd9", size = 225719, upload-time = "2026-07-06T21:34:20.576Z" }, + { url = "https://files.pythonhosted.org/packages/68/5a/e536c528bc8057496c360c0978559a2dc45653f89dd6151078aa7d8fca1a/cffi-2.1.0-cp315-cp315t-win32.whl", hash = "sha256:cb96698e3c7413d906ce83f8ffd245ec1bd94707541f299d0ce4d6b0193e982b", size = 182760, upload-time = "2026-07-06T21:34:22.059Z" }, + { url = "https://files.pythonhosted.org/packages/d3/0b/0ffe8b82d3875bced5fa1e7986a7a46b748262a40ab7f60b475eb9fb1bb3/cffi-2.1.0-cp315-cp315t-win_amd64.whl", hash = "sha256:f146d154428a2523f9cc7936c02353c2459b8f6cf07d3cd1ee1c0a611109c5d5", size = 193769, upload-time = "2026-07-06T21:34:23.589Z" }, + { url = "https://files.pythonhosted.org/packages/a0/17/1073b53b68c9b5ca6914adf5f8bf55aacc2d3be102418c90700160ea8605/cffi-2.1.0-cp315-cp315t-win_arm64.whl", hash = "sha256:cbb7640ce37159548d2147b5b8c241f962143d4c71231431820783f4dc78f210", size = 186405, upload-time = "2026-07-06T21:34:24.857Z" }, +] + [[package]] name = "cfgv" version = "3.5.0" @@ -928,6 +1109,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/80/6e/4b28b62ecb6aae56769c34a8ff1d661473ec1e9519e2d5f8b2c150086b26/pre_commit-4.6.0-py2.py3-none-any.whl", hash = "sha256:e2cf246f7299edcabcf15f9b0571fdce06058527f0a06535068a86d38089f29b", size = 226472, upload-time = "2026-04-21T20:31:40.092Z" }, ] +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + [[package]] name = "pydantic" version = "2.13.4" From 8e112bb467d89b0afa0fffebc7fb6b9ac22a0058 Mon Sep 17 00:00:00 2001 From: Michal Turek Date: Wed, 8 Jul 2026 16:30:05 +0200 Subject: [PATCH 02/30] feat: Apply review comments - Remove `brotlicffi`, only CPython runtime is supported. - Add a test with bytearray as an input. This would break only in `brotlicffi`, which is no longer in the dependencies. `brotli` dependency handles it correctly and doesn't throw. - Add `brotli` to dev dependencies and use it directly in tests. --- pyproject.toml | 6 +- src/apify_client/http_clients/_base.py | 9 +- tests/unit/test_http_clients.py | 66 ++++-------- tests/unit/test_run_charge.py | 18 +--- uv.lock | 140 +------------------------ 5 files changed, 34 insertions(+), 205 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index b4f5823f..a2dcbfe6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,8 +32,7 @@ dependencies = [ [project.optional-dependencies] brotli = [ - "brotli>=1.0.9; platform_python_implementation == 'CPython'", - "brotlicffi>=1.0.9; platform_python_implementation != 'CPython'", + "brotli>=1.0.9", ] [project.urls] @@ -68,6 +67,7 @@ dev = [ "setuptools", # setuptools are used by pytest but not explicitly required "types-colorama<0.5.0", "ty~=0.0.0", + "brotli>=1.0.9", "werkzeug<4.0.0", # Werkzeug is used by pytest-httpserver ] @@ -209,7 +209,7 @@ exclude = ["website/versioned_docs"] unused-ignore-comment = "error" [[tool.ty.overrides]] -include = ["docs/**/*.py", "website/**/*.py", "tests/unit/test_http_clients.py", "tests/unit/test_run_charge.py"] +include = ["docs/**/*.py", "website/**/*.py"] [tool.ty.overrides.rules] unresolved-import = "ignore" diff --git a/src/apify_client/http_clients/_base.py b/src/apify_client/http_clients/_base.py index c423e96e..11d46ea2 100644 --- a/src/apify_client/http_clients/_base.py +++ b/src/apify_client/http_clients/_base.py @@ -35,12 +35,7 @@ _brotli_compress = brotli.compress except ImportError: - try: - import brotlicffi - - _brotli_compress = brotlicffi.compress - except ImportError: - pass + pass @docs_group('HTTP clients') @@ -220,7 +215,7 @@ def _prepare_request_call( ) -> tuple[dict[str, str], dict[str, Any] | None, bytes | None]: """Prepare headers, params, and body for an HTTP request. Serializes JSON and compresses the body. - Uses brotli compression (`Content-Encoding: br`) when `brotli` or `brotlicffi` is installed, + Uses brotli compression (`Content-Encoding: br`) when `brotli` is installed, otherwise falls back to gzip (`Content-Encoding: gzip`). """ if json is not None and data is not None: diff --git a/tests/unit/test_http_clients.py b/tests/unit/test_http_clients.py index e4183667..161fe6d9 100644 --- a/tests/unit/test_http_clients.py +++ b/tests/unit/test_http_clients.py @@ -13,28 +13,10 @@ from apify_client.http_clients import HttpClient, HttpClientAsync, HttpResponse, ImpitHttpClient, ImpitHttpClientAsync from apify_client.http_clients._impit import _is_retryable_error -try: - import brotli as _brotli_mod +import brotli - _EXPECTED_ENCODING = 'br' - - def _decompress(data: bytes) -> bytes: - return _brotli_mod.decompress(data) - -except ImportError: - try: - import brotlicffi as _brotli_mod - - _EXPECTED_ENCODING = 'br' - - def _decompress(data: bytes) -> bytes: - return _brotli_mod.decompress(data) - - except ImportError: - _EXPECTED_ENCODING = 'gzip' - - def _decompress(data: bytes) -> bytes: - return gzip.decompress(data) +def _decompress(data: bytes) -> bytes: + return brotli.decompress(data) class _ConcreteHttpClient(HttpClient): @@ -301,7 +283,7 @@ def test_prepare_request_call_with_json() -> None: headers, _params, data = client._prepare_request_call(json=json_data) assert headers['Content-Type'] == 'application/json' - assert headers['Content-Encoding'] == _EXPECTED_ENCODING + assert headers['Content-Encoding'] == 'br' assert data is not None assert isinstance(data, bytes) @@ -313,7 +295,7 @@ def test_prepare_request_call_with_empty_dict_json() -> None: headers, _params, data = client._prepare_request_call(json={}) assert headers['Content-Type'] == 'application/json' - assert headers['Content-Encoding'] == _EXPECTED_ENCODING + assert headers['Content-Encoding'] == 'br' assert data is not None assert isinstance(data, bytes) assert _decompress(data) == b'{}' @@ -326,7 +308,7 @@ def test_prepare_request_call_with_empty_list_json() -> None: headers, _params, data = client._prepare_request_call(json=[]) assert headers['Content-Type'] == 'application/json' - assert headers['Content-Encoding'] == _EXPECTED_ENCODING + assert headers['Content-Encoding'] == 'br' assert data is not None assert isinstance(data, bytes) assert _decompress(data) == b'[]' @@ -339,7 +321,7 @@ def test_prepare_request_call_with_zero_json() -> None: headers, _params, data = client._prepare_request_call(json=0) assert headers['Content-Type'] == 'application/json' - assert headers['Content-Encoding'] == _EXPECTED_ENCODING + assert headers['Content-Encoding'] == 'br' assert data is not None assert isinstance(data, bytes) assert _decompress(data) == b'0' @@ -352,7 +334,7 @@ def test_prepare_request_call_with_false_json() -> None: headers, _params, data = client._prepare_request_call(json=False) assert headers['Content-Type'] == 'application/json' - assert headers['Content-Encoding'] == _EXPECTED_ENCODING + assert headers['Content-Encoding'] == 'br' assert data is not None assert isinstance(data, bytes) assert _decompress(data) == b'false' @@ -365,7 +347,7 @@ def test_prepare_request_call_with_empty_string_json() -> None: headers, _params, data = client._prepare_request_call(json='') assert headers['Content-Type'] == 'application/json' - assert headers['Content-Encoding'] == _EXPECTED_ENCODING + assert headers['Content-Encoding'] == 'br' assert data is not None assert isinstance(data, bytes) assert _decompress(data) == b'""' @@ -377,7 +359,7 @@ def test_prepare_request_call_with_string_data() -> None: headers, _params, data = client._prepare_request_call(data='test string') - assert headers['Content-Encoding'] == _EXPECTED_ENCODING + assert headers['Content-Encoding'] == 'br' assert isinstance(data, bytes) @@ -387,10 +369,20 @@ def test_prepare_request_call_with_bytes_data() -> None: headers, _params, data = client._prepare_request_call(data=b'test bytes') - assert headers['Content-Encoding'] == _EXPECTED_ENCODING + assert headers['Content-Encoding'] == 'br' assert isinstance(data, bytes) +def test_prepare_request_call_with_bytearray_data() -> None: + """bytearray body is compressed correctly.""" + client = _ConcreteHttpClient() + headers, _, data = client._prepare_request_call(data=bytearray(b'test bytearray')) + + assert headers['Content-Encoding'] == 'br' + assert data is not None + assert _decompress(data) == b'test bytearray' + + def test_prepare_request_call_json_and_data_error() -> None: """Test _prepare_request_call raises error when both json and data are provided.""" client = _ConcreteHttpClient() @@ -468,21 +460,7 @@ def test_build_url_with_params_mixed() -> None: def test_prepare_request_call_brotli_compression(monkeypatch: pytest.MonkeyPatch) -> None: - """When a brotli compressor is available, request body uses brotli (Content-Encoding: br).""" - brotlicffi = pytest.importorskip('brotlicffi') - monkeypatch.setattr('apify_client.http_clients._base._brotli_compress', brotlicffi.compress) - - client = _ConcreteHttpClient() - headers, _, data = client._prepare_request_call(json={'k': 'v'}) - - assert headers['Content-Encoding'] == 'br' - assert data is not None - assert brotlicffi.decompress(data) == b'{"k": "v"}' - - -def test_prepare_request_call_brotli_library_compression(monkeypatch: pytest.MonkeyPatch) -> None: - """When the brotli (C extension) library is available, request body uses brotli (Content-Encoding: br).""" - brotli = pytest.importorskip('brotli') + """When brotli is installed, request body uses brotli (Content-Encoding: br).""" monkeypatch.setattr('apify_client.http_clients._base._brotli_compress', brotli.compress) client = _ConcreteHttpClient() diff --git a/tests/unit/test_run_charge.py b/tests/unit/test_run_charge.py index f3a313b9..cd2d7906 100644 --- a/tests/unit/test_run_charge.py +++ b/tests/unit/test_run_charge.py @@ -4,23 +4,11 @@ import json from typing import TYPE_CHECKING -try: - import brotlicffi as _brotli_mod +import brotli - def _decompress(data: bytes) -> bytes: - return _brotli_mod.decompress(data) -except ImportError: - try: - import brotli as _brotli_mod # type: ignore[no-redef] - - def _decompress(data: bytes) -> bytes: - return _brotli_mod.decompress(data) - - except ImportError: - - def _decompress(data: bytes) -> bytes: - return gzip.decompress(data) +def _decompress(data: bytes) -> bytes: + return brotli.decompress(data) import pytest diff --git a/uv.lock b/uv.lock index d988ded9..69e3c318 100644 --- a/uv.lock +++ b/uv.lock @@ -52,13 +52,13 @@ dependencies = [ [package.optional-dependencies] brotli = [ - { name = "brotli", marker = "platform_python_implementation == 'CPython'" }, - { name = "brotlicffi", marker = "platform_python_implementation != 'CPython'" }, + { name = "brotli" }, ] [package.dev-dependencies] dev = [ { name = "black" }, + { name = "brotli" }, { name = "datamodel-code-generator", extra = ["http", "ruff"] }, { name = "dycw-pytest-only" }, { name = "griffe" }, @@ -80,8 +80,7 @@ dev = [ [package.metadata] requires-dist = [ - { name = "brotli", marker = "platform_python_implementation == 'CPython' and extra == 'brotli'", specifier = ">=1.0.9" }, - { name = "brotlicffi", marker = "platform_python_implementation != 'CPython' and extra == 'brotli'", specifier = ">=1.0.9" }, + { name = "brotli", marker = "extra == 'brotli'", specifier = ">=1.0.9" }, { name = "colorama", specifier = ">=0.4.0" }, { name = "impit", specifier = "~=0.13.0" }, { name = "more-itertools", specifier = ">=10.0.0" }, @@ -92,6 +91,7 @@ provides-extras = ["brotli"] [package.metadata.requires-dev] dev = [ { name = "black", specifier = ">=24.3.0" }, + { name = "brotli", specifier = ">=1.0.9" }, { name = "datamodel-code-generator", extras = ["http", "ruff"], specifier = ">=0.64.1,<1.0.0" }, { name = "dycw-pytest-only", specifier = "<3.0.0" }, { name = "griffe", specifier = "<3.0.0" }, @@ -205,31 +205,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f5/10/56978295c14794b2c12007b07f3e41ba26acda9257457d7085b0bb3bb90c/brotli-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:e7c0af964e0b4e3412a0ebf341ea26ec767fa0b4cf81abb5e897c9338b5ad6a3", size = 375639, upload-time = "2025-11-05T18:38:55.67Z" }, ] -[[package]] -name = "brotlicffi" -version = "1.2.0.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cffi" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/8a/b6/017dc5f852ed9b8735af77774509271acbf1de02d238377667145fcee01d/brotlicffi-1.2.0.1.tar.gz", hash = "sha256:c20d5c596278307ad06414a6d95a892377ea274a5c6b790c2548c009385d621c", size = 478156, upload-time = "2026-03-05T19:54:11.547Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ef/f9/dfa56316837fa798eac19358351e974de8e1e2ca9475af4cb90293cd6576/brotlicffi-1.2.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c85e65913cf2b79c57a3fdd05b98d9731d9255dc0cb696b09376cc091b9cddd", size = 433046, upload-time = "2026-03-05T19:53:46.209Z" }, - { url = "https://files.pythonhosted.org/packages/4a/f5/f8f492158c76b0d940388801f04f747028971ad5774287bded5f1e53f08d/brotlicffi-1.2.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:535f2d05d0273408abc13fc0eebb467afac17b0ad85090c8913690d40207dac5", size = 1541126, upload-time = "2026-03-05T19:53:48.248Z" }, - { url = "https://files.pythonhosted.org/packages/3b/e1/ff87af10ac419600c63e9287a0649c673673ae6b4f2bcf48e96cb2f89f60/brotlicffi-1.2.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ce17eb798ca59ecec67a9bb3fd7a4304e120d1cd02953ce522d959b9a84d58ac", size = 1541983, upload-time = "2026-03-05T19:53:50.317Z" }, - { url = "https://files.pythonhosted.org/packages/47/c0/80ecd9bd45776109fab14040e478bf63e456967c9ddee2353d8330ed8de1/brotlicffi-1.2.0.1-cp314-cp314t-win32.whl", hash = "sha256:3c9544f83cb715d95d7eab3af4adbbef8b2093ad6382288a83b3a25feb1a57ec", size = 349047, upload-time = "2026-03-05T19:53:52.215Z" }, - { url = "https://files.pythonhosted.org/packages/ab/98/13e5b250236a281b6cd9e92a01ee1ae231029fa78faee932ef3766e1cb24/brotlicffi-1.2.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:625f8115d32ae9c0740d01ea51518437c3fbaa3e78d41cb18459f6f7ac326000", size = 385652, upload-time = "2026-03-05T19:53:53.892Z" }, - { url = "https://files.pythonhosted.org/packages/9a/9f/b98dcd4af47994cee97aebac866996a006a2e5fc1fd1e2b82a8ad95cf09c/brotlicffi-1.2.0.1-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:91ba5f0ccc040f6ff8f7efaf839f797723d03ed46acb8ae9408f99ffd2572cf4", size = 432608, upload-time = "2026-03-05T19:53:56.736Z" }, - { url = "https://files.pythonhosted.org/packages/b1/7a/ac4ee56595a061e3718a6d1ea7e921f4df156894acffb28ed88a1fd52022/brotlicffi-1.2.0.1-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be9a670c6811af30a4bd42d7116dc5895d3b41beaa8ed8a89050447a0181f5ce", size = 1534257, upload-time = "2026-03-05T19:53:58.667Z" }, - { url = "https://files.pythonhosted.org/packages/99/39/e7410db7f6f56de57744ea52a115084ceb2735f4d44973f349bb92136586/brotlicffi-1.2.0.1-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f3314a3476f59e5443f9f72a6dff16edc0c3463c9b318feaef04ae3e4683f5a", size = 1536838, upload-time = "2026-03-05T19:54:00.705Z" }, - { url = "https://files.pythonhosted.org/packages/a6/75/6e7977d1935fc3fbb201cbd619be8f2c7aea25d40a096967132854b34708/brotlicffi-1.2.0.1-cp38-abi3-win32.whl", hash = "sha256:82ea52e2b5d3145b6c406ebd3efb0d55db718b7ad996bd70c62cec0439de1187", size = 343337, upload-time = "2026-03-05T19:54:02.446Z" }, - { url = "https://files.pythonhosted.org/packages/d8/ef/e7e485ce5e4ba3843a0a92feb767c7b6098fd6e65ce752918074d175ae71/brotlicffi-1.2.0.1-cp38-abi3-win_amd64.whl", hash = "sha256:da2e82a08e7778b8bc539d27ca03cdd684113e81394bfaaad8d0dfc6a17ddede", size = 379026, upload-time = "2026-03-05T19:54:04.322Z" }, - { url = "https://files.pythonhosted.org/packages/7f/53/6262c2256513e6f530d81642477cb19367270922063eaa2d7b781d8c723d/brotlicffi-1.2.0.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:e015af99584c6db1490a69a210c765953e473e63adc2d891ac3062a737c9e851", size = 402265, upload-time = "2026-03-05T19:54:05.858Z" }, - { url = "https://files.pythonhosted.org/packages/1f/d9/d5340b43cf5fbe7fe5a083d237e5338cc1caa73bea523be1c5e452c26290/brotlicffi-1.2.0.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:37cb587d32bf7168e2218c455e22e409ad1f3157c6c71945879a311f3e6b6abf", size = 406710, upload-time = "2026-03-05T19:54:07.272Z" }, - { url = "https://files.pythonhosted.org/packages/a3/82/dbced4c1e0792efdf23fd90ff6d2a320c64ff4dfef7aacc85c04fde9ddd2/brotlicffi-1.2.0.1-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d6ba65dd528892b4d9960beba2ae011a753620bcfc66cf6fa3cee18d7b0baa4", size = 402787, upload-time = "2026-03-05T19:54:08.73Z" }, - { url = "https://files.pythonhosted.org/packages/ef/6f/534205ba7590c9a8716a614f270c5c2ec419b5b7079b3f9cd31b7b5580de/brotlicffi-1.2.0.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:f2a5575653b0672638ba039b82fda56854934d7a6a24d4b8b5033f73ab43cbc1", size = 375108, upload-time = "2026-03-05T19:54:10.079Z" }, -] - [[package]] name = "certifi" version = "2026.6.17" @@ -239,104 +214,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" }, ] -[[package]] -name = "cffi" -version = "2.1.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pycparser", marker = "implementation_name != 'PyPy'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/57/5f/ff100cae70ebe9d8df1c01a00e510e45d9adb5c1fdda84791b199141de97/cffi-2.1.0.tar.gz", hash = "sha256:efc1cdd798b1aaf39b4610bba7aad28c9bea9b910f25c784ccf9ec1fa719d1f9", size = 531036, upload-time = "2026-07-06T21:34:30.382Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d3/67/85c89a59ba36a671e79638f44d466749f08179266a57e4f2ffdf92174072/cffi-2.1.0-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:02cb7ff33ded4f1532476731f89ede53e2e488a8e6205515a82144246ffa7dcc", size = 183845, upload-time = "2026-07-06T21:32:26.32Z" }, - { url = "https://files.pythonhosted.org/packages/ea/dd/e3b0baa2d3d6a857ac72b7efbf18e32e487c9cdafcc13049ad765495b15e/cffi-2.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f5bce581e6b8c235e566a14768a943b172ada3ed73537bb0c0be1edee312d4e7", size = 184186, upload-time = "2026-07-06T21:32:28.025Z" }, - { url = "https://files.pythonhosted.org/packages/65/68/9f3ef890cf3c6ab97bd531c5677f67613d302165d16f8142b2811782a614/cffi-2.1.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:30b65779d598c370374fefabf138d456fd6f3216bfa7bedfab1ba82025b0cd93", size = 211892, upload-time = "2026-07-06T21:32:29.565Z" }, - { url = "https://files.pythonhosted.org/packages/22/d7/1a74539db16d8bfd839ff1515948948efbb162e574650fd3d846896eea95/cffi-2.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88023dfe18799507b73f1dbb0d14326a17465de1bc9c9c7655c22845e9ddc3a2", size = 218793, upload-time = "2026-07-06T21:32:30.951Z" }, - { url = "https://files.pythonhosted.org/packages/ec/d1/9a5b7169499e8e8d8e636de70b97ac7c9447104d2ff1a2cd94790cea5162/cffi-2.1.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:0a96b74cda968eebbad56d973efe5098974f0a9fb323865bf99ea1fd24e3e64c", size = 205737, upload-time = "2026-07-06T21:32:32.216Z" }, - { url = "https://files.pythonhosted.org/packages/ba/b0/e131a9c41f10607926278453d9596163594fe1c4ebc46efe3b5e5b34eb84/cffi-2.1.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a5781494d4d400a3f47f8f1da94b324f6e6b440a53387774002890a2a2f4b50f", size = 204909, upload-time = "2026-07-06T21:32:33.655Z" }, - { url = "https://files.pythonhosted.org/packages/fb/d2/4398416cd699b35167947c6e22aca52c47e69ad5695073c9f1f2c52e04aa/cffi-2.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aa7a1b53a2a4452ada2d1b5dade9960b2522f1e61293a811a077439e39029565", size = 217883, upload-time = "2026-07-06T21:32:35.173Z" }, - { url = "https://files.pythonhosted.org/packages/a2/a5/d4fe77b589e5e82d43ebc809bf2e6474afe8e48e32ea050b9357645b6471/cffi-2.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9d8272c0e483b024e1b9ad029821470ed8ec65631dbd90217469da0e7cd89f1c", size = 221251, upload-time = "2026-07-06T21:32:36.527Z" }, - { url = "https://files.pythonhosted.org/packages/22/f0/a2fc43084c0433caf7f461bccc013e28f848d04ee1c5ed7fce71423cf4d9/cffi-2.1.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7762faa47e8ff7eb80bd261d9a7d8eea2d8baa69de5e95b70c1f338bbe712f02", size = 214250, upload-time = "2026-07-06T21:32:37.852Z" }, - { url = "https://files.pythonhosted.org/packages/04/8c/b925975448cf20634a9fbd5efceb807219db452653648d2897c0989cab2d/cffi-2.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:89095c1968b4ba8285840e131bf2891b09ae137fe2146905acae0354fbce1b5e", size = 219441, upload-time = "2026-07-06T21:32:39.146Z" }, - { url = "https://files.pythonhosted.org/packages/eb/da/5c4918a2d61d86fa927d716cb3d8e4626ef8dc8f605a599d32f33897f59a/cffi-2.1.0-cp311-cp311-win32.whl", hash = "sha256:64c753a0f87a256020004f37a1c8c02c480e725f910f0b2a0f3f07debd1b2479", size = 174496, upload-time = "2026-07-06T21:32:40.467Z" }, - { url = "https://files.pythonhosted.org/packages/f9/c8/6c2de1d55cf35ef8b92885d5ef280790f0fb9634d87ea1cc315176aecd61/cffi-2.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:4f26194e3d95e06501b942642855aed4f953d55e95d7d01b7c4483db3ecff458", size = 185113, upload-time = "2026-07-06T21:32:41.761Z" }, - { url = "https://files.pythonhosted.org/packages/9e/4e/e8d7cb5783f1841a3c8fb3a7735838d7484d08ec08c9f984b14cac1ac0e9/cffi-2.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:35aaea0c7ee0e58a5cd8c2fd1a48fdf7ece0d2699b7ecdda08194e9ce5dd9b3d", size = 179927, upload-time = "2026-07-06T21:32:42.961Z" }, - { url = "https://files.pythonhosted.org/packages/1e/85/990925db5df586ec90beb97529c853497e7f85ba0234830447faf41c3057/cffi-2.1.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:df2b82571a1b30f58a87bf4e5a9e78d2b1eff6c6ce8fd3aa3757221f93f0863f", size = 184829, upload-time = "2026-07-06T21:32:44.324Z" }, - { url = "https://files.pythonhosted.org/packages/4b/92/e7bb136ad6b5352603732cf907ef862ca103f20f2031c1735a46300c20c9/cffi-2.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:78474632761faa0fb96f30b1c928c84ebcf68713cbb80d15bab09dfe61640fde", size = 184728, upload-time = "2026-07-06T21:32:45.683Z" }, - { url = "https://files.pythonhosted.org/packages/c3/c0/d1ec30ffb370f748f2fb54425972bfef9871e0132e82fb589c46b6676049/cffi-2.1.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:5972433ad71a9e46516584ef60a0fda12d9dc459938d1539c3ddecf9bdc1368d", size = 214815, upload-time = "2026-07-06T21:32:48.557Z" }, - { url = "https://files.pythonhosted.org/packages/1b/dc/5620cf930688be01f2d673804291de757a934c90b946dbdc3d84130c2ea4/cffi-2.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b6422532152adf4e59b110cb2808cee7a033800952f5c036b4af047ee43199e7", size = 222429, upload-time = "2026-07-06T21:32:49.848Z" }, - { url = "https://files.pythonhosted.org/packages/4b/a4/77b53abbf7a1e0beb9637edbef2a94d15f9c822f591e85d439ffd91519a6/cffi-2.1.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:46b1c8db8f6122420f32d02fffb924c2fe9bc772d228c7c711748fff56aabb2b", size = 210315, upload-time = "2026-07-06T21:32:51.221Z" }, - { url = "https://files.pythonhosted.org/packages/58/0c/f528df19cc94b675087324d4760d9e6d5bfae97d6217aa4fac43de4f5fcc/cffi-2.1.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9fafc5aa2e2a39aaf7f8cc0c1f044a9b07fca12e558dca53a3cc5c654ad67a7", size = 208859, upload-time = "2026-07-06T21:32:52.512Z" }, - { url = "https://files.pythonhosted.org/packages/62/f2/c9522a81c32132799a1972c39f5c5f8b4c8b9f00488a23feaa6c06f07741/cffi-2.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1e9f50d192a3e525b15a75ab5114e442d83d657b7ec29182a991bc9a88fd3a66", size = 221844, upload-time = "2026-07-06T21:32:53.704Z" }, - { url = "https://files.pythonhosted.org/packages/6e/28/bd53988b9833e8f8ad539d26f4c07a6b3f6bcb1e9e02e7ca038250b3428d/cffi-2.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:98fff996e983a36d3aa2eca83af40c5821202e7e6f32d13ae94e3d2286f10cfe", size = 225287, upload-time = "2026-07-06T21:32:54.907Z" }, - { url = "https://files.pythonhosted.org/packages/79/99/0d0fd37f055224085f42bbb2c022d002e17dde4a97972822327b07d84101/cffi-2.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:379de10ce1ba048b1448599d1b37b24caee16309d1ac98d3982fc997f768700b", size = 223681, upload-time = "2026-07-06T21:32:56.329Z" }, - { url = "https://files.pythonhosted.org/packages/b0/80/c138990aa2a70b1a269f6e06348729836d733d6f970867943f61d367f8cc/cffi-2.1.0-cp312-cp312-win32.whl", hash = "sha256:9b8f0f26ca4e7513c534d351eca551947d053fac438f2a04ac96d882909b0d3a", size = 175269, upload-time = "2026-07-06T21:32:57.777Z" }, - { url = "https://files.pythonhosted.org/packages/a8/eb/f636456ff21a83fc13c032b58cc5dde061691546ac79efa284b2989b7982/cffi-2.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:c97f080ea627e2863524c5af3836e2270b5f5dfff1f104392b959f8df0c5d384", size = 185881, upload-time = "2026-07-06T21:32:59.253Z" }, - { url = "https://files.pythonhosted.org/packages/dd/2c/400ea43e721727dca8a65c4521390e9196757caba4a45643acb2b63271b8/cffi-2.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:6d194185eabd279f1c05ebe3504265ddfc5ad2b58d0714f7db9f01da592e9eb6", size = 180088, upload-time = "2026-07-06T21:33:02.278Z" }, - { url = "https://files.pythonhosted.org/packages/96/88/a996879e2eeccb815f6e3a5967b12a308257412acec882039d386bd2aa7b/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:10537b1df4967ca26d21e5072d7d54188354483b91dc75058968d3f0cf13fbda", size = 194331, upload-time = "2026-07-06T21:33:03.697Z" }, - { url = "https://files.pythonhosted.org/packages/58/85/7ae00d5c8dd6266f4e944c3db630f3c5c9a98b61d469c714d848b1d8138a/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a95b05f9baf29b91171b3a8bd2020b028835243e7b0ff6bb23e2a3c228518b1b", size = 196966, upload-time = "2026-07-06T21:33:05.353Z" }, - { url = "https://files.pythonhosted.org/packages/8c/e9/45c3a76ad8d43ad9261f4c95436da61128d3ca545d72b9612c0ab5be0b1c/cffi-2.1.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:15faec4adfff450819f3aee0e2e02c812de6edb88203aa58807955db2003472a", size = 184795, upload-time = "2026-07-06T21:33:06.699Z" }, - { url = "https://files.pythonhosted.org/packages/84/4c/82f132cb4418ee6d953d982b19191e87e2a6372c8a4ce36e50b69d6ade4a/cffi-2.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:716ff8ec22f20b4d988b12884086bcef0fc99737043e503f7a3935a6be99b1ea", size = 184746, upload-time = "2026-07-06T21:33:08.071Z" }, - { url = "https://files.pythonhosted.org/packages/a0/1c/4ed5a0e5bdca6cbc275556de3328dd1b76fd0c11cc13c88fe66d1d8715f2/cffi-2.1.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:63960549e4f8dc41e31accb97b975abaecfc44c03e396c093a6436763c2ea7db", size = 214747, upload-time = "2026-07-06T21:33:09.671Z" }, - { url = "https://files.pythonhosted.org/packages/3a/a6/e879bb68cc23a2bc9ba8f4b7d8019f0c2694bad2ab6c4a3701d429439f58/cffi-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ff067a8d8d880e7809e4ac88eb009bb848870115317b306666502ccad30b147f", size = 222392, upload-time = "2026-07-06T21:33:10.896Z" }, - { url = "https://files.pythonhosted.org/packages/88/f6/01890cfd63c08f8eb96a8319b0443690197d240a8bd6346048cf7bde9190/cffi-2.1.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3b926723c13eba9f81d2ef3820d63aeceec3b2d4639906047bf675cb8a7a500d", size = 210285, upload-time = "2026-07-06T21:33:12.251Z" }, - { url = "https://files.pythonhosted.org/packages/a6/cf/2b684132056f438567b61e19d690dd31cd0921ace051e0a458be6074369e/cffi-2.1.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:47ff3a8bfd8cb9da1af7524b965127095055654c177fcfc7578debcb015eecd0", size = 208801, upload-time = "2026-07-06T21:33:13.617Z" }, - { url = "https://files.pythonhosted.org/packages/6f/08/f2e7d62c460faae0926f2d6e423694aa409ced3bc1fe2927a0a6e5f05416/cffi-2.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:799416bae98336e400981ff6e532d67d5c709cfb30afb79865a1315f94b0e224", size = 221808, upload-time = "2026-07-06T21:33:15.466Z" }, - { url = "https://files.pythonhosted.org/packages/38/37/04f54b8e63a02f3d908332c9effbf8c366167c6f733ed8a3d4f79b7e2a1e/cffi-2.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:961be50688f7fba2fa65f63712d3b9b341a22311f5253460ce933f52f0de1c8c", size = 225241, upload-time = "2026-07-06T21:33:16.869Z" }, - { url = "https://files.pythonhosted.org/packages/a9/d6/c72eecca433cd3e681c65ed313ab4835d9d4a379704d0f628a6a05f51c2e/cffi-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bf5c6cf48238b0eb4c086978c492ad1cbc22373fc5b2d7353b3a598ce6db887a", size = 223588, upload-time = "2026-07-06T21:33:18.239Z" }, - { url = "https://files.pythonhosted.org/packages/c6/4b/e706f67279140f92939da3475ad610df18bfd52d50f14953a8e5fede71d5/cffi-2.1.0-cp313-cp313-win32.whl", hash = "sha256:db3eb7d46527159a878ec3460e9d40615bc25ba337d477db681aea6e4f05c5d2", size = 175248, upload-time = "2026-07-06T21:33:19.799Z" }, - { url = "https://files.pythonhosted.org/packages/5a/47/59eb7975cb0e4ef0afa764ea945b29a5bb4537a9f771cb7d6c8a5dd74c95/cffi-2.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:8e74a6135550c4748af665b1b1118b6aab33b1fc6a16f9aff630af107c3b4512", size = 185717, upload-time = "2026-07-06T21:33:21.47Z" }, - { url = "https://files.pythonhosted.org/packages/5a/af/34fee85c48f8d94efc8597bc09470c9dd274c145f1c12e0fbc6ab6d38d74/cffi-2.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:2282cd5e38aa8accd03e99d1256af8411c84cdbee6a89d841b563fdbd1f3e50f", size = 180114, upload-time = "2026-07-06T21:33:22.515Z" }, - { url = "https://files.pythonhosted.org/packages/d8/f0/81478e482afa03f6d18dc8f2afb5edc45b3080853b634b5ed91961be0998/cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d2117334c3af3bdcb9a88522b844a2bdb5efdc4f71c6c822df55486ae1c3347a", size = 194142, upload-time = "2026-07-06T21:33:23.657Z" }, - { url = "https://files.pythonhosted.org/packages/7d/95/8de304305cd9204974b0ca051b86d307cafca13aa575a0ef1b44d92c0d8c/cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:702c436735fbe99d59ada02a1f65cfc0d31c0ee8b7290912f8fbc5cd1e4b16c3", size = 196819, upload-time = "2026-07-06T21:33:25.007Z" }, - { url = "https://files.pythonhosted.org/packages/20/71/7c8372d30e42415602ed9f268f7cfd66f1b855fed881ecd168bcb45dbc0b/cffi-2.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1ff3456eab0d889592d1936d6125bbfbc7ae4d3354a700f8bd80450a66445d4d", size = 184965, upload-time = "2026-07-06T21:33:26.605Z" }, - { url = "https://files.pythonhosted.org/packages/d6/5c/584e626835f0375c928176c04137c96927165cb8733cdb3150ec04e5ee5e/cffi-2.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c4165821e131d6d4ca444347c2b694e2311bcfa3fe5a861cc72968f28867beac", size = 184952, upload-time = "2026-07-06T21:33:27.823Z" }, - { url = "https://files.pythonhosted.org/packages/2e/d2/065fcae1c73979fac8e054462478d0ff8a29c40cdc2ed7ea5676a061df53/cffi-2.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:276f20fffd7b396e12516ba8edf9509210ac248cbbc5acbc39cd512f9f59ebe6", size = 222353, upload-time = "2026-07-06T21:33:29.178Z" }, - { url = "https://files.pythonhosted.org/packages/ed/a5/e8bbb1ce5b3ac2f53ad6a10bde44318a5a8d99d4f4a000d44a6e39aeb3e4/cffi-2.1.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7d5980a3433d4b71a5e120f9dd551403d7824e31e2e67124fe2769c404c06913", size = 210051, upload-time = "2026-07-06T21:33:30.534Z" }, - { url = "https://files.pythonhosted.org/packages/28/ed/c127d3ac36e899c965e3361357c3befacd6578c03f40125183e41c3b219e/cffi-2.1.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6ca4919c6e4f89aa99c42510b42cf54596892c00b3f9077f6bdd1505e24b9c8d", size = 208630, upload-time = "2026-07-06T21:33:31.753Z" }, - { url = "https://files.pythonhosted.org/packages/cc/d7/97d3136f81db489ec8d1d67748c110d6c994268fd7528014aa9f2b085e4e/cffi-2.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d53d10f7da99ae46f7373b9150393e9c5eab9b224909982b43832668de4779f5", size = 221593, upload-time = "2026-07-06T21:33:33.044Z" }, - { url = "https://files.pythonhosted.org/packages/d3/27/93195977168ee63aed233a1a0993a2178798654d1f4bddcdd321d6fd3b21/cffi-2.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c351efb95e832a853a29361675f33a7ce53de1a109cd73fd47af0712213aa4ce", size = 225146, upload-time = "2026-07-06T21:33:34.224Z" }, - { url = "https://files.pythonhosted.org/packages/b3/c1/6dbd291ee2ae5a50a034aa057207081f545923bbf15dad4511e985aafff5/cffi-2.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:dbf7c7a88e2bac086f06d14577332760bdeecc42bdec8ac4077f6260557d9326", size = 223240, upload-time = "2026-07-06T21:33:35.57Z" }, - { url = "https://files.pythonhosted.org/packages/0f/6f/ade5ce9863a57992a6ea3d0d10d7e29b8749fc127204b3d493d667b2815f/cffi-2.1.0-cp314-cp314-win32.whl", hash = "sha256:1854b724d00f6654c742097d5387569021be12d3a0f770eae1df8f8acfcc6acd", size = 177723, upload-time = "2026-07-06T21:33:51.626Z" }, - { url = "https://files.pythonhosted.org/packages/41/de/92b9eeed4ae4a21d6fd9b2a2c8505cbed573299902ea73981cc13f7ff62c/cffi-2.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:1b96bfe2c4bd825681b7d311ad6d9b7280a091f43e8f63da5729638083cd3bfb", size = 187937, upload-time = "2026-07-06T21:33:53.403Z" }, - { url = "https://files.pythonhosted.org/packages/2e/1a/cc6ae6c2913a03aab8898eee57963cf1035b8df5872ed8b9115fcc7e2be8/cffi-2.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:7d28dff1db6764108bc30788d85d61c876beff416d9a49cb9dd7c5a9f34f5804", size = 183001, upload-time = "2026-07-06T21:33:54.74Z" }, - { url = "https://files.pythonhosted.org/packages/14/f0/134c00ce0779ec86dea2aa1aac69339c2741a8045072676763512363a2ea/cffi-2.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7ea6b3e2c4250ff1de21c630fe72d0f63eb95c2c32ffbf64a358cf4a8836d714", size = 188538, upload-time = "2026-07-06T21:33:36.792Z" }, - { url = "https://files.pythonhosted.org/packages/50/d8/3b86aba791cb610d24e8a3e1b2cd529e71fa15096b04e4d4e360049d4a4c/cffi-2.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6af371f3767faeffc6ac1ef57cdfd25844403e9d3f476c5537caee499de96376", size = 188230, upload-time = "2026-07-06T21:33:38.011Z" }, - { url = "https://files.pythonhosted.org/packages/14/d0/117dcd9209255ad8571fbc8c92ef32593a1d294dcec91ddc4e4db50606f2/cffi-2.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb4e8997a49aa2c08a3e43c9045d224448b8941d88e7ac163c7d383e560cbf98", size = 223899, upload-time = "2026-07-06T21:33:39.514Z" }, - { url = "https://files.pythonhosted.org/packages/b6/3d/f20f8b886b254e3ad10e15cd4186d3aed49f3e6a35ab37aab9f8f25f7c03/cffi-2.1.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:bf01d8c84cbea96b944c73b22182e6c7c432b3475632b8111dbfdc95ddad6e13", size = 211652, upload-time = "2026-07-06T21:33:40.851Z" }, - { url = "https://files.pythonhosted.org/packages/28/3b/fad54de07260b93ddeef4b96d0131d57ea900675df1d410ae1deee52d7a6/cffi-2.1.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:33eb1ad83ebe8f313e0df035c406227d55a79456704a863fad9842136af5ad7d", size = 210755, upload-time = "2026-07-06T21:33:42.183Z" }, - { url = "https://files.pythonhosted.org/packages/cc/82/3d5c705acb7abbba9bbd7d79b8e62e0f25b6120eb7ae6ac49f1b721722fe/cffi-2.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ac0f1a2d0cfa7eea3f2aaf006ab6e70e8feeb16b75d65b7e5939982ca2f11056", size = 223933, upload-time = "2026-07-06T21:33:43.603Z" }, - { url = "https://files.pythonhosted.org/packages/6c/d0/47e338384ab6b1004241002fa616301020cea4fc95f283506565d252f276/cffi-2.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c16914df9fb7f500e440e6875fa23ff5e0b31db01fa9c06af98d59a91f0dc2e4", size = 226749, upload-time = "2026-07-06T21:33:45.046Z" }, - { url = "https://files.pythonhosted.org/packages/70/25/65bd5b58ea4bfdfc15cde02cb5365f89ef8ab8b2adfb8fe5c4bd4233382f/cffi-2.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5ecbd0499275d57506d397eebe1981cee87b47fcd9ef5c22cab7ed7644a39a94", size = 225703, upload-time = "2026-07-06T21:33:46.374Z" }, - { url = "https://files.pythonhosted.org/packages/dc/78/aa01ac599a8a4322533d45a1f9bc93b338276d2d59dabbe7c6d92a775c81/cffi-2.1.0-cp314-cp314t-win32.whl", hash = "sha256:7d034dcffa09e9a46c93fa3a3be402096cb5354ac6e41ab8e5cc9cd8b642ad76", size = 182857, upload-time = "2026-07-06T21:33:47.696Z" }, - { url = "https://files.pythonhosted.org/packages/b9/26/d00496b22de4d4228f32dde94ad996f350c8aad676d63bcca0743c8dea4d/cffi-2.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0582a58f3051372229ca8e7f5f589f9e5632678208d8636fea3676711fdf7fe5", size = 194065, upload-time = "2026-07-06T21:33:48.953Z" }, - { url = "https://files.pythonhosted.org/packages/d5/dd/0c7dbf815a579ff005008a2d815a55d6bb047c349eef536d9dc53d3f0a8d/cffi-2.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:510aeeeac94811b138077451da1fb18b308a5feab47dd2b603af55804155e1c8", size = 186404, upload-time = "2026-07-06T21:33:50.309Z" }, - { url = "https://files.pythonhosted.org/packages/55/c7/8c8c50cb11c6750051daf12164098a9a6f027ac4356967fd4d800a07f242/cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:2e9dabb9abcb7ad15938c7196ad5c1718a4e6d33cc79b4c0209bdb64c4a54a5c", size = 194121, upload-time = "2026-07-06T21:33:56.109Z" }, - { url = "https://files.pythonhosted.org/packages/99/e2/67680bf19a6b60d2bb7ff83baefa2a4c3d2d7dc0f3277034b802e1fc504c/cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:37f525a7e7e50c017fdebe58b787be310ad59357ae43a053943a6e1a6c526001", size = 196820, upload-time = "2026-07-06T21:33:57.288Z" }, - { url = "https://files.pythonhosted.org/packages/ed/da/4bbe583a3b3a5c8c60892124fe17f3fa3656523faf0d3484eae90f091853/cffi-2.1.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:95f2954c2c9473d892eca6e0409f3568b37ab62a8eedb122461f73cc273476e3", size = 184936, upload-time = "2026-07-06T21:33:58.765Z" }, - { url = "https://files.pythonhosted.org/packages/e5/4b/1f4c36ab273980d7aa75bb126ea4f8971f24a96108acad3a0a084028c57b/cffi-2.1.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:cdf2448aab5f661c9315308ec8b93f4e8a1a67a3c733f8631067a2b67d5913dc", size = 185045, upload-time = "2026-07-06T21:34:00.085Z" }, - { url = "https://files.pythonhosted.org/packages/ef/c3/ad299dc38f3583f8d916b299f028af418a9ec98bc695fcbebeae7420691c/cffi-2.1.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:90bec57cf82089383bd06a605b3eb8daebf7e5a668520beaf6e327a83a947699", size = 222342, upload-time = "2026-07-06T21:34:01.814Z" }, - { url = "https://files.pythonhosted.org/packages/eb/d8/df4543cc087245044ed02ef3ad8e0a26619d0075ac7a77a12dc81177851b/cffi-2.1.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6274dcb2d15cef48daa73ed1be5a40d501d74dccd0cd6db364776d12cb6ba022", size = 210073, upload-time = "2026-07-06T21:34:03.255Z" }, - { url = "https://files.pythonhosted.org/packages/2c/0e/fac738d73728c6cea2a88a2883dca54892496cbba88a1dc1f2909cb8a6f5/cffi-2.1.0-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:2b71d409cccee78310ab5dec549aed052aaea483346e282c7b02362596e01bb0", size = 208551, upload-time = "2026-07-06T21:34:04.433Z" }, - { url = "https://files.pythonhosted.org/packages/e6/3f/0b04a700dd64f465c93020253a793a82c9b4dff9961f48facd0df945d9b8/cffi-2.1.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7d3538f9c0e50670f4deb93dbb696576e60590369cae2faf7de681e597a8a1f1", size = 221649, upload-time = "2026-07-06T21:34:06.157Z" }, - { url = "https://files.pythonhosted.org/packages/5d/7c/b7379a5704c79eda57ce075869ba70a0368d1c850f803b3c0d078d39dcaf/cffi-2.1.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:8f9ec95b8a043d3dfbc74d9abc6f7baf524dd27a8dc160b0a32ff9cdab650c28", size = 225203, upload-time = "2026-07-06T21:34:07.489Z" }, - { url = "https://files.pythonhosted.org/packages/5a/02/d5e6c43ea85c41bda2a184a3418f195fe7cf602967a8d2b94e085b83deef/cffi-2.1.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:af5e2915d41fe6c961694d7bfdc8562942638200f3ce2765dfb8b745cf997629", size = 223263, upload-time = "2026-07-06T21:34:08.712Z" }, - { url = "https://files.pythonhosted.org/packages/2c/d8/772b8259bf75749adffb1c546828978381fb516f60cf701f6c83daf60c85/cffi-2.1.0-cp315-cp315-win32.whl", hash = "sha256:0a42c688d19fca6e095a53c6a6e2295a5b050a8b289f109adab02a9e61a25de6", size = 177696, upload-time = "2026-07-06T21:34:26.355Z" }, - { url = "https://files.pythonhosted.org/packages/2f/dd/afa2191fc6d57fedd26e5844a2fe2fcc0bbfa00961bbaa5a41e4921e7cca/cffi-2.1.0-cp315-cp315-win_amd64.whl", hash = "sha256:bccbbb5ee76a61f9d99b5bf3846a51d7fca4b6a732fe46f89295610edaf41853", size = 187914, upload-time = "2026-07-06T21:34:27.58Z" }, - { url = "https://files.pythonhosted.org/packages/05/ef/6cd4f8c671517162379dc79cfae5aea9106bc38abb89628d5c16adf6a838/cffi-2.1.0-cp315-cp315-win_arm64.whl", hash = "sha256:8d35c139744adb3e727cd51b1a18324bbe44b8bd41bf8322bca4d41289f48eda", size = 183004, upload-time = "2026-07-06T21:34:28.905Z" }, - { url = "https://files.pythonhosted.org/packages/11/b6/12fc55092817a5faa26fb8c40c7f9d662e11a46ee248c137aafc42517d92/cffi-2.1.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:f9912624a0c0b834b7520d7769b3644453aabc0a7e1c839da7359f050750e9bc", size = 188378, upload-time = "2026-07-06T21:34:09.926Z" }, - { url = "https://files.pythonhosted.org/packages/8d/2e/cdac88979f295fde5daa69622c7d2111e56e7ceb94f211357fbe452339e4/cffi-2.1.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:df92f2aba50eb4d96718b68ef76f2e57a57b54f2fa62333496d16c6d585a85ca", size = 188319, upload-time = "2026-07-06T21:34:11.101Z" }, - { url = "https://files.pythonhosted.org/packages/e0/27/1d0b408497e41a74795af122d7b603c418c5fed0171450f899afd04e594f/cffi-2.1.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0520e1f4c35f44e209cbbb421b67eec42e6a157f59444dfb6058874ff3610e5d", size = 223904, upload-time = "2026-07-06T21:34:12.606Z" }, - { url = "https://files.pythonhosted.org/packages/8b/31/e115c985105dd7ffb32444505f18ceb874bb42d992af05d5dced7ecf1980/cffi-2.1.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3681e031db29958a7502f5c0c9d6bbc4c36cb20f7b104086fa642d1799631ff8", size = 211554, upload-time = "2026-07-06T21:34:13.987Z" }, - { url = "https://files.pythonhosted.org/packages/5a/67/9e6e09409336d9e515c58367e7cfcf4f89df06ad25252675595a58eb59d5/cffi-2.1.0-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:762f99479dcb369f60ab9017ad4ab97a36a1dd7c1ee5a3b15db0f4b8659120cd", size = 210795, upload-time = "2026-07-06T21:34:15.972Z" }, - { url = "https://files.pythonhosted.org/packages/19/e5/d3cc82a4a0be7902af279c04181ad038449c096734464a5ae1de3e1401bd/cffi-2.1.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0611e7ebf90573a535ebdc33ae9da222d037853983e13359f580fab781ca017f", size = 223843, upload-time = "2026-07-06T21:34:17.509Z" }, - { url = "https://files.pythonhosted.org/packages/b9/65/b434abc97ce7cecc2c640fde160507c0ecc7e21544b483ba3325d2e2ea17/cffi-2.1.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:86cf8755a791f72c85dc287128cc62d4f24d392e3f1e15837245623f4a33cccc", size = 226773, upload-time = "2026-07-06T21:34:19.05Z" }, - { url = "https://files.pythonhosted.org/packages/b5/9f/d4dc66ca651eb1145a133314cda721abf13cfac3d28c4a0402263ae6ad75/cffi-2.1.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:ba00f661f8ba35d075c937174e27c2c421cec3942fd2e0ea3e66996757c0fdd9", size = 225719, upload-time = "2026-07-06T21:34:20.576Z" }, - { url = "https://files.pythonhosted.org/packages/68/5a/e536c528bc8057496c360c0978559a2dc45653f89dd6151078aa7d8fca1a/cffi-2.1.0-cp315-cp315t-win32.whl", hash = "sha256:cb96698e3c7413d906ce83f8ffd245ec1bd94707541f299d0ce4d6b0193e982b", size = 182760, upload-time = "2026-07-06T21:34:22.059Z" }, - { url = "https://files.pythonhosted.org/packages/d3/0b/0ffe8b82d3875bced5fa1e7986a7a46b748262a40ab7f60b475eb9fb1bb3/cffi-2.1.0-cp315-cp315t-win_amd64.whl", hash = "sha256:f146d154428a2523f9cc7936c02353c2459b8f6cf07d3cd1ee1c0a611109c5d5", size = 193769, upload-time = "2026-07-06T21:34:23.589Z" }, - { url = "https://files.pythonhosted.org/packages/a0/17/1073b53b68c9b5ca6914adf5f8bf55aacc2d3be102418c90700160ea8605/cffi-2.1.0-cp315-cp315t-win_arm64.whl", hash = "sha256:cbb7640ce37159548d2147b5b8c241f962143d4c71231431820783f4dc78f210", size = 186405, upload-time = "2026-07-06T21:34:24.857Z" }, -] - [[package]] name = "cfgv" version = "3.5.0" @@ -1109,15 +986,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/80/6e/4b28b62ecb6aae56769c34a8ff1d661473ec1e9519e2d5f8b2c150086b26/pre_commit-4.6.0-py2.py3-none-any.whl", hash = "sha256:e2cf246f7299edcabcf15f9b0571fdce06058527f0a06535068a86d38089f29b", size = 226472, upload-time = "2026-04-21T20:31:40.092Z" }, ] -[[package]] -name = "pycparser" -version = "3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, -] - [[package]] name = "pydantic" version = "2.13.4" From 0dd99a9caf5a2c9a22cab2876679bcec0702907e Mon Sep 17 00:00:00 2001 From: Michal Turek Date: Wed, 8 Jul 2026 17:16:50 +0200 Subject: [PATCH 03/30] feat: Refactoring of tests --- tests/unit/test_http_clients.py | 15 ++++++--------- tests/unit/test_run_charge.py | 6 +----- 2 files changed, 7 insertions(+), 14 deletions(-) diff --git a/tests/unit/test_http_clients.py b/tests/unit/test_http_clients.py index 161fe6d9..7b107309 100644 --- a/tests/unit/test_http_clients.py +++ b/tests/unit/test_http_clients.py @@ -15,9 +15,6 @@ import brotli -def _decompress(data: bytes) -> bytes: - return brotli.decompress(data) - class _ConcreteHttpClient(HttpClient): """Minimal concrete HttpClient for testing base class helpers.""" @@ -298,7 +295,7 @@ def test_prepare_request_call_with_empty_dict_json() -> None: assert headers['Content-Encoding'] == 'br' assert data is not None assert isinstance(data, bytes) - assert _decompress(data) == b'{}' + assert brotli.decompress(data) == b'{}' def test_prepare_request_call_with_empty_list_json() -> None: @@ -311,7 +308,7 @@ def test_prepare_request_call_with_empty_list_json() -> None: assert headers['Content-Encoding'] == 'br' assert data is not None assert isinstance(data, bytes) - assert _decompress(data) == b'[]' + assert brotli.decompress(data) == b'[]' def test_prepare_request_call_with_zero_json() -> None: @@ -324,7 +321,7 @@ def test_prepare_request_call_with_zero_json() -> None: assert headers['Content-Encoding'] == 'br' assert data is not None assert isinstance(data, bytes) - assert _decompress(data) == b'0' + assert brotli.decompress(data) == b'0' def test_prepare_request_call_with_false_json() -> None: @@ -337,7 +334,7 @@ def test_prepare_request_call_with_false_json() -> None: assert headers['Content-Encoding'] == 'br' assert data is not None assert isinstance(data, bytes) - assert _decompress(data) == b'false' + assert brotli.decompress(data) == b'false' def test_prepare_request_call_with_empty_string_json() -> None: @@ -350,7 +347,7 @@ def test_prepare_request_call_with_empty_string_json() -> None: assert headers['Content-Encoding'] == 'br' assert data is not None assert isinstance(data, bytes) - assert _decompress(data) == b'""' + assert brotli.decompress(data) == b'""' def test_prepare_request_call_with_string_data() -> None: @@ -380,7 +377,7 @@ def test_prepare_request_call_with_bytearray_data() -> None: assert headers['Content-Encoding'] == 'br' assert data is not None - assert _decompress(data) == b'test bytearray' + assert brotli.decompress(data) == b'test bytearray' def test_prepare_request_call_json_and_data_error() -> None: diff --git a/tests/unit/test_run_charge.py b/tests/unit/test_run_charge.py index cd2d7906..7e5b518d 100644 --- a/tests/unit/test_run_charge.py +++ b/tests/unit/test_run_charge.py @@ -7,10 +7,6 @@ import brotli -def _decompress(data: bytes) -> bytes: - return brotli.decompress(data) - - import pytest from werkzeug import Request, Response @@ -27,7 +23,7 @@ def _decode_body(request: Request) -> dict: raw = request.get_data() encoding = request.headers.get('Content-Encoding') if encoding == 'br': - raw = _decompress(raw) + raw = brotli.decompress(raw) elif encoding == 'gzip': raw = gzip.decompress(raw) return json.loads(raw) From 3fffa6223c164d00771a3154f32083e964c1cea7 Mon Sep 17 00:00:00 2001 From: Michal Turek Date: Wed, 8 Jul 2026 17:28:47 +0200 Subject: [PATCH 04/30] feat: Use brotli quality=6; lower but faster --- src/apify_client/http_clients/_base.py | 3 ++- tests/unit/test_http_clients.py | 3 +-- tests/unit/test_run_charge.py | 2 -- 3 files changed, 3 insertions(+), 5 deletions(-) diff --git a/src/apify_client/http_clients/_base.py b/src/apify_client/http_clients/_base.py index 11d46ea2..acebd24c 100644 --- a/src/apify_client/http_clients/_base.py +++ b/src/apify_client/http_clients/_base.py @@ -1,5 +1,6 @@ from __future__ import annotations +import functools import gzip import json as jsonlib import os @@ -33,7 +34,7 @@ try: import brotli - _brotli_compress = brotli.compress + _brotli_compress = functools.partial(brotli.compress, quality=6) except ImportError: pass diff --git a/tests/unit/test_http_clients.py b/tests/unit/test_http_clients.py index 7b107309..2d2d0d6d 100644 --- a/tests/unit/test_http_clients.py +++ b/tests/unit/test_http_clients.py @@ -5,6 +5,7 @@ from typing import Any from unittest.mock import Mock +import brotli import impit import pytest @@ -13,8 +14,6 @@ from apify_client.http_clients import HttpClient, HttpClientAsync, HttpResponse, ImpitHttpClient, ImpitHttpClientAsync from apify_client.http_clients._impit import _is_retryable_error -import brotli - class _ConcreteHttpClient(HttpClient): """Minimal concrete HttpClient for testing base class helpers.""" diff --git a/tests/unit/test_run_charge.py b/tests/unit/test_run_charge.py index 7e5b518d..7e13b937 100644 --- a/tests/unit/test_run_charge.py +++ b/tests/unit/test_run_charge.py @@ -5,8 +5,6 @@ from typing import TYPE_CHECKING import brotli - - import pytest from werkzeug import Request, Response From 37f5a47c654ebb204cf41142b1759636573cf5ba Mon Sep 17 00:00:00 2001 From: Vlada Dusek Date: Thu, 9 Jul 2026 11:28:34 +0200 Subject: [PATCH 05/30] rm brotli from dev deps --- pyproject.toml | 1 - uv.lock | 2 -- 2 files changed, 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index a2dcbfe6..5de5d7c6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -67,7 +67,6 @@ dev = [ "setuptools", # setuptools are used by pytest but not explicitly required "types-colorama<0.5.0", "ty~=0.0.0", - "brotli>=1.0.9", "werkzeug<4.0.0", # Werkzeug is used by pytest-httpserver ] diff --git a/uv.lock b/uv.lock index 69e3c318..fb279a6b 100644 --- a/uv.lock +++ b/uv.lock @@ -58,7 +58,6 @@ brotli = [ [package.dev-dependencies] dev = [ { name = "black" }, - { name = "brotli" }, { name = "datamodel-code-generator", extra = ["http", "ruff"] }, { name = "dycw-pytest-only" }, { name = "griffe" }, @@ -91,7 +90,6 @@ provides-extras = ["brotli"] [package.metadata.requires-dev] dev = [ { name = "black", specifier = ">=24.3.0" }, - { name = "brotli", specifier = ">=1.0.9" }, { name = "datamodel-code-generator", extras = ["http", "ruff"], specifier = ">=0.64.1,<1.0.0" }, { name = "dycw-pytest-only", specifier = "<3.0.0" }, { name = "griffe", specifier = "<3.0.0" }, From 7617ed02fc36f8c61f85aa19901f44e907bf99a2 Mon Sep 17 00:00:00 2001 From: Michal Turek Date: Thu, 9 Jul 2026 13:31:29 +0200 Subject: [PATCH 06/30] feat: Documentation for the compression --- docs/01_introduction/index.mdx | 17 ++++++++++++ docs/02_concepts/13_compression.mdx | 42 +++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+) create mode 100644 docs/02_concepts/13_compression.mdx diff --git a/docs/01_introduction/index.mdx b/docs/01_introduction/index.mdx index 2661b7a2..8af89741 100644 --- a/docs/01_introduction/index.mdx +++ b/docs/01_introduction/index.mdx @@ -46,6 +46,23 @@ The Apify client is available as the `apify-client` package [on PyPI](https://py +To enable brotli request-body compression (better than gzip, especially for large payloads), install the optional extra: + + + + ```bash + pip install "apify-client[brotli]" + ``` + + + ```bash + conda install conda-forge::apify-client conda-forge::brotli + ``` + + + +Without this extra the client falls back to gzip automatically — no code changes needed. See [Request body compression](../02_concepts/13_compression.mdx) for a full comparison of the two algorithms. + ## Quick example The following example shows how to run an Actor and retrieve its results: diff --git a/docs/02_concepts/13_compression.mdx b/docs/02_concepts/13_compression.mdx new file mode 100644 index 00000000..6a56462a --- /dev/null +++ b/docs/02_concepts/13_compression.mdx @@ -0,0 +1,42 @@ +--- +id: compression +title: Request body compression +description: The client compresses every request body automatically, using brotli when available or gzip as a fallback. +--- + +The Apify client compresses every request body before sending it to the API. This reduces the amount of data transferred over the network, resulting in faster requests and lower bandwidth usage — especially for large payloads such as Actor inputs, dataset uploads, or key-value store records. + +## How it works + +The client selects the compression algorithm automatically at startup, with no configuration required: + +1. If the [`brotli`](https://pypi.org/project/brotli/) package is installed, the client uses brotli compression at quality level 6 (out of a maximum of 11) and sends the `Content-Encoding: br` header. Quality 6 is a deliberate trade-off: it compresses better than gzip while keeping CPU overhead low compared to maximum-quality brotli. +2. Otherwise, it falls back to gzip compression and sends the `Content-Encoding: gzip` header. + +The server supports both algorithms and decompresses the request body transparently. + +## Enabling brotli + +Brotli is available as an optional extra. Install it alongside the client: + +```bash +pip install "apify-client[brotli]" +# or +uv add "apify-client[brotli]" +``` + +Once installed, the client detects it at startup and switches to brotli automatically — no code changes needed. To revert to gzip, uninstall the extra. + +## Comparison + +| | Brotli | Gzip | +|---|---|---| +| **Compression ratio** | Better than gzip at quality 6 | Good | +| **CPU cost** | Moderate at quality 6 | Low | +| **Availability** | Requires the `brotli` extra | Built-in — no extra needed | +| **`Content-Encoding` header** | `br` | `gzip` | +| **Best for** | Large payloads where bandwidth matters | Minimal-dependency environments | + +:::tip +For most workloads, the bandwidth savings from brotli outweigh the small CPU overhead. Install the `brotli` extra unless you are running in an environment where installing additional packages is not feasible. +::: From 28f34e24a64c52047d49e3caaef3d9d326c064eb Mon Sep 17 00:00:00 2001 From: Michal Turek Date: Thu, 9 Jul 2026 13:31:29 +0200 Subject: [PATCH 07/30] feat: Formatting --- docs/02_concepts/13_compression.mdx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/02_concepts/13_compression.mdx b/docs/02_concepts/13_compression.mdx index 6a56462a..52782b62 100644 --- a/docs/02_concepts/13_compression.mdx +++ b/docs/02_concepts/13_compression.mdx @@ -29,13 +29,13 @@ Once installed, the client detects it at startup and switches to brotli automati ## Comparison -| | Brotli | Gzip | -|---|---|---| -| **Compression ratio** | Better than gzip at quality 6 | Good | -| **CPU cost** | Moderate at quality 6 | Low | -| **Availability** | Requires the `brotli` extra | Built-in — no extra needed | -| **`Content-Encoding` header** | `br` | `gzip` | -| **Best for** | Large payloads where bandwidth matters | Minimal-dependency environments | +| | Brotli | Gzip | +|-------------------------------|----------------------------------------|---------------------------------| +| **Compression ratio** | Better than gzip at quality 6 | Good | +| **CPU cost** | Moderate at quality 6 | Low | +| **Availability** | Requires the `brotli` extra | Built-in — no extra needed | +| **`Content-Encoding` header** | `br` | `gzip` | +| **Best for** | Large payloads where bandwidth matters | Minimal-dependency environments | :::tip For most workloads, the bandwidth savings from brotli outweigh the small CPU overhead. Install the `brotli` extra unless you are running in an environment where installing additional packages is not feasible. From 10c528c3843c217275a2ddc68bdb6a5ba7a16c92 Mon Sep 17 00:00:00 2001 From: Michal Turek Date: Fri, 10 Jul 2026 12:44:20 +0200 Subject: [PATCH 08/30] feat: Apply review comments --- docs/02_concepts/13_compression.mdx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/02_concepts/13_compression.mdx b/docs/02_concepts/13_compression.mdx index 52782b62..abdf08b5 100644 --- a/docs/02_concepts/13_compression.mdx +++ b/docs/02_concepts/13_compression.mdx @@ -4,7 +4,7 @@ title: Request body compression description: The client compresses every request body automatically, using brotli when available or gzip as a fallback. --- -The Apify client compresses every request body before sending it to the API. This reduces the amount of data transferred over the network, resulting in faster requests and lower bandwidth usage — especially for large payloads such as Actor inputs, dataset uploads, or key-value store records. +The Apify client compresses every request body before sending it to the API. This reduces the amount of data transferred over the network, resulting in faster requests and lower bandwidth usage, especially for large payloads such as Actor inputs, dataset uploads, or key-value store records. ## How it works @@ -25,7 +25,7 @@ pip install "apify-client[brotli]" uv add "apify-client[brotli]" ``` -Once installed, the client detects it at startup and switches to brotli automatically — no code changes needed. To revert to gzip, uninstall the extra. +Once installed, the client detects it at startup and switches to brotli automatically. No code changes are needed. To revert to gzip, uninstall the extra. ## Comparison @@ -38,5 +38,5 @@ Once installed, the client detects it at startup and switches to brotli automati | **Best for** | Large payloads where bandwidth matters | Minimal-dependency environments | :::tip -For most workloads, the bandwidth savings from brotli outweigh the small CPU overhead. Install the `brotli` extra unless you are running in an environment where installing additional packages is not feasible. +For most workloads, the bandwidth savings from brotli outweigh the small CPU overhead. Install the `brotli` extra unless you're running in an environment where installing additional packages isn't feasible. ::: From 284711c58ee57fbf3e59c8ebf61092244ec40079 Mon Sep 17 00:00:00 2001 From: Michal Turek Date: Fri, 10 Jul 2026 15:47:36 +0200 Subject: [PATCH 09/30] feat: Make compression algo and quality configurable --- README.md | 8 ++- docs/02_concepts/13_compression.mdx | 30 +++++++++-- src/apify_client/_apify_client.py | 26 ++++++++++ src/apify_client/_consts.py | 22 ++++++++ src/apify_client/http_clients/_base.py | 47 +++++++++++++---- src/apify_client/http_clients/_impit.py | 18 ++++++- src/apify_client/types.py | 8 +++ tests/unit/test_http_clients.py | 69 +++++++++++++++++++++++-- 8 files changed, 209 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index e7cc4fd5..cb5a6b40 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,13 @@ uv add "apify-client[brotli]" ``` - Without this extra the client falls back to gzip automatically — no code changes needed. + Without this extra the client falls back to gzip automatically. You can also force gzip at runtime without uninstalling the extra: + + ```python + client = ApifyClient(token='MY-APIFY-TOKEN', compression_algorithm='gzip', compression_quality=9) + ``` + + Both `compression_algorithm` (`'brotli'` or `'gzip'`) and `compression_quality` (brotli: `1–11`, gzip: `1–9`, default `6`) are configurable on the client constructor. - From [conda-forge](https://anaconda.org/conda-forge/apify-client), it can be installed with [conda](https://docs.conda.io/en/latest/): diff --git a/docs/02_concepts/13_compression.mdx b/docs/02_concepts/13_compression.mdx index abdf08b5..d1376904 100644 --- a/docs/02_concepts/13_compression.mdx +++ b/docs/02_concepts/13_compression.mdx @@ -8,10 +8,10 @@ The Apify client compresses every request body before sending it to the API. Thi ## How it works -The client selects the compression algorithm automatically at startup, with no configuration required: +The client compresses request bodies using the algorithm set by `compression_algorithm` (default `'brotli'`) at the quality level set by `compression_quality` (default `6`): -1. If the [`brotli`](https://pypi.org/project/brotli/) package is installed, the client uses brotli compression at quality level 6 (out of a maximum of 11) and sends the `Content-Encoding: br` header. Quality 6 is a deliberate trade-off: it compresses better than gzip while keeping CPU overhead low compared to maximum-quality brotli. -2. Otherwise, it falls back to gzip compression and sends the `Content-Encoding: gzip` header. +- **`'brotli'` (default)**: Uses brotli compression (`Content-Encoding: br`) when the [`brotli`](https://pypi.org/project/brotli/) extra is installed. Falls back to gzip at the default level when the extra is not installed. +- **`'gzip'`**: Always uses gzip compression (`Content-Encoding: gzip`) with the configured quality level, regardless of whether the brotli extra is installed. The server supports both algorithms and decompresses the request body transparently. @@ -25,7 +25,27 @@ pip install "apify-client[brotli]" uv add "apify-client[brotli]" ``` -Once installed, the client detects it at startup and switches to brotli automatically. No code changes are needed. To revert to gzip, uninstall the extra. +Once installed, the client detects it at startup and switches to brotli automatically. No code changes are needed. + +## Configuration + +Pass `compression_algorithm` and `compression_quality` to the client constructor to override the defaults: + +```python +from apify_client import ApifyClient + +# Force gzip without uninstalling the brotli extra +client = ApifyClient(token='MY-APIFY-TOKEN', compression_algorithm='gzip', compression_quality=9) + +# Use brotli at a lower quality for faster compression +client = ApifyClient(token='MY-APIFY-TOKEN', compression_algorithm='brotli', compression_quality=4) +``` + +Valid quality ranges: +- **Brotli**: `1–11` (higher = better compression, more CPU) +- **Gzip**: `1–9` (higher = better compression, more CPU) + +The default quality of `6` for brotli is a deliberate trade-off: it compresses better than gzip defaults while keeping CPU overhead low compared to maximum-quality brotli. ## Comparison @@ -35,6 +55,8 @@ Once installed, the client detects it at startup and switches to brotli automati | **CPU cost** | Moderate at quality 6 | Low | | **Availability** | Requires the `brotli` extra | Built-in — no extra needed | | **`Content-Encoding` header** | `br` | `gzip` | +| **Quality range** | `1–11` | `1–9` | +| **Force via config** | `compression_algorithm='brotli'` | `compression_algorithm='gzip'` | | **Best for** | Large payloads where bandwidth matters | Minimal-dependency environments | :::tip diff --git a/src/apify_client/_apify_client.py b/src/apify_client/_apify_client.py index 30137aa8..d41735a1 100644 --- a/src/apify_client/_apify_client.py +++ b/src/apify_client/_apify_client.py @@ -7,6 +7,8 @@ from apify_client._consts import ( API_VERSION, DEFAULT_API_URL, + DEFAULT_COMPRESSION_ALGORITHM, + DEFAULT_COMPRESSION_QUALITY, DEFAULT_MAX_RETRIES, DEFAULT_MIN_DELAY_BETWEEN_RETRIES, DEFAULT_TIMEOUT_LONG, @@ -78,6 +80,8 @@ if TYPE_CHECKING: from datetime import timedelta + from apify_client.types import CompressionAlgorithm + @docs_group('Apify API clients') class ApifyClient: @@ -122,6 +126,8 @@ def __init__( timeout_long: timedelta = DEFAULT_TIMEOUT_LONG, timeout_max: timedelta = DEFAULT_TIMEOUT_MAX, headers: dict[str, str] | None = None, + compression_algorithm: CompressionAlgorithm = DEFAULT_COMPRESSION_ALGORITHM, + compression_quality: int = DEFAULT_COMPRESSION_QUALITY, ) -> None: """Initialize the Apify API client. @@ -143,6 +149,11 @@ def __init__( timeout_long: Default timeout for long-duration API operations (long-polling, streaming, ...). timeout_max: Maximum timeout cap for exponential timeout growth across retries. headers: Additional HTTP headers to include in all API requests. + compression_algorithm: Algorithm used to compress request bodies. `'brotli'` (default) uses brotli + when the `brotli` extra is installed and falls back to gzip when unavailable. `'gzip'` always + uses gzip regardless of whether the extra is installed. + compression_quality: Compression quality level. Valid range is `1-11` for brotli and `1-9` for gzip. + Defaults to `6`. """ # We need to do this because of mocking in tests and default mutable arguments. api_url = DEFAULT_API_URL if api_url is None else api_url @@ -205,6 +216,8 @@ def __init__( self._timeout_long = timeout_long self._timeout_max = timeout_max self._headers = headers + self._compression_algorithm = compression_algorithm + self._compression_quality = compression_quality @classmethod def with_custom_http_client( @@ -270,6 +283,8 @@ def http_client(self) -> HttpClient: min_delay_between_retries=self._min_delay_between_retries, statistics=self._statistics, headers=self._headers, + compression_algorithm=self._compression_algorithm, + compression_quality=self._compression_quality, ) return self._http_client @@ -476,6 +491,8 @@ def __init__( timeout_long: timedelta = DEFAULT_TIMEOUT_LONG, timeout_max: timedelta = DEFAULT_TIMEOUT_MAX, headers: dict[str, str] | None = None, + compression_algorithm: CompressionAlgorithm = DEFAULT_COMPRESSION_ALGORITHM, + compression_quality: int = DEFAULT_COMPRESSION_QUALITY, ) -> None: """Initialize the Apify API client. @@ -497,6 +514,11 @@ def __init__( timeout_long: Default timeout for long-duration API operations (long-polling, streaming, ...). timeout_max: Maximum timeout cap for exponential timeout growth across retries. headers: Additional HTTP headers to include in all API requests. + compression_algorithm: Algorithm used to compress request bodies. `'brotli'` (default) uses brotli + when the `brotli` extra is installed and falls back to gzip when unavailable. `'gzip'` always + uses gzip regardless of whether the extra is installed. + compression_quality: Compression quality level. Valid range is `1-11` for brotli and `1-9` for gzip. + Defaults to `6`. """ # We need to do this because of mocking in tests and default mutable arguments. api_url = DEFAULT_API_URL if api_url is None else api_url @@ -559,6 +581,8 @@ def __init__( self._timeout_long = timeout_long self._timeout_max = timeout_max self._headers = headers + self._compression_algorithm = compression_algorithm + self._compression_quality = compression_quality @classmethod def with_custom_http_client( @@ -624,6 +648,8 @@ def http_client(self) -> HttpClientAsync: min_delay_between_retries=self._min_delay_between_retries, statistics=self._statistics, headers=self._headers, + compression_algorithm=self._compression_algorithm, + compression_quality=self._compression_quality, ) return self._http_client diff --git a/src/apify_client/_consts.py b/src/apify_client/_consts.py index 134e0e7b..8af7bf81 100644 --- a/src/apify_client/_consts.py +++ b/src/apify_client/_consts.py @@ -1,6 +1,10 @@ from __future__ import annotations from datetime import timedelta +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from apify_client.types import CompressionAlgorithm DEFAULT_API_URL = 'https://api.apify.com' """Default base URL for the Apify API.""" @@ -34,3 +38,21 @@ OVERRIDABLE_DEFAULT_HEADERS = {'Accept', 'Authorization', 'Accept-Encoding', 'User-Agent'} """Headers that can be overridden by users, but will trigger a warning if they do so, as it may lead to API errors.""" + +DEFAULT_COMPRESSION_ALGORITHM: CompressionAlgorithm = 'brotli' +"""Default compression algorithm for request bodies.""" + +DEFAULT_COMPRESSION_QUALITY: int = 6 +"""Default compression quality for request bodies (brotli: 1-11, gzip: 1-9).""" + +BROTLI_QUALITY_MIN: int = 1 +"""Minimum quality level for brotli compression.""" + +BROTLI_QUALITY_MAX: int = 11 +"""Maximum quality level for brotli compression.""" + +GZIP_QUALITY_MIN: int = 1 +"""Minimum quality level for gzip compression.""" + +GZIP_QUALITY_MAX: int = 9 +"""Maximum quality level for gzip compression.""" diff --git a/src/apify_client/http_clients/_base.py b/src/apify_client/http_clients/_base.py index acebd24c..485964a3 100644 --- a/src/apify_client/http_clients/_base.py +++ b/src/apify_client/http_clients/_base.py @@ -1,6 +1,5 @@ from __future__ import annotations -import functools import gzip import json as jsonlib import os @@ -12,12 +11,18 @@ from urllib.parse import urlencode from apify_client._consts import ( + BROTLI_QUALITY_MAX, + BROTLI_QUALITY_MIN, + DEFAULT_COMPRESSION_ALGORITHM, + DEFAULT_COMPRESSION_QUALITY, DEFAULT_MAX_RETRIES, DEFAULT_MIN_DELAY_BETWEEN_RETRIES, DEFAULT_TIMEOUT_LONG, DEFAULT_TIMEOUT_MAX, DEFAULT_TIMEOUT_MEDIUM, DEFAULT_TIMEOUT_SHORT, + GZIP_QUALITY_MAX, + GZIP_QUALITY_MIN, ) from apify_client._docs import docs_group from apify_client._statistics import ClientStatistics @@ -26,15 +31,15 @@ if TYPE_CHECKING: from collections.abc import AsyncIterator, Callable, Iterator, Mapping - from apify_client.types import JsonSerializable, Timeout + from apify_client.types import CompressionAlgorithm, JsonSerializable, Timeout -_brotli_compress: Callable[[bytes | bytearray], bytes] | None = None +_brotli_compress: Callable[..., bytes] | None = None if not TYPE_CHECKING: try: - import brotli + import brotli as _brotli_module - _brotli_compress = functools.partial(brotli.compress, quality=6) + _brotli_compress = _brotli_module.compress except ImportError: pass @@ -111,6 +116,8 @@ def __init__( min_delay_between_retries: timedelta = DEFAULT_MIN_DELAY_BETWEEN_RETRIES, statistics: ClientStatistics | None = None, headers: dict[str, str] | None = None, + compression_algorithm: CompressionAlgorithm = DEFAULT_COMPRESSION_ALGORITHM, + compression_quality: int = DEFAULT_COMPRESSION_QUALITY, ) -> None: """Initialize the HTTP client base. @@ -124,7 +131,23 @@ def __init__( min_delay_between_retries: Minimum delay between retries. statistics: Statistics tracker for API calls. Created automatically if not provided. headers: Additional HTTP headers to include in all requests. + compression_algorithm: Algorithm used to compress request bodies. `'brotli'` uses brotli when the + `brotli` extra is installed and falls back to gzip when unavailable. `'gzip'` always uses gzip. + compression_quality: Compression quality level. Valid range is `1-11` for brotli and `1-9` for gzip. """ + if compression_algorithm == 'brotli' and not (BROTLI_QUALITY_MIN <= compression_quality <= BROTLI_QUALITY_MAX): + raise ValueError( + f'Brotli compression quality must be between {BROTLI_QUALITY_MIN} and {BROTLI_QUALITY_MAX},' + f' got {compression_quality}.' + ) + if compression_algorithm == 'gzip' and not (GZIP_QUALITY_MIN <= compression_quality <= GZIP_QUALITY_MAX): + raise ValueError( + f'Gzip compression quality must be between {GZIP_QUALITY_MIN} and {GZIP_QUALITY_MAX},' + f' got {compression_quality}.' + ) + + self._compression_algorithm = compression_algorithm + self._compression_quality = compression_quality self._timeout_short = timeout_short self._timeout_medium = timeout_medium self._timeout_long = timeout_long @@ -216,8 +239,10 @@ def _prepare_request_call( ) -> tuple[dict[str, str], dict[str, Any] | None, bytes | None]: """Prepare headers, params, and body for an HTTP request. Serializes JSON and compresses the body. - Uses brotli compression (`Content-Encoding: br`) when `brotli` is installed, - otherwise falls back to gzip (`Content-Encoding: gzip`). + Uses the configured `compression_algorithm`. When `'brotli'` is selected and the `brotli` library is + installed, uses brotli (`Content-Encoding: br`) with the configured quality. When `'brotli'` is selected + but the library is unavailable, falls back to gzip at the default level. When `'gzip'` is selected, + always uses gzip (`Content-Encoding: gzip`) with the configured quality. """ if json is not None and data is not None: raise ValueError('Cannot pass both "json" and "data" parameters at the same time!') @@ -232,10 +257,14 @@ def _prepare_request_call( if isinstance(data, (str, bytes, bytearray)): if isinstance(data, str): data = data.encode('utf-8') - if _brotli_compress is not None: - data = _brotli_compress(data) + if self._compression_algorithm == 'brotli' and _brotli_compress is not None: + data = _brotli_compress(data, quality=self._compression_quality) headers['Content-Encoding'] = 'br' + elif self._compression_algorithm == 'gzip': + data = gzip.compress(data, compresslevel=self._compression_quality) + headers['Content-Encoding'] = 'gzip' else: + # brotli requested but library not installed — fall back to gzip at the default level data = gzip.compress(data) headers['Content-Encoding'] = 'gzip' diff --git a/src/apify_client/http_clients/_impit.py b/src/apify_client/http_clients/_impit.py index c137dde7..1f892152 100644 --- a/src/apify_client/http_clients/_impit.py +++ b/src/apify_client/http_clients/_impit.py @@ -11,6 +11,8 @@ import impit from apify_client._consts import ( + DEFAULT_COMPRESSION_ALGORITHM, + DEFAULT_COMPRESSION_QUALITY, DEFAULT_MAX_RETRIES, DEFAULT_MIN_DELAY_BETWEEN_RETRIES, DEFAULT_TIMEOUT_LONG, @@ -29,7 +31,7 @@ from apify_client._statistics import ClientStatistics from apify_client.http_clients._base import HttpResponse - from apify_client.types import JsonSerializable, Timeout + from apify_client.types import CompressionAlgorithm, JsonSerializable, Timeout T = TypeVar('T') @@ -73,6 +75,8 @@ def __init__( min_delay_between_retries: timedelta = DEFAULT_MIN_DELAY_BETWEEN_RETRIES, statistics: ClientStatistics | None = None, headers: dict[str, str] | None = None, + compression_algorithm: CompressionAlgorithm = DEFAULT_COMPRESSION_ALGORITHM, + compression_quality: int = DEFAULT_COMPRESSION_QUALITY, ) -> None: """Initialize the Impit-based synchronous HTTP client. @@ -86,6 +90,9 @@ def __init__( min_delay_between_retries: Minimum delay between retries (increases exponentially with each attempt). statistics: Statistics tracker for API calls. Created automatically if not provided. headers: Additional HTTP headers to include in all requests. + compression_algorithm: Algorithm used to compress request bodies. `'brotli'` uses brotli when the + `brotli` extra is installed and falls back to gzip when unavailable. `'gzip'` always uses gzip. + compression_quality: Compression quality level. Valid range is `1-11` for brotli and `1-9` for gzip. """ super().__init__( token=token, @@ -97,6 +104,8 @@ def __init__( min_delay_between_retries=min_delay_between_retries, statistics=statistics, headers=headers, + compression_algorithm=compression_algorithm, + compression_quality=compression_quality, ) self._impit_client = impit.Client( @@ -320,6 +329,8 @@ def __init__( min_delay_between_retries: timedelta = DEFAULT_MIN_DELAY_BETWEEN_RETRIES, statistics: ClientStatistics | None = None, headers: dict[str, str] | None = None, + compression_algorithm: CompressionAlgorithm = DEFAULT_COMPRESSION_ALGORITHM, + compression_quality: int = DEFAULT_COMPRESSION_QUALITY, ) -> None: """Initialize the Impit-based asynchronous HTTP client. @@ -333,6 +344,9 @@ def __init__( min_delay_between_retries: Minimum delay between retries (increases exponentially with each attempt). statistics: Statistics tracker for API calls. Created automatically if not provided. headers: Additional HTTP headers to include in all requests. + compression_algorithm: Algorithm used to compress request bodies. `'brotli'` uses brotli when the + `brotli` extra is installed and falls back to gzip when unavailable. `'gzip'` always uses gzip. + compression_quality: Compression quality level. Valid range is `1-11` for brotli and `1-9` for gzip. """ super().__init__( token=token, @@ -344,6 +358,8 @@ def __init__( min_delay_between_retries=min_delay_between_retries, statistics=statistics, headers=headers, + compression_algorithm=compression_algorithm, + compression_quality=compression_quality, ) self._impit_async_client = impit.AsyncClient( diff --git a/src/apify_client/types.py b/src/apify_client/types.py index 748fb846..9c385136 100644 --- a/src/apify_client/types.py +++ b/src/apify_client/types.py @@ -11,6 +11,13 @@ WebhookRepresentationDict, ) +CompressionAlgorithm = Literal['brotli', 'gzip'] +"""Compression algorithm for request bodies. + +`'brotli'` uses brotli when the `brotli` extra is installed, falling back to gzip when unavailable. +`'gzip'` always uses gzip regardless of whether the brotli extra is installed. +""" + Timeout = timedelta | Literal['no_timeout', 'short', 'medium', 'long'] """Type for the `timeout` parameter on resource client methods. @@ -42,6 +49,7 @@ """ __all__ = [ + 'CompressionAlgorithm', 'JsonSerializable', 'Timeout', 'WebhooksList', diff --git a/tests/unit/test_http_clients.py b/tests/unit/test_http_clients.py index 2d2d0d6d..5f39015a 100644 --- a/tests/unit/test_http_clients.py +++ b/tests/unit/test_http_clients.py @@ -456,10 +456,10 @@ def test_build_url_with_params_mixed() -> None: def test_prepare_request_call_brotli_compression(monkeypatch: pytest.MonkeyPatch) -> None: - """When brotli is installed, request body uses brotli (Content-Encoding: br).""" + """When compression_algorithm='brotli' and brotli is installed, request body uses brotli.""" monkeypatch.setattr('apify_client.http_clients._base._brotli_compress', brotli.compress) - client = _ConcreteHttpClient() + client = _ConcreteHttpClient(compression_algorithm='brotli') headers, _, data = client._prepare_request_call(json={'k': 'v'}) assert headers['Content-Encoding'] == 'br' @@ -468,12 +468,73 @@ def test_prepare_request_call_brotli_compression(monkeypatch: pytest.MonkeyPatch def test_prepare_request_call_gzip_fallback_without_brotli(monkeypatch: pytest.MonkeyPatch) -> None: - """When no brotli library is available, request body falls back to gzip (Content-Encoding: gzip).""" + """When compression_algorithm='brotli' but the library is unavailable, falls back to gzip.""" monkeypatch.setattr('apify_client.http_clients._base._brotli_compress', None) - client = _ConcreteHttpClient() + client = _ConcreteHttpClient(compression_algorithm='brotli') + headers, _, data = client._prepare_request_call(json={'k': 'v'}) + + assert headers['Content-Encoding'] == 'gzip' + assert data is not None + assert gzip.decompress(data) == b'{"k": "v"}' + + +def test_prepare_request_call_explicit_gzip(monkeypatch: pytest.MonkeyPatch) -> None: + """When compression_algorithm='gzip', always uses gzip even when brotli is available.""" + monkeypatch.setattr('apify_client.http_clients._base._brotli_compress', brotli.compress) + + client = _ConcreteHttpClient(compression_algorithm='gzip') + headers, _, data = client._prepare_request_call(json={'k': 'v'}) + + assert headers['Content-Encoding'] == 'gzip' + assert data is not None + assert gzip.decompress(data) == b'{"k": "v"}' + + +def test_prepare_request_call_brotli_custom_quality(monkeypatch: pytest.MonkeyPatch) -> None: + """compression_quality is forwarded to the brotli compressor.""" + captured_kwargs: dict = {} + + def capturing_compress(data: bytes, **kwargs: object) -> bytes: + captured_kwargs.update(kwargs) + return brotli.compress(data, **kwargs) + + monkeypatch.setattr('apify_client.http_clients._base._brotli_compress', capturing_compress) + + client = _ConcreteHttpClient(compression_algorithm='brotli', compression_quality=1) + headers, _, data = client._prepare_request_call(json={'k': 'v'}) + + assert headers['Content-Encoding'] == 'br' + assert captured_kwargs.get('quality') == 1 + assert data is not None + assert brotli.decompress(data) == b'{"k": "v"}' + + +def test_prepare_request_call_gzip_custom_quality(monkeypatch: pytest.MonkeyPatch) -> None: + """compression_quality is forwarded to the gzip compressor.""" + monkeypatch.setattr('apify_client.http_clients._base._brotli_compress', None) + + client = _ConcreteHttpClient(compression_algorithm='gzip', compression_quality=1) headers, _, data = client._prepare_request_call(json={'k': 'v'}) assert headers['Content-Encoding'] == 'gzip' assert data is not None assert gzip.decompress(data) == b'{"k": "v"}' + + +def test_prepare_request_call_invalid_brotli_quality() -> None: + """Raises ValueError when brotli quality is out of range 1-11.""" + with pytest.raises(ValueError, match='Brotli compression quality'): + _ConcreteHttpClient(compression_algorithm='brotli', compression_quality=12) + + with pytest.raises(ValueError, match='Brotli compression quality'): + _ConcreteHttpClient(compression_algorithm='brotli', compression_quality=0) + + +def test_prepare_request_call_invalid_gzip_quality() -> None: + """Raises ValueError when gzip quality is out of range 1-9.""" + with pytest.raises(ValueError, match='Gzip compression quality'): + _ConcreteHttpClient(compression_algorithm='gzip', compression_quality=10) + + with pytest.raises(ValueError, match='Gzip compression quality'): + _ConcreteHttpClient(compression_algorithm='gzip', compression_quality=0) From cdcbac3bfd65e39cce9fb587f589f78535534d5d Mon Sep 17 00:00:00 2001 From: Michal Turek Date: Fri, 10 Jul 2026 16:16:46 +0200 Subject: [PATCH 10/30] feat: Fix formatting --- docs/02_concepts/13_compression.mdx | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/02_concepts/13_compression.mdx b/docs/02_concepts/13_compression.mdx index d1376904..c98cf376 100644 --- a/docs/02_concepts/13_compression.mdx +++ b/docs/02_concepts/13_compression.mdx @@ -42,6 +42,7 @@ client = ApifyClient(token='MY-APIFY-TOKEN', compression_algorithm='brotli', com ``` Valid quality ranges: + - **Brotli**: `1–11` (higher = better compression, more CPU) - **Gzip**: `1–9` (higher = better compression, more CPU) From 3f6373d819f084bc66a742c952be030059f47ba2 Mon Sep 17 00:00:00 2001 From: Michal Turek Date: Tue, 14 Jul 2026 13:24:35 +0200 Subject: [PATCH 11/30] feat: Refactor the implementation to use hierarchy of classes --- README.md | 17 ++- src/apify_client/_apify_client.py | 69 +++++++---- src/apify_client/_consts.py | 22 ---- src/apify_client/_utils.py | 50 +++++++- src/apify_client/http_clients/_base.py | 66 ++--------- src/apify_client/http_clients/_impit.py | 25 ++-- src/apify_client/http_compressors/__init__.py | 12 ++ src/apify_client/http_compressors/_base.py | 25 ++++ src/apify_client/http_compressors/_brotli.py | 25 ++++ src/apify_client/http_compressors/_gzip.py | 25 ++++ src/apify_client/types.py | 13 ++- tests/unit/test_http_clients.py | 109 +++++++----------- 12 files changed, 260 insertions(+), 198 deletions(-) create mode 100644 src/apify_client/http_compressors/__init__.py create mode 100644 src/apify_client/http_compressors/_base.py create mode 100644 src/apify_client/http_compressors/_brotli.py create mode 100644 src/apify_client/http_compressors/_gzip.py diff --git a/README.md b/README.md index cb5a6b40..6534d3ea 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,8 @@ or any other Python package manager that consumes PyPI. - To enable brotli request-body compression (better than gzip, especially for large payloads), install the optional extra: + The client compresses request bodies with gzip by default (no extra dependencies required). To opt in to brotli + (better compression ratio), install the optional extra and pass `encoding='brotli'`: ```bash pip install "apify-client[brotli]" @@ -56,13 +57,19 @@ uv add "apify-client[brotli]" ``` - Without this extra the client falls back to gzip automatically. You can also force gzip at runtime without uninstalling the extra: - ```python - client = ApifyClient(token='MY-APIFY-TOKEN', compression_algorithm='gzip', compression_quality=9) + client = ApifyClient(token='MY-APIFY-TOKEN', encoding='brotli') ``` - Both `compression_algorithm` (`'brotli'` or `'gzip'`) and `compression_quality` (brotli: `1–11`, gzip: `1–9`, default `6`) are configurable on the client constructor. + For fine-grained control over compression quality, inject a compressor instance directly: + + ```python + from apify_client.http_compressors import BrotliHttpCompressor, GzipHttpCompressor + + client = ApifyClient(token='MY-APIFY-TOKEN', encoding=BrotliHttpCompressor(quality=11)) + # or + client = ApifyClient(token='MY-APIFY-TOKEN', encoding=GzipHttpCompressor(quality=9)) + ``` - From [conda-forge](https://anaconda.org/conda-forge/apify-client), it can be installed with [conda](https://docs.conda.io/en/latest/): diff --git a/src/apify_client/_apify_client.py b/src/apify_client/_apify_client.py index d41735a1..5948c596 100644 --- a/src/apify_client/_apify_client.py +++ b/src/apify_client/_apify_client.py @@ -7,8 +7,6 @@ from apify_client._consts import ( API_VERSION, DEFAULT_API_URL, - DEFAULT_COMPRESSION_ALGORITHM, - DEFAULT_COMPRESSION_QUALITY, DEFAULT_MAX_RETRIES, DEFAULT_MIN_DELAY_BETWEEN_RETRIES, DEFAULT_TIMEOUT_LONG, @@ -76,11 +74,42 @@ from apify_client._statistics import ClientStatistics from apify_client._utils import check_custom_headers from apify_client.http_clients import HttpClient, HttpClientAsync, ImpitHttpClient, ImpitHttpClientAsync +from apify_client.http_compressors import GzipHttpCompressor +from apify_client.http_compressors._base import HttpCompressor if TYPE_CHECKING: from datetime import timedelta - from apify_client.types import CompressionAlgorithm + from apify_client.types import HttpCompressionAlgorithm + + +def _resolve_compressor(encoding: HttpCompressionAlgorithm | HttpCompressor) -> HttpCompressor: + """Convert an encoding string or `HttpCompressor` instance into a concrete `HttpCompressor`. + + Args: + encoding: Either a string literal (`'gzip'` or `'brotli'`) or an `HttpCompressor` instance. + + Returns: + A ready-to-use `HttpCompressor`. + + Raises: + ImportError: If `'brotli'` is requested but the `brotli` extra is not installed. + ValueError: If `encoding` is not a recognized compression algorithm. + """ + if isinstance(encoding, HttpCompressor): + return encoding + elif encoding == 'gzip': + return GzipHttpCompressor() + elif encoding == 'brotli': + # The import is here so the ImportError is raised at call time, + # not at module import time, giving users a clear message. + from apify_client.http_compressors import BrotliHttpCompressor # noqa: PLC0415 + + return BrotliHttpCompressor() + else: + # The backend supports also `deflate` and `identity` (no compression). One can build + # a custom compressor if needed. + raise ValueError(f'Unsupported compression algorithm: {encoding!r}') @docs_group('Apify API clients') @@ -126,8 +155,7 @@ def __init__( timeout_long: timedelta = DEFAULT_TIMEOUT_LONG, timeout_max: timedelta = DEFAULT_TIMEOUT_MAX, headers: dict[str, str] | None = None, - compression_algorithm: CompressionAlgorithm = DEFAULT_COMPRESSION_ALGORITHM, - compression_quality: int = DEFAULT_COMPRESSION_QUALITY, + encoding: HttpCompressionAlgorithm | HttpCompressor = 'gzip', ) -> None: """Initialize the Apify API client. @@ -149,11 +177,9 @@ def __init__( timeout_long: Default timeout for long-duration API operations (long-polling, streaming, ...). timeout_max: Maximum timeout cap for exponential timeout growth across retries. headers: Additional HTTP headers to include in all API requests. - compression_algorithm: Algorithm used to compress request bodies. `'brotli'` (default) uses brotli - when the `brotli` extra is installed and falls back to gzip when unavailable. `'gzip'` always - uses gzip regardless of whether the extra is installed. - compression_quality: Compression quality level. Valid range is `1-11` for brotli and `1-9` for gzip. - Defaults to `6`. + encoding: Compression algorithm for request bodies. Pass `'gzip'` (default, no extra required) or + `'brotli'` (requires `pip install "apify-client[brotli]"`). For custom quality or full control, + pass an `HttpCompressor` instance directly, e.g. `BrotliHttpCompressor(quality=11)`. """ # We need to do this because of mocking in tests and default mutable arguments. api_url = DEFAULT_API_URL if api_url is None else api_url @@ -216,8 +242,7 @@ def __init__( self._timeout_long = timeout_long self._timeout_max = timeout_max self._headers = headers - self._compression_algorithm = compression_algorithm - self._compression_quality = compression_quality + self._encoding = encoding @classmethod def with_custom_http_client( @@ -283,8 +308,7 @@ def http_client(self) -> HttpClient: min_delay_between_retries=self._min_delay_between_retries, statistics=self._statistics, headers=self._headers, - compression_algorithm=self._compression_algorithm, - compression_quality=self._compression_quality, + compressor=_resolve_compressor(self._encoding), ) return self._http_client @@ -491,8 +515,7 @@ def __init__( timeout_long: timedelta = DEFAULT_TIMEOUT_LONG, timeout_max: timedelta = DEFAULT_TIMEOUT_MAX, headers: dict[str, str] | None = None, - compression_algorithm: CompressionAlgorithm = DEFAULT_COMPRESSION_ALGORITHM, - compression_quality: int = DEFAULT_COMPRESSION_QUALITY, + encoding: HttpCompressionAlgorithm | HttpCompressor = 'gzip', ) -> None: """Initialize the Apify API client. @@ -514,11 +537,9 @@ def __init__( timeout_long: Default timeout for long-duration API operations (long-polling, streaming, ...). timeout_max: Maximum timeout cap for exponential timeout growth across retries. headers: Additional HTTP headers to include in all API requests. - compression_algorithm: Algorithm used to compress request bodies. `'brotli'` (default) uses brotli - when the `brotli` extra is installed and falls back to gzip when unavailable. `'gzip'` always - uses gzip regardless of whether the extra is installed. - compression_quality: Compression quality level. Valid range is `1-11` for brotli and `1-9` for gzip. - Defaults to `6`. + encoding: Compression algorithm for request bodies. Pass `'gzip'` (default, no extra required) or + `'brotli'` (requires `pip install "apify-client[brotli]"`). For custom quality or full control, + pass an `HttpCompressor` instance directly, e.g. `BrotliHttpCompressor(quality=11)`. """ # We need to do this because of mocking in tests and default mutable arguments. api_url = DEFAULT_API_URL if api_url is None else api_url @@ -581,8 +602,7 @@ def __init__( self._timeout_long = timeout_long self._timeout_max = timeout_max self._headers = headers - self._compression_algorithm = compression_algorithm - self._compression_quality = compression_quality + self._encoding = encoding @classmethod def with_custom_http_client( @@ -648,8 +668,7 @@ def http_client(self) -> HttpClientAsync: min_delay_between_retries=self._min_delay_between_retries, statistics=self._statistics, headers=self._headers, - compression_algorithm=self._compression_algorithm, - compression_quality=self._compression_quality, + compressor=_resolve_compressor(self._encoding), ) return self._http_client diff --git a/src/apify_client/_consts.py b/src/apify_client/_consts.py index 8af7bf81..134e0e7b 100644 --- a/src/apify_client/_consts.py +++ b/src/apify_client/_consts.py @@ -1,10 +1,6 @@ from __future__ import annotations from datetime import timedelta -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from apify_client.types import CompressionAlgorithm DEFAULT_API_URL = 'https://api.apify.com' """Default base URL for the Apify API.""" @@ -38,21 +34,3 @@ OVERRIDABLE_DEFAULT_HEADERS = {'Accept', 'Authorization', 'Accept-Encoding', 'User-Agent'} """Headers that can be overridden by users, but will trigger a warning if they do so, as it may lead to API errors.""" - -DEFAULT_COMPRESSION_ALGORITHM: CompressionAlgorithm = 'brotli' -"""Default compression algorithm for request bodies.""" - -DEFAULT_COMPRESSION_QUALITY: int = 6 -"""Default compression quality for request bodies (brotli: 1-11, gzip: 1-9).""" - -BROTLI_QUALITY_MIN: int = 1 -"""Minimum quality level for brotli compression.""" - -BROTLI_QUALITY_MAX: int = 11 -"""Maximum quality level for brotli compression.""" - -GZIP_QUALITY_MIN: int = 1 -"""Minimum quality level for gzip compression.""" - -GZIP_QUALITY_MAX: int = 9 -"""Maximum quality level for gzip compression.""" diff --git a/src/apify_client/_utils.py b/src/apify_client/_utils.py index 92ef5c63..1cd70882 100644 --- a/src/apify_client/_utils.py +++ b/src/apify_client/_utils.py @@ -5,10 +5,14 @@ import io import json import string +import sys import time import warnings from base64 import b64encode, urlsafe_b64encode +from contextlib import contextmanager +from dataclasses import dataclass from functools import cache +from types import ModuleType from typing import TYPE_CHECKING, Any, Literal, TypeVar, overload import impit @@ -18,6 +22,7 @@ from apify_client.errors import InvalidResponseBodyError, NotFoundError if TYPE_CHECKING: + from collections.abc import Generator from datetime import timedelta from apify_client.errors import ApifyApiError @@ -27,7 +32,50 @@ T = TypeVar('T') _BASE62_CHARSET = string.digits + string.ascii_letters -"""Module-level constant for base62 encoding.""" + + +@dataclass +class _FailedImport: + message: str + + +class _ImportWrapper(ModuleType): + """Module subclass that converts `_FailedImport` attribute accesses into `ImportError`.""" + + def __getattr__(self, name: str) -> object: + obj = super().__getattribute__(name) + if isinstance(obj, _FailedImport): + raise ImportError(obj.message) # noqa: TRY004 + return obj + + +@contextmanager +def try_import(module_name: str, symbol_name: str) -> Generator[None, None, None]: + """Context manager for optional imports. + + If the import inside the block raises `ImportError`, the named symbol in the given module is + replaced with a `_FailedImport` placeholder. Accessing that placeholder later (after + `install_import_hook` has been called) raises a clear `ImportError` with the original message. + + Args: + module_name: Fully-qualified name of the module whose namespace to update (pass `__name__`). + symbol_name: The name that would have been imported, used as the placeholder key. + """ + try: + yield + except ImportError as exc: + sys.modules[module_name].__dict__[symbol_name] = _FailedImport(str(exc)) + + +def install_import_hook(module_name: str) -> None: + """Replace a module's class with `_ImportWrapper` to activate deferred `ImportError` raising. + + Must be called after all `try_import` blocks in the same `__init__.py`. + + Args: + module_name: Fully-qualified name of the module to wrap (pass `__name__`). + """ + sys.modules[module_name].__class__ = _ImportWrapper @overload diff --git a/src/apify_client/http_clients/_base.py b/src/apify_client/http_clients/_base.py index 485964a3..9bca0fa5 100644 --- a/src/apify_client/http_clients/_base.py +++ b/src/apify_client/http_clients/_base.py @@ -1,6 +1,5 @@ from __future__ import annotations -import gzip import json as jsonlib import os import sys @@ -11,37 +10,23 @@ from urllib.parse import urlencode from apify_client._consts import ( - BROTLI_QUALITY_MAX, - BROTLI_QUALITY_MIN, - DEFAULT_COMPRESSION_ALGORITHM, - DEFAULT_COMPRESSION_QUALITY, DEFAULT_MAX_RETRIES, DEFAULT_MIN_DELAY_BETWEEN_RETRIES, DEFAULT_TIMEOUT_LONG, DEFAULT_TIMEOUT_MAX, DEFAULT_TIMEOUT_MEDIUM, DEFAULT_TIMEOUT_SHORT, - GZIP_QUALITY_MAX, - GZIP_QUALITY_MIN, ) from apify_client._docs import docs_group from apify_client._statistics import ClientStatistics from apify_client._utils import to_seconds +from apify_client.http_compressors._gzip import GzipHttpCompressor if TYPE_CHECKING: - from collections.abc import AsyncIterator, Callable, Iterator, Mapping + from collections.abc import AsyncIterator, Iterator, Mapping - from apify_client.types import CompressionAlgorithm, JsonSerializable, Timeout - -_brotli_compress: Callable[..., bytes] | None = None - -if not TYPE_CHECKING: - try: - import brotli as _brotli_module - - _brotli_compress = _brotli_module.compress - except ImportError: - pass + from apify_client.http_compressors._base import HttpCompressor + from apify_client.types import JsonSerializable, Timeout @docs_group('HTTP clients') @@ -116,8 +101,7 @@ def __init__( min_delay_between_retries: timedelta = DEFAULT_MIN_DELAY_BETWEEN_RETRIES, statistics: ClientStatistics | None = None, headers: dict[str, str] | None = None, - compression_algorithm: CompressionAlgorithm = DEFAULT_COMPRESSION_ALGORITHM, - compression_quality: int = DEFAULT_COMPRESSION_QUALITY, + compressor: HttpCompressor | None = None, ) -> None: """Initialize the HTTP client base. @@ -131,23 +115,9 @@ def __init__( min_delay_between_retries: Minimum delay between retries. statistics: Statistics tracker for API calls. Created automatically if not provided. headers: Additional HTTP headers to include in all requests. - compression_algorithm: Algorithm used to compress request bodies. `'brotli'` uses brotli when the - `brotli` extra is installed and falls back to gzip when unavailable. `'gzip'` always uses gzip. - compression_quality: Compression quality level. Valid range is `1-11` for brotli and `1-9` for gzip. + compressor: Compressor used to compress request bodies. Defaults to `GzipHttpCompressor`. """ - if compression_algorithm == 'brotli' and not (BROTLI_QUALITY_MIN <= compression_quality <= BROTLI_QUALITY_MAX): - raise ValueError( - f'Brotli compression quality must be between {BROTLI_QUALITY_MIN} and {BROTLI_QUALITY_MAX},' - f' got {compression_quality}.' - ) - if compression_algorithm == 'gzip' and not (GZIP_QUALITY_MIN <= compression_quality <= GZIP_QUALITY_MAX): - raise ValueError( - f'Gzip compression quality must be between {GZIP_QUALITY_MIN} and {GZIP_QUALITY_MAX},' - f' got {compression_quality}.' - ) - - self._compression_algorithm = compression_algorithm - self._compression_quality = compression_quality + self._compressor = compressor if compressor is not None else GzipHttpCompressor() self._timeout_short = timeout_short self._timeout_medium = timeout_medium self._timeout_long = timeout_long @@ -237,13 +207,7 @@ def _prepare_request_call( data: str | bytes | bytearray | None = None, json: JsonSerializable | None = None, ) -> tuple[dict[str, str], dict[str, Any] | None, bytes | None]: - """Prepare headers, params, and body for an HTTP request. Serializes JSON and compresses the body. - - Uses the configured `compression_algorithm`. When `'brotli'` is selected and the `brotli` library is - installed, uses brotli (`Content-Encoding: br`) with the configured quality. When `'brotli'` is selected - but the library is unavailable, falls back to gzip at the default level. When `'gzip'` is selected, - always uses gzip (`Content-Encoding: gzip`) with the configured quality. - """ + """Prepare headers, params, and body for an HTTP request. Serializes JSON and compresses the body.""" if json is not None and data is not None: raise ValueError('Cannot pass both "json" and "data" parameters at the same time!') @@ -257,16 +221,10 @@ def _prepare_request_call( if isinstance(data, (str, bytes, bytearray)): if isinstance(data, str): data = data.encode('utf-8') - if self._compression_algorithm == 'brotli' and _brotli_compress is not None: - data = _brotli_compress(data, quality=self._compression_quality) - headers['Content-Encoding'] = 'br' - elif self._compression_algorithm == 'gzip': - data = gzip.compress(data, compresslevel=self._compression_quality) - headers['Content-Encoding'] = 'gzip' - else: - # brotli requested but library not installed — fall back to gzip at the default level - data = gzip.compress(data) - headers['Content-Encoding'] = 'gzip' + elif isinstance(data, bytearray): + data = bytes(data) + data = self._compressor.compress(data) + headers['Content-Encoding'] = self._compressor.content_encoding return (headers, self._parse_params(params), data) diff --git a/src/apify_client/http_clients/_impit.py b/src/apify_client/http_clients/_impit.py index 1f892152..cd0a0442 100644 --- a/src/apify_client/http_clients/_impit.py +++ b/src/apify_client/http_clients/_impit.py @@ -11,8 +11,6 @@ import impit from apify_client._consts import ( - DEFAULT_COMPRESSION_ALGORITHM, - DEFAULT_COMPRESSION_QUALITY, DEFAULT_MAX_RETRIES, DEFAULT_MIN_DELAY_BETWEEN_RETRIES, DEFAULT_TIMEOUT_LONG, @@ -31,7 +29,8 @@ from apify_client._statistics import ClientStatistics from apify_client.http_clients._base import HttpResponse - from apify_client.types import CompressionAlgorithm, JsonSerializable, Timeout + from apify_client.http_compressors._base import HttpCompressor + from apify_client.types import JsonSerializable, Timeout T = TypeVar('T') @@ -75,8 +74,7 @@ def __init__( min_delay_between_retries: timedelta = DEFAULT_MIN_DELAY_BETWEEN_RETRIES, statistics: ClientStatistics | None = None, headers: dict[str, str] | None = None, - compression_algorithm: CompressionAlgorithm = DEFAULT_COMPRESSION_ALGORITHM, - compression_quality: int = DEFAULT_COMPRESSION_QUALITY, + compressor: HttpCompressor | None = None, ) -> None: """Initialize the Impit-based synchronous HTTP client. @@ -90,9 +88,7 @@ def __init__( min_delay_between_retries: Minimum delay between retries (increases exponentially with each attempt). statistics: Statistics tracker for API calls. Created automatically if not provided. headers: Additional HTTP headers to include in all requests. - compression_algorithm: Algorithm used to compress request bodies. `'brotli'` uses brotli when the - `brotli` extra is installed and falls back to gzip when unavailable. `'gzip'` always uses gzip. - compression_quality: Compression quality level. Valid range is `1-11` for brotli and `1-9` for gzip. + compressor: Compressor used to compress request bodies. Defaults to `GzipHttpCompressor`. """ super().__init__( token=token, @@ -104,8 +100,7 @@ def __init__( min_delay_between_retries=min_delay_between_retries, statistics=statistics, headers=headers, - compression_algorithm=compression_algorithm, - compression_quality=compression_quality, + compressor=compressor, ) self._impit_client = impit.Client( @@ -329,8 +324,7 @@ def __init__( min_delay_between_retries: timedelta = DEFAULT_MIN_DELAY_BETWEEN_RETRIES, statistics: ClientStatistics | None = None, headers: dict[str, str] | None = None, - compression_algorithm: CompressionAlgorithm = DEFAULT_COMPRESSION_ALGORITHM, - compression_quality: int = DEFAULT_COMPRESSION_QUALITY, + compressor: HttpCompressor | None = None, ) -> None: """Initialize the Impit-based asynchronous HTTP client. @@ -344,9 +338,7 @@ def __init__( min_delay_between_retries: Minimum delay between retries (increases exponentially with each attempt). statistics: Statistics tracker for API calls. Created automatically if not provided. headers: Additional HTTP headers to include in all requests. - compression_algorithm: Algorithm used to compress request bodies. `'brotli'` uses brotli when the - `brotli` extra is installed and falls back to gzip when unavailable. `'gzip'` always uses gzip. - compression_quality: Compression quality level. Valid range is `1-11` for brotli and `1-9` for gzip. + compressor: Compressor used to compress request bodies. Defaults to `GzipHttpCompressor`. """ super().__init__( token=token, @@ -358,8 +350,7 @@ def __init__( min_delay_between_retries=min_delay_between_retries, statistics=statistics, headers=headers, - compression_algorithm=compression_algorithm, - compression_quality=compression_quality, + compressor=compressor, ) self._impit_async_client = impit.AsyncClient( diff --git a/src/apify_client/http_compressors/__init__.py b/src/apify_client/http_compressors/__init__.py new file mode 100644 index 00000000..2e7bb7f3 --- /dev/null +++ b/src/apify_client/http_compressors/__init__.py @@ -0,0 +1,12 @@ +from apify_client._utils import install_import_hook, try_import +from apify_client.http_compressors._base import HttpCompressor +from apify_client.http_compressors._gzip import GzipHttpCompressor + +# `brotli` is an optional extra. Defer the ImportError until +# BrotliHttpCompressor is actually accessed. +with try_import(__name__, 'BrotliHttpCompressor'): + from apify_client.http_compressors._brotli import BrotliHttpCompressor + +install_import_hook(__name__) + +__all__ = ['BrotliHttpCompressor', 'GzipHttpCompressor', 'HttpCompressor'] diff --git a/src/apify_client/http_compressors/_base.py b/src/apify_client/http_compressors/_base.py new file mode 100644 index 00000000..60d3f774 --- /dev/null +++ b/src/apify_client/http_compressors/_base.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod + + +class HttpCompressor(ABC): + """Strategy for compressing HTTP request bodies. + + Extend this class to create a custom compressor. Set `content_encoding` to the value + that should be sent in the `Content-Encoding` header and implement `compress`. + """ + + content_encoding: str + """Value sent in the `Content-Encoding` header, for example `gzip` or `br`.""" + + @abstractmethod + def compress(self, data: bytes) -> bytes: + """Compress a request body. + + Args: + data: The raw bytes to compress. + + Returns: + The compressed bytes. + """ diff --git a/src/apify_client/http_compressors/_brotli.py b/src/apify_client/http_compressors/_brotli.py new file mode 100644 index 00000000..cf815d90 --- /dev/null +++ b/src/apify_client/http_compressors/_brotli.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +import brotli + +from apify_client.http_compressors._base import HttpCompressor + + +class BrotliHttpCompressor(HttpCompressor): + """Compresses request bodies using brotli. + + Requires the `brotli` extra: `pip install "apify-client[brotli]"`. + """ + + content_encoding = 'br' + + def __init__(self, *, quality: int = 6) -> None: + """Initialize the brotli compressor. + + Args: + quality: Compression level, `1` (fastest) to `11` (the best compression). Defaults to `6`. + """ + self._quality = quality + + def compress(self, data: bytes) -> bytes: + return brotli.compress(bytes(data), quality=self._quality) diff --git a/src/apify_client/http_compressors/_gzip.py b/src/apify_client/http_compressors/_gzip.py new file mode 100644 index 00000000..dbf50a74 --- /dev/null +++ b/src/apify_client/http_compressors/_gzip.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +import gzip + +from apify_client.http_compressors._base import HttpCompressor + + +class GzipHttpCompressor(HttpCompressor): + """Compresses request bodies using gzip. + + Uses the standard library `gzip` module — no extra dependencies required. + """ + + content_encoding = 'gzip' + + def __init__(self, *, quality: int = 9) -> None: + """Initialize the gzip compressor. + + Args: + quality: Compression level, `1` (fastest) to `9` (the best compression). Defaults to `9`. + """ + self._quality = quality + + def compress(self, data: bytes) -> bytes: + return gzip.compress(bytes(data), compresslevel=self._quality) diff --git a/src/apify_client/types.py b/src/apify_client/types.py index 9c385136..959dfcef 100644 --- a/src/apify_client/types.py +++ b/src/apify_client/types.py @@ -11,11 +11,14 @@ WebhookRepresentationDict, ) -CompressionAlgorithm = Literal['brotli', 'gzip'] -"""Compression algorithm for request bodies. +HttpCompressionAlgorithm = Literal['brotli', 'gzip'] +"""Accepted string literals for the `encoding` parameter on `ApifyClient` and `ApifyClientAsync`. -`'brotli'` uses brotli when the `brotli` extra is installed, falling back to gzip when unavailable. -`'gzip'` always uses gzip regardless of whether the brotli extra is installed. +`'gzip'` (the default) always uses gzip with no extra dependencies. +`'brotli'` requires the `brotli` extra (`pip install "apify-client[brotli]"`) and raises `ImportError` +if the extra is not installed. + +For custom quality or full control, inject an `HttpCompressor` instance directly instead. """ Timeout = timedelta | Literal['no_timeout', 'short', 'medium', 'long'] @@ -49,7 +52,7 @@ """ __all__ = [ - 'CompressionAlgorithm', + 'HttpCompressionAlgorithm', 'JsonSerializable', 'Timeout', 'WebhooksList', diff --git a/tests/unit/test_http_clients.py b/tests/unit/test_http_clients.py index 5f39015a..05af1ceb 100644 --- a/tests/unit/test_http_clients.py +++ b/tests/unit/test_http_clients.py @@ -13,6 +13,7 @@ from apify_client.errors import InvalidResponseBodyError from apify_client.http_clients import HttpClient, HttpClientAsync, HttpResponse, ImpitHttpClient, ImpitHttpClientAsync from apify_client.http_clients._impit import _is_retryable_error +from apify_client.http_compressors import BrotliHttpCompressor, GzipHttpCompressor class _ConcreteHttpClient(HttpClient): @@ -272,16 +273,17 @@ def test_prepare_request_call_basic() -> None: def test_prepare_request_call_with_json() -> None: - """Test _prepare_request_call with JSON data.""" + """Default compressor is gzip — JSON body is compressed with gzip.""" client = _ConcreteHttpClient() json_data = {'key': 'value', 'number': 42} headers, _params, data = client._prepare_request_call(json=json_data) assert headers['Content-Type'] == 'application/json' - assert headers['Content-Encoding'] == 'br' + assert headers['Content-Encoding'] == 'gzip' assert data is not None assert isinstance(data, bytes) + assert gzip.decompress(data) == b'{"key": "value", "number": 42}' def test_prepare_request_call_with_empty_dict_json() -> None: @@ -291,10 +293,10 @@ def test_prepare_request_call_with_empty_dict_json() -> None: headers, _params, data = client._prepare_request_call(json={}) assert headers['Content-Type'] == 'application/json' - assert headers['Content-Encoding'] == 'br' + assert headers['Content-Encoding'] == 'gzip' assert data is not None assert isinstance(data, bytes) - assert brotli.decompress(data) == b'{}' + assert gzip.decompress(data) == b'{}' def test_prepare_request_call_with_empty_list_json() -> None: @@ -304,10 +306,10 @@ def test_prepare_request_call_with_empty_list_json() -> None: headers, _params, data = client._prepare_request_call(json=[]) assert headers['Content-Type'] == 'application/json' - assert headers['Content-Encoding'] == 'br' + assert headers['Content-Encoding'] == 'gzip' assert data is not None assert isinstance(data, bytes) - assert brotli.decompress(data) == b'[]' + assert gzip.decompress(data) == b'[]' def test_prepare_request_call_with_zero_json() -> None: @@ -317,10 +319,10 @@ def test_prepare_request_call_with_zero_json() -> None: headers, _params, data = client._prepare_request_call(json=0) assert headers['Content-Type'] == 'application/json' - assert headers['Content-Encoding'] == 'br' + assert headers['Content-Encoding'] == 'gzip' assert data is not None assert isinstance(data, bytes) - assert brotli.decompress(data) == b'0' + assert gzip.decompress(data) == b'0' def test_prepare_request_call_with_false_json() -> None: @@ -330,10 +332,10 @@ def test_prepare_request_call_with_false_json() -> None: headers, _params, data = client._prepare_request_call(json=False) assert headers['Content-Type'] == 'application/json' - assert headers['Content-Encoding'] == 'br' + assert headers['Content-Encoding'] == 'gzip' assert data is not None assert isinstance(data, bytes) - assert brotli.decompress(data) == b'false' + assert gzip.decompress(data) == b'false' def test_prepare_request_call_with_empty_string_json() -> None: @@ -343,10 +345,10 @@ def test_prepare_request_call_with_empty_string_json() -> None: headers, _params, data = client._prepare_request_call(json='') assert headers['Content-Type'] == 'application/json' - assert headers['Content-Encoding'] == 'br' + assert headers['Content-Encoding'] == 'gzip' assert data is not None assert isinstance(data, bytes) - assert brotli.decompress(data) == b'""' + assert gzip.decompress(data) == b'""' def test_prepare_request_call_with_string_data() -> None: @@ -355,7 +357,7 @@ def test_prepare_request_call_with_string_data() -> None: headers, _params, data = client._prepare_request_call(data='test string') - assert headers['Content-Encoding'] == 'br' + assert headers['Content-Encoding'] == 'gzip' assert isinstance(data, bytes) @@ -365,7 +367,7 @@ def test_prepare_request_call_with_bytes_data() -> None: headers, _params, data = client._prepare_request_call(data=b'test bytes') - assert headers['Content-Encoding'] == 'br' + assert headers['Content-Encoding'] == 'gzip' assert isinstance(data, bytes) @@ -374,9 +376,9 @@ def test_prepare_request_call_with_bytearray_data() -> None: client = _ConcreteHttpClient() headers, _, data = client._prepare_request_call(data=bytearray(b'test bytearray')) - assert headers['Content-Encoding'] == 'br' + assert headers['Content-Encoding'] == 'gzip' assert data is not None - assert brotli.decompress(data) == b'test bytearray' + assert gzip.decompress(data) == b'test bytearray' def test_prepare_request_call_json_and_data_error() -> None: @@ -455,11 +457,9 @@ def test_build_url_with_params_mixed() -> None: assert 'name=test' in url -def test_prepare_request_call_brotli_compression(monkeypatch: pytest.MonkeyPatch) -> None: - """When compression_algorithm='brotli' and brotli is installed, request body uses brotli.""" - monkeypatch.setattr('apify_client.http_clients._base._brotli_compress', brotli.compress) - - client = _ConcreteHttpClient(compression_algorithm='brotli') +def test_prepare_request_call_brotli_compression() -> None: + """When a BrotliHttpCompressor is injected, request body uses brotli.""" + client = _ConcreteHttpClient(compressor=BrotliHttpCompressor()) headers, _, data = client._prepare_request_call(json={'k': 'v'}) assert headers['Content-Encoding'] == 'br' @@ -467,11 +467,9 @@ def test_prepare_request_call_brotli_compression(monkeypatch: pytest.MonkeyPatch assert brotli.decompress(data) == b'{"k": "v"}' -def test_prepare_request_call_gzip_fallback_without_brotli(monkeypatch: pytest.MonkeyPatch) -> None: - """When compression_algorithm='brotli' but the library is unavailable, falls back to gzip.""" - monkeypatch.setattr('apify_client.http_clients._base._brotli_compress', None) - - client = _ConcreteHttpClient(compression_algorithm='brotli') +def test_prepare_request_call_explicit_gzip() -> None: + """When a GzipHttpCompressor is injected, request body uses gzip.""" + client = _ConcreteHttpClient(compressor=GzipHttpCompressor()) headers, _, data = client._prepare_request_call(json={'k': 'v'}) assert headers['Content-Encoding'] == 'gzip' @@ -479,42 +477,19 @@ def test_prepare_request_call_gzip_fallback_without_brotli(monkeypatch: pytest.M assert gzip.decompress(data) == b'{"k": "v"}' -def test_prepare_request_call_explicit_gzip(monkeypatch: pytest.MonkeyPatch) -> None: - """When compression_algorithm='gzip', always uses gzip even when brotli is available.""" - monkeypatch.setattr('apify_client.http_clients._base._brotli_compress', brotli.compress) - - client = _ConcreteHttpClient(compression_algorithm='gzip') - headers, _, data = client._prepare_request_call(json={'k': 'v'}) - - assert headers['Content-Encoding'] == 'gzip' - assert data is not None - assert gzip.decompress(data) == b'{"k": "v"}' - - -def test_prepare_request_call_brotli_custom_quality(monkeypatch: pytest.MonkeyPatch) -> None: - """compression_quality is forwarded to the brotli compressor.""" - captured_kwargs: dict = {} - - def capturing_compress(data: bytes, **kwargs: object) -> bytes: - captured_kwargs.update(kwargs) - return brotli.compress(data, **kwargs) - - monkeypatch.setattr('apify_client.http_clients._base._brotli_compress', capturing_compress) - - client = _ConcreteHttpClient(compression_algorithm='brotli', compression_quality=1) +def test_prepare_request_call_brotli_custom_quality() -> None: + """Custom quality is forwarded to the brotli compressor.""" + client = _ConcreteHttpClient(compressor=BrotliHttpCompressor(quality=1)) headers, _, data = client._prepare_request_call(json={'k': 'v'}) assert headers['Content-Encoding'] == 'br' - assert captured_kwargs.get('quality') == 1 assert data is not None assert brotli.decompress(data) == b'{"k": "v"}' -def test_prepare_request_call_gzip_custom_quality(monkeypatch: pytest.MonkeyPatch) -> None: - """compression_quality is forwarded to the gzip compressor.""" - monkeypatch.setattr('apify_client.http_clients._base._brotli_compress', None) - - client = _ConcreteHttpClient(compression_algorithm='gzip', compression_quality=1) +def test_prepare_request_call_gzip_custom_quality() -> None: + """Custom quality is forwarded to the gzip compressor.""" + client = _ConcreteHttpClient(compressor=GzipHttpCompressor(quality=1)) headers, _, data = client._prepare_request_call(json={'k': 'v'}) assert headers['Content-Encoding'] == 'gzip' @@ -522,19 +497,15 @@ def test_prepare_request_call_gzip_custom_quality(monkeypatch: pytest.MonkeyPatc assert gzip.decompress(data) == b'{"k": "v"}' -def test_prepare_request_call_invalid_brotli_quality() -> None: - """Raises ValueError when brotli quality is out of range 1-11.""" - with pytest.raises(ValueError, match='Brotli compression quality'): - _ConcreteHttpClient(compression_algorithm='brotli', compression_quality=12) - - with pytest.raises(ValueError, match='Brotli compression quality'): - _ConcreteHttpClient(compression_algorithm='brotli', compression_quality=0) - +def test_default_compressor_is_gzip() -> None: + """HttpClientBase uses GzipHttpCompressor when no compressor is specified.""" + client = _ConcreteHttpClient() + assert isinstance(client._compressor, GzipHttpCompressor) + assert client._compressor.content_encoding == 'gzip' -def test_prepare_request_call_invalid_gzip_quality() -> None: - """Raises ValueError when gzip quality is out of range 1-9.""" - with pytest.raises(ValueError, match='Gzip compression quality'): - _ConcreteHttpClient(compression_algorithm='gzip', compression_quality=10) - with pytest.raises(ValueError, match='Gzip compression quality'): - _ConcreteHttpClient(compression_algorithm='gzip', compression_quality=0) +def test_custom_compressor_is_stored() -> None: + """An injected compressor is stored and used.""" + compressor = BrotliHttpCompressor(quality=9) + client = _ConcreteHttpClient(compressor=compressor) + assert client._compressor is compressor From 2bf86e3f52ae494b4f4667bdaf237f8b8dee057e Mon Sep 17 00:00:00 2001 From: Michal Turek Date: Tue, 14 Jul 2026 14:02:50 +0200 Subject: [PATCH 12/30] feat: Fix elif/else, doc update, guard checks --- docs/02_concepts/13_compression.mdx | 73 +++++++++++++------- src/apify_client/_apify_client.py | 12 ++-- src/apify_client/http_compressors/_brotli.py | 7 ++ src/apify_client/http_compressors/_gzip.py | 7 ++ 4 files changed, 67 insertions(+), 32 deletions(-) diff --git a/docs/02_concepts/13_compression.mdx b/docs/02_concepts/13_compression.mdx index c98cf376..4e2186ce 100644 --- a/docs/02_concepts/13_compression.mdx +++ b/docs/02_concepts/13_compression.mdx @@ -1,19 +1,28 @@ --- id: compression title: Request body compression -description: The client compresses every request body automatically, using brotli when available or gzip as a fallback. +description: The client compresses every request body automatically using gzip by default, with optional brotli via an explicit opt-in. --- The Apify client compresses every request body before sending it to the API. This reduces the amount of data transferred over the network, resulting in faster requests and lower bandwidth usage, especially for large payloads such as Actor inputs, dataset uploads, or key-value store records. ## How it works -The client compresses request bodies using the algorithm set by `compression_algorithm` (default `'brotli'`) at the quality level set by `compression_quality` (default `6`): +The client compresses request bodies using the compressor configured via the `encoding` parameter (default `'gzip'`). The server supports both gzip and brotli and decompresses the request body transparently. -- **`'brotli'` (default)**: Uses brotli compression (`Content-Encoding: br`) when the [`brotli`](https://pypi.org/project/brotli/) extra is installed. Falls back to gzip at the default level when the extra is not installed. -- **`'gzip'`**: Always uses gzip compression (`Content-Encoding: gzip`) with the configured quality level, regardless of whether the brotli extra is installed. +## Configuration + +Pass `encoding` to the client constructor to choose the compression algorithm: + +```python +from apify_client import ApifyClient + +# Default: gzip, no extra dependency required +client = ApifyClient(token='MY-APIFY-TOKEN') -The server supports both algorithms and decompresses the request body transparently. +# Opt in to brotli (requires apify-client[brotli]) +client = ApifyClient(token='MY-APIFY-TOKEN', encoding='brotli') +``` ## Enabling brotli @@ -25,41 +34,53 @@ pip install "apify-client[brotli]" uv add "apify-client[brotli]" ``` -Once installed, the client detects it at startup and switches to brotli automatically. No code changes are needed. +Then pass `encoding='brotli'` to the client constructor. If you request brotli without installing the extra, the client raises a clear `ImportError` immediately — there is no silent fallback. -## Configuration +## Custom quality and advanced control -Pass `compression_algorithm` and `compression_quality` to the client constructor to override the defaults: +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 -# Force gzip without uninstalling the brotli extra -client = ApifyClient(token='MY-APIFY-TOKEN', compression_algorithm='gzip', compression_quality=9) +# Brotli at maximum quality +client = ApifyClient(token='MY-APIFY-TOKEN', encoding=BrotliHttpCompressor(quality=11)) -# Use brotli at a lower quality for faster compression -client = ApifyClient(token='MY-APIFY-TOKEN', compression_algorithm='brotli', compression_quality=4) +# Gzip at maximum quality +client = ApifyClient(token='MY-APIFY-TOKEN', encoding=GzipHttpCompressor(quality=9)) ``` -Valid quality ranges: +You can also implement a fully custom compressor by subclassing `HttpCompressor`: -- **Brotli**: `1–11` (higher = better compression, more CPU) -- **Gzip**: `1–9` (higher = better compression, more CPU) +```python +from apify_client import ApifyClient +from apify_client.http_compressors import HttpCompressor + + +class IdentityCompressor(HttpCompressor): + content_encoding = 'identity' -The default quality of `6` for brotli is a deliberate trade-off: it compresses better than gzip defaults while keeping CPU overhead low compared to maximum-quality brotli. + def compress(self, data: bytes) -> bytes: + return data + + +client = ApifyClient(token='MY-APIFY-TOKEN', encoding=IdentityCompressor()) +``` ## Comparison -| | Brotli | Gzip | -|-------------------------------|----------------------------------------|---------------------------------| -| **Compression ratio** | Better than gzip at quality 6 | Good | -| **CPU cost** | Moderate at quality 6 | Low | -| **Availability** | Requires the `brotli` extra | Built-in — no extra needed | -| **`Content-Encoding` header** | `br` | `gzip` | -| **Quality range** | `1–11` | `1–9` | -| **Force via config** | `compression_algorithm='brotli'` | `compression_algorithm='gzip'` | -| **Best for** | Large payloads where bandwidth matters | Minimal-dependency environments | +| | Brotli | Gzip | +|-------------------------------|-----------------------------------------|---------------------------------| +| **Compression ratio** | Better than gzip at comparable quality | Good | +| **CPU cost** | Moderate at quality 6 | Low | +| **Availability** | Requires the `brotli` extra | Built-in — no extra needed | +| **`Content-Encoding` header** | `br` | `gzip` | +| **Quality range** | `1–11` | `1–9` | +| **Default quality** | `6` | `9` | +| **Enable via config** | `encoding='brotli'` | `encoding='gzip'` (default) | +| **Best for** | Large payloads where bandwidth matters | Minimal-dependency environments | :::tip -For most workloads, the bandwidth savings from brotli outweigh the small CPU overhead. Install the `brotli` extra unless you're running in an environment where installing additional packages isn't feasible. +For most workloads, the bandwidth savings from brotli outweigh the small CPU overhead. Install the `brotli` extra and pass `encoding='brotli'` unless you're running in an environment where installing additional packages isn't feasible. ::: diff --git a/src/apify_client/_apify_client.py b/src/apify_client/_apify_client.py index 5948c596..f0dff167 100644 --- a/src/apify_client/_apify_client.py +++ b/src/apify_client/_apify_client.py @@ -98,18 +98,18 @@ def _resolve_compressor(encoding: HttpCompressionAlgorithm | HttpCompressor) -> """ if isinstance(encoding, HttpCompressor): return encoding - elif encoding == 'gzip': + if encoding == 'gzip': return GzipHttpCompressor() - elif encoding == 'brotli': + if encoding == 'brotli': # The import is here so the ImportError is raised at call time, # not at module import time, giving users a clear message. from apify_client.http_compressors import BrotliHttpCompressor # noqa: PLC0415 return BrotliHttpCompressor() - else: - # The backend supports also `deflate` and `identity` (no compression). One can build - # a custom compressor if needed. - raise ValueError(f'Unsupported compression algorithm: {encoding!r}') + + # The backend supports also `deflate` and `identity` (no compression). One can build + # a custom compressor if needed. + raise ValueError(f'Unsupported compression algorithm: {encoding!r}') @docs_group('Apify API clients') diff --git a/src/apify_client/http_compressors/_brotli.py b/src/apify_client/http_compressors/_brotli.py index cf815d90..481e494e 100644 --- a/src/apify_client/http_compressors/_brotli.py +++ b/src/apify_client/http_compressors/_brotli.py @@ -12,13 +12,20 @@ class BrotliHttpCompressor(HttpCompressor): """ content_encoding = 'br' + max_quality = 11 def __init__(self, *, quality: int = 6) -> None: """Initialize the brotli compressor. Args: quality: Compression level, `1` (fastest) to `11` (the best compression). Defaults to `6`. + + Raises: + ValueError: If `quality` is out of range. """ + if 1 > quality > self.max_quality: + raise ValueError(f'Compression quality out of range: {quality}') + self._quality = quality def compress(self, data: bytes) -> bytes: diff --git a/src/apify_client/http_compressors/_gzip.py b/src/apify_client/http_compressors/_gzip.py index dbf50a74..56b04ef8 100644 --- a/src/apify_client/http_compressors/_gzip.py +++ b/src/apify_client/http_compressors/_gzip.py @@ -12,13 +12,20 @@ class GzipHttpCompressor(HttpCompressor): """ content_encoding = 'gzip' + max_quality = 9 def __init__(self, *, quality: int = 9) -> None: """Initialize the gzip compressor. Args: quality: Compression level, `1` (fastest) to `9` (the best compression). Defaults to `9`. + + Raises: + ValueError: If `quality` is out of range. """ + if 1 > quality > self.max_quality: + raise ValueError(f'Compression quality out of range: {quality}') + self._quality = quality def compress(self, data: bytes) -> bytes: From d6459846334f68a7feab3a98f13551c981712697 Mon Sep 17 00:00:00 2001 From: Michal Turek Date: Tue, 14 Jul 2026 14:04:55 +0200 Subject: [PATCH 13/30] feat: Revert changes in tests --- tests/unit/test_http_clients.py | 89 ++++++--------------------------- 1 file changed, 16 insertions(+), 73 deletions(-) diff --git a/tests/unit/test_http_clients.py b/tests/unit/test_http_clients.py index 05af1ceb..b54c9fee 100644 --- a/tests/unit/test_http_clients.py +++ b/tests/unit/test_http_clients.py @@ -5,7 +5,6 @@ from typing import Any from unittest.mock import Mock -import brotli import impit import pytest @@ -13,7 +12,6 @@ from apify_client.errors import InvalidResponseBodyError from apify_client.http_clients import HttpClient, HttpClientAsync, HttpResponse, ImpitHttpClient, ImpitHttpClientAsync from apify_client.http_clients._impit import _is_retryable_error -from apify_client.http_compressors import BrotliHttpCompressor, GzipHttpCompressor class _ConcreteHttpClient(HttpClient): @@ -273,7 +271,7 @@ def test_prepare_request_call_basic() -> None: def test_prepare_request_call_with_json() -> None: - """Default compressor is gzip — JSON body is compressed with gzip.""" + """Test _prepare_request_call with JSON data.""" client = _ConcreteHttpClient() json_data = {'key': 'value', 'number': 42} @@ -283,7 +281,6 @@ def test_prepare_request_call_with_json() -> None: assert headers['Content-Encoding'] == 'gzip' assert data is not None assert isinstance(data, bytes) - assert gzip.decompress(data) == b'{"key": "value", "number": 42}' def test_prepare_request_call_with_empty_dict_json() -> None: @@ -296,7 +293,9 @@ def test_prepare_request_call_with_empty_dict_json() -> None: assert headers['Content-Encoding'] == 'gzip' assert data is not None assert isinstance(data, bytes) - assert gzip.decompress(data) == b'{}' + # Verify the gzipped data contains the JSON + decompressed = gzip.decompress(data) + assert decompressed == b'{}' def test_prepare_request_call_with_empty_list_json() -> None: @@ -309,7 +308,9 @@ def test_prepare_request_call_with_empty_list_json() -> None: assert headers['Content-Encoding'] == 'gzip' assert data is not None assert isinstance(data, bytes) - assert gzip.decompress(data) == b'[]' + # Verify the gzipped data contains the JSON + decompressed = gzip.decompress(data) + assert decompressed == b'[]' def test_prepare_request_call_with_zero_json() -> None: @@ -322,7 +323,9 @@ def test_prepare_request_call_with_zero_json() -> None: assert headers['Content-Encoding'] == 'gzip' assert data is not None assert isinstance(data, bytes) - assert gzip.decompress(data) == b'0' + # Verify the gzipped data contains the JSON + decompressed = gzip.decompress(data) + assert decompressed == b'0' def test_prepare_request_call_with_false_json() -> None: @@ -335,7 +338,9 @@ def test_prepare_request_call_with_false_json() -> None: assert headers['Content-Encoding'] == 'gzip' assert data is not None assert isinstance(data, bytes) - assert gzip.decompress(data) == b'false' + # Verify the gzipped data contains the JSON + decompressed = gzip.decompress(data) + assert decompressed == b'false' def test_prepare_request_call_with_empty_string_json() -> None: @@ -348,7 +353,9 @@ def test_prepare_request_call_with_empty_string_json() -> None: assert headers['Content-Encoding'] == 'gzip' assert data is not None assert isinstance(data, bytes) - assert gzip.decompress(data) == b'""' + # Verify the gzipped data contains the JSON + decompressed = gzip.decompress(data) + assert decompressed == b'""' def test_prepare_request_call_with_string_data() -> None: @@ -371,16 +378,6 @@ def test_prepare_request_call_with_bytes_data() -> None: assert isinstance(data, bytes) -def test_prepare_request_call_with_bytearray_data() -> None: - """bytearray body is compressed correctly.""" - client = _ConcreteHttpClient() - headers, _, data = client._prepare_request_call(data=bytearray(b'test bytearray')) - - assert headers['Content-Encoding'] == 'gzip' - assert data is not None - assert gzip.decompress(data) == b'test bytearray' - - def test_prepare_request_call_json_and_data_error() -> None: """Test _prepare_request_call raises error when both json and data are provided.""" client = _ConcreteHttpClient() @@ -455,57 +452,3 @@ def test_build_url_with_params_mixed() -> None: assert 'tags=a' in url assert 'tags=b' in url assert 'name=test' in url - - -def test_prepare_request_call_brotli_compression() -> None: - """When a BrotliHttpCompressor is injected, request body uses brotli.""" - client = _ConcreteHttpClient(compressor=BrotliHttpCompressor()) - headers, _, data = client._prepare_request_call(json={'k': 'v'}) - - assert headers['Content-Encoding'] == 'br' - assert data is not None - assert brotli.decompress(data) == b'{"k": "v"}' - - -def test_prepare_request_call_explicit_gzip() -> None: - """When a GzipHttpCompressor is injected, request body uses gzip.""" - client = _ConcreteHttpClient(compressor=GzipHttpCompressor()) - headers, _, data = client._prepare_request_call(json={'k': 'v'}) - - assert headers['Content-Encoding'] == 'gzip' - assert data is not None - assert gzip.decompress(data) == b'{"k": "v"}' - - -def test_prepare_request_call_brotli_custom_quality() -> None: - """Custom quality is forwarded to the brotli compressor.""" - client = _ConcreteHttpClient(compressor=BrotliHttpCompressor(quality=1)) - headers, _, data = client._prepare_request_call(json={'k': 'v'}) - - assert headers['Content-Encoding'] == 'br' - assert data is not None - assert brotli.decompress(data) == b'{"k": "v"}' - - -def test_prepare_request_call_gzip_custom_quality() -> None: - """Custom quality is forwarded to the gzip compressor.""" - client = _ConcreteHttpClient(compressor=GzipHttpCompressor(quality=1)) - headers, _, data = client._prepare_request_call(json={'k': 'v'}) - - assert headers['Content-Encoding'] == 'gzip' - assert data is not None - assert gzip.decompress(data) == b'{"k": "v"}' - - -def test_default_compressor_is_gzip() -> None: - """HttpClientBase uses GzipHttpCompressor when no compressor is specified.""" - client = _ConcreteHttpClient() - assert isinstance(client._compressor, GzipHttpCompressor) - assert client._compressor.content_encoding == 'gzip' - - -def test_custom_compressor_is_stored() -> None: - """An injected compressor is stored and used.""" - compressor = BrotliHttpCompressor(quality=9) - client = _ConcreteHttpClient(compressor=compressor) - assert client._compressor is compressor From 89ce7177a5f8e56aef3be9ffeaa145ac4586cd0a Mon Sep 17 00:00:00 2001 From: Michal Turek Date: Tue, 14 Jul 2026 14:43:32 +0200 Subject: [PATCH 14/30] feat: Make the tests parameterized --- tests/unit/test_http_clients.py | 92 +++++++++++++++++++-------------- 1 file changed, 53 insertions(+), 39 deletions(-) diff --git a/tests/unit/test_http_clients.py b/tests/unit/test_http_clients.py index b54c9fee..903d39e9 100644 --- a/tests/unit/test_http_clients.py +++ b/tests/unit/test_http_clients.py @@ -5,6 +5,7 @@ from typing import Any from unittest.mock import Mock +import brotli import impit import pytest @@ -12,6 +13,8 @@ from apify_client.errors import InvalidResponseBodyError from apify_client.http_clients import HttpClient, HttpClientAsync, HttpResponse, ImpitHttpClient, ImpitHttpClientAsync from apify_client.http_clients._impit import _is_retryable_error +from apify_client.http_compressors._brotli import BrotliHttpCompressor +from apify_client.http_compressors._gzip import GzipHttpCompressor class _ConcreteHttpClient(HttpClient): @@ -260,6 +263,16 @@ def test_is_retryable_error() -> None: assert not _is_retryable_error(Exception('test')) +@pytest.fixture( + params=[ + pytest.param((GzipHttpCompressor(), 'gzip', gzip.decompress), id='gzip'), + pytest.param((BrotliHttpCompressor(), 'br', brotli.decompress), id='brotli'), + ] +) +def compressor_case(request: pytest.FixtureRequest) -> tuple: + return request.param # type: ignore[return-value] + + def test_prepare_request_call_basic() -> None: """Test _prepare_request_call with basic parameters.""" client = _ConcreteHttpClient() @@ -270,112 +283,113 @@ def test_prepare_request_call_basic() -> None: assert data is None -def test_prepare_request_call_with_json() -> None: +def test_prepare_request_call_with_json(compressor_case: tuple) -> None: """Test _prepare_request_call with JSON data.""" - client = _ConcreteHttpClient() + compressor, content_encoding, decompress = compressor_case + client = _ConcreteHttpClient(compressor=compressor) json_data = {'key': 'value', 'number': 42} headers, _params, data = client._prepare_request_call(json=json_data) assert headers['Content-Type'] == 'application/json' - assert headers['Content-Encoding'] == 'gzip' + assert headers['Content-Encoding'] == content_encoding assert data is not None assert isinstance(data, bytes) + assert decompress(data) == b'{"key": "value", "number": 42}' -def test_prepare_request_call_with_empty_dict_json() -> None: +def test_prepare_request_call_with_empty_dict_json(compressor_case: tuple) -> None: """Test _prepare_request_call with empty dict JSON (falsy but valid).""" - client = _ConcreteHttpClient() + compressor, content_encoding, decompress = compressor_case + client = _ConcreteHttpClient(compressor=compressor) headers, _params, data = client._prepare_request_call(json={}) assert headers['Content-Type'] == 'application/json' - assert headers['Content-Encoding'] == 'gzip' + assert headers['Content-Encoding'] == content_encoding assert data is not None assert isinstance(data, bytes) - # Verify the gzipped data contains the JSON - decompressed = gzip.decompress(data) - assert decompressed == b'{}' + assert decompress(data) == b'{}' -def test_prepare_request_call_with_empty_list_json() -> None: +def test_prepare_request_call_with_empty_list_json(compressor_case: tuple) -> None: """Test _prepare_request_call with empty list JSON (falsy but valid).""" - client = _ConcreteHttpClient() + compressor, content_encoding, decompress = compressor_case + client = _ConcreteHttpClient(compressor=compressor) headers, _params, data = client._prepare_request_call(json=[]) assert headers['Content-Type'] == 'application/json' - assert headers['Content-Encoding'] == 'gzip' + assert headers['Content-Encoding'] == content_encoding assert data is not None assert isinstance(data, bytes) - # Verify the gzipped data contains the JSON - decompressed = gzip.decompress(data) - assert decompressed == b'[]' + assert decompress(data) == b'[]' -def test_prepare_request_call_with_zero_json() -> None: +def test_prepare_request_call_with_zero_json(compressor_case: tuple) -> None: """Test _prepare_request_call with zero JSON (falsy but valid).""" - client = _ConcreteHttpClient() + compressor, content_encoding, decompress = compressor_case + client = _ConcreteHttpClient(compressor=compressor) headers, _params, data = client._prepare_request_call(json=0) assert headers['Content-Type'] == 'application/json' - assert headers['Content-Encoding'] == 'gzip' + assert headers['Content-Encoding'] == content_encoding assert data is not None assert isinstance(data, bytes) - # Verify the gzipped data contains the JSON - decompressed = gzip.decompress(data) - assert decompressed == b'0' + assert decompress(data) == b'0' -def test_prepare_request_call_with_false_json() -> None: +def test_prepare_request_call_with_false_json(compressor_case: tuple) -> None: """Test _prepare_request_call with False JSON (falsy but valid).""" - client = _ConcreteHttpClient() + compressor, content_encoding, decompress = compressor_case + client = _ConcreteHttpClient(compressor=compressor) headers, _params, data = client._prepare_request_call(json=False) assert headers['Content-Type'] == 'application/json' - assert headers['Content-Encoding'] == 'gzip' + assert headers['Content-Encoding'] == content_encoding assert data is not None assert isinstance(data, bytes) - # Verify the gzipped data contains the JSON - decompressed = gzip.decompress(data) - assert decompressed == b'false' + assert decompress(data) == b'false' -def test_prepare_request_call_with_empty_string_json() -> None: +def test_prepare_request_call_with_empty_string_json(compressor_case: tuple) -> None: """Test _prepare_request_call with empty string JSON (falsy but valid).""" - client = _ConcreteHttpClient() + compressor, content_encoding, decompress = compressor_case + client = _ConcreteHttpClient(compressor=compressor) headers, _params, data = client._prepare_request_call(json='') assert headers['Content-Type'] == 'application/json' - assert headers['Content-Encoding'] == 'gzip' + assert headers['Content-Encoding'] == content_encoding assert data is not None assert isinstance(data, bytes) - # Verify the gzipped data contains the JSON - decompressed = gzip.decompress(data) - assert decompressed == b'""' + assert decompress(data) == b'""' -def test_prepare_request_call_with_string_data() -> None: +def test_prepare_request_call_with_string_data(compressor_case: tuple) -> None: """Test _prepare_request_call with string data.""" - client = _ConcreteHttpClient() + compressor, content_encoding, decompress = compressor_case + client = _ConcreteHttpClient(compressor=compressor) headers, _params, data = client._prepare_request_call(data='test string') - assert headers['Content-Encoding'] == 'gzip' + assert headers['Content-Encoding'] == content_encoding assert isinstance(data, bytes) + assert decompress(data) == b'test string' -def test_prepare_request_call_with_bytes_data() -> None: +def test_prepare_request_call_with_bytes_data(compressor_case: tuple) -> None: """Test _prepare_request_call with bytes data.""" - client = _ConcreteHttpClient() + compressor, content_encoding, decompress = compressor_case + client = _ConcreteHttpClient(compressor=compressor) headers, _params, data = client._prepare_request_call(data=b'test bytes') - assert headers['Content-Encoding'] == 'gzip' + assert headers['Content-Encoding'] == content_encoding assert isinstance(data, bytes) + assert decompress(data) == b'test bytes' def test_prepare_request_call_json_and_data_error() -> None: From 25d7afaabfc77845726cea7f870049d8b99811ea Mon Sep 17 00:00:00 2001 From: Michal Turek Date: Tue, 14 Jul 2026 15:09:28 +0200 Subject: [PATCH 15/30] feat: Polishing --- docs/01_introduction/index.mdx | 4 +--- docs/02_concepts/13_compression.mdx | 17 +++++++++++++---- src/apify_client/http_compressors/_brotli.py | 16 ++++++++++++---- src/apify_client/http_compressors/_gzip.py | 11 ++++++++--- 4 files changed, 34 insertions(+), 14 deletions(-) diff --git a/docs/01_introduction/index.mdx b/docs/01_introduction/index.mdx index 8af89741..957c0d58 100644 --- a/docs/01_introduction/index.mdx +++ b/docs/01_introduction/index.mdx @@ -46,7 +46,7 @@ The Apify client is available as the `apify-client` package [on PyPI](https://py -To enable brotli request-body compression (better than gzip, especially for large payloads), install the optional extra: +To enable brotli request-body compression (better than gzip, especially for large payloads), install the optional extra and [configure the compression](../02_concepts/13_compression.mdx): @@ -61,8 +61,6 @@ To enable brotli request-body compression (better than gzip, especially for larg -Without this extra the client falls back to gzip automatically — no code changes needed. See [Request body compression](../02_concepts/13_compression.mdx) for a full comparison of the two algorithms. - ## Quick example The following example shows how to run an Actor and retrieve its results: diff --git a/docs/02_concepts/13_compression.mdx b/docs/02_concepts/13_compression.mdx index 4e2186ce..a91cd2da 100644 --- a/docs/02_concepts/13_compression.mdx +++ b/docs/02_concepts/13_compression.mdx @@ -60,9 +60,18 @@ from apify_client.http_compressors import HttpCompressor class IdentityCompressor(HttpCompressor): content_encoding = 'identity' + """Value sent in the `Content-Encoding` header.""" def compress(self, data: bytes) -> bytes: - return data + """Compress a request body. + + Args: + data: The raw bytes to compress. + + Returns: + The compressed bytes. + """ + return data client = ApifyClient(token='MY-APIFY-TOKEN', encoding=IdentityCompressor()) @@ -72,11 +81,11 @@ client = ApifyClient(token='MY-APIFY-TOKEN', encoding=IdentityCompressor()) | | Brotli | Gzip | |-------------------------------|-----------------------------------------|---------------------------------| -| **Compression ratio** | Better than gzip at comparable quality | Good | -| **CPU cost** | Moderate at quality 6 | Low | +| **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** | `1–11` | `1–9` | +| **Quality range** | `0–11` | `1–9` | | **Default quality** | `6` | `9` | | **Enable via config** | `encoding='brotli'` | `encoding='gzip'` (default) | | **Best for** | Large payloads where bandwidth matters | Minimal-dependency environments | diff --git a/src/apify_client/http_compressors/_brotli.py b/src/apify_client/http_compressors/_brotli.py index 481e494e..b3880505 100644 --- a/src/apify_client/http_compressors/_brotli.py +++ b/src/apify_client/http_compressors/_brotli.py @@ -11,19 +11,27 @@ class BrotliHttpCompressor(HttpCompressor): Requires the `brotli` extra: `pip install "apify-client[brotli]"`. """ + _fast_quality = 0 + """The fastest compression, but the lowest compression ratio.""" + + _default_quality = 6 + """Reasonable compromise between the speed and compression ratio.""" + + _best_quality = 11 + """The best compression ratio, but the slowest compression.""" + content_encoding = 'br' - max_quality = 11 - def __init__(self, *, quality: int = 6) -> None: + def __init__(self, *, quality: int = _default_quality) -> None: """Initialize the brotli compressor. Args: - quality: Compression level, `1` (fastest) to `11` (the best compression). Defaults to `6`. + quality: Compression level, `0` (fastest) to `11` (the best compression). Defaults to `6`. Raises: ValueError: If `quality` is out of range. """ - if 1 > quality > self.max_quality: + if self._fast_quality > quality > self._best_quality: raise ValueError(f'Compression quality out of range: {quality}') self._quality = quality diff --git a/src/apify_client/http_compressors/_gzip.py b/src/apify_client/http_compressors/_gzip.py index 56b04ef8..58b76f7c 100644 --- a/src/apify_client/http_compressors/_gzip.py +++ b/src/apify_client/http_compressors/_gzip.py @@ -11,10 +11,15 @@ class GzipHttpCompressor(HttpCompressor): Uses the standard library `gzip` module — no extra dependencies required. """ + _fast_quality = 1 + """The fastest compression, but the lowest compression ratio.""" + + _best_quality = 9 + """The best compression ratio, but the slowest compression.""" + content_encoding = 'gzip' - max_quality = 9 - def __init__(self, *, quality: int = 9) -> None: + def __init__(self, *, quality: int = _best_quality) -> None: """Initialize the gzip compressor. Args: @@ -23,7 +28,7 @@ def __init__(self, *, quality: int = 9) -> None: Raises: ValueError: If `quality` is out of range. """ - if 1 > quality > self.max_quality: + if self._fast_quality > quality > self._best_quality: raise ValueError(f'Compression quality out of range: {quality}') self._quality = quality From a0650b6b4ad1ccfad7511207154daff78ad510a7 Mon Sep 17 00:00:00 2001 From: Vlada Dusek Date: Tue, 14 Jul 2026 19:47:07 +0200 Subject: [PATCH 16/30] refactor: Split _utils module into a _utils subpackage --- src/apify_client/_apify_client.py | 2 +- .../_resource_clients/_resource_client.py | 10 +- src/apify_client/_resource_clients/actor.py | 9 +- .../_resource_clients/actor_collection.py | 2 +- src/apify_client/_resource_clients/build.py | 2 +- src/apify_client/_resource_clients/dataset.py | 7 +- .../_resource_clients/key_value_store.py | 11 +- src/apify_client/_resource_clients/log.py | 2 +- .../_resource_clients/request_queue.py | 4 +- src/apify_client/_resource_clients/run.py | 4 +- .../_resource_clients/schedule.py | 2 +- src/apify_client/_resource_clients/task.py | 4 +- .../_resource_clients/task_collection.py | 2 +- src/apify_client/_resource_clients/user.py | 2 +- src/apify_client/_resource_clients/webhook.py | 2 +- src/apify_client/_status_message_watcher.py | 2 +- src/apify_client/_utils.py | 361 ------------------ src/apify_client/_utils/__init__.py | 0 src/apify_client/_utils/crypto.py | 87 +++++ src/apify_client/_utils/encoding.py | 87 +++++ src/apify_client/_utils/errors.py | 51 +++ src/apify_client/_utils/http.py | 78 ++++ src/apify_client/_utils/time.py | 32 ++ src/apify_client/_utils/try_import.py | 62 +++ src/apify_client/http_clients/_base.py | 2 +- src/apify_client/http_clients/_impit.py | 2 +- src/apify_client/http_compressors/__init__.py | 15 +- tests/integration/conftest.py | 2 +- tests/unit/test_url_generation.py | 2 +- tests/unit/test_utils.py | 16 +- 30 files changed, 444 insertions(+), 420 deletions(-) delete mode 100644 src/apify_client/_utils.py create mode 100644 src/apify_client/_utils/__init__.py create mode 100644 src/apify_client/_utils/crypto.py create mode 100644 src/apify_client/_utils/encoding.py create mode 100644 src/apify_client/_utils/errors.py create mode 100644 src/apify_client/_utils/http.py create mode 100644 src/apify_client/_utils/time.py create mode 100644 src/apify_client/_utils/try_import.py diff --git a/src/apify_client/_apify_client.py b/src/apify_client/_apify_client.py index f0dff167..5d2b3c26 100644 --- a/src/apify_client/_apify_client.py +++ b/src/apify_client/_apify_client.py @@ -72,7 +72,7 @@ WebhookDispatchCollectionClientAsync, ) from apify_client._statistics import ClientStatistics -from apify_client._utils import check_custom_headers +from apify_client._utils.http import check_custom_headers from apify_client.http_clients import HttpClient, HttpClientAsync, ImpitHttpClient, ImpitHttpClientAsync from apify_client.http_compressors import GzipHttpCompressor from apify_client.http_compressors._base import HttpCompressor diff --git a/src/apify_client/_resource_clients/_resource_client.py b/src/apify_client/_resource_clients/_resource_client.py index 59060572..a2cba1e3 100644 --- a/src/apify_client/_resource_clients/_resource_client.py +++ b/src/apify_client/_resource_clients/_resource_client.py @@ -9,13 +9,9 @@ from apify_client._consts import DEFAULT_WAIT_FOR_FINISH, DEFAULT_WAIT_WHEN_JOB_NOT_EXIST from apify_client._docs import docs_group from apify_client._logging import WithLogDetailsClient -from apify_client._utils import ( - catch_not_found_for_resource_or_throw, - catch_not_found_or_throw, - response_to_dict, - to_safe_id, - to_seconds, -) +from apify_client._utils.errors import catch_not_found_for_resource_or_throw, catch_not_found_or_throw +from apify_client._utils.http import response_to_dict, to_safe_id +from apify_client._utils.time import to_seconds from apify_client.errors import ApifyApiError if TYPE_CHECKING: diff --git a/src/apify_client/_resource_clients/actor.py b/src/apify_client/_resource_clients/actor.py index 4afeb921..01655352 100644 --- a/src/apify_client/_resource_clients/actor.py +++ b/src/apify_client/_resource_clients/actor.py @@ -23,12 +23,9 @@ UpdateActorRequest, ) from apify_client._resource_clients._resource_client import ResourceClient, ResourceClientAsync -from apify_client._utils import ( - encode_key_value_store_record_value, - encode_webhooks_to_base64, - response_to_dict, - to_seconds, -) +from apify_client._utils.encoding import encode_key_value_store_record_value, encode_webhooks_to_base64 +from apify_client._utils.http import response_to_dict +from apify_client._utils.time import to_seconds if TYPE_CHECKING: from datetime import timedelta diff --git a/src/apify_client/_resource_clients/actor_collection.py b/src/apify_client/_resource_clients/actor_collection.py index 892f4f58..f30146a0 100644 --- a/src/apify_client/_resource_clients/actor_collection.py +++ b/src/apify_client/_resource_clients/actor_collection.py @@ -15,7 +15,7 @@ ) from apify_client._pagination import get_items_iterator, get_items_iterator_async from apify_client._resource_clients._resource_client import ResourceClient, ResourceClientAsync -from apify_client._utils import to_seconds +from apify_client._utils.time import to_seconds if TYPE_CHECKING: from collections.abc import AsyncIterator, Iterator diff --git a/src/apify_client/_resource_clients/build.py b/src/apify_client/_resource_clients/build.py index ed68fbe6..c51981a0 100644 --- a/src/apify_client/_resource_clients/build.py +++ b/src/apify_client/_resource_clients/build.py @@ -5,7 +5,7 @@ from apify_client._docs import docs_group from apify_client._models import Build, BuildResponse from apify_client._resource_clients._resource_client import ResourceClient, ResourceClientAsync -from apify_client._utils import response_to_dict +from apify_client._utils.http import response_to_dict if TYPE_CHECKING: from datetime import timedelta diff --git a/src/apify_client/_resource_clients/dataset.py b/src/apify_client/_resource_clients/dataset.py index d5f5b430..dabc9b03 100644 --- a/src/apify_client/_resource_clients/dataset.py +++ b/src/apify_client/_resource_clients/dataset.py @@ -9,11 +9,8 @@ from apify_client._models import Dataset, DatasetResponse, DatasetStatistics, DatasetStatisticsResponse from apify_client._pagination import DEFAULT_CHUNK_SIZE, get_items_iterator, get_items_iterator_async from apify_client._resource_clients._resource_client import ResourceClient, ResourceClientAsync -from apify_client._utils import ( - create_storage_content_signature, - response_to_dict, - response_to_list, -) +from apify_client._utils.crypto import create_storage_content_signature +from apify_client._utils.http import response_to_dict, response_to_list if TYPE_CHECKING: from collections.abc import AsyncIterator, Iterator diff --git a/src/apify_client/_resource_clients/key_value_store.py b/src/apify_client/_resource_clients/key_value_store.py index 5b83bbb8..391c0969 100644 --- a/src/apify_client/_resource_clients/key_value_store.py +++ b/src/apify_client/_resource_clients/key_value_store.py @@ -16,13 +16,10 @@ ) from apify_client._pagination import DEFAULT_CHUNK_SIZE, get_cursor_iterator, get_cursor_iterator_async from apify_client._resource_clients._resource_client import ResourceClient, ResourceClientAsync -from apify_client._utils import ( - catch_not_found_or_throw, - create_hmac_signature, - create_storage_content_signature, - encode_key_value_store_record_value, - response_to_dict, -) +from apify_client._utils.crypto import create_hmac_signature, create_storage_content_signature +from apify_client._utils.encoding import encode_key_value_store_record_value +from apify_client._utils.errors import catch_not_found_or_throw +from apify_client._utils.http import response_to_dict from apify_client.errors import ApifyApiError, InvalidResponseBodyError if TYPE_CHECKING: diff --git a/src/apify_client/_resource_clients/log.py b/src/apify_client/_resource_clients/log.py index 723b0312..f8f8cdd5 100644 --- a/src/apify_client/_resource_clients/log.py +++ b/src/apify_client/_resource_clients/log.py @@ -5,7 +5,7 @@ from apify_client._docs import docs_group from apify_client._resource_clients._resource_client import ResourceClient, ResourceClientAsync -from apify_client._utils import catch_not_found_for_resource_or_throw +from apify_client._utils.errors import catch_not_found_for_resource_or_throw from apify_client.errors import ApifyApiError if TYPE_CHECKING: diff --git a/src/apify_client/_resource_clients/request_queue.py b/src/apify_client/_resource_clients/request_queue.py index 0e2c8ab2..639c1835 100644 --- a/src/apify_client/_resource_clients/request_queue.py +++ b/src/apify_client/_resource_clients/request_queue.py @@ -36,7 +36,9 @@ ) from apify_client._pagination import DEFAULT_CHUNK_SIZE, get_cursor_iterator, get_cursor_iterator_async from apify_client._resource_clients._resource_client import ResourceClient, ResourceClientAsync -from apify_client._utils import catch_not_found_or_throw, response_to_dict, to_seconds +from apify_client._utils.errors import catch_not_found_or_throw +from apify_client._utils.http import response_to_dict +from apify_client._utils.time import to_seconds from apify_client.errors import ApifyApiError if TYPE_CHECKING: diff --git a/src/apify_client/_resource_clients/run.py b/src/apify_client/_resource_clients/run.py index fa1e8977..cd006793 100644 --- a/src/apify_client/_resource_clients/run.py +++ b/src/apify_client/_resource_clients/run.py @@ -13,7 +13,9 @@ from apify_client._resource_clients._resource_client import ResourceClient, ResourceClientAsync from apify_client._status_message_watcher import StatusMessageWatcher, StatusMessageWatcherAsync from apify_client._streamed_log import StreamedLog, StreamedLogAsync -from apify_client._utils import encode_key_value_store_record_value, response_to_dict, to_safe_id, to_seconds +from apify_client._utils.encoding import encode_key_value_store_record_value +from apify_client._utils.http import response_to_dict, to_safe_id +from apify_client._utils.time import to_seconds if TYPE_CHECKING: import logging diff --git a/src/apify_client/_resource_clients/schedule.py b/src/apify_client/_resource_clients/schedule.py index b6d2b3e2..8820b942 100644 --- a/src/apify_client/_resource_clients/schedule.py +++ b/src/apify_client/_resource_clients/schedule.py @@ -11,7 +11,7 @@ ScheduleResponse, ) from apify_client._resource_clients._resource_client import ResourceClient, ResourceClientAsync -from apify_client._utils import response_to_dict +from apify_client._utils.http import response_to_dict if TYPE_CHECKING: from apify_client.types import Timeout diff --git a/src/apify_client/_resource_clients/task.py b/src/apify_client/_resource_clients/task.py index 0492e981..ff79e72d 100644 --- a/src/apify_client/_resource_clients/task.py +++ b/src/apify_client/_resource_clients/task.py @@ -14,7 +14,9 @@ UpdateTaskRequest, ) from apify_client._resource_clients._resource_client import ResourceClient, ResourceClientAsync -from apify_client._utils import encode_webhooks_to_base64, response_to_dict, to_seconds +from apify_client._utils.encoding import encode_webhooks_to_base64 +from apify_client._utils.http import response_to_dict +from apify_client._utils.time import to_seconds if TYPE_CHECKING: from datetime import timedelta diff --git a/src/apify_client/_resource_clients/task_collection.py b/src/apify_client/_resource_clients/task_collection.py index 22075129..cabef51f 100644 --- a/src/apify_client/_resource_clients/task_collection.py +++ b/src/apify_client/_resource_clients/task_collection.py @@ -15,7 +15,7 @@ ) from apify_client._pagination import get_items_iterator, get_items_iterator_async from apify_client._resource_clients._resource_client import ResourceClient, ResourceClientAsync -from apify_client._utils import to_seconds +from apify_client._utils.time import to_seconds if TYPE_CHECKING: from collections.abc import AsyncIterator, Iterator diff --git a/src/apify_client/_resource_clients/user.py b/src/apify_client/_resource_clients/user.py index 26a18acd..9d7c3754 100644 --- a/src/apify_client/_resource_clients/user.py +++ b/src/apify_client/_resource_clients/user.py @@ -16,7 +16,7 @@ UserPublicInfo, ) from apify_client._resource_clients._resource_client import ResourceClient, ResourceClientAsync -from apify_client._utils import response_to_dict +from apify_client._utils.http import response_to_dict if TYPE_CHECKING: from apify_client.types import Timeout diff --git a/src/apify_client/_resource_clients/webhook.py b/src/apify_client/_resource_clients/webhook.py index 439ba34c..18cb44da 100644 --- a/src/apify_client/_resource_clients/webhook.py +++ b/src/apify_client/_resource_clients/webhook.py @@ -14,7 +14,7 @@ WebhookUpdate, ) from apify_client._resource_clients._resource_client import ResourceClient, ResourceClientAsync -from apify_client._utils import response_to_dict +from apify_client._utils.http import response_to_dict if TYPE_CHECKING: from apify_client._literals import WebhookEventType diff --git a/src/apify_client/_status_message_watcher.py b/src/apify_client/_status_message_watcher.py index 61febdca..a1bf1e38 100644 --- a/src/apify_client/_status_message_watcher.py +++ b/src/apify_client/_status_message_watcher.py @@ -9,7 +9,7 @@ from typing import TYPE_CHECKING, Self from apify_client._docs import docs_group -from apify_client._utils import to_seconds +from apify_client._utils.time import to_seconds if TYPE_CHECKING: import logging diff --git a/src/apify_client/_utils.py b/src/apify_client/_utils.py deleted file mode 100644 index 1cd70882..00000000 --- a/src/apify_client/_utils.py +++ /dev/null @@ -1,361 +0,0 @@ -from __future__ import annotations - -import hashlib -import hmac -import io -import json -import string -import sys -import time -import warnings -from base64 import b64encode, urlsafe_b64encode -from contextlib import contextmanager -from dataclasses import dataclass -from functools import cache -from types import ModuleType -from typing import TYPE_CHECKING, Any, Literal, TypeVar, overload - -import impit - -from apify_client._consts import OVERRIDABLE_DEFAULT_HEADERS -from apify_client._models import WebhookCreate, WebhookRepresentation -from apify_client.errors import InvalidResponseBodyError, NotFoundError - -if TYPE_CHECKING: - from collections.abc import Generator - from datetime import timedelta - - from apify_client.errors import ApifyApiError - from apify_client.http_clients import HttpResponse - from apify_client.types import WebhooksList - -T = TypeVar('T') - -_BASE62_CHARSET = string.digits + string.ascii_letters - - -@dataclass -class _FailedImport: - message: str - - -class _ImportWrapper(ModuleType): - """Module subclass that converts `_FailedImport` attribute accesses into `ImportError`.""" - - def __getattr__(self, name: str) -> object: - obj = super().__getattribute__(name) - if isinstance(obj, _FailedImport): - raise ImportError(obj.message) # noqa: TRY004 - return obj - - -@contextmanager -def try_import(module_name: str, symbol_name: str) -> Generator[None, None, None]: - """Context manager for optional imports. - - If the import inside the block raises `ImportError`, the named symbol in the given module is - replaced with a `_FailedImport` placeholder. Accessing that placeholder later (after - `install_import_hook` has been called) raises a clear `ImportError` with the original message. - - Args: - module_name: Fully-qualified name of the module whose namespace to update (pass `__name__`). - symbol_name: The name that would have been imported, used as the placeholder key. - """ - try: - yield - except ImportError as exc: - sys.modules[module_name].__dict__[symbol_name] = _FailedImport(str(exc)) - - -def install_import_hook(module_name: str) -> None: - """Replace a module's class with `_ImportWrapper` to activate deferred `ImportError` raising. - - Must be called after all `try_import` blocks in the same `__init__.py`. - - Args: - module_name: Fully-qualified name of the module to wrap (pass `__name__`). - """ - sys.modules[module_name].__class__ = _ImportWrapper - - -@overload -def to_seconds(td: None, *, as_int: bool = ...) -> None: ... -@overload -def to_seconds(td: timedelta) -> float: ... -@overload -def to_seconds(td: timedelta, *, as_int: Literal[True]) -> int: ... -@overload -def to_seconds(td: timedelta, *, as_int: Literal[False]) -> float: ... - - -def to_seconds(td: timedelta | None, *, as_int: bool = False) -> float | int | None: - """Convert timedelta to seconds. - - Args: - td: The timedelta to convert, or None. - as_int: If True, round and return as int. Defaults to False. - - Returns: - The total seconds as a float (or int if as_int=True), or None if input is None. - """ - if td is None: - return None - seconds = td.total_seconds() - return int(seconds) if as_int else seconds - - -def catch_not_found_or_throw(exc: ApifyApiError) -> None: - """Suppress 404 Not Found errors and re-raise all other API errors. - - Args: - exc: The API error to check. - - Raises: - ApifyApiError: If the error is not a 404 Not Found error. - """ - if not isinstance(exc, NotFoundError): - raise exc - - -def catch_not_found_for_resource_or_throw(exc: ApifyApiError, resource_id: str | None) -> None: - """Like `catch_not_found_or_throw`, but only suppress 404s when the client targets a specific resource by ID. - - For chained clients without a `resource_id` (e.g. `run.dataset()`, `run.log()`), a 404 could mean either the - parent or the default sub-resource is missing — the API body cannot disambiguate — so the error propagates - rather than being swallowed. - """ - if resource_id is None: - raise exc - catch_not_found_or_throw(exc) - - -def encode_key_value_store_record_value(value: Any, *, content_type: str | None = None) -> tuple[Any, str]: - """Encode a value for storage in a key-value store record. - - Args: - value: The value to encode (can be dict, str, bytes, or file-like object). - content_type: The content type; if None, it's inferred from the value type. - - Returns: - A tuple of (encoded_value, content_type). - """ - if not content_type: - if isinstance(value, (bytes, bytearray, io.IOBase)): - content_type = 'application/octet-stream' - elif isinstance(value, str): - content_type = 'text/plain; charset=utf-8' - else: - content_type = 'application/json; charset=utf-8' - - if ( - 'application/json' in content_type - and not isinstance(value, (bytes, bytearray, io.IOBase)) - and not isinstance(value, str) - ): - # Don't use indentation to reduce size. - value = json.dumps( - value, - ensure_ascii=False, - allow_nan=False, - default=str, - ).encode('utf-8') - - return (value, content_type) - - -def is_retryable_error(exc: Exception) -> bool: - """Check if the given error is retryable. - - All `impit.HTTPError` subclasses are considered retryable because they represent transport-level failures - (network issues, timeouts, protocol errors, body decoding errors) that are typically transient. HTTP status - code errors are handled separately in `_make_request` based on the response status code, not here. - """ - return isinstance( - exc, - ( - InvalidResponseBodyError, - impit.HTTPError, - ), - ) - - -def to_safe_id(id: str) -> str: - """Convert a resource ID to URL-safe format by replacing forward slashes with tildes. - - Args: - id: The resource identifier in format `resource_id` or `username/resource_id`. - - Returns: - The resource identifier with `/` characters replaced by `~`. - """ - return id.replace('/', '~') - - -def response_to_dict(response: HttpResponse) -> dict: - """Parse the API response as a dictionary and validate its type. - - Args: - response: The HTTP response object from the API. - - Returns: - The parsed response as a dictionary. - - Raises: - ValueError: If the response is not a dictionary. - """ - data = response.json() - - if isinstance(data, dict): - return data - - raise ValueError(f'The response is not a dictionary. Got: {type(data).__name__}') - - -def response_to_list(response: HttpResponse) -> list: - """Parse the API response as a list and validate its type. - - Args: - response: The HTTP response object from the API. - - Returns: - The parsed response as a list. - - Raises: - ValueError: If the response is not a list. - """ - data = response.json() - - if isinstance(data, list): - return data - - if isinstance(data, dict): - return [data] - - raise ValueError(f'The response is not a list. Got: {type(data).__name__}') - - -def encode_base62(num: int) -> str: - """Encode an integer to a base62 string. - - Args: - num: The number to encode. - - Returns: - The base62-encoded string. - """ - if num == 0: - return _BASE62_CHARSET[0] - - # Use list to build result for O(n) complexity instead of O(n^2) string concatenation. - parts = [] - while num > 0: - num, remainder = divmod(num, 62) - parts.append(_BASE62_CHARSET[remainder]) - - # Reverse and join once at the end. - return ''.join(reversed(parts)) - - -def create_hmac_signature(secret_key: str, message: str) -> str: - """Generate an HMAC-SHA256 signature and encode it using base62. - - The HMAC signature is truncated to 30 characters and then encoded in base62 to reduce the signature length. - - Args: - secret_key: The secret key used for signing. - message: The message to be signed. - - Returns: - The base62-encoded signature. - """ - signature = hmac.new(secret_key.encode('utf-8'), message.encode('utf-8'), hashlib.sha256).hexdigest()[:30] - - decimal_signature = int(signature, 16) - - return encode_base62(decimal_signature) - - -def create_storage_content_signature( - resource_id: str, - url_signing_secret_key: str, - *, - expires_in: timedelta | None = None, - version: int = 0, -) -> str: - """Create a secure signature for a storage resource like a dataset or key-value store. - - This signature is used to generate a signed URL for authenticated access, which can be expiring or permanent. - The signature is created using HMAC with the provided secret key and includes the resource ID, expiration time, - and version. - - Args: - resource_id: The unique identifier of the storage resource. - url_signing_secret_key: The secret key for signing the URL. - expires_in: Optional expiration duration; if None, the signature never expires. - version: The signature version number (default: 0). - - Returns: - The base64url-encoded signature string. - """ - expires_at = int(time.time() * 1000) + int(to_seconds(expires_in) * 1000) if expires_in is not None else 0 - - message_to_sign = f'{version}.{expires_at}.{resource_id}' - hmac_sig = create_hmac_signature(url_signing_secret_key, message_to_sign) - - base64url_encoded_payload = urlsafe_b64encode(f'{version}.{expires_at}.{hmac_sig}'.encode()) - return base64url_encoded_payload.decode('utf-8') - - -def check_custom_headers(class_name: str, headers: dict[str, str]) -> None: - """Warn if custom headers override important default headers.""" - overwrite_headers = [key for key in headers if key.title() in OVERRIDABLE_DEFAULT_HEADERS] - - if overwrite_headers: - warnings.warn( - f'{", ".join(overwrite_headers)} headers of {class_name} was overridden with an ' - 'explicit value. A wrong header value can lead to API errors, it is recommended to use the default ' - f'value for following headers: {", ".join(OVERRIDABLE_DEFAULT_HEADERS)}.', - category=UserWarning, - stacklevel=3, - ) - - -@cache -def _webhook_representation_keys() -> frozenset[str]: - """Return all field names and aliases declared on `WebhookRepresentation`.""" - keys = set[str]() - for name, info in WebhookRepresentation.model_fields.items(): - keys.add(name) - if info.alias is not None: - keys.add(info.alias) - return frozenset(keys) - - -def encode_webhooks_to_base64(webhooks: WebhooksList | None) -> str | None: - """Encode a list of ad-hoc webhooks to a base64 string for the `webhooks` query parameter. - - Returns `None` for `None` or an empty list, so the query parameter is omitted. - - See `WebhooksList` for the accepted shapes. `WebhookRepresentation` instances are used as-is. `WebhookCreate` - instances and dict shapes are projected onto the fields `WebhookRepresentation` declares, dropping anything else - (e.g. persistent-only fields like `condition`). Filtering by the declared field names and aliases means new - ad-hoc fields added to `WebhookRepresentation` flow through automatically, without touching this function. - """ - if not webhooks: - return None - - representations = list[WebhookRepresentation]() - allowed = _webhook_representation_keys() - - for webhook in webhooks: - if isinstance(webhook, WebhookRepresentation): - representations.append(webhook) - continue - - data = webhook.model_dump(by_alias=True) if isinstance(webhook, WebhookCreate) else dict(webhook) - filtered = {key: value for key, value in data.items() if key in allowed} - representations.append(WebhookRepresentation.model_validate(filtered)) - - data = [r.model_dump(by_alias=True, exclude_none=True) for r in representations] - json_string = json.dumps(data).encode(encoding='utf-8') - return b64encode(json_string).decode(encoding='ascii') diff --git a/src/apify_client/_utils/__init__.py b/src/apify_client/_utils/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/apify_client/_utils/crypto.py b/src/apify_client/_utils/crypto.py new file mode 100644 index 00000000..5f09fa48 --- /dev/null +++ b/src/apify_client/_utils/crypto.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +import hashlib +import hmac +import string +import time +from base64 import urlsafe_b64encode +from typing import TYPE_CHECKING + +from apify_client._utils.time import to_seconds + +if TYPE_CHECKING: + from datetime import timedelta + +_BASE62_CHARSET = string.digits + string.ascii_letters + + +def encode_base62(num: int) -> str: + """Encode an integer to a base62 string. + + Args: + num: The number to encode. + + Returns: + The base62-encoded string. + """ + if num == 0: + return _BASE62_CHARSET[0] + + # Use list to build result for O(n) complexity instead of O(n^2) string concatenation. + parts = [] + while num > 0: + num, remainder = divmod(num, 62) + parts.append(_BASE62_CHARSET[remainder]) + + # Reverse and join once at the end. + return ''.join(reversed(parts)) + + +def create_hmac_signature(secret_key: str, message: str) -> str: + """Generate an HMAC-SHA256 signature and encode it using base62. + + The HMAC signature is truncated to 30 characters and then encoded in base62 to reduce the signature length. + + Args: + secret_key: The secret key used for signing. + message: The message to be signed. + + Returns: + The base62-encoded signature. + """ + signature = hmac.new(secret_key.encode('utf-8'), message.encode('utf-8'), hashlib.sha256).hexdigest()[:30] + + decimal_signature = int(signature, 16) + + return encode_base62(decimal_signature) + + +def create_storage_content_signature( + resource_id: str, + url_signing_secret_key: str, + *, + expires_in: timedelta | None = None, + version: int = 0, +) -> str: + """Create a secure signature for a storage resource like a dataset or key-value store. + + This signature is used to generate a signed URL for authenticated access, which can be expiring or permanent. + The signature is created using HMAC with the provided secret key and includes the resource ID, expiration time, + and version. + + Args: + resource_id: The unique identifier of the storage resource. + url_signing_secret_key: The secret key for signing the URL. + expires_in: Optional expiration duration; if None, the signature never expires. + version: The signature version number (default: 0). + + Returns: + The base64url-encoded signature string. + """ + expires_at = int(time.time() * 1000) + int(to_seconds(expires_in) * 1000) if expires_in is not None else 0 + + message_to_sign = f'{version}.{expires_at}.{resource_id}' + hmac_sig = create_hmac_signature(url_signing_secret_key, message_to_sign) + + base64url_encoded_payload = urlsafe_b64encode(f'{version}.{expires_at}.{hmac_sig}'.encode()) + return base64url_encoded_payload.decode('utf-8') diff --git a/src/apify_client/_utils/encoding.py b/src/apify_client/_utils/encoding.py new file mode 100644 index 00000000..b4adf385 --- /dev/null +++ b/src/apify_client/_utils/encoding.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +import io +import json +from base64 import b64encode +from functools import cache +from typing import TYPE_CHECKING, Any + +from apify_client._models import WebhookCreate, WebhookRepresentation + +if TYPE_CHECKING: + from apify_client.types import WebhooksList + + +def encode_key_value_store_record_value(value: Any, *, content_type: str | None = None) -> tuple[Any, str]: + """Encode a value for storage in a key-value store record. + + Args: + value: The value to encode (can be dict, str, bytes, or file-like object). + content_type: The content type; if None, it's inferred from the value type. + + Returns: + A tuple of (encoded_value, content_type). + """ + if not content_type: + if isinstance(value, (bytes, bytearray, io.IOBase)): + content_type = 'application/octet-stream' + elif isinstance(value, str): + content_type = 'text/plain; charset=utf-8' + else: + content_type = 'application/json; charset=utf-8' + + if ( + 'application/json' in content_type + and not isinstance(value, (bytes, bytearray, io.IOBase)) + and not isinstance(value, str) + ): + # Don't use indentation to reduce size. + value = json.dumps( + value, + ensure_ascii=False, + allow_nan=False, + default=str, + ).encode('utf-8') + + return (value, content_type) + + +@cache +def _webhook_representation_keys() -> frozenset[str]: + """Return all field names and aliases declared on `WebhookRepresentation`.""" + keys = set[str]() + for name, info in WebhookRepresentation.model_fields.items(): + keys.add(name) + if info.alias is not None: + keys.add(info.alias) + return frozenset(keys) + + +def encode_webhooks_to_base64(webhooks: WebhooksList | None) -> str | None: + """Encode a list of ad-hoc webhooks to a base64 string for the `webhooks` query parameter. + + Returns `None` for `None` or an empty list, so the query parameter is omitted. + + See `WebhooksList` for the accepted shapes. `WebhookRepresentation` instances are used as-is. `WebhookCreate` + instances and dict shapes are projected onto the fields `WebhookRepresentation` declares, dropping anything else + (e.g. persistent-only fields like `condition`). Filtering by the declared field names and aliases means new + ad-hoc fields added to `WebhookRepresentation` flow through automatically, without touching this function. + """ + if not webhooks: + return None + + representations = list[WebhookRepresentation]() + allowed = _webhook_representation_keys() + + for webhook in webhooks: + if isinstance(webhook, WebhookRepresentation): + representations.append(webhook) + continue + + data = webhook.model_dump(by_alias=True) if isinstance(webhook, WebhookCreate) else dict(webhook) + filtered = {key: value for key, value in data.items() if key in allowed} + representations.append(WebhookRepresentation.model_validate(filtered)) + + data = [r.model_dump(by_alias=True, exclude_none=True) for r in representations] + json_string = json.dumps(data).encode(encoding='utf-8') + return b64encode(json_string).decode(encoding='ascii') diff --git a/src/apify_client/_utils/errors.py b/src/apify_client/_utils/errors.py new file mode 100644 index 00000000..989d3966 --- /dev/null +++ b/src/apify_client/_utils/errors.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +import impit + +from apify_client.errors import InvalidResponseBodyError, NotFoundError + +if TYPE_CHECKING: + from apify_client.errors import ApifyApiError + + +def catch_not_found_or_throw(exc: ApifyApiError) -> None: + """Suppress 404 Not Found errors and re-raise all other API errors. + + Args: + exc: The API error to check. + + Raises: + ApifyApiError: If the error is not a 404 Not Found error. + """ + if not isinstance(exc, NotFoundError): + raise exc + + +def catch_not_found_for_resource_or_throw(exc: ApifyApiError, resource_id: str | None) -> None: + """Like `catch_not_found_or_throw`, but only suppress 404s when the client targets a specific resource by ID. + + For chained clients without a `resource_id` (e.g. `run.dataset()`, `run.log()`), a 404 could mean either the + parent or the default sub-resource is missing — the API body cannot disambiguate — so the error propagates + rather than being swallowed. + """ + if resource_id is None: + raise exc + catch_not_found_or_throw(exc) + + +def is_retryable_error(exc: Exception) -> bool: + """Check if the given error is retryable. + + All `impit.HTTPError` subclasses are considered retryable because they represent transport-level failures + (network issues, timeouts, protocol errors, body decoding errors) that are typically transient. HTTP status + code errors are handled separately in `_make_request` based on the response status code, not here. + """ + return isinstance( + exc, + ( + InvalidResponseBodyError, + impit.HTTPError, + ), + ) diff --git a/src/apify_client/_utils/http.py b/src/apify_client/_utils/http.py new file mode 100644 index 00000000..448746bd --- /dev/null +++ b/src/apify_client/_utils/http.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +import warnings +from typing import TYPE_CHECKING + +from apify_client._consts import OVERRIDABLE_DEFAULT_HEADERS + +if TYPE_CHECKING: + from apify_client.http_clients import HttpResponse + + +def to_safe_id(id: str) -> str: + """Convert a resource ID to URL-safe format by replacing forward slashes with tildes. + + Args: + id: The resource identifier in format `resource_id` or `username/resource_id`. + + Returns: + The resource identifier with `/` characters replaced by `~`. + """ + return id.replace('/', '~') + + +def response_to_dict(response: HttpResponse) -> dict: + """Parse the API response as a dictionary and validate its type. + + Args: + response: The HTTP response object from the API. + + Returns: + The parsed response as a dictionary. + + Raises: + ValueError: If the response is not a dictionary. + """ + data = response.json() + + if isinstance(data, dict): + return data + + raise ValueError(f'The response is not a dictionary. Got: {type(data).__name__}') + + +def response_to_list(response: HttpResponse) -> list: + """Parse the API response as a list and validate its type. + + Args: + response: The HTTP response object from the API. + + Returns: + The parsed response as a list. + + Raises: + ValueError: If the response is not a list. + """ + data = response.json() + + if isinstance(data, list): + return data + + if isinstance(data, dict): + return [data] + + raise ValueError(f'The response is not a list. Got: {type(data).__name__}') + + +def check_custom_headers(class_name: str, headers: dict[str, str]) -> None: + """Warn if custom headers override important default headers.""" + overwrite_headers = [key for key in headers if key.title() in OVERRIDABLE_DEFAULT_HEADERS] + + if overwrite_headers: + warnings.warn( + f'{", ".join(overwrite_headers)} headers of {class_name} was overridden with an ' + 'explicit value. A wrong header value can lead to API errors, it is recommended to use the default ' + f'value for following headers: {", ".join(OVERRIDABLE_DEFAULT_HEADERS)}.', + category=UserWarning, + stacklevel=3, + ) diff --git a/src/apify_client/_utils/time.py b/src/apify_client/_utils/time.py new file mode 100644 index 00000000..4e8c761b --- /dev/null +++ b/src/apify_client/_utils/time.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Literal, overload + +if TYPE_CHECKING: + from datetime import timedelta + + +@overload +def to_seconds(td: None, *, as_int: bool = ...) -> None: ... +@overload +def to_seconds(td: timedelta) -> float: ... +@overload +def to_seconds(td: timedelta, *, as_int: Literal[True]) -> int: ... +@overload +def to_seconds(td: timedelta, *, as_int: Literal[False]) -> float: ... + + +def to_seconds(td: timedelta | None, *, as_int: bool = False) -> float | int | None: + """Convert timedelta to seconds. + + Args: + td: The timedelta to convert, or None. + as_int: If True, round and return as int. Defaults to False. + + Returns: + The total seconds as a float (or int if as_int=True), or None if input is None. + """ + if td is None: + return None + seconds = td.total_seconds() + return int(seconds) if as_int else seconds diff --git a/src/apify_client/_utils/try_import.py b/src/apify_client/_utils/try_import.py new file mode 100644 index 00000000..f9d96997 --- /dev/null +++ b/src/apify_client/_utils/try_import.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +import sys +from contextlib import contextmanager +from dataclasses import dataclass +from types import ModuleType +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import Generator + + +@dataclass +class _FailedImport: + message: str + + +class _ImportWrapper(ModuleType): + """Module subclass that converts `_FailedImport` attribute accesses into `ImportError`. + + Must override `__getattribute__` (not `__getattr__`): the `_FailedImport` placeholder lives in + the module's `__dict__`, so a normal lookup would find it and `__getattr__` (the fallback for + missing attributes) would never fire. + """ + + def __getattribute__(self, name: str) -> object: + obj = super().__getattribute__(name) + if isinstance(obj, _FailedImport): + raise ImportError(obj.message) # noqa: TRY004 + return obj + + +@contextmanager +def try_import(module_name: str, *symbol_names: str) -> Generator[None, None, None]: + """Context manager for optional imports. + + If the import inside the block raises `ImportError`, each named symbol in the given module is + replaced with a `_FailedImport` placeholder. Accessing that placeholder later (after + `install_import_hook` has been called) raises a clear `ImportError` with the original message. + + Args: + module_name: Fully-qualified name of the module whose namespace to update (pass `__name__`). + symbol_names: The names that would have been imported, used as the placeholder keys. + """ + try: + yield + except ImportError as exc: + for symbol_name in symbol_names: + setattr(sys.modules[module_name], symbol_name, _FailedImport(str(exc))) + + +def install_import_hook(module_name: str) -> None: + """Replace a module's class with `_ImportWrapper` to activate deferred `ImportError` raising. + + Call this in the package `__init__.py` alongside the `try_import` blocks (see `http_compressors`). + The placeholder is resolved lazily on attribute access, so the relative order of this call and + the `try_import` blocks does not matter. + + Args: + module_name: Fully-qualified name of the module to wrap (pass `__name__`). + """ + sys.modules[module_name].__class__ = _ImportWrapper diff --git a/src/apify_client/http_clients/_base.py b/src/apify_client/http_clients/_base.py index 9bca0fa5..e4367552 100644 --- a/src/apify_client/http_clients/_base.py +++ b/src/apify_client/http_clients/_base.py @@ -19,7 +19,7 @@ ) from apify_client._docs import docs_group from apify_client._statistics import ClientStatistics -from apify_client._utils import to_seconds +from apify_client._utils.time import to_seconds from apify_client.http_compressors._gzip import GzipHttpCompressor if TYPE_CHECKING: diff --git a/src/apify_client/http_clients/_impit.py b/src/apify_client/http_clients/_impit.py index cd0a0442..61847cb7 100644 --- a/src/apify_client/http_clients/_impit.py +++ b/src/apify_client/http_clients/_impit.py @@ -20,7 +20,7 @@ ) from apify_client._docs import docs_group from apify_client._logging import log_context, logger_name -from apify_client._utils import to_seconds +from apify_client._utils.time import to_seconds from apify_client.errors import ApifyApiError, InvalidResponseBodyError from apify_client.http_clients._base import HttpClient, HttpClientAsync diff --git a/src/apify_client/http_compressors/__init__.py b/src/apify_client/http_compressors/__init__.py index 2e7bb7f3..bd0edf5d 100644 --- a/src/apify_client/http_compressors/__init__.py +++ b/src/apify_client/http_compressors/__init__.py @@ -1,12 +1,15 @@ -from apify_client._utils import install_import_hook, try_import +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 + +# These imports have only mandatory dependencies, so they are imported directly. from apify_client.http_compressors._base import HttpCompressor from apify_client.http_compressors._gzip import GzipHttpCompressor -# `brotli` is an optional extra. Defer the ImportError until -# BrotliHttpCompressor is actually accessed. -with try_import(__name__, 'BrotliHttpCompressor'): - from apify_client.http_compressors._brotli import BrotliHttpCompressor +_install_import_hook(__name__) -install_import_hook(__name__) +# `brotli` is an optional extra, so it's wrapped in try_import. Accessing `BrotliHttpCompressor` +# without the extra installed raises a clear ImportError instead of failing at package import time. +with _try_import(__name__, 'BrotliHttpCompressor'): + from apify_client.http_compressors._brotli import BrotliHttpCompressor __all__ = ['BrotliHttpCompressor', 'GzipHttpCompressor', 'HttpCompressor'] diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 1cdb3689..1db53b71 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -16,7 +16,7 @@ ) from apify_client import ApifyClient, ApifyClientAsync from apify_client._consts import DEFAULT_API_URL -from apify_client._utils import create_hmac_signature, create_storage_content_signature +from apify_client._utils.crypto import create_hmac_signature, create_storage_content_signature if TYPE_CHECKING: from collections.abc import Generator diff --git a/tests/unit/test_url_generation.py b/tests/unit/test_url_generation.py index a1a2aec6..a48b704d 100644 --- a/tests/unit/test_url_generation.py +++ b/tests/unit/test_url_generation.py @@ -8,7 +8,7 @@ from apify_client import ApifyClient, ApifyClientAsync from apify_client._consts import DEFAULT_API_URL -from apify_client._utils import create_hmac_signature, create_storage_content_signature +from apify_client._utils.crypto import create_hmac_signature, create_storage_content_signature # ============================================================================ # Test data and helpers diff --git a/tests/unit/test_utils.py b/tests/unit/test_utils.py index 90e918af..a52d3fb4 100644 --- a/tests/unit/test_utils.py +++ b/tests/unit/test_utils.py @@ -13,18 +13,10 @@ from apify_client._models import WebhookCondition, WebhookCreate from apify_client._resource_clients._resource_client import ResourceClientBase -from apify_client._utils import ( - catch_not_found_or_throw, - create_hmac_signature, - create_storage_content_signature, - encode_base62, - encode_key_value_store_record_value, - encode_webhooks_to_base64, - is_retryable_error, - response_to_dict, - response_to_list, - to_safe_id, -) +from apify_client._utils.crypto import create_hmac_signature, create_storage_content_signature, encode_base62 +from apify_client._utils.encoding import encode_key_value_store_record_value, encode_webhooks_to_base64 +from apify_client._utils.errors import catch_not_found_or_throw, is_retryable_error +from apify_client._utils.http import response_to_dict, response_to_list, to_safe_id from apify_client.errors import ApifyApiError, InvalidResponseBodyError if TYPE_CHECKING: From f7f6a3437f57cb2ed009a0714d616d898ca14c65 Mon Sep 17 00:00:00 2001 From: Vlada Dusek Date: Tue, 14 Jul 2026 19:47:15 +0200 Subject: [PATCH 17/30] refactor: Delegate compressor quality validation to the underlying modules --- src/apify_client/http_compressors/_brotli.py | 23 +++++--------------- src/apify_client/http_compressors/_gzip.py | 19 ++++------------ 2 files changed, 9 insertions(+), 33 deletions(-) diff --git a/src/apify_client/http_compressors/_brotli.py b/src/apify_client/http_compressors/_brotli.py index b3880505..d853dd95 100644 --- a/src/apify_client/http_compressors/_brotli.py +++ b/src/apify_client/http_compressors/_brotli.py @@ -11,30 +11,17 @@ class BrotliHttpCompressor(HttpCompressor): Requires the `brotli` extra: `pip install "apify-client[brotli]"`. """ - _fast_quality = 0 - """The fastest compression, but the lowest compression ratio.""" - - _default_quality = 6 - """Reasonable compromise between the speed and compression ratio.""" - - _best_quality = 11 - """The best compression ratio, but the slowest compression.""" - content_encoding = 'br' - def __init__(self, *, quality: int = _default_quality) -> None: + def __init__(self, *, quality: int = 6) -> None: """Initialize the brotli compressor. Args: - quality: Compression level, `0` (fastest) to `11` (the best compression). Defaults to `6`. - - Raises: - ValueError: If `quality` is out of range. + quality: Compression level, `0` (fastest) to `11` (the best compression). Defaults to `6`, + a reasonable compromise between speed and compression ratio. Passed straight to the + `brotli` module, which validates it. """ - if self._fast_quality > quality > self._best_quality: - raise ValueError(f'Compression quality out of range: {quality}') - self._quality = quality def compress(self, data: bytes) -> bytes: - return brotli.compress(bytes(data), quality=self._quality) + return brotli.compress(data, quality=self._quality) diff --git a/src/apify_client/http_compressors/_gzip.py b/src/apify_client/http_compressors/_gzip.py index 58b76f7c..60dfa980 100644 --- a/src/apify_client/http_compressors/_gzip.py +++ b/src/apify_client/http_compressors/_gzip.py @@ -8,30 +8,19 @@ class GzipHttpCompressor(HttpCompressor): """Compresses request bodies using gzip. - Uses the standard library `gzip` module — no extra dependencies required. + Uses the standard library `gzip` module. No extra dependencies required. """ - _fast_quality = 1 - """The fastest compression, but the lowest compression ratio.""" - - _best_quality = 9 - """The best compression ratio, but the slowest compression.""" - content_encoding = 'gzip' - def __init__(self, *, quality: int = _best_quality) -> None: + def __init__(self, *, quality: int = 9) -> None: """Initialize the gzip compressor. Args: quality: Compression level, `1` (fastest) to `9` (the best compression). Defaults to `9`. - - Raises: - ValueError: If `quality` is out of range. + Passed straight to the `gzip` module, which validates it. """ - if self._fast_quality > quality > self._best_quality: - raise ValueError(f'Compression quality out of range: {quality}') - self._quality = quality def compress(self, data: bytes) -> bytes: - return gzip.compress(bytes(data), compresslevel=self._quality) + return gzip.compress(data, compresslevel=self._quality) From 9fc37df98a38d40d15b57d2748383868ceb14241 Mon Sep 17 00:00:00 2001 From: Vlada Dusek Date: Tue, 14 Jul 2026 19:47:22 +0200 Subject: [PATCH 18/30] docs: Polish the compression concept guide --- docs/02_concepts/13_compression.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/02_concepts/13_compression.mdx b/docs/02_concepts/13_compression.mdx index a91cd2da..34af656f 100644 --- a/docs/02_concepts/13_compression.mdx +++ b/docs/02_concepts/13_compression.mdx @@ -34,7 +34,7 @@ pip install "apify-client[brotli]" uv add "apify-client[brotli]" ``` -Then pass `encoding='brotli'` to the client constructor. If you request brotli without installing the extra, the client raises a clear `ImportError` immediately — there is no silent fallback. +Then pass `encoding='brotli'` to the client constructor. If you request brotli without installing the extra, the client raises a clear `ImportError`. There is no silent fallback. ## Custom quality and advanced control @@ -83,7 +83,7 @@ client = ApifyClient(token='MY-APIFY-TOKEN', encoding=IdentityCompressor()) |-------------------------------|-----------------------------------------|---------------------------------| | **Compression ratio** | Typically better than gzip | Good | | **CPU cost** | Moderate, depends on quality | Low | -| **Availability** | Requires the `brotli` extra | Built-in — no extra needed | +| **Availability** | Requires the `brotli` extra | Built-in, no extra needed | | **`Content-Encoding` header** | `br` | `gzip` | | **Quality range** | `0–11` | `1–9` | | **Default quality** | `6` | `9` | From 0cf66e31499c0999f1a4aab5592b2a2cc283b457 Mon Sep 17 00:00:00 2001 From: Vlada Dusek Date: Tue, 14 Jul 2026 19:47:29 +0200 Subject: [PATCH 19/30] test: Add compressor tests and parametrize charge tests by encoding --- tests/unit/test_http_clients.py | 14 +++- tests/unit/test_http_compressors.py | 119 ++++++++++++++++++++++++++++ tests/unit/test_run_charge.py | 12 ++- 3 files changed, 141 insertions(+), 4 deletions(-) create mode 100644 tests/unit/test_http_compressors.py diff --git a/tests/unit/test_http_clients.py b/tests/unit/test_http_clients.py index 903d39e9..519fdcff 100644 --- a/tests/unit/test_http_clients.py +++ b/tests/unit/test_http_clients.py @@ -270,7 +270,7 @@ def test_is_retryable_error() -> None: ] ) def compressor_case(request: pytest.FixtureRequest) -> tuple: - return request.param # type: ignore[return-value] + return request.param def test_prepare_request_call_basic() -> None: @@ -392,6 +392,18 @@ def test_prepare_request_call_with_bytes_data(compressor_case: tuple) -> None: assert decompress(data) == b'test bytes' +def test_prepare_request_call_with_bytearray_data(compressor_case: tuple) -> None: + """Test _prepare_request_call with bytearray data (regression: must compress without error).""" + compressor, content_encoding, decompress = compressor_case + client = _ConcreteHttpClient(compressor=compressor) + + headers, _params, data = client._prepare_request_call(data=bytearray(b'test bytearray')) + + assert headers['Content-Encoding'] == content_encoding + assert isinstance(data, bytes) + assert decompress(data) == b'test bytearray' + + def test_prepare_request_call_json_and_data_error() -> None: """Test _prepare_request_call raises error when both json and data are provided.""" client = _ConcreteHttpClient() diff --git a/tests/unit/test_http_compressors.py b/tests/unit/test_http_compressors.py new file mode 100644 index 00000000..fa5a1de6 --- /dev/null +++ b/tests/unit/test_http_compressors.py @@ -0,0 +1,119 @@ +from __future__ import annotations + +import gzip +import importlib +import sys +from contextlib import contextmanager +from typing import TYPE_CHECKING + +import brotli +import pytest + +from apify_client._apify_client import _resolve_compressor +from apify_client.http_compressors import BrotliHttpCompressor, GzipHttpCompressor + +if TYPE_CHECKING: + from collections.abc import Iterator + + +@contextmanager +def _brotli_unavailable() -> Iterator[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. + """ + + 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}'") + + def _affected(name: str) -> bool: + return name == 'brotli' or name.startswith(('brotli.', 'apify_client.http_compressors')) + + saved = {name: mod for name, mod in list(sys.modules.items()) if _affected(name)} + blocker = _Blocker() + sys.meta_path.insert(0, blocker) + for name in saved: + del sys.modules[name] + + try: + importlib.import_module('apify_client.http_compressors') + yield + finally: + sys.meta_path.remove(blocker) + for name in [name for name in sys.modules if _affected(name)]: + del sys.modules[name] + sys.modules.update(saved) + + +# Round-trip and content encoding + + +@pytest.mark.parametrize('quality', [1, 9]) +def test_gzip_compressor_round_trips_at_quality(quality: int) -> None: + """Gzip compressor round-trips data at the boundary quality levels.""" + assert gzip.decompress(GzipHttpCompressor(quality=quality).compress(b'payload')) == b'payload' + + +@pytest.mark.parametrize('quality', [0, 11]) +def test_brotli_compressor_round_trips_at_quality(quality: int) -> None: + """Brotli compressor round-trips data at the boundary quality levels.""" + assert brotli.decompress(BrotliHttpCompressor(quality=quality).compress(b'payload')) == b'payload' + + +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() + assert compressor.content_encoding == 'gzip' + assert gzip.decompress(compressor.compress(b'hello world')) == b'hello world' + + +def test_brotli_compressor_round_trips_and_sets_content_encoding() -> None: + """Brotli compressor round-trips data and reports `br` as its content encoding.""" + compressor = BrotliHttpCompressor() + assert compressor.content_encoding == 'br' + assert brotli.decompress(compressor.compress(b'hello world')) == b'hello world' + + +# Compressor resolution + + +def test_resolve_compressor_gzip() -> None: + """The `'gzip'` literal resolves to a `GzipHttpCompressor`.""" + assert isinstance(_resolve_compressor('gzip'), GzipHttpCompressor) + + +def test_resolve_compressor_brotli() -> None: + """The `'brotli'` literal resolves to a `BrotliHttpCompressor`.""" + assert isinstance(_resolve_compressor('brotli'), BrotliHttpCompressor) + + +def test_resolve_compressor_passes_through_instance() -> None: + """An `HttpCompressor` instance is returned unchanged.""" + compressor = BrotliHttpCompressor(quality=11) + assert _resolve_compressor(compressor) is compressor + + +def test_resolve_compressor_rejects_unknown_algorithm() -> None: + """An unrecognized encoding raises `ValueError`.""" + with pytest.raises(ValueError, match='Unsupported compression algorithm'): + _resolve_compressor('deflate') # ty: ignore[invalid-argument-type] + + +# Optional brotli extra + + +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(): + module = importlib.import_module('apify_client.http_compressors') + with pytest.raises(ImportError): + _ = module.BrotliHttpCompressor + + +def test_resolve_compressor_brotli_raises_clear_error_when_extra_missing() -> None: + """Resolving `encoding='brotli'` without the extra raises `ImportError` at resolution time.""" + with _brotli_unavailable(), pytest.raises(ImportError): + _resolve_compressor('brotli') diff --git a/tests/unit/test_run_charge.py b/tests/unit/test_run_charge.py index 7e13b937..1dd7e105 100644 --- a/tests/unit/test_run_charge.py +++ b/tests/unit/test_run_charge.py @@ -13,6 +13,8 @@ if TYPE_CHECKING: from pytest_httpserver import HTTPServer + from apify_client.types import HttpCompressionAlgorithm + _MOCKED_RUN_ID = 'test_run_id' _CHARGE_PATH = f'/v2/actor-runs/{_MOCKED_RUN_ID}/charge' @@ -27,6 +29,7 @@ def _decode_body(request: Request) -> dict: return json.loads(raw) +@pytest.mark.parametrize('encoding', ['gzip', 'brotli']) @pytest.mark.parametrize( 'count', [0, 1, 5], @@ -34,8 +37,9 @@ def _decode_body(request: Request) -> dict: def test_run_charge_preserves_count_sync( httpserver: HTTPServer, count: int, + encoding: HttpCompressionAlgorithm, ) -> None: - """Ensure `count` is sent as-is (in particular, `0` is preserved).""" + """Ensure `count` is sent as-is (in particular, `0` is preserved), regardless of the request-body encoding.""" captured_requests: list[Request] = [] def capture_request(request: Request) -> Response: @@ -45,7 +49,7 @@ def capture_request(request: Request) -> Response: httpserver.expect_request(_CHARGE_PATH, method='POST').respond_with_handler(capture_request) api_url = httpserver.url_for('/').removesuffix('/') - client = ApifyClient(token='test_token', api_url=api_url) + client = ApifyClient(token='test_token', api_url=api_url, encoding=encoding) client.run(_MOCKED_RUN_ID).charge('test-event', count=count) @@ -54,6 +58,7 @@ def capture_request(request: Request) -> Response: assert body['count'] == count +@pytest.mark.parametrize('encoding', ['gzip', 'brotli']) @pytest.mark.parametrize( 'count', [0, 1, 5], @@ -61,6 +66,7 @@ def capture_request(request: Request) -> Response: async def test_run_charge_preserves_count_async( httpserver: HTTPServer, count: int, + encoding: HttpCompressionAlgorithm, ) -> None: """Async variant of `test_run_charge_preserves_count_sync`.""" captured_requests: list[Request] = [] @@ -72,7 +78,7 @@ def capture_request(request: Request) -> Response: httpserver.expect_request(_CHARGE_PATH, method='POST').respond_with_handler(capture_request) api_url = httpserver.url_for('/').removesuffix('/') - client = ApifyClientAsync(token='test_token', api_url=api_url) + client = ApifyClientAsync(token='test_token', api_url=api_url, encoding=encoding) await client.run(_MOCKED_RUN_ID).charge('test-event', count=count) From 0a766343a07c836a0adf6a398762e3347f203e8e Mon Sep 17 00:00:00 2001 From: Vlada Dusek Date: Tue, 14 Jul 2026 20:50:00 +0200 Subject: [PATCH 20/30] refactor: Unify compression naming and extract compressor resolution --- src/apify_client/_apify_client.py | 55 ++++--------------- src/apify_client/http_clients/_base.py | 10 ++-- src/apify_client/http_clients/_impit.py | 12 ++-- src/apify_client/http_compressors/_resolve.py | 38 +++++++++++++ src/apify_client/types.py | 9 +-- 5 files changed, 62 insertions(+), 62 deletions(-) create mode 100644 src/apify_client/http_compressors/_resolve.py diff --git a/src/apify_client/_apify_client.py b/src/apify_client/_apify_client.py index 5d2b3c26..b880cfb4 100644 --- a/src/apify_client/_apify_client.py +++ b/src/apify_client/_apify_client.py @@ -74,44 +74,15 @@ from apify_client._statistics import ClientStatistics from apify_client._utils.http import check_custom_headers from apify_client.http_clients import HttpClient, HttpClientAsync, ImpitHttpClient, ImpitHttpClientAsync -from apify_client.http_compressors import GzipHttpCompressor -from apify_client.http_compressors._base import HttpCompressor +from apify_client.http_compressors._resolve import resolve_compressor if TYPE_CHECKING: from datetime import timedelta + from apify_client.http_compressors._base import HttpCompressor from apify_client.types import HttpCompressionAlgorithm -def _resolve_compressor(encoding: HttpCompressionAlgorithm | HttpCompressor) -> HttpCompressor: - """Convert an encoding string or `HttpCompressor` instance into a concrete `HttpCompressor`. - - Args: - encoding: Either a string literal (`'gzip'` or `'brotli'`) or an `HttpCompressor` instance. - - Returns: - A ready-to-use `HttpCompressor`. - - Raises: - ImportError: If `'brotli'` is requested but the `brotli` extra is not installed. - ValueError: If `encoding` is not a recognized compression algorithm. - """ - if isinstance(encoding, HttpCompressor): - return encoding - if encoding == 'gzip': - return GzipHttpCompressor() - if encoding == 'brotli': - # The import is here so the ImportError is raised at call time, - # not at module import time, giving users a clear message. - from apify_client.http_compressors import BrotliHttpCompressor # noqa: PLC0415 - - return BrotliHttpCompressor() - - # The backend supports also `deflate` and `identity` (no compression). One can build - # a custom compressor if needed. - raise ValueError(f'Unsupported compression algorithm: {encoding!r}') - - @docs_group('Apify API clients') class ApifyClient: """Synchronous client for the Apify API. @@ -155,7 +126,7 @@ def __init__( timeout_long: timedelta = DEFAULT_TIMEOUT_LONG, timeout_max: timedelta = DEFAULT_TIMEOUT_MAX, headers: dict[str, str] | None = None, - encoding: HttpCompressionAlgorithm | HttpCompressor = 'gzip', + compression: HttpCompressionAlgorithm | HttpCompressor = 'gzip', ) -> None: """Initialize the Apify API client. @@ -177,9 +148,8 @@ def __init__( timeout_long: Default timeout for long-duration API operations (long-polling, streaming, ...). timeout_max: Maximum timeout cap for exponential timeout growth across retries. headers: Additional HTTP headers to include in all API requests. - encoding: Compression algorithm for request bodies. Pass `'gzip'` (default, no extra required) or - `'brotli'` (requires `pip install "apify-client[brotli]"`). For custom quality or full control, - pass an `HttpCompressor` instance directly, e.g. `BrotliHttpCompressor(quality=11)`. + compression: Compression algorithm for request bodies. Pass a string literal to select an algorithm, + or an `HttpCompressor` instance for finer-grained control. """ # We need to do this because of mocking in tests and default mutable arguments. api_url = DEFAULT_API_URL if api_url is None else api_url @@ -242,7 +212,7 @@ def __init__( self._timeout_long = timeout_long self._timeout_max = timeout_max self._headers = headers - self._encoding = encoding + self._compression = compression @classmethod def with_custom_http_client( @@ -308,7 +278,7 @@ def http_client(self) -> HttpClient: min_delay_between_retries=self._min_delay_between_retries, statistics=self._statistics, headers=self._headers, - compressor=_resolve_compressor(self._encoding), + http_compressor=resolve_compressor(self._compression), ) return self._http_client @@ -515,7 +485,7 @@ def __init__( timeout_long: timedelta = DEFAULT_TIMEOUT_LONG, timeout_max: timedelta = DEFAULT_TIMEOUT_MAX, headers: dict[str, str] | None = None, - encoding: HttpCompressionAlgorithm | HttpCompressor = 'gzip', + compression: HttpCompressionAlgorithm | HttpCompressor = 'gzip', ) -> None: """Initialize the Apify API client. @@ -537,9 +507,8 @@ def __init__( timeout_long: Default timeout for long-duration API operations (long-polling, streaming, ...). timeout_max: Maximum timeout cap for exponential timeout growth across retries. headers: Additional HTTP headers to include in all API requests. - encoding: Compression algorithm for request bodies. Pass `'gzip'` (default, no extra required) or - `'brotli'` (requires `pip install "apify-client[brotli]"`). For custom quality or full control, - pass an `HttpCompressor` instance directly, e.g. `BrotliHttpCompressor(quality=11)`. + compression: Compression algorithm for request bodies. Pass a string literal to select an algorithm, + or an `HttpCompressor` instance for finer-grained control. """ # We need to do this because of mocking in tests and default mutable arguments. api_url = DEFAULT_API_URL if api_url is None else api_url @@ -602,7 +571,7 @@ def __init__( self._timeout_long = timeout_long self._timeout_max = timeout_max self._headers = headers - self._encoding = encoding + self._compression = compression @classmethod def with_custom_http_client( @@ -668,7 +637,7 @@ def http_client(self) -> HttpClientAsync: min_delay_between_retries=self._min_delay_between_retries, statistics=self._statistics, headers=self._headers, - compressor=_resolve_compressor(self._encoding), + http_compressor=resolve_compressor(self._compression), ) return self._http_client diff --git a/src/apify_client/http_clients/_base.py b/src/apify_client/http_clients/_base.py index e4367552..2fd2aeea 100644 --- a/src/apify_client/http_clients/_base.py +++ b/src/apify_client/http_clients/_base.py @@ -101,7 +101,7 @@ def __init__( min_delay_between_retries: timedelta = DEFAULT_MIN_DELAY_BETWEEN_RETRIES, statistics: ClientStatistics | None = None, headers: dict[str, str] | None = None, - compressor: HttpCompressor | None = None, + http_compressor: HttpCompressor | None = None, ) -> None: """Initialize the HTTP client base. @@ -115,9 +115,9 @@ def __init__( min_delay_between_retries: Minimum delay between retries. statistics: Statistics tracker for API calls. Created automatically if not provided. headers: Additional HTTP headers to include in all requests. - compressor: Compressor used to compress request bodies. Defaults to `GzipHttpCompressor`. + http_compressor: Compressor used to compress request bodies. Defaults to `GzipHttpCompressor`. """ - self._compressor = compressor if compressor is not None else GzipHttpCompressor() + self._http_compressor = http_compressor if http_compressor is not None else GzipHttpCompressor() self._timeout_short = timeout_short self._timeout_medium = timeout_medium self._timeout_long = timeout_long @@ -223,8 +223,8 @@ def _prepare_request_call( data = data.encode('utf-8') elif isinstance(data, bytearray): data = bytes(data) - data = self._compressor.compress(data) - headers['Content-Encoding'] = self._compressor.content_encoding + data = self._http_compressor.compress(data) + headers['Content-Encoding'] = self._http_compressor.content_encoding return (headers, self._parse_params(params), data) diff --git a/src/apify_client/http_clients/_impit.py b/src/apify_client/http_clients/_impit.py index 61847cb7..02e9a3d4 100644 --- a/src/apify_client/http_clients/_impit.py +++ b/src/apify_client/http_clients/_impit.py @@ -74,7 +74,7 @@ def __init__( min_delay_between_retries: timedelta = DEFAULT_MIN_DELAY_BETWEEN_RETRIES, statistics: ClientStatistics | None = None, headers: dict[str, str] | None = None, - compressor: HttpCompressor | None = None, + http_compressor: HttpCompressor | None = None, ) -> None: """Initialize the Impit-based synchronous HTTP client. @@ -88,7 +88,7 @@ def __init__( min_delay_between_retries: Minimum delay between retries (increases exponentially with each attempt). statistics: Statistics tracker for API calls. Created automatically if not provided. headers: Additional HTTP headers to include in all requests. - compressor: Compressor used to compress request bodies. Defaults to `GzipHttpCompressor`. + http_compressor: Compressor used to compress request bodies. Defaults to `GzipHttpCompressor`. """ super().__init__( token=token, @@ -100,7 +100,7 @@ def __init__( min_delay_between_retries=min_delay_between_retries, statistics=statistics, headers=headers, - compressor=compressor, + http_compressor=http_compressor, ) self._impit_client = impit.Client( @@ -324,7 +324,7 @@ def __init__( min_delay_between_retries: timedelta = DEFAULT_MIN_DELAY_BETWEEN_RETRIES, statistics: ClientStatistics | None = None, headers: dict[str, str] | None = None, - compressor: HttpCompressor | None = None, + http_compressor: HttpCompressor | None = None, ) -> None: """Initialize the Impit-based asynchronous HTTP client. @@ -338,7 +338,7 @@ def __init__( min_delay_between_retries: Minimum delay between retries (increases exponentially with each attempt). statistics: Statistics tracker for API calls. Created automatically if not provided. headers: Additional HTTP headers to include in all requests. - compressor: Compressor used to compress request bodies. Defaults to `GzipHttpCompressor`. + http_compressor: Compressor used to compress request bodies. Defaults to `GzipHttpCompressor`. """ super().__init__( token=token, @@ -350,7 +350,7 @@ def __init__( min_delay_between_retries=min_delay_between_retries, statistics=statistics, headers=headers, - compressor=compressor, + http_compressor=http_compressor, ) self._impit_async_client = impit.AsyncClient( diff --git a/src/apify_client/http_compressors/_resolve.py b/src/apify_client/http_compressors/_resolve.py new file mode 100644 index 00000000..ccd5d77c --- /dev/null +++ b/src/apify_client/http_compressors/_resolve.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from apify_client.http_compressors._base import HttpCompressor +from apify_client.http_compressors._gzip import GzipHttpCompressor + +if TYPE_CHECKING: + from apify_client.types import HttpCompressionAlgorithm + + +def resolve_compressor(compression: HttpCompressionAlgorithm | HttpCompressor) -> HttpCompressor: + """Convert a compression string or `HttpCompressor` instance into a concrete `HttpCompressor`. + + Args: + compression: A string literal naming an HTTP compression algorithm, or an `HttpCompressor` instance. + + Returns: + A ready-to-use `HttpCompressor`. + + Raises: + ImportError: If the requested algorithm needs an optional extra that is not installed. + ValueError: If `compression` is not a recognized compression algorithm. + """ + if isinstance(compression, HttpCompressor): + return compression + if compression == 'gzip': + return GzipHttpCompressor() + if compression == 'brotli': + # The import is here so the ImportError is raised at call time, + # not at module import time, giving users a clear message. + from apify_client.http_compressors import BrotliHttpCompressor # noqa: PLC0415 + + return BrotliHttpCompressor() + + # The backend supports also `deflate` and `identity` (no compression). One can build + # a custom compressor if needed. + raise ValueError(f'Unsupported compression algorithm: {compression!r}') diff --git a/src/apify_client/types.py b/src/apify_client/types.py index 959dfcef..47c5d7b6 100644 --- a/src/apify_client/types.py +++ b/src/apify_client/types.py @@ -12,14 +12,7 @@ ) HttpCompressionAlgorithm = Literal['brotli', 'gzip'] -"""Accepted string literals for the `encoding` parameter on `ApifyClient` and `ApifyClientAsync`. - -`'gzip'` (the default) always uses gzip with no extra dependencies. -`'brotli'` requires the `brotli` extra (`pip install "apify-client[brotli]"`) and raises `ImportError` -if the extra is not installed. - -For custom quality or full control, inject an `HttpCompressor` instance directly instead. -""" +"""Accepted string literals for the `compression` parameter on `ApifyClient` and `ApifyClientAsync`.""" Timeout = timedelta | Literal['no_timeout', 'short', 'medium', 'long'] """Type for the `timeout` parameter on resource client methods. From 29f5315ca0be14ae4ce4bf7aa097deccd4c4ad14 Mon Sep 17 00:00:00 2001 From: Vlada Dusek Date: Tue, 14 Jul 2026 20:50:08 +0200 Subject: [PATCH 21/30] test: Align compressor tests with renamed param and terminology --- tests/unit/test_http_clients.py | 18 +++++++++--------- tests/unit/test_http_compressors.py | 23 +++++++---------------- tests/unit/test_run_charge.py | 14 +++++++------- 3 files changed, 23 insertions(+), 32 deletions(-) diff --git a/tests/unit/test_http_clients.py b/tests/unit/test_http_clients.py index 519fdcff..71a018f8 100644 --- a/tests/unit/test_http_clients.py +++ b/tests/unit/test_http_clients.py @@ -286,7 +286,7 @@ def test_prepare_request_call_basic() -> None: def test_prepare_request_call_with_json(compressor_case: tuple) -> None: """Test _prepare_request_call with JSON data.""" compressor, content_encoding, decompress = compressor_case - client = _ConcreteHttpClient(compressor=compressor) + client = _ConcreteHttpClient(http_compressor=compressor) json_data = {'key': 'value', 'number': 42} headers, _params, data = client._prepare_request_call(json=json_data) @@ -301,7 +301,7 @@ def test_prepare_request_call_with_json(compressor_case: tuple) -> None: def test_prepare_request_call_with_empty_dict_json(compressor_case: tuple) -> None: """Test _prepare_request_call with empty dict JSON (falsy but valid).""" compressor, content_encoding, decompress = compressor_case - client = _ConcreteHttpClient(compressor=compressor) + client = _ConcreteHttpClient(http_compressor=compressor) headers, _params, data = client._prepare_request_call(json={}) @@ -315,7 +315,7 @@ def test_prepare_request_call_with_empty_dict_json(compressor_case: tuple) -> No def test_prepare_request_call_with_empty_list_json(compressor_case: tuple) -> None: """Test _prepare_request_call with empty list JSON (falsy but valid).""" compressor, content_encoding, decompress = compressor_case - client = _ConcreteHttpClient(compressor=compressor) + client = _ConcreteHttpClient(http_compressor=compressor) headers, _params, data = client._prepare_request_call(json=[]) @@ -329,7 +329,7 @@ def test_prepare_request_call_with_empty_list_json(compressor_case: tuple) -> No def test_prepare_request_call_with_zero_json(compressor_case: tuple) -> None: """Test _prepare_request_call with zero JSON (falsy but valid).""" compressor, content_encoding, decompress = compressor_case - client = _ConcreteHttpClient(compressor=compressor) + client = _ConcreteHttpClient(http_compressor=compressor) headers, _params, data = client._prepare_request_call(json=0) @@ -343,7 +343,7 @@ def test_prepare_request_call_with_zero_json(compressor_case: tuple) -> None: def test_prepare_request_call_with_false_json(compressor_case: tuple) -> None: """Test _prepare_request_call with False JSON (falsy but valid).""" compressor, content_encoding, decompress = compressor_case - client = _ConcreteHttpClient(compressor=compressor) + client = _ConcreteHttpClient(http_compressor=compressor) headers, _params, data = client._prepare_request_call(json=False) @@ -357,7 +357,7 @@ def test_prepare_request_call_with_false_json(compressor_case: tuple) -> None: def test_prepare_request_call_with_empty_string_json(compressor_case: tuple) -> None: """Test _prepare_request_call with empty string JSON (falsy but valid).""" compressor, content_encoding, decompress = compressor_case - client = _ConcreteHttpClient(compressor=compressor) + client = _ConcreteHttpClient(http_compressor=compressor) headers, _params, data = client._prepare_request_call(json='') @@ -371,7 +371,7 @@ def test_prepare_request_call_with_empty_string_json(compressor_case: tuple) -> def test_prepare_request_call_with_string_data(compressor_case: tuple) -> None: """Test _prepare_request_call with string data.""" compressor, content_encoding, decompress = compressor_case - client = _ConcreteHttpClient(compressor=compressor) + client = _ConcreteHttpClient(http_compressor=compressor) headers, _params, data = client._prepare_request_call(data='test string') @@ -383,7 +383,7 @@ def test_prepare_request_call_with_string_data(compressor_case: tuple) -> None: def test_prepare_request_call_with_bytes_data(compressor_case: tuple) -> None: """Test _prepare_request_call with bytes data.""" compressor, content_encoding, decompress = compressor_case - client = _ConcreteHttpClient(compressor=compressor) + client = _ConcreteHttpClient(http_compressor=compressor) headers, _params, data = client._prepare_request_call(data=b'test bytes') @@ -395,7 +395,7 @@ def test_prepare_request_call_with_bytes_data(compressor_case: tuple) -> None: def test_prepare_request_call_with_bytearray_data(compressor_case: tuple) -> None: """Test _prepare_request_call with bytearray data (regression: must compress without error).""" compressor, content_encoding, decompress = compressor_case - client = _ConcreteHttpClient(compressor=compressor) + client = _ConcreteHttpClient(http_compressor=compressor) headers, _params, data = client._prepare_request_call(data=bytearray(b'test bytearray')) diff --git a/tests/unit/test_http_compressors.py b/tests/unit/test_http_compressors.py index fa5a1de6..b3cfad19 100644 --- a/tests/unit/test_http_compressors.py +++ b/tests/unit/test_http_compressors.py @@ -9,8 +9,8 @@ import brotli import pytest -from apify_client._apify_client import _resolve_compressor from apify_client.http_compressors import BrotliHttpCompressor, GzipHttpCompressor +from apify_client.http_compressors._resolve import resolve_compressor if TYPE_CHECKING: from collections.abc import Iterator @@ -48,9 +48,6 @@ def _affected(name: str) -> bool: sys.modules.update(saved) -# Round-trip and content encoding - - @pytest.mark.parametrize('quality', [1, 9]) def test_gzip_compressor_round_trips_at_quality(quality: int) -> None: """Gzip compressor round-trips data at the boundary quality levels.""" @@ -77,32 +74,26 @@ def test_brotli_compressor_round_trips_and_sets_content_encoding() -> None: assert brotli.decompress(compressor.compress(b'hello world')) == b'hello world' -# Compressor resolution - - def test_resolve_compressor_gzip() -> None: """The `'gzip'` literal resolves to a `GzipHttpCompressor`.""" - assert isinstance(_resolve_compressor('gzip'), GzipHttpCompressor) + assert isinstance(resolve_compressor('gzip'), GzipHttpCompressor) def test_resolve_compressor_brotli() -> None: """The `'brotli'` literal resolves to a `BrotliHttpCompressor`.""" - assert isinstance(_resolve_compressor('brotli'), BrotliHttpCompressor) + assert isinstance(resolve_compressor('brotli'), BrotliHttpCompressor) def test_resolve_compressor_passes_through_instance() -> None: """An `HttpCompressor` instance is returned unchanged.""" compressor = BrotliHttpCompressor(quality=11) - assert _resolve_compressor(compressor) is compressor + assert resolve_compressor(compressor) is compressor def test_resolve_compressor_rejects_unknown_algorithm() -> None: """An unrecognized encoding raises `ValueError`.""" with pytest.raises(ValueError, match='Unsupported compression algorithm'): - _resolve_compressor('deflate') # ty: ignore[invalid-argument-type] - - -# Optional brotli extra + resolve_compressor('deflate') # ty: ignore[invalid-argument-type] def test_brotli_import_hook_raises_clear_error_when_extra_missing() -> None: @@ -114,6 +105,6 @@ 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 `encoding='brotli'` without the extra raises `ImportError` at resolution time.""" + """Resolving `compression='brotli'` without the extra raises `ImportError` at resolution time.""" with _brotli_unavailable(), pytest.raises(ImportError): - _resolve_compressor('brotli') + resolve_compressor('brotli') diff --git a/tests/unit/test_run_charge.py b/tests/unit/test_run_charge.py index 1dd7e105..5d0b75c0 100644 --- a/tests/unit/test_run_charge.py +++ b/tests/unit/test_run_charge.py @@ -29,7 +29,7 @@ def _decode_body(request: Request) -> dict: return json.loads(raw) -@pytest.mark.parametrize('encoding', ['gzip', 'brotli']) +@pytest.mark.parametrize('compression', ['gzip', 'brotli']) @pytest.mark.parametrize( 'count', [0, 1, 5], @@ -37,9 +37,9 @@ def _decode_body(request: Request) -> dict: def test_run_charge_preserves_count_sync( httpserver: HTTPServer, count: int, - encoding: HttpCompressionAlgorithm, + compression: HttpCompressionAlgorithm, ) -> None: - """Ensure `count` is sent as-is (in particular, `0` is preserved), regardless of the request-body encoding.""" + """Ensure `count` is sent as-is (in particular, `0` is preserved), regardless of the request-body compression.""" captured_requests: list[Request] = [] def capture_request(request: Request) -> Response: @@ -49,7 +49,7 @@ def capture_request(request: Request) -> Response: httpserver.expect_request(_CHARGE_PATH, method='POST').respond_with_handler(capture_request) api_url = httpserver.url_for('/').removesuffix('/') - client = ApifyClient(token='test_token', api_url=api_url, encoding=encoding) + client = ApifyClient(token='test_token', api_url=api_url, compression=compression) client.run(_MOCKED_RUN_ID).charge('test-event', count=count) @@ -58,7 +58,7 @@ def capture_request(request: Request) -> Response: assert body['count'] == count -@pytest.mark.parametrize('encoding', ['gzip', 'brotli']) +@pytest.mark.parametrize('compression', ['gzip', 'brotli']) @pytest.mark.parametrize( 'count', [0, 1, 5], @@ -66,7 +66,7 @@ def capture_request(request: Request) -> Response: async def test_run_charge_preserves_count_async( httpserver: HTTPServer, count: int, - encoding: HttpCompressionAlgorithm, + compression: HttpCompressionAlgorithm, ) -> None: """Async variant of `test_run_charge_preserves_count_sync`.""" captured_requests: list[Request] = [] @@ -78,7 +78,7 @@ def capture_request(request: Request) -> Response: httpserver.expect_request(_CHARGE_PATH, method='POST').respond_with_handler(capture_request) api_url = httpserver.url_for('/').removesuffix('/') - client = ApifyClientAsync(token='test_token', api_url=api_url, encoding=encoding) + client = ApifyClientAsync(token='test_token', api_url=api_url, compression=compression) await client.run(_MOCKED_RUN_ID).charge('test-event', count=count) From 1feee711d9b25e9b5c194218dee74d43ccdcadd4 Mon Sep 17 00:00:00 2001 From: Vlada Dusek Date: Tue, 14 Jul 2026 20:50:23 +0200 Subject: [PATCH 22/30] docs: Rename compression page to HTTP compression and trim install docs --- README.md | 18 ++------------- docs/01_introduction/index.mdx | 4 +++- ...ompression.mdx => 13_http_compression.mdx} | 22 +++++++++---------- 3 files changed, 16 insertions(+), 28 deletions(-) rename docs/02_concepts/{13_compression.mdx => 13_http_compression.mdx} (73%) diff --git a/README.md b/README.md index 6534d3ea..17efeb24 100644 --- a/README.md +++ b/README.md @@ -48,8 +48,8 @@ or any other Python package manager that consumes PyPI. - The client compresses request bodies with gzip by default (no extra dependencies required). To opt in to brotli - (better compression ratio), install the optional extra and pass `encoding='brotli'`: + The client compresses request bodies with `gzip` by default (no extra dependencies required). To opt in to + `brotli` (better compression ratio), install the optional extra and pass `compression='brotli'`: ```bash pip install "apify-client[brotli]" @@ -57,20 +57,6 @@ uv add "apify-client[brotli]" ``` - ```python - client = ApifyClient(token='MY-APIFY-TOKEN', encoding='brotli') - ``` - - For fine-grained control over compression quality, inject a compressor instance directly: - - ```python - from apify_client.http_compressors import BrotliHttpCompressor, GzipHttpCompressor - - client = ApifyClient(token='MY-APIFY-TOKEN', encoding=BrotliHttpCompressor(quality=11)) - # or - client = ApifyClient(token='MY-APIFY-TOKEN', encoding=GzipHttpCompressor(quality=9)) - ``` - - From [conda-forge](https://anaconda.org/conda-forge/apify-client), it can be installed with [conda](https://docs.conda.io/en/latest/): ```bash diff --git a/docs/01_introduction/index.mdx b/docs/01_introduction/index.mdx index 957c0d58..1502ae8a 100644 --- a/docs/01_introduction/index.mdx +++ b/docs/01_introduction/index.mdx @@ -46,7 +46,7 @@ The Apify client is available as the `apify-client` package [on PyPI](https://py -To enable brotli request-body compression (better than gzip, especially for large payloads), install the optional extra and [configure the compression](../02_concepts/13_compression.mdx): +For better request-body compression, opt in to `brotli`, which compresses better than `gzip`, especially for large payloads. Install the optional extra: @@ -61,6 +61,8 @@ To enable brotli request-body compression (better than gzip, especially for larg +For details, see [HTTP compression](../02_concepts/13_http_compression.mdx). + ## Quick example The following example shows how to run an Actor and retrieve its results: diff --git a/docs/02_concepts/13_compression.mdx b/docs/02_concepts/13_http_compression.mdx similarity index 73% rename from docs/02_concepts/13_compression.mdx rename to docs/02_concepts/13_http_compression.mdx index 34af656f..30df4b2b 100644 --- a/docs/02_concepts/13_compression.mdx +++ b/docs/02_concepts/13_http_compression.mdx @@ -1,6 +1,6 @@ --- -id: compression -title: Request body compression +id: http-compression +title: HTTP compression description: The client compresses every request body automatically using gzip by default, with optional brotli via an explicit opt-in. --- @@ -8,11 +8,11 @@ The Apify client compresses every request body before sending it to the API. Thi ## How it works -The client compresses request bodies using the compressor configured via the `encoding` parameter (default `'gzip'`). The server supports both gzip and brotli and decompresses the request body transparently. +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. ## Configuration -Pass `encoding` to the client constructor to choose the compression algorithm: +Pass `compression` to the client constructor to choose the compression algorithm: ```python from apify_client import ApifyClient @@ -21,7 +21,7 @@ from apify_client import ApifyClient client = ApifyClient(token='MY-APIFY-TOKEN') # Opt in to brotli (requires apify-client[brotli]) -client = ApifyClient(token='MY-APIFY-TOKEN', encoding='brotli') +client = ApifyClient(token='MY-APIFY-TOKEN', compression='brotli') ``` ## Enabling brotli @@ -34,7 +34,7 @@ pip install "apify-client[brotli]" uv add "apify-client[brotli]" ``` -Then pass `encoding='brotli'` to the client constructor. If you request brotli without installing the extra, the client raises a clear `ImportError`. There is no silent fallback. +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. ## Custom quality and advanced control @@ -45,10 +45,10 @@ from apify_client import ApifyClient from apify_client.http_compressors import BrotliHttpCompressor, GzipHttpCompressor # Brotli at maximum quality -client = ApifyClient(token='MY-APIFY-TOKEN', encoding=BrotliHttpCompressor(quality=11)) +client = ApifyClient(token='MY-APIFY-TOKEN', compression=BrotliHttpCompressor(quality=11)) # Gzip at maximum quality -client = ApifyClient(token='MY-APIFY-TOKEN', encoding=GzipHttpCompressor(quality=9)) +client = ApifyClient(token='MY-APIFY-TOKEN', compression=GzipHttpCompressor(quality=9)) ``` You can also implement a fully custom compressor by subclassing `HttpCompressor`: @@ -74,7 +74,7 @@ class IdentityCompressor(HttpCompressor): return data -client = ApifyClient(token='MY-APIFY-TOKEN', encoding=IdentityCompressor()) +client = ApifyClient(token='MY-APIFY-TOKEN', compression=IdentityCompressor()) ``` ## Comparison @@ -87,9 +87,9 @@ client = ApifyClient(token='MY-APIFY-TOKEN', encoding=IdentityCompressor()) | **`Content-Encoding` header** | `br` | `gzip` | | **Quality range** | `0–11` | `1–9` | | **Default quality** | `6` | `9` | -| **Enable via config** | `encoding='brotli'` | `encoding='gzip'` (default) | +| **Enable via config** | `compression='brotli'` | `compression='gzip'` (default) | | **Best for** | Large payloads where bandwidth matters | Minimal-dependency environments | :::tip -For most workloads, the bandwidth savings from brotli outweigh the small CPU overhead. Install the `brotli` extra and pass `encoding='brotli'` unless you're running in an environment where installing additional packages isn't feasible. +For most workloads, the bandwidth savings from brotli outweigh the small CPU overhead. Install the `brotli` extra and pass `compression='brotli'` unless you're running in an environment where installing additional packages isn't feasible. ::: From 4674110c2c4c3eff82a7e1a9e9f7d0b45be62ce0 Mon Sep 17 00:00:00 2001 From: Vlada Dusek Date: Tue, 14 Jul 2026 20:50:32 +0200 Subject: [PATCH 23/30] build: Format brotli optional-dependency on a single line --- pyproject.toml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 5de5d7c6..a8fb2b51 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,9 +31,7 @@ dependencies = [ ] [project.optional-dependencies] -brotli = [ - "brotli>=1.0.9", -] +brotli = ["brotli>=1.0.9"] [project.urls] "Apify Homepage" = "https://apify.com" From ff4f21e08a62c23901ca64fb79dc459d5e743658 Mon Sep 17 00:00:00 2001 From: Vlada Dusek Date: Tue, 14 Jul 2026 23:54:44 +0200 Subject: [PATCH 24/30] feat: Validate compressor quality range and fail fast --- src/apify_client/http_compressors/_brotli.py | 17 ++++++++++++++--- src/apify_client/http_compressors/_gzip.py | 18 +++++++++++++++--- tests/unit/test_http_compressors.py | 14 ++++++++++++++ 3 files changed, 43 insertions(+), 6 deletions(-) diff --git a/src/apify_client/http_compressors/_brotli.py b/src/apify_client/http_compressors/_brotli.py index d853dd95..fcd1e78c 100644 --- a/src/apify_client/http_compressors/_brotli.py +++ b/src/apify_client/http_compressors/_brotli.py @@ -13,14 +13,25 @@ class BrotliHttpCompressor(HttpCompressor): content_encoding = 'br' + _min_quality = 0 + """Lowest valid quality (fastest, least compression).""" + + _max_quality = 11 + """Highest valid quality (slowest, best compression).""" + def __init__(self, *, quality: int = 6) -> None: """Initialize the brotli compressor. Args: - quality: Compression level, `0` (fastest) to `11` (the best compression). Defaults to `6`, - a reasonable compromise between speed and compression ratio. Passed straight to the - `brotli` module, which validates it. + quality: Compression level, from the fastest to the best compression. + + Raises: + ValueError: If `quality` is out of the valid range. """ + if not self._min_quality <= quality <= self._max_quality: + raise ValueError( + f'brotli quality must be between {self._min_quality} and {self._max_quality}, got {quality}.' + ) self._quality = quality def compress(self, data: bytes) -> bytes: diff --git a/src/apify_client/http_compressors/_gzip.py b/src/apify_client/http_compressors/_gzip.py index 60dfa980..d4a3c6f4 100644 --- a/src/apify_client/http_compressors/_gzip.py +++ b/src/apify_client/http_compressors/_gzip.py @@ -13,13 +13,25 @@ class GzipHttpCompressor(HttpCompressor): content_encoding = 'gzip' - def __init__(self, *, quality: int = 9) -> None: + _min_quality = 1 + """Lowest valid quality (fastest, least compression).""" + + _max_quality = 9 + """Highest valid quality (slowest, best compression).""" + + def __init__(self, *, quality: int = _max_quality) -> None: """Initialize the gzip compressor. Args: - quality: Compression level, `1` (fastest) to `9` (the best compression). Defaults to `9`. - Passed straight to the `gzip` module, which validates it. + quality: Compression level, from the fastest to the best compression. + + Raises: + ValueError: If `quality` is out of the valid range. """ + if not self._min_quality <= quality <= self._max_quality: + raise ValueError( + f'gzip quality must be between {self._min_quality} and {self._max_quality}, got {quality}.' + ) self._quality = quality def compress(self, data: bytes) -> bytes: diff --git a/tests/unit/test_http_compressors.py b/tests/unit/test_http_compressors.py index b3cfad19..f55bd4ac 100644 --- a/tests/unit/test_http_compressors.py +++ b/tests/unit/test_http_compressors.py @@ -60,6 +60,20 @@ def test_brotli_compressor_round_trips_at_quality(quality: int) -> None: assert brotli.decompress(BrotliHttpCompressor(quality=quality).compress(b'payload')) == b'payload' +@pytest.mark.parametrize('quality', [0, 10, -1]) +def test_gzip_compressor_rejects_out_of_range_quality(quality: int) -> None: + """Gzip compressor raises `ValueError` at construction for a quality not between `1` and `9`.""" + with pytest.raises(ValueError, match='gzip quality must be between 1 and 9'): + GzipHttpCompressor(quality=quality) + + +@pytest.mark.parametrize('quality', [-1, 12]) +def test_brotli_compressor_rejects_out_of_range_quality(quality: int) -> None: + """Brotli compressor raises `ValueError` at construction for a quality not between `0` and `11`.""" + with pytest.raises(ValueError, match='brotli quality must be between 0 and 11'): + BrotliHttpCompressor(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() From a90bb02969445941c742f34d22d2d648e31c9b1d Mon Sep 17 00:00:00 2001 From: Vlada Dusek Date: Tue, 14 Jul 2026 23:57:17 +0200 Subject: [PATCH 25/30] test: Use pytest.param with explicit ids in compression tests --- tests/unit/test_http_compressors.py | 33 +++++++++++++++++++++++++---- tests/unit/test_run_charge.py | 28 ++++++++++++++++++++---- 2 files changed, 53 insertions(+), 8 deletions(-) diff --git a/tests/unit/test_http_compressors.py b/tests/unit/test_http_compressors.py index f55bd4ac..de858f45 100644 --- a/tests/unit/test_http_compressors.py +++ b/tests/unit/test_http_compressors.py @@ -48,26 +48,51 @@ def _affected(name: str) -> bool: sys.modules.update(saved) -@pytest.mark.parametrize('quality', [1, 9]) +@pytest.mark.parametrize( + 'quality', + [ + pytest.param(1, id='fastest'), + pytest.param(9, id='best'), + ], +) def test_gzip_compressor_round_trips_at_quality(quality: int) -> None: """Gzip compressor round-trips data at the boundary quality levels.""" assert gzip.decompress(GzipHttpCompressor(quality=quality).compress(b'payload')) == b'payload' -@pytest.mark.parametrize('quality', [0, 11]) +@pytest.mark.parametrize( + 'quality', + [ + pytest.param(0, id='fastest'), + pytest.param(11, id='best'), + ], +) def test_brotli_compressor_round_trips_at_quality(quality: int) -> None: """Brotli compressor round-trips data at the boundary quality levels.""" assert brotli.decompress(BrotliHttpCompressor(quality=quality).compress(b'payload')) == b'payload' -@pytest.mark.parametrize('quality', [0, 10, -1]) +@pytest.mark.parametrize( + 'quality', + [ + pytest.param(0, id='below minimum'), + pytest.param(10, id='above maximum'), + pytest.param(-1, id='negative'), + ], +) def test_gzip_compressor_rejects_out_of_range_quality(quality: int) -> None: """Gzip compressor raises `ValueError` at construction for a quality not between `1` and `9`.""" with pytest.raises(ValueError, match='gzip quality must be between 1 and 9'): GzipHttpCompressor(quality=quality) -@pytest.mark.parametrize('quality', [-1, 12]) +@pytest.mark.parametrize( + 'quality', + [ + pytest.param(-1, id='negative'), + pytest.param(12, id='above maximum'), + ], +) def test_brotli_compressor_rejects_out_of_range_quality(quality: int) -> None: """Brotli compressor raises `ValueError` at construction for a quality not between `0` and `11`.""" with pytest.raises(ValueError, match='brotli quality must be between 0 and 11'): diff --git a/tests/unit/test_run_charge.py b/tests/unit/test_run_charge.py index 5d0b75c0..aa176734 100644 --- a/tests/unit/test_run_charge.py +++ b/tests/unit/test_run_charge.py @@ -29,10 +29,20 @@ def _decode_body(request: Request) -> dict: return json.loads(raw) -@pytest.mark.parametrize('compression', ['gzip', 'brotli']) +@pytest.mark.parametrize( + 'compression', + [ + pytest.param('gzip', id='gzip'), + pytest.param('brotli', id='brotli'), + ], +) @pytest.mark.parametrize( 'count', - [0, 1, 5], + [ + pytest.param(0, id='zero'), + pytest.param(1, id='one'), + pytest.param(5, id='five'), + ], ) def test_run_charge_preserves_count_sync( httpserver: HTTPServer, @@ -58,10 +68,20 @@ def capture_request(request: Request) -> Response: assert body['count'] == count -@pytest.mark.parametrize('compression', ['gzip', 'brotli']) +@pytest.mark.parametrize( + 'compression', + [ + pytest.param('gzip', id='gzip'), + pytest.param('brotli', id='brotli'), + ], +) @pytest.mark.parametrize( 'count', - [0, 1, 5], + [ + pytest.param(0, id='zero'), + pytest.param(1, id='one'), + pytest.param(5, id='five'), + ], ) async def test_run_charge_preserves_count_async( httpserver: HTTPServer, From 72c4e74a91f63b10611602d08be7aec26f74b87c Mon Sep 17 00:00:00 2001 From: Vlada Dusek Date: Wed, 15 Jul 2026 09:50:01 +0200 Subject: [PATCH 26/30] docs: Apply review suggestions to HTTP compression page --- docs/02_concepts/13_http_compression.mdx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/02_concepts/13_http_compression.mdx b/docs/02_concepts/13_http_compression.mdx index 30df4b2b..3073ed3c 100644 --- a/docs/02_concepts/13_http_compression.mdx +++ b/docs/02_concepts/13_http_compression.mdx @@ -4,7 +4,7 @@ title: HTTP compression description: The client compresses every request body automatically using gzip by default, with optional brotli via an explicit opt-in. --- -The Apify client compresses every request body before sending it to the API. This reduces the amount of data transferred over the network, resulting in faster requests and lower bandwidth usage, especially for large payloads such as Actor inputs, dataset uploads, or key-value store records. +The Apify client compresses every request body before sending it to the API. It reduces the amount of data transferred over the network, resulting in faster requests and lower bandwidth usage, especially for large payloads such as Actor inputs, dataset uploads, or key-value store records. ## How it works @@ -12,7 +12,7 @@ The client compresses request bodies using the compressor configured via the `co ## Configuration -Pass `compression` to the client constructor to choose the compression algorithm: +To choose the compression algorithm, pass `compression` to the client constructor: ```python from apify_client import ApifyClient @@ -91,5 +91,5 @@ client = ApifyClient(token='MY-APIFY-TOKEN', compression=IdentityCompressor()) | **Best for** | Large payloads where bandwidth matters | Minimal-dependency environments | :::tip -For most workloads, the bandwidth savings from brotli outweigh the small CPU overhead. Install the `brotli` extra and pass `compression='brotli'` unless you're running in an environment where installing additional packages isn't feasible. +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 dfbaca06f11c11be8ca96a294ada5057060ddb99 Mon Sep 17 00:00:00 2001 From: Vlada Dusek Date: Wed, 15 Jul 2026 10:07:16 +0200 Subject: [PATCH 27/30] refactor: Validate compression eagerly at client construction --- src/apify_client/_apify_client.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/apify_client/_apify_client.py b/src/apify_client/_apify_client.py index b880cfb4..2c5595a7 100644 --- a/src/apify_client/_apify_client.py +++ b/src/apify_client/_apify_client.py @@ -212,7 +212,7 @@ def __init__( self._timeout_long = timeout_long self._timeout_max = timeout_max self._headers = headers - self._compression = compression + self._http_compressor = resolve_compressor(compression) @classmethod def with_custom_http_client( @@ -278,7 +278,7 @@ def http_client(self) -> HttpClient: min_delay_between_retries=self._min_delay_between_retries, statistics=self._statistics, headers=self._headers, - http_compressor=resolve_compressor(self._compression), + http_compressor=self._http_compressor, ) return self._http_client @@ -571,7 +571,7 @@ def __init__( self._timeout_long = timeout_long self._timeout_max = timeout_max self._headers = headers - self._compression = compression + self._http_compressor = resolve_compressor(compression) @classmethod def with_custom_http_client( @@ -637,7 +637,7 @@ def http_client(self) -> HttpClientAsync: min_delay_between_retries=self._min_delay_between_retries, statistics=self._statistics, headers=self._headers, - http_compressor=resolve_compressor(self._compression), + http_compressor=self._http_compressor, ) return self._http_client From 6596cef6691e47efe4502981f0dc844bacbb4be2 Mon Sep 17 00:00:00 2001 From: Vlada Dusek Date: Wed, 15 Jul 2026 10:07:18 +0200 Subject: [PATCH 28/30] refactor: Align try_import with crawlee-python --- src/apify_client/_utils/try_import.py | 72 ++++++++++----------------- 1 file changed, 27 insertions(+), 45 deletions(-) diff --git a/src/apify_client/_utils/try_import.py b/src/apify_client/_utils/try_import.py index f9d96997..9cc29deb 100644 --- a/src/apify_client/_utils/try_import.py +++ b/src/apify_client/_utils/try_import.py @@ -1,62 +1,44 @@ -from __future__ import annotations - import sys +from collections.abc import Iterator from contextlib import contextmanager from dataclasses import dataclass from types import ModuleType -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from collections.abc import Generator - - -@dataclass -class _FailedImport: - message: str - - -class _ImportWrapper(ModuleType): - """Module subclass that converts `_FailedImport` attribute accesses into `ImportError`. - - Must override `__getattribute__` (not `__getattr__`): the `_FailedImport` placeholder lives in - the module's `__dict__`, so a normal lookup would find it and `__getattr__` (the fallback for - missing attributes) would never fire. - """ - - def __getattribute__(self, name: str) -> object: - obj = super().__getattribute__(name) - if isinstance(obj, _FailedImport): - raise ImportError(obj.message) # noqa: TRY004 - return obj +from typing import Any @contextmanager -def try_import(module_name: str, *symbol_names: str) -> Generator[None, None, None]: - """Context manager for optional imports. +def try_import(module_name: str, *symbol_names: str) -> Iterator[None]: + """Context manager to attempt importing symbols into a module. - If the import inside the block raises `ImportError`, each named symbol in the given module is - replaced with a `_FailedImport` placeholder. Accessing that placeholder later (after - `install_import_hook` has been called) raises a clear `ImportError` with the original message. - - Args: - module_name: Fully-qualified name of the module whose namespace to update (pass `__name__`). - symbol_names: The names that would have been imported, used as the placeholder keys. + If an `ImportError` is raised during the import, the symbol will be replaced with a `FailedImport` object. """ try: yield - except ImportError as exc: + except ImportError as e: for symbol_name in symbol_names: - setattr(sys.modules[module_name], symbol_name, _FailedImport(str(exc))) + setattr(sys.modules[module_name], symbol_name, FailedImport(e.args[0])) def install_import_hook(module_name: str) -> None: - """Replace a module's class with `_ImportWrapper` to activate deferred `ImportError` raising. + """Install an import hook for a specified module.""" + sys.modules[module_name].__class__ = ImportWrapper - Call this in the package `__init__.py` alongside the `try_import` blocks (see `http_compressors`). - The placeholder is resolved lazily on attribute access, so the relative order of this call and - the `try_import` blocks does not matter. - Args: - module_name: Fully-qualified name of the module to wrap (pass `__name__`). - """ - sys.modules[module_name].__class__ = _ImportWrapper +@dataclass +class FailedImport: + """Represent a placeholder for a failed import.""" + + message: str + """The error message associated with the failed import.""" + + +class ImportWrapper(ModuleType): + """A wrapper class for modules to handle attribute access for failed imports.""" + + def __getattribute__(self, name: str) -> Any: + result = super().__getattribute__(name) + + if isinstance(result, FailedImport): + raise ImportError(result.message) # noqa: TRY004 + + return result From 273c09fe7b1e10cc4f7d055f85d296215f8e46d9 Mon Sep 17 00:00:00 2001 From: Vlada Dusek Date: Wed, 15 Jul 2026 10:16:10 +0200 Subject: [PATCH 29/30] style: Keep future annotations import in try_import --- src/apify_client/_utils/try_import.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/apify_client/_utils/try_import.py b/src/apify_client/_utils/try_import.py index 9cc29deb..450e63cf 100644 --- a/src/apify_client/_utils/try_import.py +++ b/src/apify_client/_utils/try_import.py @@ -1,9 +1,14 @@ +from __future__ import annotations + import sys -from collections.abc import Iterator from contextlib import contextmanager from dataclasses import dataclass from types import ModuleType -from typing import Any +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import Iterator + from typing import Any @contextmanager From c810cfa6534a8c5f470d22489e8da764afce44ec Mon Sep 17 00:00:00 2001 From: Vlada Dusek Date: Wed, 15 Jul 2026 10:16:12 +0200 Subject: [PATCH 30/30] test: Add missing future annotations import to unit tests --- tests/unit/conftest.py | 7 ++++++- tests/unit/test_http_clients.py | 9 +++++++-- tests/unit/test_statistics.py | 2 ++ 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index 64d73cd9..8ee3d0a8 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -1,9 +1,14 @@ -from collections.abc import Iterable +from __future__ import annotations + from logging import getLogger +from typing import TYPE_CHECKING import pytest from pytest_httpserver import HTTPServer +if TYPE_CHECKING: + from collections.abc import Iterable + @pytest.fixture(scope='session') def make_httpserver() -> Iterable[HTTPServer]: diff --git a/tests/unit/test_http_clients.py b/tests/unit/test_http_clients.py index 71a018f8..e748d647 100644 --- a/tests/unit/test_http_clients.py +++ b/tests/unit/test_http_clients.py @@ -1,8 +1,9 @@ +from __future__ import annotations + import gzip import time -from collections.abc import Callable from datetime import UTC, datetime, timedelta -from typing import Any +from typing import TYPE_CHECKING from unittest.mock import Mock import brotli @@ -16,6 +17,10 @@ from apify_client.http_compressors._brotli import BrotliHttpCompressor from apify_client.http_compressors._gzip import GzipHttpCompressor +if TYPE_CHECKING: + from collections.abc import Callable + from typing import Any + class _ConcreteHttpClient(HttpClient): """Minimal concrete HttpClient for testing base class helpers.""" diff --git a/tests/unit/test_statistics.py b/tests/unit/test_statistics.py index 69fbc3c4..37634a7b 100644 --- a/tests/unit/test_statistics.py +++ b/tests/unit/test_statistics.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import pytest from apify_client._statistics import ClientStatistics