Files
600ef17378 refactor(driver-clients): add annotations to client, httpclient, asyncclient (part of #692) (#806)
* refactor(driver-clients): add type annotations to client, httpclient, asyncclient

* fix(driver): guard binary parameter binds in command() after rebase onto driver-utils annotations

* refactor(driver): drop type:ignore suppressions made unnecessary by #805

* regression tests, async command guard, baseline cleanup

---------

Co-authored-by: Joe S <[email protected]>
2026-06-22 15:32:05 -07:00

2119 lines
89 KiB
Python

from __future__ import annotations
import asyncio
import gzip
import inspect
import io
import json
import logging
import re
import ssl
import sys
import time
import uuid
import zlib
import zoneinfo
from base64 import b64encode
from collections.abc import Awaitable, Callable, Generator, Sequence
from datetime import timezone, tzinfo
from importlib import import_module
from importlib.metadata import version as dist_version
from typing import TYPE_CHECKING, Any, BinaryIO, cast
import aiohttp
import lz4.frame
import zstandard
if TYPE_CHECKING:
import pandas
import polars
import pyarrow
from clickhouse_connect import common
from clickhouse_connect.datatypes import dynamic as dynamic_module
from clickhouse_connect.datatypes.base import ClickHouseType
from clickhouse_connect.datatypes.registry import get_from_name
from clickhouse_connect.driver import httputil, options, tzutil
from clickhouse_connect.driver.asyncqueue import EOF_SENTINEL, AsyncSyncQueue
from clickhouse_connect.driver.binding import bind_query, quote_identifier, use_form_encoding
from clickhouse_connect.driver.client import Client, _apply_arrow_tz_policy
from clickhouse_connect.driver.common import StreamContext, coerce_bool, dict_copy
from clickhouse_connect.driver.compression import available_compression
from clickhouse_connect.driver.constants import CH_VERSION_WITH_PROTOCOL, PROTOCOL_VERSION_WITH_LOW_CARD
from clickhouse_connect.driver.ctypes import RespBuffCls
from clickhouse_connect.driver.exceptions import (
DatabaseError,
DataError,
OperationalError,
ProgrammingError,
error_code_from_header,
error_name_from_body,
)
from clickhouse_connect.driver.external import ExternalData
from clickhouse_connect.driver.insert import InsertContext
from clickhouse_connect.driver.models import ColumnDef, SettingDef
from clickhouse_connect.driver.options import check_arrow, check_numpy, check_pandas, check_polars
from clickhouse_connect.driver.query import (
QueryContext,
QueryResult,
TzMode,
TzSource,
arrow_buffer,
returns_empty_string_on_empty_body,
)
from clickhouse_connect.driver.streaming import StreamingFileAdapter, StreamingInsertSource, StreamingResponseSource
from clickhouse_connect.driver.summary import QuerySummary
from clickhouse_connect.driver.transform import NativeTransform
from clickhouse_connect.driver.types import Closable
logger = logging.getLogger(__name__)
columns_only_re = re.compile(r"LIMIT 0\s*$", re.IGNORECASE)
ex_header = "X-ClickHouse-Exception-Code"
auth_failed_ex_code = "516" # ClickHouse AUTHENTICATION_FAILED
ex_tag_header = "X-ClickHouse-Exception-Tag"
if "br" in available_compression:
import brotli
else:
brotli = None
def decompress_response(data: bytes, encoding: str | None) -> bytes:
"""Decompress response data based on Content-Encoding header."""
if not encoding or encoding == "identity":
return data
if encoding == "lz4":
lz4_decom = lz4.frame.LZ4FrameDecompressor()
return lz4_decom.decompress(data, len(data))
if encoding == "zstd":
zstd_decom = zstandard.ZstdDecompressor()
return zstd_decom.stream_reader(io.BytesIO(data)).read()
if encoding == "br":
if brotli is not None:
return brotli.decompress(data)
raise OperationalError("Brotli compression requested but not installed.")
if encoding == "gzip":
return gzip.decompress(data)
if encoding == "deflate":
return zlib.decompress(data)
raise OperationalError(f"Unsupported compression type: '{encoding}'. Supported compression: {', '.join(available_compression)}")
class BytesSource:
"""Wrapper to make bytes compatible with ResponseBuffer expectations."""
def __init__(self, data: bytes):
self.data = data
self.gen = self._make_generator()
def _make_generator(self):
yield self.data
def close(self):
"""No-op close method for compatibility."""
class _SessionLease:
"""An aiohttp.ClientSession with an in-flight request count, so close()
can wait for outstanding requests to drain before tearing down the session."""
__slots__ = ("session", "_inflight", "_drained")
def __init__(self, session: aiohttp.ClientSession):
self.session = session
self._inflight = 0
self._drained = asyncio.Event()
self._drained.set()
def acquire(self) -> None:
self._inflight += 1
if self._inflight == 1:
self._drained.clear()
def release(self) -> None:
self._inflight -= 1
if self._inflight == 0:
self._drained.set()
async def wait_drained(self) -> None:
await self._drained.wait()
def _one_shot(fn: Callable[[], None]) -> Callable[[], None]:
"""Returns a wrapper that invokes fn at most once."""
fired = False
def call():
nonlocal fired
if not fired:
fired = True
fn()
return call
def _release_lease(response: aiohttp.ClientResponse | None) -> None:
if response is None:
return
release = getattr(response, "_lease_release", None)
if release is not None:
release()
_REMOTE_CLOSE_ERRORS = (ConnectionResetError, BrokenPipeError)
def _is_retryable_async_connection_error(error: aiohttp.ClientConnectionError) -> bool:
if isinstance(error, (aiohttp.ServerTimeoutError, aiohttp.ClientConnectorError, aiohttp.ServerFingerprintMismatch)):
return False
if isinstance(error, aiohttp.ServerDisconnectedError):
return True
if isinstance(error, _REMOTE_CLOSE_ERRORS):
return True
if isinstance(error.__cause__, _REMOTE_CLOSE_ERRORS):
return True
return isinstance(error.__context__, _REMOTE_CLOSE_ERRORS)
class AsyncClient(Client):
valid_transport_settings = {
"database",
"buffer_size",
"session_id",
"compress",
"decompress",
"session_timeout",
"session_check",
"query_id",
"quota_key",
"wait_end_of_query",
"client_protocol_version",
"role",
}
optional_transport_settings = {
"send_progress_in_http_headers",
"http_headers_progress_interval_ms",
"enable_http_compression",
}
def __init__(
self,
interface: str,
host: str,
port: int,
username: str | None = None,
password: str | None = None,
database: str | None = None,
access_token: str | None = None,
token_provider: Callable[[], str | Awaitable[str]] | None = None,
compress: bool | str = True,
connect_timeout: int = 10,
send_receive_timeout: int = 300,
client_name: str | None = None,
verify: bool | str = True,
ca_cert: str | None = None,
client_cert: str | None = None,
client_cert_key: str | None = None,
http_proxy: str | None = None,
https_proxy: str | None = None,
server_host_name: str | None = None,
tls_mode: str | None = None,
proxy_path: str = "",
connector_limit: int = 100,
connector_limit_per_host: int = 20,
keepalive_timeout: float = 30.0,
session_id: str | None = None,
settings: dict[str, Any] | None = None,
query_limit: int = 0,
query_retries: int = 2,
tz_source: TzSource | None = None,
tz_mode: TzMode | None = None,
show_clickhouse_errors: bool | None = None,
autogenerate_session_id: bool | None = None,
autogenerate_query_id: bool | None = None,
form_encode_query_params: bool = False,
rename_response_column: str | None = None,
headers: dict[str, str] | None = None,
):
"""
Async HTTP Client using aiohttp. Initialization is handled via _initialize().
"""
proxy_path = proxy_path.lstrip("/")
if proxy_path:
proxy_path = "/" + proxy_path
self.uri = f"{interface}://{host}:{port}{proxy_path}"
self.url = self.uri
self.form_encode_query_params = form_encode_query_params
self._rename_response_column = rename_response_column
self._initial_settings = settings
self.headers = {}
if interface == "https":
if isinstance(verify, str) and verify.lower() == "proxy":
verify = True
tls_mode = tls_mode or "proxy"
self._token_provider = token_provider # initial token is resolved in _initialize()
# Priority: access_token > mutual TLS > basic auth
if client_cert and (tls_mode is None or tls_mode == "mutual"):
if not username:
raise ProgrammingError("username parameter is required for Mutual TLS authentication")
self.headers["X-ClickHouse-User"] = username
self.headers["X-ClickHouse-SSL-Certificate-Auth"] = "on"
elif access_token:
self.headers["Authorization"] = f"Bearer {access_token}"
elif username and (not client_cert or tls_mode in ("strict", "proxy")):
credentials = b64encode(f"{username}:{password}".encode()).decode()
self.headers["Authorization"] = f"Basic {credentials}"
self.headers["User-Agent"] = common.build_client_name(client_name)
# Prevent aiohttp from automatically requesting compressed responses
# We'll manually set Accept-Encoding when compression is desired
self.headers["Accept-Encoding"] = "identity"
self._send_receive_timeout = send_receive_timeout
connect_timeout_val = float(connect_timeout) if connect_timeout is not None else None
send_receive_timeout_val = float(send_receive_timeout) if send_receive_timeout is not None else None
self._timeout = aiohttp.ClientTimeout(
total=None,
connect=connect_timeout_val,
sock_connect=connect_timeout_val,
sock_read=send_receive_timeout_val,
)
connector_limit_per_host = min(connector_limit_per_host, connector_limit)
proxy_url = None
if http_proxy:
if not http_proxy.startswith("http://") and not http_proxy.startswith("https://"):
proxy_url = f"http://{http_proxy}"
else:
proxy_url = http_proxy
elif https_proxy:
if not https_proxy.startswith("http://") and not https_proxy.startswith("https://"):
proxy_url = f"http://{https_proxy}"
else:
proxy_url = https_proxy
else:
scheme = "https" if self.url.startswith("https://") else "http"
env_proxy = httputil.check_env_proxy(scheme, host, port)
if env_proxy:
if not env_proxy.startswith("http://") and not env_proxy.startswith("https://"):
proxy_url = f"http://{env_proxy}"
else:
proxy_url = env_proxy
ssl_context = None
if interface == "https":
ssl_context = ssl.create_default_context()
ssl_verify = verify if isinstance(verify, bool) else coerce_bool(verify)
if not ssl_verify:
ssl_context.check_hostname = False
ssl_context.verify_mode = ssl.CERT_NONE
elif ca_cert:
ssl_context.load_verify_locations(httputil.resolve_ca_cert(ca_cert))
if client_cert:
ssl_context.load_cert_chain(client_cert, client_cert_key)
self._ssl_context = ssl_context
self._proxy_url = proxy_url
self._connector_kwargs = {
"limit": connector_limit,
"limit_per_host": connector_limit_per_host,
"keepalive_timeout": keepalive_timeout,
"force_close": False,
"ssl": ssl_context,
}
# enable_cleanup_closed is only needed for Python < 3.12.7 or == 3.13.0
# The underlying SSL connection leak was fixed in 3.12.7 and 3.13.1+
# https://github.com/python/cpython/pull/118960
if sys.version_info < (3, 12, 7) or sys.version_info[:3] == (3, 13, 0):
self._connector_kwargs["enable_cleanup_closed"] = True
self._session_lease: _SessionLease | None = None
self._session_lock = asyncio.Lock()
self._read_format = "Native"
self._write_format = "Native"
self._transform = NativeTransform()
self._client_settings: dict[str, str] = {}
self._initialized = False
self._reported_libs: set[str] = set()
self._last_pool_reset: float | None = None
self.headers["User-Agent"] = self.headers["User-Agent"].replace("mode:sync;", "mode:async;")
if headers:
self.headers.update(headers)
# Store aiohttp-specific params for deferred initialization
self._compress_param = compress
self._session_id_param = session_id
self._autogenerate_session_id_param = autogenerate_session_id
self._autogenerate_query_id = (
common.get_setting("autogenerate_query_id") if autogenerate_query_id is None else autogenerate_query_id
)
self._active_session = None
self._send_progress = None
self._progress_interval = None
# Call parent init with autoconnect=False to set up config without blocking I/O
super().__init__(
database=database,
query_limit=query_limit,
uri=self.uri,
query_retries=query_retries,
server_host_name=server_host_name,
tz_source=tz_source,
tz_mode=tz_mode,
show_clickhouse_errors=show_clickhouse_errors,
autoconnect=False,
)
@property
def _session(self) -> aiohttp.ClientSession | None:
lease = self._session_lease
return lease.session if lease is not None else None
@_session.setter
def _session(self, value: aiohttp.ClientSession | None) -> None:
self._session_lease = _SessionLease(value) if value is not None else None
async def _initialize(self):
"""
Async equivalent of Client._init_common_settings.
Fetches server version, timezone, and settings.
"""
if not self._session:
connector = aiohttp.TCPConnector(**self._connector_kwargs)
self._session = aiohttp.ClientSession(
connector=connector,
timeout=self._timeout,
headers=self.headers,
trust_env=False,
auto_decompress=False,
skip_auto_headers={"Accept-Encoding"},
)
if self._initialized:
return
if self._token_provider:
self.set_access_token(await self._resolve_token())
try:
tz_source = self._deferred_tz_source
self.server_tz, self._dst_safe = timezone.utc, True
row = await self.command("SELECT version(), timezone()", use_database=False)
self.server_version, server_tz_str = tuple(row)
try:
server_tz = tzutil.resolve_zone(server_tz_str)
server_tz, self._dst_safe = tzutil.normalize_timezone(server_tz, trust_fixed_offset=True)
self.server_tz = server_tz
except zoneinfo.ZoneInfoNotFoundError:
logger.warning(
"Server timezone %s could not be resolved, falling back to UTC; %s",
server_tz_str,
tzutil.TZDATA_HINT,
)
if tz_source == "auto":
self._apply_server_tz = self._dst_safe
else:
self._apply_server_tz = tz_source == "server"
if not self._apply_server_tz and not tzutil.local_tz_dst_safe:
logger.warning("local timezone %s may return unexpected times due to Daylight Savings Time", tzutil.local_tz.tzname(None))
readonly = "readonly"
if not self.min_version("19.17"):
readonly = common.get_setting("readonly")
server_settings = await self.query(f"SELECT name, value, {readonly} as readonly FROM system.settings LIMIT 10000")
self.server_settings = {row["name"]: SettingDef(**row) for row in server_settings.named_results()}
if self.min_version(CH_VERSION_WITH_PROTOCOL) and common.get_setting("use_protocol_version"):
try:
test_data = await self.raw_query(
"SELECT 1 AS check", fmt="Native", settings={"client_protocol_version": PROTOCOL_VERSION_WITH_LOW_CARD}
)
if test_data[8:16] == b"\x01\x01\x05check":
self.protocol_version = PROTOCOL_VERSION_WITH_LOW_CARD
except Exception:
pass
cancel_setting = self._setting_status("cancel_http_readonly_queries_on_client_close")
if (
cancel_setting.is_writable
and not cancel_setting.is_set
and "cancel_http_readonly_queries_on_client_close" not in (self._initial_settings or {})
):
self._client_settings["cancel_http_readonly_queries_on_client_close"] = "1"
if self._initial_settings:
for key, value in self._initial_settings.items():
self.set_client_setting(key, value)
compress = self._compress_param
if coerce_bool(compress):
compression = ",".join(available_compression)
self.write_compression = available_compression[0]
elif compress and compress not in ("False", "false", "0"):
if compress not in available_compression:
raise ProgrammingError(f"Unsupported compression method {compress}")
compression = compress
self.write_compression = compress
else:
compression = None
comp_setting = self._setting_status("enable_http_compression")
self._send_comp_setting = not comp_setting.is_set and comp_setting.is_writable
if comp_setting.is_set or comp_setting.is_writable:
self.compression = compression
session_id = self._session_id_param
autogenerate_session_id = self._autogenerate_session_id_param
if autogenerate_session_id is None:
autogenerate_session_id = common.get_setting("autogenerate_session_id")
if session_id:
self.set_client_setting("session_id", session_id)
elif self.get_client_setting("session_id"):
pass
elif autogenerate_session_id:
self.set_client_setting("session_id", str(uuid.uuid4()))
send_setting = self._setting_status("send_progress_in_http_headers")
self._send_progress = not send_setting.is_set and send_setting.is_writable
if (send_setting.is_set or send_setting.is_writable) and self._setting_status("http_headers_progress_interval_ms").is_writable:
self._progress_interval = str(min(120000, max(10000, (self._send_receive_timeout - 5) * 1000)))
if self._setting_status("date_time_input_format").is_writable:
self.set_client_setting("date_time_input_format", "best_effort")
if (
self._setting_status("allow_experimental_json_type").is_set
and self._setting_status("cast_string_to_dynamic_use_inference").is_writable
):
self.set_client_setting("cast_string_to_dynamic_use_inference", "1")
if self.min_version("24.8") and not self.min_version("24.10"):
dynamic_module.json_serialization_format = 0
self._initialized = True
except Exception:
if self._session and not self._session.closed:
await self._session.close()
self._session = None
raise
async def __aenter__(self):
"""Async context manager entry."""
if not self._initialized:
await self._initialize()
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
"""Async context manager exit."""
await self.close()
return False
async def close(self): # type: ignore[override]
async with self._session_lock:
old_lease = self._session_lease
self._session_lease = None
if old_lease is not None:
await old_lease.wait_drained()
await old_lease.session.close()
async def close_connections(self): # type: ignore[override]
"""Rotate the connection pool: new requests use a fresh session; in-flight
requests keep using the old session until they complete, then it's closed."""
async with self._session_lock:
old_lease = self._session_lease
connector = aiohttp.TCPConnector(**self._connector_kwargs)
new_session = aiohttp.ClientSession(
connector=connector,
timeout=self._timeout,
headers=self.headers,
trust_env=False,
auto_decompress=False,
skip_auto_headers={"Accept-Encoding"},
)
self._session_lease = _SessionLease(new_session)
if old_lease is not None:
await old_lease.wait_drained()
await old_lease.session.close()
def set_client_setting(self, key, value):
str_value = self._validate_setting(key, value, common.get_setting("invalid_setting_action"))
if str_value is not None:
self._client_settings[key] = str_value
def get_client_setting(self, key) -> str | None:
return self._client_settings.get(key)
async def _resolve_token(self) -> str:
# Run sync providers off the event loop; await async providers.
# The provider may be called concurrently if multiple requests get a 516 at the same time;
# it must be safe to invoke in parallel (e.g. if it hits an IdP, consider rate limiting).
result = await asyncio.get_running_loop().run_in_executor(None, cast(Callable[[], str | Awaitable[str]], self._token_provider))
if inspect.isawaitable(result):
result = await result
return result
def set_access_token(self, access_token: str):
auth_header = self.headers.get("Authorization")
if auth_header and not auth_header.startswith("Bearer"):
raise ProgrammingError("Cannot set access token when a different auth type is used")
self.headers["Authorization"] = f"Bearer {access_token}"
if self._session:
self._session.headers["Authorization"] = f"Bearer {access_token}"
def _prep_query(self, context: QueryContext):
final_query = super()._prep_query(context)
if context.is_insert:
return final_query
fmt = f"\n FORMAT {self._read_format}"
if isinstance(final_query, bytes):
return final_query + fmt.encode()
return final_query + fmt
async def _query_with_context(self, context: QueryContext) -> QueryResult: # type: ignore[override]
headers: dict[str, Any] = {}
params: dict[str, str] = {}
if self.database:
params["database"] = self.database
if self.protocol_version:
params["client_protocol_version"] = str(self.protocol_version)
context.block_info = True
params.update(self._validate_settings(context.settings))
context.rename_response_column = self._rename_response_column
use_form = use_form_encoding(context.final_query, context.bind_params, self.form_encode_query_params)
files: dict[str, Any] | None = None
if not context.is_insert and columns_only_re.search(context.uncommented_query):
fmt_json_query = f"{context.final_query}\n FORMAT JSON"
fields = {"query": fmt_json_query}
fields.update(context.bind_params)
if use_form:
files = {}
if context.external_data:
params.update(context.external_data.query_params)
files.update(context.external_data.form_data)
for k, v in fields.items():
files[k] = (None, str(v))
response = await self._raw_request(None, params, headers, files=files, retries=self.query_retries)
elif context.external_data:
params.update(context.bind_params)
params.update(context.external_data.query_params)
params["query"] = fmt_json_query
response = await self._raw_request(None, params, headers, files=context.external_data.form_data, retries=self.query_retries)
else:
params.update(context.bind_params)
response = await self._raw_request(fmt_json_query, params, headers, retries=self.query_retries)
try:
body = await response.read()
encoding = response.headers.get("Content-Encoding")
finally:
_release_lease(response)
loop = asyncio.get_running_loop()
def decompress_and_parse_json():
if encoding:
decompressed_body = decompress_response(body, encoding)
else:
decompressed_body = body
return json.loads(decompressed_body)
# Offload to executor
json_result = await loop.run_in_executor(None, decompress_and_parse_json)
names: list[str] = []
types: list[ClickHouseType] = []
renamer = context.column_renamer
for col in json_result["meta"]:
name = col["name"]
if renamer is not None:
try:
name = renamer(name)
except Exception as e:
logger.debug("Failed to rename col '%s'. Skipping rename. Error: %s", name, e)
names.append(name)
types.append(get_from_name(col["type"]))
return QueryResult([], None, tuple(names), tuple(types)) # type: ignore[arg-type]
if self.compression:
headers["Accept-Encoding"] = self.compression
if self._send_comp_setting:
params["enable_http_compression"] = "1"
final_query = self._prep_query(context)
files = None
data: Any = None
if use_form:
fields = {"query": final_query}
fields.update(context.bind_params)
files = {}
if context.external_data:
params.update(context.external_data.query_params)
files.update(context.external_data.form_data)
for k, v in fields.items():
files[k] = (None, str(v))
elif context.external_data:
params.update(context.bind_params)
params.update(context.external_data.query_params)
params["query"] = final_query
files = context.external_data.form_data
else:
params.update(context.bind_params)
data = final_query
headers["Content-Type"] = "text/plain; charset=utf-8"
headers = dict_copy(headers, context.transport_settings)
response = await self._raw_request(
data,
params,
headers,
files=files,
server_wait=not context.streaming,
stream=True,
retries=self.query_retries,
)
encoding = response.headers.get("Content-Encoding")
tz_header = response.headers.get("X-ClickHouse-Timezone")
exception_tag = response.headers.get(ex_tag_header)
loop = asyncio.get_running_loop()
streaming_source = StreamingResponseSource(response, encoding=encoding, exception_tag=exception_tag)
await streaming_source.start_producer(loop)
def parse_streaming():
"""Parse response from streaming queue (runs in executor)."""
# Wrap streaming source with ResponseBuffer. The streaming source provides a
# .gen property that yields decompressed chunks.
byte_source = RespBuffCls(streaming_source)
context.set_response_tz(self._check_tz_change(tz_header))
result = self._transform.parse_response(byte_source, context)
# For Pandas/Numpy, we must materialize in the executor because the resulting objects
# (DataFrame, Array) are fully in-memory structures.
# For standard queries, we return a lazy QueryResult. Accessing .result_set on the event loop
# will raise a ProgrammingError (deadlock check), encouraging usage of .rows_stream.
if not context.streaming:
if context.as_pandas and hasattr(result, "df_result"):
_ = result.df_result
elif context.use_numpy and hasattr(result, "np_result"):
_ = result.np_result
elif isinstance(result, QueryResult):
_ = result.result_set
return result
# Run parser in executor (pulls from queue, decompresses & parses)
try:
query_result = await loop.run_in_executor(None, parse_streaming)
except Exception:
await streaming_source.aclose()
raise
query_result.summary = self._summary(response)
# Attach streaming_source to query_result.source to ensure it gets closed
# when the query result is closed (e.g. by StreamContext.__exit__)
query_result.source = streaming_source
return query_result
async def query( # type: ignore[override]
self,
query: str | None = None,
parameters: Sequence | dict[str, Any] | None = None,
settings: dict[str, Any] | None = None,
query_formats: dict[str, str] | None = None,
column_formats: dict[str, str | dict[str, str]] | None = None,
encoding: str | None = None,
use_none: bool | None = None,
column_oriented: bool | None = None,
use_numpy: bool | None = None,
max_str_len: int | None = None,
context: QueryContext | None = None,
query_tz: str | tzinfo | None = None,
column_tzs: dict[str, str | tzinfo] | None = None,
external_data: ExternalData | None = None,
transport_settings: dict[str, str] | None = None,
tz_mode: TzMode | None = None,
) -> QueryResult:
"""
Main query method for SELECT, DESCRIBE and other SQL statements that return a result matrix. For
parameters, see the create_query_context method
:return: QueryResult -- data and metadata from response
"""
if query and query.lower().strip().startswith("select __connect_version__"):
return QueryResult(
[[f"ClickHouse Connect v.{common.version()} â“’ ClickHouse Inc."]],
None, # type: ignore[arg-type] # QueryContext.generator not yet Optional; widen after #805 merges
("connect_version",),
(get_from_name("String"),), # type: ignore[arg-type]
)
if not context:
context = self.create_query_context(
query=query,
parameters=parameters,
settings=settings,
query_formats=query_formats,
column_formats=column_formats,
encoding=encoding,
use_none=use_none,
column_oriented=column_oriented,
use_numpy=use_numpy,
max_str_len=max_str_len,
query_tz=query_tz,
column_tzs=column_tzs,
external_data=external_data,
transport_settings=transport_settings,
tz_mode=tz_mode,
)
if context.is_command:
response = await self.command(
query,
parameters=context.parameters,
settings=context.settings,
external_data=context.external_data,
transport_settings=context.transport_settings,
)
if isinstance(response, QuerySummary):
return response.as_query_result()
return QueryResult([response] if isinstance(response, list) else [[response]])
return await self._query_with_context(context)
async def query_column_block_stream( # type: ignore[override]
self,
query: str | None = None,
parameters: Sequence | dict[str, Any] | None = None,
settings: dict[str, Any] | None = None,
query_formats: dict[str, str] | None = None,
column_formats: dict[str, str | dict[str, str]] | None = None,
encoding: str | None = None,
use_none: bool | None = None,
context: QueryContext | None = None,
query_tz: str | tzinfo | None = None,
column_tzs: dict[str, str | tzinfo] | None = None,
external_data: ExternalData | None = None,
transport_settings: dict[str, str] | None = None,
tz_mode: TzMode | None = None,
) -> StreamContext:
"""
Async version of query_column_block_stream.
Returns a StreamContext that yields column-oriented blocks.
"""
return (await self._context_query(locals(), use_numpy=False, streaming=True)).column_block_stream
async def query_row_block_stream( # type: ignore[override]
self,
query: str | None = None,
parameters: Sequence | dict[str, Any] | None = None,
settings: dict[str, Any] | None = None,
query_formats: dict[str, str] | None = None,
column_formats: dict[str, str | dict[str, str]] | None = None,
encoding: str | None = None,
use_none: bool | None = None,
context: QueryContext | None = None,
query_tz: str | tzinfo | None = None,
column_tzs: dict[str, str | tzinfo] | None = None,
external_data: ExternalData | None = None,
transport_settings: dict[str, str] | None = None,
tz_mode: TzMode | None = None,
) -> StreamContext:
"""
Async version of query_row_block_stream.
Returns a StreamContext that yields row-oriented blocks.
"""
return (await self._context_query(locals(), use_numpy=False, streaming=True)).row_block_stream
async def query_rows_stream( # type: ignore[override]
self,
query: str | None = None,
parameters: Sequence | dict[str, Any] | None = None,
settings: dict[str, Any] | None = None,
query_formats: dict[str, str] | None = None,
column_formats: dict[str, str | dict[str, str]] | None = None,
encoding: str | None = None,
use_none: bool | None = None,
context: QueryContext | None = None,
query_tz: str | tzinfo | None = None,
column_tzs: dict[str, str | tzinfo] | None = None,
external_data: ExternalData | None = None,
transport_settings: dict[str, str] | None = None,
tz_mode: TzMode | None = None,
) -> StreamContext:
"""
Async version of query_rows_stream.
Returns a StreamContext that yields individual rows.
"""
return (await self._context_query(locals(), use_numpy=False, streaming=True)).rows_stream
async def query_np( # type: ignore[override]
self,
query: str | None = None,
parameters: Sequence | dict[str, Any] | None = None,
settings: dict[str, Any] | None = None,
query_formats: dict[str, str] | None = None,
column_formats: dict[str, str] | None = None,
encoding: str | None = None,
use_none: bool | None = None,
max_str_len: int | None = None,
context: QueryContext | None = None,
external_data: ExternalData | None = None,
transport_settings: dict[str, str] | None = None,
):
check_numpy()
self._add_integration_tag("numpy")
return (await self._context_query(locals(), use_numpy=True)).np_result
async def query_np_stream( # type: ignore[override]
self,
query: str | None = None,
parameters: Sequence | dict[str, Any] | None = None,
settings: dict[str, Any] | None = None,
query_formats: dict[str, str] | None = None,
column_formats: dict[str, str] | None = None,
encoding: str | None = None,
use_none: bool | None = None,
max_str_len: int | None = None,
context: QueryContext | None = None,
external_data: ExternalData | None = None,
transport_settings: dict[str, str] | None = None,
) -> StreamContext:
check_numpy()
self._add_integration_tag("numpy")
return (await self._context_query(locals(), use_numpy=True, streaming=True)).np_stream
async def query_df(
self,
query: str | None = None,
parameters: Sequence | dict[str, Any] | None = None,
settings: dict[str, Any] | None = None,
query_formats: dict[str, str] | None = None,
column_formats: dict[str, str] | None = None,
encoding: str | None = None,
use_none: bool | None = None,
max_str_len: int | None = None,
use_na_values: bool | None = None,
query_tz: str | None = None,
column_tzs: dict[str, str | tzinfo] | None = None,
context: QueryContext | None = None,
external_data: ExternalData | None = None,
use_extended_dtypes: bool | None = None,
transport_settings: dict[str, str] | None = None,
tz_mode: TzMode | None = None,
):
check_pandas()
self._add_integration_tag("pandas")
return (await self._context_query(locals(), use_numpy=True, as_pandas=True)).df_result
async def query_df_stream( # type: ignore[override]
self,
query: str | None = None,
parameters: Sequence | dict[str, Any] | None = None,
settings: dict[str, Any] | None = None,
query_formats: dict[str, str] | None = None,
column_formats: dict[str, str] | None = None,
encoding: str | None = None,
use_none: bool | None = None,
max_str_len: int | None = None,
use_na_values: bool | None = None,
query_tz: str | None = None,
column_tzs: dict[str, str | tzinfo] | None = None,
context: QueryContext | None = None,
external_data: ExternalData | None = None,
use_extended_dtypes: bool | None = None,
transport_settings: dict[str, str] | None = None,
tz_mode: TzMode | None = None,
) -> StreamContext:
check_pandas()
self._add_integration_tag("pandas")
return (await self._context_query(locals(), use_numpy=True, as_pandas=True, streaming=True)).df_stream
async def _context_query(self, lcls: dict, **overrides): # type: ignore[override]
"""
Helper method to create query context and execute query.
Matches sync client pattern for consistency.
"""
kwargs = lcls.copy()
kwargs.pop("self")
kwargs.update(overrides)
return await self._query_with_context(self.create_query_context(**kwargs))
async def command( # type: ignore[override]
self,
cmd,
parameters: Sequence | dict[str, Any] | None = None,
data: str | bytes | None = None,
settings: dict | None = None,
use_database: bool = True,
external_data: ExternalData | None = None,
transport_settings: dict[str, str] | None = None,
) -> str | int | Sequence[str] | QuerySummary:
"""
See BaseClient doc_string for this method
"""
cmd, bind_params = bind_query(cmd, parameters, self.server_tz)
params = bind_params.copy()
headers = {}
payload = None
files = None
if external_data:
if data:
raise ProgrammingError("Cannot combine command data with external data") from None
files = external_data.form_data
params.update(external_data.query_params)
elif isinstance(data, str):
headers["Content-Type"] = "text/plain; charset=utf-8"
payload = data.encode()
elif isinstance(data, bytes):
headers["Content-Type"] = "application/octet-stream"
payload = data
if payload is None and not cmd:
raise ProgrammingError("Command sent without query or recognized data") from None
if payload or files:
if isinstance(cmd, bytes):
raise ProgrammingError("Binary parameter bind cannot be combined with command data or external data") from None
params["query"] = cmd
else:
payload = cmd
if use_database and self.database:
params["database"] = self.database
params.update(self._validate_settings(settings or {}))
headers = dict_copy(headers, transport_settings)
method = "POST" if payload or files else "GET"
response = await self._raw_request(payload, params, headers, files=files, method=method, server_wait=False)
try:
body = await response.read()
encoding = response.headers.get("Content-Encoding")
summary = self._summary(response)
finally:
_release_lease(response)
if not body:
if returns_empty_string_on_empty_body(cmd):
return ""
return QuerySummary(summary)
loop = asyncio.get_running_loop()
def decompress_and_decode():
if encoding:
decompressed_body = decompress_response(body, encoding)
else:
decompressed_body = body
try:
result = decompressed_body.decode()[:-1].split("\t")
if len(result) == 1:
try:
return int(result[0])
except ValueError:
return result[0]
return result
except UnicodeDecodeError:
return str(decompressed_body)
return await loop.run_in_executor(None, decompress_and_decode)
async def ping(self) -> bool: # type: ignore[override]
async with self._session_lock:
lease = self._session_lease
if lease is None or lease.session.closed:
return False
session = lease.session
lease.acquire()
try:
url = f"{self.url}/ping"
timeout = aiohttp.ClientTimeout(total=3.0)
get_kwargs: dict[str, Any] = {"timeout": timeout}
if self._proxy_url:
get_kwargs["proxy"] = self._proxy_url
if self.server_host_name:
get_kwargs["headers"] = {"Host": self.server_host_name}
if self._ssl_context is not None:
get_kwargs["ssl"] = self._ssl_context
get_kwargs["server_hostname"] = self.server_host_name
async with session.get(url, **get_kwargs) as response:
return 200 <= response.status < 300
except (aiohttp.ClientError, asyncio.TimeoutError):
logger.debug("ping failed", exc_info=True)
return False
finally:
lease.release()
async def raw_query( # type: ignore[override]
self,
query: str,
parameters: Sequence | dict[str, Any] | None = None,
settings: dict[str, Any] | None = None,
fmt: str | None = None,
use_database: bool = True,
external_data: ExternalData | None = None,
transport_settings: dict[str, str] | None = None,
) -> bytes:
"""
See BaseClient doc_string for this method
"""
body, params, headers, files = self._prep_raw_query(query, parameters, settings, fmt, use_database, external_data)
if transport_settings:
headers = dict_copy(headers, transport_settings)
response = await self._raw_request(body, params, headers=headers, files=files, retries=self.query_retries)
try:
response_data = await response.read()
encoding = response.headers.get("Content-Encoding")
finally:
_release_lease(response)
if encoding:
loop = asyncio.get_running_loop()
response_data = await loop.run_in_executor(None, decompress_response, response_data, encoding)
return response_data
async def raw_stream( # type: ignore[override]
self,
query: str,
parameters: Sequence | dict[str, Any] | None = None,
settings: dict[str, Any] | None = None,
fmt: str | None = None,
use_database: bool = True,
external_data: ExternalData | None = None,
transport_settings: dict[str, str] | None = None,
) -> StreamContext:
body, params, headers, files = self._prep_raw_query(query, parameters, settings, fmt, use_database, external_data)
if transport_settings:
headers = dict_copy(headers, transport_settings)
response = await self._raw_request(
body, params, headers=headers, files=files, stream=True, server_wait=False, retries=self.query_retries
)
async def byte_iterator():
async for chunk in response.content.iter_any():
yield chunk
class _RawStreamSource(Closable):
def close(self):
try:
response.close()
finally:
_release_lease(response)
return StreamContext(_RawStreamSource(), byte_iterator())
def _prep_raw_query(self, query, parameters, settings, fmt, use_database, external_data):
"""
Prepare raw query for execution.
Note: Unlike sync client which returns (body, params, fields), this async version
returns (body, params, headers, files) because aiohttp requires headers to be
configured before the request() call, while urllib3 can add them during request.
"""
if fmt:
query += f"\n FORMAT {fmt}"
final_query, bind_params = bind_query(query, parameters, self.server_tz)
params = self._validate_settings(settings or {})
if use_database and self.database:
params["database"] = self.database
headers = {}
files = None
body = None
use_form = use_form_encoding(final_query, bind_params, self.form_encode_query_params)
if external_data and not use_form and isinstance(final_query, bytes):
raise ProgrammingError("Binary query cannot be placed in URL when using External Data; enable form encoding.")
if use_form:
files = {}
files["query"] = (None, final_query if isinstance(final_query, str) else final_query.decode())
for k, v in bind_params.items():
files[k] = (None, str(v))
if external_data:
params.update(external_data.query_params)
files.update(external_data.form_data)
body = None
elif external_data:
params.update(bind_params)
params["query"] = final_query
params.update(external_data.query_params)
files = external_data.form_data
body = None
else:
params.update(bind_params)
body = final_query.encode() if isinstance(final_query, str) else final_query
return body, params, headers, files
async def insert( # type: ignore[override]
self,
table: str | None = None,
data: Sequence[Sequence[Any]] | None = None,
column_names: str | Sequence[str] | None = "*",
database: str | None = None,
column_types: Sequence[ClickHouseType] | None = None,
column_type_names: Sequence[str] | None = None,
column_oriented: bool = False,
settings: dict[str, Any] | None = None,
context: InsertContext | None = None,
transport_settings: dict[str, str] | None = None,
) -> QuerySummary:
"""
Method to insert multiple rows/data matrix of native Python objects. If context is specified arguments
other than data are ignored
:param table: Target table
:param data: Sequence of sequences of Python data
:param column_names: Ordered list of column names or '*' if column types should be retrieved from the
ClickHouse table definition
:param database: Target database -- will use client default database if not specified.
:param column_types: ClickHouse column types. If set then column data does not need to be retrieved from
the server
:param column_type_names: ClickHouse column type names. If set then column data does not need to be
retrieved from the server
:param column_oriented: If true the data is already "pivoted" in column form
:param settings: Optional dictionary of ClickHouse settings (key/string values)
:param context: Optional reusable insert context to allow repeated inserts into the same table with
different data batches
:param transport_settings: Optional dictionary of transport level settings (HTTP headers, etc.)
:return: QuerySummary with summary information, throws exception if insert fails
"""
if (context is None or context.empty) and data is None:
raise ProgrammingError("No data specified for insert") from None
if context is None:
if table is None:
raise ProgrammingError("No table specified for insert") from None
context = await self.create_insert_context(
table,
column_names,
database,
column_types,
column_type_names,
column_oriented,
settings,
transport_settings=transport_settings,
)
if data is not None:
if not context.empty:
raise ProgrammingError("Attempting to insert new data with non-empty insert context") from None
context.data = data
return await self.data_insert(context)
async def query_arrow(
self,
query: str,
parameters: Sequence | dict[str, Any] | None = None,
settings: dict[str, Any] | None = None,
use_strings: bool | None = None,
external_data: ExternalData | None = None,
transport_settings: dict[str, str] | None = None,
):
"""
Query method using the ClickHouse Arrow format to return a PyArrow table
:param query: Query statement/format string
:param parameters: Optional dictionary used to format the query
:param settings: Optional dictionary of ClickHouse settings (key/string values)
:param use_strings: Convert ClickHouse String type to Arrow string type (instead of binary)
:param external_data: ClickHouse "external data" to send with query
:param transport_settings: Optional dictionary of transport level settings (HTTP headers, etc.)
:return: PyArrow.Table
"""
check_arrow()
self._add_integration_tag("arrow")
settings = self._update_arrow_settings(settings, use_strings)
body, params, headers, files = self._prep_raw_query(
query,
parameters,
settings,
fmt="ArrowStream",
use_database=True,
external_data=external_data,
)
if transport_settings:
headers = dict_copy(headers, transport_settings)
response = await self._raw_request(
body,
params,
headers=headers,
files=files,
stream=True,
server_wait=False,
retries=self.query_retries,
)
encoding = response.headers.get("Content-Encoding")
exception_tag = response.headers.get(ex_tag_header)
loop = asyncio.get_running_loop()
streaming_source = StreamingResponseSource(response, encoding=encoding, exception_tag=exception_tag)
await streaming_source.start_producer(loop)
def parse_arrow_stream():
file_adapter = StreamingFileAdapter(streaming_source)
reader = options.arrow.ipc.open_stream(file_adapter)
table = reader.read_all()
return _apply_arrow_tz_policy(table, self.tz_mode)
try:
return await loop.run_in_executor(None, parse_arrow_stream)
finally:
await streaming_source.aclose()
async def query_arrow_stream( # type: ignore[override]
self,
query: str,
parameters: Sequence | dict[str, Any] | None = None,
settings: dict[str, Any] | None = None,
use_strings: bool | None = None,
external_data: ExternalData | None = None,
transport_settings: dict[str, str] | None = None,
) -> StreamContext:
"""
Query method that returns the results as a stream of Arrow record batches.
:param query: Query statement/format string
:param parameters: Optional dictionary used to format the query
:param settings: Optional dictionary of ClickHouse settings (key/string values)
:param use_strings: Convert ClickHouse String type to Arrow string type (instead of binary)
:param external_data: ClickHouse "external data" to send with query
:param transport_settings: Optional dictionary of transport level settings (HTTP headers, etc.)
:return: StreamContext that yields PyArrow RecordBatch objects asynchronously
"""
check_arrow()
self._add_integration_tag("arrow")
settings = self._update_arrow_settings(settings, use_strings)
body, params, headers, files = self._prep_raw_query(
query, parameters, settings, fmt="ArrowStream", use_database=True, external_data=external_data
)
if transport_settings:
headers = dict_copy(headers, transport_settings)
response = await self._raw_request(
body, params, headers=headers, files=files, stream=True, server_wait=False, retries=self.query_retries
)
encoding = response.headers.get("Content-Encoding")
exception_tag = response.headers.get(ex_tag_header)
loop = asyncio.get_running_loop()
streaming_source = StreamingResponseSource(response, encoding=encoding, exception_tag=exception_tag)
await streaming_source.start_producer(loop)
queue: AsyncSyncQueue = AsyncSyncQueue(maxsize=10)
class _ArrowStreamSource(Closable):
def __init__(self, source, q):
self._source = source
self._queue = q
async def aclose(self):
self._queue.shutdown()
await self._source.aclose()
def close(self):
self._queue.shutdown()
self._source.close()
def parse_arrow_streaming():
"""Parse Arrow stream incrementally in executor (off event loop)."""
try:
file_adapter = StreamingFileAdapter(streaming_source)
reader = options.arrow.ipc.open_stream(file_adapter)
for batch in reader:
try:
batch = _apply_arrow_tz_policy(batch, self.tz_mode)
queue.sync_q.put(batch)
except RuntimeError:
return
try:
queue.sync_q.put(EOF_SENTINEL)
except RuntimeError:
return
except Exception as e:
try:
queue.sync_q.put(e)
except Exception:
pass
finally:
queue.shutdown()
loop.run_in_executor(None, parse_arrow_streaming)
async def arrow_batch_generator():
"""Async generator that yields record batches without blocking event loop."""
while True:
item = await queue.async_q.get()
if item is EOF_SENTINEL:
break
if isinstance(item, Exception):
raise item
yield item
return StreamContext(_ArrowStreamSource(streaming_source, queue), arrow_batch_generator())
async def query_df_arrow(
self,
query: str,
parameters: Sequence | dict[str, Any] | None = None,
settings: dict[str, Any] | None = None,
use_strings: bool | None = None,
external_data: ExternalData | None = None,
transport_settings: dict[str, str] | None = None,
dataframe_library: str = "pandas",
) -> pandas.DataFrame | polars.DataFrame:
"""
Query method using the ClickHouse Arrow format to return a DataFrame
with PyArrow dtype backend. This provides better performance and memory efficiency
compared to the standard query_df method, though fewer output formatting options.
:param query: Query statement/format string
:param parameters: Optional dictionary used to format the query
:param settings: Optional dictionary of ClickHouse settings (key/string values)
:param use_strings: Convert ClickHouse String type to Arrow string type (instead of binary)
:param external_data: ClickHouse "external data" to send with query
:param transport_settings: Optional dictionary of transport level settings (HTTP headers, etc.)
:param dataframe_library: Library to use for DataFrame creation ("pandas" or "polars")
:return: DataFrame (pandas or polars based on dataframe_library parameter)
"""
check_arrow()
if dataframe_library == "pandas":
check_pandas()
self._add_integration_tag("pandas")
def converter(table: pyarrow.Table) -> pandas.DataFrame:
table = _apply_arrow_tz_policy(table, self.tz_mode)
return table.to_pandas(types_mapper=options.pd.ArrowDtype, safe=False)
elif dataframe_library == "polars":
check_polars()
self._add_integration_tag("polars")
def converter(table: pyarrow.Table) -> polars.DataFrame: # type: ignore[misc]
table = _apply_arrow_tz_policy(table, self.tz_mode)
return options.pl.from_arrow(table)
else:
raise ValueError(f"dataframe_library must be 'pandas' or 'polars', got '{dataframe_library}'")
arrow_table = await self.query_arrow(
query=query,
parameters=parameters,
settings=settings,
use_strings=use_strings,
external_data=external_data,
transport_settings=transport_settings,
)
return converter(arrow_table)
async def query_df_arrow_stream( # type: ignore[override]
self,
query: str,
parameters: Sequence | dict[str, Any] | None = None,
settings: dict[str, Any] | None = None,
use_strings: bool | None = None,
external_data: ExternalData | None = None,
transport_settings: dict[str, str] | None = None,
dataframe_library: str = "pandas",
) -> StreamContext:
"""
Query method that returns the results as a stream of DataFrames with PyArrow dtype backend.
Each DataFrame represents a record batch from the ClickHouse response.
:param query: Query statement/format string
:param parameters: Optional dictionary used to format the query
:param settings: Optional dictionary of ClickHouse settings (key/string values)
:param use_strings: Convert ClickHouse String type to Arrow string type (instead of binary)
:param external_data: ClickHouse "external data" to send with query
:param transport_settings: Optional dictionary of transport level settings (HTTP headers, etc.)
:param dataframe_library: Library to use for DataFrame creation ("pandas" or "polars")
:return: StreamContext that yields DataFrames asynchronously (pandas or polars based on dataframe_library parameter)
"""
check_arrow()
if dataframe_library == "pandas":
check_pandas()
self._add_integration_tag("pandas")
def converter(table: pyarrow.Table) -> pandas.DataFrame:
table = _apply_arrow_tz_policy(table, self.tz_mode)
return table.to_pandas(types_mapper=options.pd.ArrowDtype, safe=False)
elif dataframe_library == "polars":
check_polars()
self._add_integration_tag("polars")
def converter(table: pyarrow.Table) -> polars.DataFrame: # type: ignore[misc]
table = _apply_arrow_tz_policy(table, self.tz_mode)
return options.pl.from_arrow(table)
else:
raise ValueError(f"dataframe_library must be 'pandas' or 'polars', got '{dataframe_library}'")
settings = self._update_arrow_settings(settings, use_strings)
body, params, headers, files = self._prep_raw_query(
query, parameters, settings, fmt="ArrowStream", use_database=True, external_data=external_data
)
if transport_settings:
headers = dict_copy(headers, transport_settings)
response = await self._raw_request(
body, params, headers=headers, files=files, stream=True, server_wait=False, retries=self.query_retries
)
encoding = response.headers.get("Content-Encoding")
exception_tag = response.headers.get(ex_tag_header)
loop = asyncio.get_running_loop()
streaming_source = StreamingResponseSource(response, encoding=encoding, exception_tag=exception_tag)
await streaming_source.start_producer(loop)
queue: AsyncSyncQueue = AsyncSyncQueue(maxsize=10)
class _ArrowDFStreamSource(Closable):
def __init__(self, source, q):
self._source = source
self._queue = q
async def aclose(self):
self._queue.shutdown()
await self._source.aclose()
def close(self):
self._queue.shutdown()
self._source.close()
def parse_and_convert_streaming():
"""Parse Arrow stream and convert to DataFrames in executor (off event loop)."""
try:
file_adapter = StreamingFileAdapter(streaming_source)
# PyArrow reads incrementally from adapter (which pulls from queue)
reader = options.arrow.ipc.open_stream(file_adapter)
for batch in reader:
try:
queue.sync_q.put(converter(batch))
except RuntimeError:
return
try:
queue.sync_q.put(EOF_SENTINEL)
except RuntimeError:
return
except Exception as e:
try:
queue.sync_q.put(e)
except Exception:
pass
finally:
queue.shutdown()
loop.run_in_executor(None, parse_and_convert_streaming)
async def df_generator():
"""Async generator that yields DataFrames without blocking event loop."""
while True:
item = await queue.async_q.get()
if item is EOF_SENTINEL:
break
if isinstance(item, Exception):
raise item
yield item
return StreamContext(_ArrowDFStreamSource(streaming_source, queue), df_generator())
async def insert_arrow( # type: ignore[override]
self,
table: str,
arrow_table,
database: str | None = None,
settings: dict | None = None,
transport_settings: dict[str, str] | None = None,
) -> QuerySummary:
"""
Insert a PyArrow table DataFrame into ClickHouse using raw Arrow format
:param table: ClickHouse table
:param arrow_table: PyArrow Table object
:param database: Optional ClickHouse database
:param settings: Optional dictionary of ClickHouse settings (key/string values)
:param transport_settings: Optional dictionary of transport level settings (HTTP headers, etc.)
"""
check_arrow()
self._add_integration_tag("arrow")
full_table = table if "." in table or not database else f"{database}.{table}"
compression = self.write_compression if self.write_compression in ("zstd", "lz4") else None
column_names, insert_block = arrow_buffer(arrow_table, compression)
if hasattr(insert_block, "to_pybytes"):
insert_block = insert_block.to_pybytes()
return await self.raw_insert(full_table, column_names, insert_block, settings, "Arrow", transport_settings=transport_settings)
async def insert_df_arrow( # type: ignore[override]
self,
table: str,
df: pandas.DataFrame | polars.DataFrame,
database: str | None = None,
settings: dict | None = None,
transport_settings: dict[str, str] | None = None,
) -> QuerySummary:
"""
Insert a pandas DataFrame with PyArrow backend or a polars DataFrame into ClickHouse using Arrow format.
This method is optimized for DataFrames that already use Arrow format, providing
better performance than the standard insert_df method.
Validation is performed and an exception will be raised if this requirement is not met.
Polars DataFrames are natively Arrow-based and don't require additional validation.
:param table: ClickHouse table name
:param df: Pandas DataFrame with PyArrow dtype backend or Polars DataFrame
:param database: Optional ClickHouse database name
:param settings: Optional dictionary of ClickHouse settings (key/string values)
:param transport_settings: Optional dictionary of transport level settings (HTTP headers, etc.)
:return: QuerySummary with summary information, throws exception if insert fails
"""
check_arrow()
if options.pd is not None and isinstance(df, options.pd.DataFrame):
df_lib = "pandas"
elif options.pl is not None and isinstance(df, options.pl.DataFrame):
df_lib = "polars"
else:
if options.pd is None and options.pl is None:
raise ImportError("A DataFrame library (pandas or polars) must be installed to use insert_df_arrow.")
raise TypeError(f"df must be either a pandas DataFrame or polars DataFrame, got {type(df).__name__}")
if df_lib == "pandas":
non_arrow_cols = [col for col, dtype in df.dtypes.items() if not isinstance(dtype, options.pd.ArrowDtype)]
if non_arrow_cols:
raise ProgrammingError(
f"insert_df_arrow requires all columns to use PyArrow dtypes. Non-Arrow columns found: [{', '.join(non_arrow_cols)}]. "
)
try:
arrow_table = options.arrow.Table.from_pandas(df, preserve_index=False)
except Exception as e:
raise DataError(f"Failed to convert pandas DataFrame to Arrow table: {e}") from e
else:
try:
arrow_table = df.to_arrow()
except Exception as e:
raise DataError(f"Failed to convert polars DataFrame to Arrow table: {e}") from e
self._add_integration_tag(df_lib)
return await self.insert_arrow(
table=table,
arrow_table=arrow_table,
database=database,
settings=settings,
transport_settings=transport_settings,
)
async def create_insert_context( # type: ignore[override]
self,
table: str,
column_names: str | Sequence[str] | None = None,
database: str | None = None,
column_types: Sequence[ClickHouseType] | None = None,
column_type_names: Sequence[str] | None = None,
column_oriented: bool = False,
settings: dict[str, Any] | None = None,
data: Sequence[Sequence[Any]] | None = None,
transport_settings: dict[str, str] | None = None,
) -> InsertContext:
"""
Builds a reusable insert context to hold state for a duration of an insert
:param table: Target table
:param database: Target database. If not set, uses the client default database
:param column_names: Optional ordered list of column names. If not set, all columns ('*') will be assumed
in the order specified by the table definition
:param database: Target database -- will use client default database if not specified
:param column_types: ClickHouse column types. Optional Sequence of ClickHouseType objects. If neither column
types nor column type names are set, actual column types will be retrieved from the server.
:param column_type_names: ClickHouse column type names. Specified column types by name string
:param column_oriented: If true the data is already "pivoted" in column form
:param settings: Optional dictionary of ClickHouse settings (key/string values)
:param data: Initial dataset for insert
:param transport_settings: Optional dictionary of transport level settings (HTTP headers, etc.)
:return: Reusable insert context
"""
full_table = table
if "." not in table:
if database:
full_table = f"{quote_identifier(database)}.{quote_identifier(table)}"
else:
full_table = quote_identifier(table)
column_defs = []
if column_types is None and column_type_names is None:
describe_result = await self.query(f"DESCRIBE TABLE {full_table}", settings=settings)
column_defs = [
ColumnDef(**row) for row in describe_result.named_results() if row["default_type"] not in ("ALIAS", "MATERIALIZED")
]
if column_names is None or isinstance(column_names, str) and column_names == "*":
column_names = [cd.name for cd in column_defs]
column_types = [cd.ch_type for cd in column_defs]
elif isinstance(column_names, str):
column_names = [column_names]
if len(column_names) == 0:
raise ValueError("Column names must be specified for insert")
if not column_types:
if column_type_names:
column_types = [get_from_name(name) for name in column_type_names]
else:
column_map = {d.name: d for d in column_defs}
try:
column_types = [column_map[name].ch_type for name in column_names]
except KeyError as ex:
raise ProgrammingError(f"Unrecognized column {ex} in table {table}") from None
if len(column_names) != len(column_types):
raise ProgrammingError("Column names do not match column types") from None
return InsertContext(
full_table,
column_names,
column_types,
column_oriented=column_oriented,
settings=settings,
transport_settings=transport_settings,
data=data,
)
async def data_insert(self, context: InsertContext) -> QuerySummary: # type: ignore[override]
"""
See BaseClient doc_string for this method.
Uses true streaming via reverse bridge pattern:
- Sync producer (serializer) runs in executor, puts blocks in queue
- Async consumer (network) pulls from queue and yields to aiohttp
- Bounded queue provides backpressure to prevent memory bloat
"""
if context.empty:
logger.debug("No data included in insert, skipping")
return QuerySummary()
if context.compression is None:
context.compression = self.write_compression
loop = asyncio.get_running_loop()
active_source = StreamingInsertSource(transform=self._transform, context=context, loop=loop, maxsize=10)
active_source.start_producer()
async def rebuild_body():
nonlocal active_source
await active_source.close(timeout=None)
context.current_row = 0
context.current_block = 0
active_source = StreamingInsertSource(transform=self._transform, context=context, loop=loop, maxsize=10)
active_source.start_producer()
return active_source.async_generator()
headers: dict[str, Any] = {"Content-Type": "application/octet-stream"}
if isinstance(context.compression, str):
headers["Content-Encoding"] = context.compression
params = {}
if self.database:
params["database"] = self.database
params.update(self._validate_settings(context.settings))
headers = dict_copy(headers, context.transport_settings)
response = None
try:
response = await self._raw_request(
active_source.async_generator(),
params,
headers=headers,
server_wait=False,
retry_body=rebuild_body,
)
logger.debug("Context insert response code: %d", response.status)
summary = self._summary(response)
except Exception:
await active_source.close()
if context.insert_exception:
ex = context.insert_exception
context.insert_exception = None
raise ex from None
raise
finally:
await active_source.close()
context.data = None
if response is not None:
response.close()
_release_lease(response)
return QuerySummary(summary)
async def insert_df( # type: ignore[override]
self,
table: str | None = None,
df=None,
database: str | None = None,
settings: dict | None = None,
column_names: Sequence[str] | None = None,
column_types: Sequence[ClickHouseType] | None = None,
column_type_names: Sequence[str] | None = None,
context: InsertContext | None = None,
transport_settings: dict[str, str] | None = None,
) -> QuerySummary:
"""
Insert a pandas DataFrame into ClickHouse. If context is specified arguments other than df are ignored
:param table: ClickHouse table
:param df: two-dimensional pandas dataframe
:param database: Optional ClickHouse database
:param settings: Optional dictionary of ClickHouse settings (key/string values)
:param column_names: An optional list of ClickHouse column names. If not set, the DataFrame column names
will be used
:param column_types: ClickHouse column types. If set then column data does not need to be retrieved from
the server
:param column_type_names: ClickHouse column type names. If set then column data does not need to be
retrieved from the server
:param context: Optional reusable insert context to allow repeated inserts into the same table with
different data batches
:param transport_settings: Optional dictionary of transport level settings (HTTP headers, etc.)
:return: QuerySummary with summary information, throws exception if insert fails
"""
check_pandas()
self._add_integration_tag("pandas")
if context is None:
if column_names is None:
column_names = df.columns
elif len(column_names) != len(df.columns):
raise ProgrammingError("DataFrame column count does not match insert_columns") from None
return await self.insert(
table,
df,
column_names,
database,
column_types=column_types,
column_type_names=column_type_names,
settings=settings,
transport_settings=transport_settings,
context=context,
)
async def raw_insert( # type: ignore[override]
self,
table: str | None = None,
column_names: Sequence[str] | None = None,
insert_block: str | bytes | Generator[bytes, None, None] | BinaryIO | None = None,
settings: dict | None = None,
fmt: str | None = None,
compression: str | None = None,
transport_settings: dict[str, str] | None = None,
) -> QuerySummary:
"""
See BaseClient doc_string for this method
"""
params = {}
headers = {"Content-Type": "application/octet-stream"}
if compression:
headers["Content-Encoding"] = compression
if table:
cols = f" ({', '.join([quote_identifier(x) for x in column_names])})" if column_names is not None else ""
fmt_str = fmt if fmt else self._write_format
query = f"INSERT INTO {table}{cols} FORMAT {fmt_str}"
if not compression and isinstance(insert_block, str):
insert_block = query + "\n" + insert_block
elif not compression and isinstance(insert_block, (bytes, bytearray)):
insert_block = (query + "\n").encode() + insert_block
else:
# Generators, file-like objects, and compressed data: send the
# INSERT query as a URL param and stream the body as-is.
params["query"] = query
if self.database:
params["database"] = self.database
params.update(self._validate_settings(settings or {}))
headers = dict_copy(headers, transport_settings)
response = await self._raw_request(insert_block, params, headers, server_wait=False)
try:
logger.debug("Raw insert response code: %d", response.status)
return QuerySummary(self._summary(response))
finally:
response.close()
_release_lease(response)
def _add_integration_tag(self, name: str):
"""
Dynamically adds a product (like pandas or sqlalchemy) to the User-Agent string details section.
"""
if not common.get_setting("send_integration_tags") or name in self._reported_libs:
return
try:
ver = "unknown"
try:
ver = dist_version(name)
except Exception:
try:
mod = import_module(name)
ver = getattr(mod, "__version__", "unknown")
except Exception:
pass
product_info = f"{name}/{ver}"
ua = self.headers.get("User-Agent", "")
start = ua.find("(")
if start == -1:
return
end = ua.find(")", start + 1)
if end == -1:
return
details = ua[start + 1 : end].strip()
if product_info in details:
self._reported_libs.add(name)
return
new_details = f"{product_info}; {details}" if details else product_info
new_ua = f"{ua[: start + 1]}{new_details}{ua[end:]}"
self.headers["User-Agent"] = new_ua.strip()
if self._session:
self._session.headers["User-Agent"] = new_ua.strip()
self._reported_libs.add(name)
logger.debug("Added '%s' to User-Agent", product_info)
except Exception as e:
logger.debug("Problem adding '%s' to User-Agent: %s", name, e)
async def _error_handler(self, response: aiohttp.ClientResponse, retried: bool = False):
"""
Handles HTTP errors. Tries to be robust and provide maximum context.
"""
try:
body = ""
full_body = ""
try:
raw_body = await response.read()
encoding = response.headers.get("Content-Encoding")
loop = asyncio.get_running_loop()
if encoding:
def decompress_and_decode():
return decompress_response(raw_body, encoding).decode(errors="backslashreplace")
full_body = await loop.run_in_executor(None, decompress_and_decode)
else:
full_body = await loop.run_in_executor(None, lambda: raw_body.decode(errors="backslashreplace"))
body = common.format_error(full_body).strip()
except Exception:
logger.warning("Failed to read error response body", exc_info=True)
err_code = response.headers.get(ex_header)
code = error_code_from_header(err_code)
name = error_name_from_body(full_body) if self.show_clickhouse_errors else None
if self.show_clickhouse_errors:
if err_code:
err_str = f"Received ClickHouse exception, code: {err_code}"
else:
err_str = f"HTTP driver received HTTP status {response.status}"
if body:
err_str = f"{err_str}, server response: {body}"
else:
err_str = "The ClickHouse server returned an error"
err_str = f"{err_str} (for url {self.url})"
finally:
response.close()
err_type = OperationalError if retried else DatabaseError
raise err_type(err_str, code=code, name=name) from None
async def _raw_request(
self,
data,
params,
headers=None,
files=None,
method="POST",
stream=False,
server_wait=True,
retries: int = 0,
retry_body: Callable[[], Awaitable[Any]] | None = None,
) -> aiohttp.ClientResponse:
if self._session is None:
raise ProgrammingError(
"Session not initialized. Use 'async with get_async_client(...)' or call 'await client._initialize()' first."
)
reset_seconds = common.get_setting("max_connection_age")
if reset_seconds:
now = time.time()
if self._last_pool_reset is None:
self._last_pool_reset = now
elif self._last_pool_reset < now - reset_seconds:
# Stamp before await so concurrent callers don't all queue redundant resets.
self._last_pool_reset = now
logger.debug("connection expiration - resetting connection pool")
await self.close_connections()
final_params = dict_copy(self._client_settings, params)
if server_wait:
final_params.setdefault("wait_end_of_query", "1")
if self._send_progress:
final_params.setdefault("send_progress_in_http_headers", "1")
if self._progress_interval:
final_params.setdefault("http_headers_progress_interval_ms", self._progress_interval)
if self._autogenerate_query_id and "query_id" not in final_params:
final_params["query_id"] = str(uuid.uuid4())
req_headers = dict_copy(self.headers, headers)
if self.server_host_name:
req_headers["Host"] = self.server_host_name
query_session = final_params.get("session_id")
attempts = 0
auth_retried = False
while True:
attempts += 1
if query_session:
if query_session == self._active_session:
raise ProgrammingError(
"Attempt to execute concurrent queries within the same session. "
"Please use a separate client instance per concurrent query."
)
self._active_session = query_session
# Snapshot+acquire under lock so close_connections() can't pass the
# drain check between our session read and our refcount increment.
async with self._session_lock:
lease = self._session_lease
if lease is None or lease.session.closed:
if query_session:
self._active_session = None
raise ProgrammingError("Client session is unavailable; the client may have been closed.")
session = lease.session
lease.acquire()
lease_released = False
try:
# Construct full URL (aiohttp doesn't have base_url)
url = f"{self.url}/"
request_kwargs = {"method": method, "url": url, "params": final_params, "headers": req_headers}
if self.server_host_name and self._ssl_context is not None:
request_kwargs["ssl"] = self._ssl_context
request_kwargs["server_hostname"] = self.server_host_name
if hasattr(self, "_proxy_url") and self._proxy_url:
request_kwargs["proxy"] = self._proxy_url
if files:
# IMPORTANT: Must set content_type on text fields to force multipart/form-data encoding
# Without content_type, aiohttp uses application/x-www-form-urlencoded
form = aiohttp.FormData()
for field_name, field_value in files.items():
if isinstance(field_value, tuple):
if field_value[0] is None:
form.add_field(field_name, str(field_value[1]), content_type="text/plain")
else:
filename = field_value[0]
file_data = field_value[1]
content_type = field_value[2] if len(field_value) > 2 else None
form.add_field(field_name, file_data, filename=filename, content_type=content_type)
else:
form.add_field(field_name, field_value, content_type="text/plain")
request_kwargs["data"] = form
elif isinstance(data, dict):
request_kwargs["data"] = data
else:
request_kwargs["data"] = data
response = await session.request(**request_kwargs)
if 200 <= response.status < 300 and not response.headers.get(ex_header):
# Caller releases lease after consuming the body.
response._lease_release = _one_shot(lease.release) # type: ignore[attr-defined]
lease_released = True
return response
if response.status in (429, 503, 504):
if attempts > retries:
await self._error_handler(response, retried=True)
else:
logger.debug("Retrying request with status code %s (attempt %s/%s)", response.status, attempts, retries + 1)
await asyncio.sleep(0.1 * attempts)
response.close()
continue
if self._token_provider and not auth_retried and response.headers.get(ex_header) == auth_failed_ex_code:
if retry_body is None and not (data is None or isinstance(data, (bytes, bytearray, str, dict))):
await self._error_handler(response) # non-replayable body, surface the auth error instead of retrying
auth_retried = True
self.set_access_token(await self._resolve_token())
req_headers["Authorization"] = self.headers["Authorization"]
if retry_body is not None:
data = await retry_body()
logger.debug("Refreshing access token after authentication failure")
response.close()
continue
await self._error_handler(response)
except aiohttp.ClientConnectionError as e:
msg = str(e)
if _is_retryable_async_connection_error(e):
# Always allow at least one retry on a clean connection error so a single stale
# keep-alive socket doesn't surface to the caller, and additionally honor the
# retries budget when it is larger (e.g. query_retries for reads), so that
# bursts of stale pooled connections can be drained before giving up.
max_attempts = max(2, retries + 1)
if attempts < max_attempts:
if retry_body is not None:
data = await retry_body()
logger.debug("Retrying after connection error with rebuilt body (attempt %s/%s)", attempts, max_attempts)
await asyncio.sleep(0.1 * attempts)
continue
if data is None or isinstance(data, (bytes, bytearray, str, dict)):
logger.debug("Retrying after connection error from remote host (attempt %s/%s)", attempts, max_attempts)
await asyncio.sleep(0.1 * attempts)
continue
logger.debug("Non-retryable aiohttp connection error type=%s", type(e).__name__)
raise OperationalError(f"Network Error: {msg}") from e
except (aiohttp.ClientError, asyncio.TimeoutError) as e:
raise OperationalError(f"Network Error: {str(e)}") from e
finally:
if not lease_released:
lease.release()
if query_session:
self._active_session = None
@staticmethod
def _summary(response: aiohttp.ClientResponse):
summary = {}
if "X-ClickHouse-Summary" in response.headers:
try:
summary = json.loads(response.headers["X-ClickHouse-Summary"])
except json.JSONDecodeError:
pass
summary["query_id"] = response.headers.get("X-ClickHouse-Query-Id", "")
return summary