From 8bf3dbfc52037ac4251e1abb04903222821a2e2b Mon Sep 17 00:00:00 2001 From: Bilal Tonga Date: Thu, 22 Oct 2026 16:44:14 +0200 Subject: [PATCH 1/3] Add experimental asyncio client for CrateDB --- pyproject.toml | 17 +++++++++++++++- src/crate/client/_response.py | 1 + src/crate/client/aio/__init__.py | 35 ++++++++++++++++++++++++++++++++ 3 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 src/crate/client/aio/__init__.py diff --git a/pyproject.toml b/pyproject.toml index 13bacc48..1dcc3794 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -56,10 +56,13 @@ dependencies = [ [dependency-groups] dev = [ + "aiohttp>=3.12,<4", "certifi", "coverage", "mypy<2.4", "pytest<10", + "pytest-asyncio<2", + "pytest-timeout<3", "pytz", "ruff<0.17", ] @@ -102,9 +105,16 @@ install_types = true namespace_packages = true non_interactive = true +[[tool.mypy.overrides]] +module = ["crate.client.aio.*", "crate.client._tls"] +disallow_untyped_defs = true +disallow_incomplete_defs = true + [tool.pytest.ini_options] addopts = "-rA --verbosity=3 --doctest-modules" +asyncio_mode = "strict" +asyncio_default_fixture_loop_scope = "function" doctest_optionflags = "NORMALIZE_WHITESPACE ELLIPSIS" minversion = "2.0" testpaths = [ @@ -148,6 +158,11 @@ lint.select = [ "YTT", ] +lint.extend-select = [ + # flake8-async: blocking calls in async functions + "ASYNC", +] + lint.extend-ignore = [ # Unnecessary variable assignment before `return` statement "RET504", @@ -174,7 +189,7 @@ lint.per-file-ignores."tests/*" = [ "S310", # False positive; it's a https url "S311", # Standard pseudo-random generators are not suitable for cryptographic purposes ] -lint.per-file-ignores."src/crate/client/{connection.py,http.py,_pool.py,_response.py}" = [ +lint.per-file-ignores."src/crate/client/{connection.py,http.py,_pool.py,_response.py,aio/__init__.py,aio/_http.py}" = [ "A004", # Import `ConnectionError` is shadowing a Python builtin "A005", # Import `ConnectionError` is shadowing a Python builtin ] diff --git a/src/crate/client/_response.py b/src/crate/client/_response.py index b6dc6e95..b01dc550 100644 --- a/src/crate/client/_response.py +++ b/src/crate/client/_response.py @@ -32,6 +32,7 @@ ) SRV_UNAVAILABLE_STATUSES = {502, 503, 504, 509} +MAX_REDIRECTS = 10 PRESERVE_ACTIVE_SERVER_EXCEPTIONS = {ConnectionResetError, BrokenPipeError} diff --git a/src/crate/client/aio/__init__.py b/src/crate/client/aio/__init__.py new file mode 100644 index 00000000..30da9ed9 --- /dev/null +++ b/src/crate/client/aio/__init__.py @@ -0,0 +1,35 @@ +# -*- coding: utf-8; -*- +# +# Licensed to CRATE Technology GmbH ("Crate") under one or more contributor +# license agreements. See the NOTICE file distributed with this work for +# additional information regarding copyright ownership. Crate licenses +# this file to you under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. You may +# obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +# +# However, if you have executed another commercial license agreement +# with Crate these terms will supersede the license and you may use the +# software solely pursuant to the terms of the relevant commercial agreement. + +""" +Experimental asyncio client for CrateDB, over CrateDB's HTTP endpoint. + +It is not public yet: the API may change without notice until it is +documented. +""" + +try: + import aiohttp # noqa: F401 +except ImportError as ex: # pragma: no cover + raise ImportError( + "crate.client.aio requires aiohttp. " + 'Install it with: pip install "aiohttp>=3.12,<4"' + ) from ex From 55f628c5f0a0937f2ec0fedc4b599b9e149ed12d Mon Sep 17 00:00:00 2001 From: Bilal Tonga Date: Thu, 22 Oct 2026 17:11:31 +0200 Subject: [PATCH 2/3] Add workflow job to test CrateDB client without aiohttp dependency --- .github/workflows/tests.yml | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 3af3583c..1b5a29d3 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -82,3 +82,33 @@ jobs: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} with: fail_ci_if_error: true + + without-aiohttp: + name: "Without aiohttp" + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + + - name: Set up uv + uses: astral-sh/setup-uv@v7 + with: + python-version: "3.14" + activate-environment: true + version: "latest" + + - name: Setup env + run: uv pip install -e . + + - name: Check that the async client is optional + run: | + python -c "import crate.client" + python - <<'PYTHON' + try: + import crate.client.aio + except ImportError as ex: + assert "requires aiohttp" in str(ex), ex + else: + raise AssertionError("crate.client.aio imported without aiohttp") + PYTHON From 65768a00e7456ec4d489f5ba3e3154265ad70b67 Mon Sep 17 00:00:00 2001 From: Bilal Tonga Date: Thu, 22 Oct 2026 17:11:55 +0200 Subject: [PATCH 3/3] Add TLS utility functions for CA certificate resolution and integrate into HTTP client --- src/crate/client/_tls.py | 65 ++++++++++++++++++++++++++++++++++++++++ src/crate/client/http.py | 7 ++--- 2 files changed, 67 insertions(+), 5 deletions(-) create mode 100644 src/crate/client/_tls.py diff --git a/src/crate/client/_tls.py b/src/crate/client/_tls.py new file mode 100644 index 00000000..78b8fbfe --- /dev/null +++ b/src/crate/client/_tls.py @@ -0,0 +1,65 @@ +# -*- coding: utf-8; -*- +# +# Licensed to CRATE Technology GmbH ("Crate") under one or more contributor +# license agreements. See the NOTICE file distributed with this work for +# additional information regarding copyright ownership. Crate licenses +# this file to you under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. You may +# obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +# +# However, if you have executed another commercial license agreement +# with Crate these terms will supersede the license and you may use the +# software solely pursuant to the terms of the relevant commercial agreement. + +import os +import ssl +import typing as t + +from urllib3.util.ssl_ import create_urllib3_context + + +def resolve_ca_cert(ca_cert: t.Optional[str]) -> t.Optional[str]: + """ + Return the CA bundle to use, falling back to ``REQUESTS_CA_BUNDLE``. + """ + ca_cert = ca_cert or os.environ.get("REQUESTS_CA_BUNDLE", None) + if ca_cert and not os.path.exists(ca_cert): + # Sanity check + raise IOError('CA bundle file "{}" does not exist.'.format(ca_cert)) + return ca_cert + + +def make_ssl_context( + verify_ssl_cert: bool = True, + ca_cert: t.Optional[str] = None, + cert_file: t.Optional[str] = None, + key_file: t.Optional[str] = None, + ssl_relax_minimum_version: bool = False, +) -> ssl.SSLContext: + """ + Build the `SSLContext` that urllib3 builds for the same options. + """ + context = create_urllib3_context( + cert_reqs=ssl.CERT_REQUIRED if verify_ssl_cert else ssl.CERT_NONE, + ssl_minimum_version=( + ssl.TLSVersion.MINIMUM_SUPPORTED + if ssl_relax_minimum_version + else None + ), + ) + ca_cert = resolve_ca_cert(ca_cert) + if ca_cert: + context.load_verify_locations(cafile=ca_cert) + else: + context.load_default_certs() + if cert_file: + context.load_cert_chain(cert_file, key_file) + return context diff --git a/src/crate/client/http.py b/src/crate/client/http.py index 4259628f..74f3cfeb 100644 --- a/src/crate/client/http.py +++ b/src/crate/client/http.py @@ -21,7 +21,6 @@ import logging -import os import ssl import threading import typing as t @@ -74,6 +73,7 @@ redirect_target, should_preserve_server, ) +from crate.client._tls import resolve_ca_cert from crate.client.exceptions import ( ConnectionError, DigestNotFoundException, @@ -194,10 +194,7 @@ def _pool_kw_args( timeout=None, pool_size=None, ): - ca_cert = ca_cert or os.environ.get("REQUESTS_CA_BUNDLE", None) - if ca_cert and not os.path.exists(ca_cert): - # Sanity check - raise IOError('CA bundle file "{}" does not exist.'.format(ca_cert)) + ca_cert = resolve_ca_cert(ca_cert) kw = { "ca_certs": ca_cert,