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/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..52782b62 --- /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. +::: diff --git a/pyproject.toml b/pyproject.toml index 6753ec8c..5de5d7c6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,6 +30,11 @@ dependencies = [ "pydantic[email]>=2.11.0", ] +[project.optional-dependencies] +brotli = [ + "brotli>=1.0.9", +] + [project.urls] "Apify Homepage" = "https://apify.com" "Homepage" = "https://docs.apify.com/api/client/python/" diff --git a/src/apify_client/http_clients/_base.py b/src/apify_client/http_clients/_base.py index 6f97f16e..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 @@ -23,10 +24,20 @@ 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 = functools.partial(brotli.compress, quality=6) + except ImportError: + pass + @docs_group('HTTP clients') @runtime_checkable @@ -203,13 +214,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` 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 +232,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..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 @@ -278,7 +279,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'] == 'br' assert data is not None assert isinstance(data, bytes) @@ -290,12 +291,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'] == 'br' assert data is not None assert isinstance(data, bytes) - # Verify the gzipped data contains the JSON - decompressed = gzip.decompress(data) - assert decompressed == b'{}' + assert brotli.decompress(data) == b'{}' def test_prepare_request_call_with_empty_list_json() -> None: @@ -305,12 +304,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'] == 'br' assert data is not None assert isinstance(data, bytes) - # Verify the gzipped data contains the JSON - decompressed = gzip.decompress(data) - assert decompressed == b'[]' + assert brotli.decompress(data) == b'[]' def test_prepare_request_call_with_zero_json() -> None: @@ -320,12 +317,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'] == 'br' 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 brotli.decompress(data) == b'0' def test_prepare_request_call_with_false_json() -> None: @@ -335,12 +330,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'] == 'br' 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 brotli.decompress(data) == b'false' def test_prepare_request_call_with_empty_string_json() -> None: @@ -350,12 +343,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'] == 'br' assert data is not None assert isinstance(data, bytes) - # Verify the gzipped data contains the JSON - decompressed = gzip.decompress(data) - assert decompressed == b'""' + assert brotli.decompress(data) == b'""' def test_prepare_request_call_with_string_data() -> None: @@ -364,7 +355,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'] == 'br' assert isinstance(data, bytes) @@ -374,10 +365,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'] == 'gzip' + 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 brotli.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() @@ -452,3 +453,27 @@ 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 brotli is installed, request body uses brotli (Content-Encoding: br).""" + 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..7e13b937 100644 --- a/tests/unit/test_run_charge.py +++ b/tests/unit/test_run_charge.py @@ -4,6 +4,7 @@ import json from typing import TYPE_CHECKING +import brotli import pytest from werkzeug import Request, Response @@ -18,7 +19,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 = brotli.decompress(raw) + elif encoding == 'gzip': raw = gzip.decompress(raw) return json.loads(raw) diff --git a/uv.lock b/uv.lock index ea6ec2b7..fb279a6b 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,11 @@ dependencies = [ { name = "pydantic", extra = ["email"] }, ] +[package.optional-dependencies] +brotli = [ + { name = "brotli" }, +] + [package.dev-dependencies] dev = [ { name = "black" }, @@ -73,11 +79,13 @@ dev = [ [package.metadata] requires-dist = [ + { 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" }, { name = "pydantic", extras = ["email"], specifier = ">=2.11.0" }, ] +provides-extras = ["brotli"] [package.metadata.requires-dev] dev = [ @@ -147,6 +155,54 @@ 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 = "certifi" version = "2026.6.17"