summaryrefslogtreecommitdiffstats
path: root/contrib/python
diff options
context:
space:
mode:
Diffstat (limited to 'contrib/python')
-rw-r--r--contrib/python/Flask-Cors/py3/.dist-info/METADATA3
-rw-r--r--contrib/python/Flask-Cors/py3/flask_cors/core.py418
-rw-r--r--contrib/python/Flask-Cors/py3/flask_cors/decorator.py34
-rw-r--r--contrib/python/Flask-Cors/py3/flask_cors/extension.py79
-rw-r--r--contrib/python/Flask-Cors/py3/flask_cors/py.typed0
-rw-r--r--contrib/python/Flask-Cors/py3/ya.make3
-rw-r--r--contrib/python/clickhouse-connect/.dist-info/METADATA2
-rw-r--r--contrib/python/clickhouse-connect/clickhouse_connect/_version.py2
-rw-r--r--contrib/python/clickhouse-connect/clickhouse_connect/cc_sqlalchemy/alembic/impl.py9
-rw-r--r--contrib/python/clickhouse-connect/clickhouse_connect/cc_sqlalchemy/datatypes/sqltypes.py57
-rw-r--r--contrib/python/clickhouse-connect/clickhouse_connect/cc_sqlalchemy/dialect.py10
-rw-r--r--contrib/python/clickhouse-connect/clickhouse_connect/cc_sqlalchemy/inspector.py64
-rw-r--r--contrib/python/clickhouse-connect/clickhouse_connect/cc_sqlalchemy/sql/compiler.py99
-rw-r--r--contrib/python/clickhouse-connect/clickhouse_connect/cc_sqlalchemy/sql/preparer.py5
-rw-r--r--contrib/python/clickhouse-connect/clickhouse_connect/dbapi/cursor.py19
-rw-r--r--contrib/python/clickhouse-connect/clickhouse_connect/driver/__init__.py114
-rw-r--r--contrib/python/clickhouse-connect/clickhouse_connect/driver/asyncclient.py73
-rw-r--r--contrib/python/clickhouse-connect/clickhouse_connect/driver/binding.py94
-rw-r--r--contrib/python/clickhouse-connect/clickhouse_connect/driver/exceptions.py31
-rw-r--r--contrib/python/clickhouse-connect/clickhouse_connect/driver/httpclient.py59
-rw-r--r--contrib/python/clickhouse-connect/clickhouse_connect/driver/query.py2
-rw-r--r--contrib/python/clickhouse-connect/ya.make2
-rw-r--r--contrib/python/cryptography/next/rust/.yandex_meta/build.ym12
-rw-r--r--contrib/python/ydb/py3/.dist-info/METADATA5
-rw-r--r--contrib/python/ydb/py3/README.md1
-rw-r--r--contrib/python/ydb/py3/patches/03-change-inport-protobufs.patch5
-rw-r--r--contrib/python/ydb/py3/ya.make6
-rw-r--r--contrib/python/ydb/py3/ydb/_topic_reader/topic_reader_asyncio.py131
-rw-r--r--contrib/python/ydb/py3/ydb/_topic_writer/topic_writer_asyncio.py37
-rw-r--r--contrib/python/ydb/py3/ydb/aio/connection.py19
-rw-r--r--contrib/python/ydb/py3/ydb/aio/driver.py1
-rw-r--r--contrib/python/ydb/py3/ydb/aio/pool.py40
-rw-r--r--contrib/python/ydb/py3/ydb/aio/query/base.py16
-rw-r--r--contrib/python/ydb/py3/ydb/aio/query/session.py39
-rw-r--r--contrib/python/ydb/py3/ydb/aio/query/transaction.py81
-rw-r--r--contrib/python/ydb/py3/ydb/aio/table.py3
-rw-r--r--contrib/python/ydb/py3/ydb/connection.py26
-rw-r--r--contrib/python/ydb/py3/ydb/convert.py53
-rw-r--r--contrib/python/ydb/py3/ydb/opentelemetry/__init__.py36
-rw-r--r--contrib/python/ydb/py3/ydb/opentelemetry/plugin.py134
-rw-r--r--contrib/python/ydb/py3/ydb/opentelemetry/tracing.py165
-rw-r--r--contrib/python/ydb/py3/ydb/pool.py51
-rw-r--r--contrib/python/ydb/py3/ydb/query/base.py17
-rw-r--r--contrib/python/ydb/py3/ydb/query/session.py67
-rw-r--r--contrib/python/ydb/py3/ydb/query/transaction.py85
-rw-r--r--contrib/python/ydb/py3/ydb/resolver.py6
-rw-r--r--contrib/python/ydb/py3/ydb/retries.py64
-rw-r--r--contrib/python/ydb/py3/ydb/ydb_version.py2
48 files changed, 1790 insertions, 491 deletions
diff --git a/contrib/python/Flask-Cors/py3/.dist-info/METADATA b/contrib/python/Flask-Cors/py3/.dist-info/METADATA
index 513e909c173..39686d45aa6 100644
--- a/contrib/python/Flask-Cors/py3/.dist-info/METADATA
+++ b/contrib/python/Flask-Cors/py3/.dist-info/METADATA
@@ -1,6 +1,6 @@
Metadata-Version: 2.4
Name: flask-cors
-Version: 6.0.3
+Version: 6.0.5
Summary: A Flask extension simplifying CORS support
Author-email: Cory Dolphin <[email protected]>
License-Expression: MIT
@@ -21,6 +21,7 @@ Requires-Python: <4.0,>=3.9
Description-Content-Type: text/x-rst
Requires-Dist: flask>=0.9
Requires-Dist: Werkzeug>=0.7
+Requires-Dist: typing_extensions>=4.6.0; python_version < "3.11"
Flask-CORS
==========
diff --git a/contrib/python/Flask-Cors/py3/flask_cors/core.py b/contrib/python/Flask-Cors/py3/flask_cors/core.py
index 8df343c935a..1eeb20a83a6 100644
--- a/contrib/python/Flask-Cors/py3/flask_cors/core.py
+++ b/contrib/python/Flask-Cors/py3/flask_cors/core.py
@@ -1,13 +1,106 @@
+from __future__ import annotations
+
import logging
import re
-from collections.abc import Iterable
+from collections.abc import Iterable, Mapping
+from dataclasses import dataclass
from datetime import timedelta
+from typing import Any, TypedDict, Union, cast
-from flask import current_app, request
+from flask import Blueprint, Flask, Response, current_app, request
from werkzeug.datastructures import Headers, MultiDict
LOG = logging.getLogger(__name__)
+# A resource/origin/header pattern may either be a literal string or a
+# pre-compiled regular expression.
+ResourcePattern = Union[str, "re.Pattern[str]"]
+
+# What a user may pass as ``resources``: a single pattern, a list of patterns,
+# or a mapping of pattern -> per-resource (raw) options.
+ResourceSpec = Union[
+ str,
+ "re.Pattern[str]",
+ "list[ResourcePattern]",
+ "dict[ResourcePattern, dict[str, Any]]",
+]
+
+# Input shapes accepted from users for individual options, before they are
+# normalized by :func:`serialize_options`. These are deliberately broad: a
+# scalar or an iterable of scalars are both accepted in most cases.
+OriginsInput = Union[ResourcePattern, "Iterable[ResourcePattern]"]
+HeadersInput = Union[ResourcePattern, "Iterable[ResourcePattern]"]
+MethodsInput = Union[str, "Iterable[str]"]
+ExposeHeadersInput = Union[str, "Iterable[str]", None]
+MaxAgeInput = Union[timedelta, int, str, None]
+
+
+@dataclass(frozen=True)
+class _ComputedCorsOptions:
+ """The fully-resolved CORS options for a single route.
+
+ Private internal representation, built only by :func:`serialize_options`.
+ Configure CORS via :class:`~flask_cors.CORS` / :func:`cross_origin` keyword
+ arguments, not by constructing this directly.
+ """
+
+ # ``origins``/``allow_headers`` are normalized to a list of patterns: each
+ # entry is either a compiled ``re.Pattern`` (regex) or a literal ``str``,
+ # so request-time matching never has to guess which it is.
+ origins: list[ResourcePattern]
+ allow_headers: list[ResourcePattern]
+ # Whether ``origins`` includes the catch-all ``.*`` wildcard. Computed once
+ # here because the wildcard is otherwise indistinguishable from a regex
+ # after the origins have been compiled.
+ allow_all_origins: bool
+ # ``methods``/``expose_headers`` join to a comma-separated string, or are
+ # ``None`` when not configured.
+ methods: str | None
+ expose_headers: str | None
+ # ``max_age`` is left as-is unless a timedelta is given, then stringified.
+ max_age: str | int | None
+ supports_credentials: bool
+ send_wildcard: bool
+ automatic_options: bool
+ vary_header: bool
+ intercept_exceptions: bool
+ always_send: bool
+ allow_private_network: bool
+
+
+class CrossOriginOptionsInput(TypedDict, total=False):
+ """The keyword options accepted by :func:`cross_origin`.
+
+ Every key is optional (``total=False``): only the options a caller
+ actually passes are forwarded, so app-level ``CORS_*`` configuration and
+ the defaults still apply to anything left out. Used with PEP 692
+ ``Unpack`` to type ``**kwargs`` precisely.
+ """
+
+ origins: OriginsInput
+ methods: MethodsInput
+ expose_headers: ExposeHeadersInput
+ allow_headers: HeadersInput
+ supports_credentials: bool
+ max_age: MaxAgeInput
+ send_wildcard: bool
+ vary_header: bool
+ automatic_options: bool
+ always_send: bool
+ allow_private_network: bool
+
+
+class CorsOptionsInput(CrossOriginOptionsInput, total=False):
+ """The keyword options accepted by :class:`CORS` / :meth:`CORS.init_app`.
+
+ Extends :class:`CrossOriginOptionsInput` with the application-level options that
+ only make sense for the extension.
+ """
+
+ resources: ResourceSpec
+ intercept_exceptions: bool
+
+
# Response Headers
ACL_ORIGIN = "Access-Control-Allow-Origin"
ACL_METHODS = "Access-Control-Allow-Methods"
@@ -43,77 +136,97 @@ CONFIG_OPTIONS = [
# to a view.
FLASK_CORS_EVALUATED = "_FLASK_CORS_EVALUATED"
-# Strange, but this gets the type of a compiled regex, which is otherwise not
-# exposed in a public API.
-RegexObject = type(re.compile(""))
-DEFAULT_OPTIONS = dict(
- origins="*",
- methods=ALL_METHODS,
- allow_headers="*",
- expose_headers=None,
- supports_credentials=False,
- max_age=None,
- send_wildcard=False,
- automatic_options=True,
- vary_header=True,
- resources=r"/*",
- intercept_exceptions=True,
- always_send=True,
- allow_private_network=False,
-)
+# The type of a compiled regular expression. Exposed publicly as
+# ``re.Pattern`` since Python 3.8.
+RegexObject = re.Pattern
+
+# Characters commonly found in regular expressions. Their presence is used as
+# a heuristic for whether a string is a regex rather than a literal value.
+_REGEX_HINT_CHARS = frozenset("*\\]?$^[]()")
+
+# The set of recognized option names, derived from the input schema (which
+# lists exactly the keys a user may pass; the resolved dataclass additionally
+# carries computed fields such as ``allow_all_origins``).
+_KNOWN_OPTIONS = frozenset(CorsOptionsInput.__required_keys__) | frozenset(CorsOptionsInput.__optional_keys__)
+
+# The raw, un-normalized option defaults. These are merged with app config and
+# user kwargs before being handed to :func:`serialize_options`, which produces
+# the strongly-typed :class:`_ComputedCorsOptions`. Typing this as ``CorsOptionsInput``
+# lets mypy verify the defaults match the documented input schema.
+DEFAULT_OPTIONS: CorsOptionsInput = {
+ "origins": "*",
+ "methods": ALL_METHODS,
+ "allow_headers": "*",
+ "expose_headers": None,
+ "supports_credentials": False,
+ "max_age": None,
+ "send_wildcard": False,
+ "automatic_options": True,
+ "vary_header": True,
+ "resources": r"/*",
+ "intercept_exceptions": True,
+ "always_send": True,
+ "allow_private_network": False,
+}
-def parse_resources(resources):
+def parse_resources(resources: ResourceSpec) -> list[tuple[ResourcePattern, dict[str, Any]]]:
if isinstance(resources, dict):
# To make the API more consistent with the decorator, allow a
# resource of '*', which is not actually a valid regexp.
- resources = [(re_fix(k), v) for k, v in resources.items()]
+ resource_pairs = [(re_fix(k), v) for k, v in resources.items()]
# Sort patterns with static (literal) paths first, then by regex specificity
- def sort_key(pair):
+ def sort_key(pair: tuple[ResourcePattern, Any]) -> tuple[int, int, int, int]:
pattern, _ = pair
- if isinstance(pattern, RegexObject):
+ if isinstance(pattern, re.Pattern):
return (1, 0, -pattern.pattern.count("/"), -len(pattern.pattern))
elif probably_regex(pattern):
return (1, 1, -pattern.count("/"), -len(pattern))
else:
return (0, 0, -pattern.count("/"), -len(pattern))
- return sorted(resources, key=sort_key)
+ resource_pairs.sort(key=sort_key)
elif isinstance(resources, str):
- return [(re_fix(resources), {})]
+ resource_pairs = [(re_fix(resources), {})]
elif isinstance(resources, Iterable):
- return [(re_fix(r), {}) for r in resources]
+ resource_pairs = [(re_fix(r), {}) for r in resources]
# Type of compiled regex is not part of the public API. Test for this
# at runtime.
- elif isinstance(resources, RegexObject):
- return [(re_fix(resources), {})]
+ elif isinstance(resources, re.Pattern):
+ resource_pairs = [(re_fix(resources), {})]
else:
- raise ValueError("Unexpected value for resources argument.")
+ # ValueError (rather than the arguably more correct TypeError) is the
+ # historical public contract here; callers may catch it.
+ raise ValueError("Unexpected value for resources argument.") # noqa: TRY004
+ # Resolve each path pattern once (paths are matched case-sensitively), so
+ # request handling never re-runs the regex heuristic. Patterns are sorted
+ # above, before compilation, so specificity ordering is preserved.
+ return [(_resolve_pattern(pattern, ignore_case=False), opts) for (pattern, opts) in resource_pairs]
-def get_regexp_pattern(regexp):
+
+def get_regexp_pattern(regexp: ResourcePattern) -> str:
"""
Helper that returns regexp pattern from given value.
:param regexp: regular expression to stringify
- :type regexp: _sre.SRE_Pattern or str
+ :type regexp: re.Pattern or str
:returns: string representation of given regexp pattern
:rtype: str
"""
- try:
+ if isinstance(regexp, re.Pattern):
return regexp.pattern
- except AttributeError:
- return str(regexp)
+ return str(regexp)
-def get_cors_origins(options, request_origin):
- origins = options.get("origins")
- wildcard = r".*" in origins
+def get_cors_origins(options: _ComputedCorsOptions, request_origin: str | None) -> list[str] | None:
+ origins = options.origins
+ wildcard = options.allow_all_origins
# If the Origin header is not present terminate this set of steps.
# The request is outside the scope of this specification.-- W3Spec
@@ -121,7 +234,7 @@ def get_cors_origins(options, request_origin):
LOG.debug("CORS request received with 'Origin' %s", request_origin)
# If the allowed origins is an asterisk or 'wildcard', always match
- if wildcard and options.get("send_wildcard"):
+ if wildcard and options.send_wildcard:
LOG.debug("Allowed origins are set to '*'. Sending wildcard CORS header.")
return ["*"]
# If the value of the Origin header is a case-insensitive match
@@ -139,7 +252,7 @@ def get_cors_origins(options, request_origin):
LOG.debug("The request's Origin header does not match any of allowed origins.")
return None
- elif options.get("always_send"):
+ elif options.always_send:
if wildcard:
# If wildcard is in the origins, even if 'send_wildcard' is False,
# simply send the wildcard. Unless supports_credentials is True,
@@ -147,13 +260,14 @@ def get_cors_origins(options, request_origin):
# It is the most-likely to be correct thing to do (the only other
# option is to return nothing, which almost certainly not what
# the developer wants if the '*' origin was specified.
- if options.get("supports_credentials"):
+ if options.supports_credentials:
return None
else:
return ["*"]
else:
- # Return all origins that are not regexes.
- return sorted([o for o in origins if not probably_regex(o)])
+ # Return all literal (non-regex) origins. After resolution a plain
+ # ``str`` entry is always a literal; regexes are compiled patterns.
+ return sorted(o for o in origins if isinstance(o, str))
# Terminate these steps, return the original request untouched.
else:
@@ -163,38 +277,46 @@ def get_cors_origins(options, request_origin):
return None
-def get_allow_headers(options, acl_request_headers):
+def get_allow_headers(options: _ComputedCorsOptions, acl_request_headers: str | None) -> str | None:
if acl_request_headers:
+ allow_headers = options.allow_headers
request_headers = [h.strip() for h in acl_request_headers.split(",")]
# any header that matches in the allow_headers
- matching_headers = filter(lambda h: try_match_any_pattern(h, options.get("allow_headers"), caseSensitive=False), request_headers)
+ matching_headers = filter(
+ lambda h: try_match_any_pattern(h, allow_headers, caseSensitive=False),
+ request_headers,
+ )
return ", ".join(sorted(matching_headers))
return None
-def get_cors_headers(options, request_headers, request_method):
+def get_cors_headers(
+ options: _ComputedCorsOptions, request_headers: Headers, request_method: str
+) -> MultiDict[str, str | int | None]:
origins_to_set = get_cors_origins(options, request_headers.get("Origin"))
- headers = MultiDict()
+ # Values are header values (str), with ``max_age`` allowing int and a few
+ # options allowing None; the trailing comprehension drops the None/empty
+ # entries before the result is returned.
+ headers: MultiDict[str, str | int | None] = MultiDict()
if not origins_to_set: # CORS is not enabled for this route
return headers
- for origin in origins_to_set:
- headers.add(ACL_ORIGIN, origin)
+ headers.setlist(ACL_ORIGIN, origins_to_set)
- headers[ACL_EXPOSE_HEADERS] = options.get("expose_headers")
+ headers[ACL_EXPOSE_HEADERS] = options.expose_headers
- if options.get("supports_credentials"):
+ if options.supports_credentials:
headers[ACL_CREDENTIALS] = "true" # case sensitive
if (
ACL_REQUEST_HEADER_PRIVATE_NETWORK in request_headers
and request_headers.get(ACL_REQUEST_HEADER_PRIVATE_NETWORK) == "true"
):
- allow_private_network = "true" if options.get("allow_private_network") else "false"
+ allow_private_network = "true" if options.allow_private_network else "false"
headers[ACL_RESPONSE_PRIVATE_NETWORK] = allow_private_network
# This is a preflight request
@@ -204,36 +326,35 @@ def get_cors_headers(options, request_headers, request_method):
# If there is no Access-Control-Request-Method header or if parsing
# failed, do not set any additional headers
- if acl_request_method and acl_request_method in options.get("methods"):
+ methods = options.methods
+ if acl_request_method and methods and acl_request_method in methods:
# If method is not a case-sensitive match for any of the values in
# list of methods do not set any additional headers and terminate
# this set of steps.
headers[ACL_ALLOW_HEADERS] = get_allow_headers(options, request_headers.get(ACL_REQUEST_HEADERS))
- headers[ACL_MAX_AGE] = options.get("max_age")
- headers[ACL_METHODS] = options.get("methods")
+ headers[ACL_MAX_AGE] = options.max_age
+ headers[ACL_METHODS] = methods
else:
LOG.info(
"The request's Access-Control-Request-Method header does not match allowed methods. CORS headers will not be applied."
)
# http://www.w3.org/TR/cors/#resource-implementation
- if options.get("vary_header"):
- # Only set header if the origin returned will vary dynamically,
- # i.e. if we are not returning an asterisk, and there are multiple
- # origins that can be matched.
- if headers[ACL_ORIGIN] == "*":
- pass
- elif (
- len(options.get("origins")) > 1
- or len(origins_to_set) > 1
- or any(map(probably_regex, options.get("origins")))
+ if options.vary_header:
+ # Only set the header if the resolved origin can vary dynamically:
+ # i.e. we are not returning a wildcard, and more than one origin (or a
+ # regex) could have matched.
+ origins = options.origins
+ returns_wildcard = headers[ACL_ORIGIN] == "*"
+ if not returns_wildcard and (
+ len(origins) > 1 or len(origins_to_set) > 1 or any(isinstance(o, re.Pattern) for o in origins)
):
headers.add("Vary", "Origin")
return MultiDict((k, v) for k, v in headers.items() if v)
-def set_cors_headers(resp, options):
+def set_cors_headers(resp: Response, options: _ComputedCorsOptions) -> Response:
"""
Performs the actual evaluation of Flask-CORS options and actually
modifies the response object.
@@ -251,6 +372,8 @@ def set_cors_headers(resp, options):
# objects (Werkzeug Headers work as well). This is a problem because
# headers allow repeated values.
if not isinstance(resp.headers, Headers) and not isinstance(resp.headers, MultiDict):
+ # The non-standard header container is replaced with a MultiDict so
+ # repeated headers behave consistently.
resp.headers = MultiDict(resp.headers)
headers_to_set = get_cors_headers(options, request.headers, request.method)
@@ -263,17 +386,16 @@ def set_cors_headers(resp, options):
return resp
-def probably_regex(maybe_regex):
- if isinstance(maybe_regex, RegexObject):
+def probably_regex(maybe_regex: ResourcePattern) -> bool:
+ if isinstance(maybe_regex, re.Pattern):
return True
- else:
- common_regex_chars = ["*", "\\", "]", "?", "$", "^", "[", "]", "(", ")"]
- # Use common characters used in regular expressions as a proxy
- # for if this string is in fact a regex.
- return any(c in maybe_regex for c in common_regex_chars)
+
+ # Use characters common in regular expressions as a proxy for whether
+ # this string is in fact a regex.
+ return not _REGEX_HINT_CHARS.isdisjoint(maybe_regex)
-def re_fix(reg):
+def re_fix(reg: ResourcePattern) -> ResourcePattern:
"""
Replace the invalid regex r'*' with the valid, wildcard regex r'/.*' to
enable the CORS app extension to have a more user friendly api.
@@ -281,58 +403,57 @@ def re_fix(reg):
return r".*" if reg == r"*" else reg
-def try_match_any_pattern(inst, patterns, caseSensitive=True):
+def try_match_any_pattern(inst: str, patterns: Iterable[ResourcePattern], caseSensitive: bool = True) -> bool:
return any(try_match_pattern(inst, pattern, caseSensitive) for pattern in patterns)
-def try_match_pattern(value, pattern, caseSensitive=True):
+
+def try_match_pattern(value: Any, pattern: ResourcePattern, caseSensitive: bool = True) -> bool:
"""
- Safely attempts to match a pattern or string to a value. This
- function can be used to match request origins, headers, or paths.
- The value of caseSensitive should be set in accordance to the
- data being compared e.g. origins and headers are case insensitive
- whereas paths are case-sensitive
+ Match a value against a *resolved* pattern: a compiled ``re.Pattern``
+ (whose case-sensitivity is already baked in) or a literal string. Used to
+ match request origins, headers, or paths; ``caseSensitive`` only affects
+ the literal comparison (origins and headers are case insensitive, paths are
+ case sensitive).
"""
- if isinstance(pattern, RegexObject):
- return re.match(pattern, value)
- if probably_regex(pattern):
- flags = 0 if caseSensitive else re.IGNORECASE
- try:
- return re.match(pattern, value, flags=flags)
- except re.error:
- return False
+ if isinstance(pattern, re.Pattern):
+ return bool(pattern.match(value))
try:
v = str(value)
p = str(pattern)
- return v == p if caseSensitive else v.casefold() == p.casefold()
except Exception:
- return value == pattern
+ return bool(value == pattern)
+ return v == p if caseSensitive else v.casefold() == p.casefold()
+
-def get_cors_options(appInstance, *dicts):
+def merge_options(appInstance: Flask | Blueprint | None, *dicts: Mapping[str, Any]) -> dict[str, Any]:
"""
- Compute CORS options for an application by combining the DEFAULT_OPTIONS,
- the app's configuration-specified options and any dictionaries passed. The
- last specified option wins.
+ Merge the DEFAULT_OPTIONS, the app's configuration-specified options and any
+ dictionaries passed into a single raw options mapping. The last specified
+ option wins.
"""
- options = DEFAULT_OPTIONS.copy()
+ options: dict[str, Any] = dict(DEFAULT_OPTIONS)
options.update(get_app_kwarg_dict(appInstance))
- if dicts:
- for d in dicts:
- options.update(d)
+ for d in dicts:
+ options.update(d)
+ return options
- return serialize_options(options)
+def get_cors_options(appInstance: Flask | Blueprint | None, *dicts: Mapping[str, Any]) -> _ComputedCorsOptions:
+ """Compute the resolved CORS options for an application (see merge_options)."""
+ return serialize_options(merge_options(appInstance, *dicts))
-def get_app_kwarg_dict(appInstance=None):
+
+def get_app_kwarg_dict(appInstance: Flask | Blueprint | None = None) -> dict[str, Any]:
"""Returns the dictionary of CORS specific app configurations."""
app = appInstance or current_app
# In order to support blueprints which do not have a config attribute
app_config = getattr(app, "config", {})
- return {k.lower().replace("cors_", ""): app_config.get(k) for k in CONFIG_OPTIONS if app_config.get(k) is not None}
+ return {k.lower().replace("cors_", ""): value for k in CONFIG_OPTIONS if (value := app_config.get(k)) is not None}
-def flexible_str(obj):
+def flexible_str(obj: Any) -> str | None:
"""
A more flexible str function which intelligently handles stringifying
strings, lists and other iterables. The results are lexographically sorted
@@ -347,53 +468,98 @@ def flexible_str(obj):
return str(obj)
-def serialize_option(options_dict, key, upper=False):
- if key in options_dict:
- value = flexible_str(options_dict[key])
- options_dict[key] = value.upper() if upper else value
-
-
-def ensure_iterable(inst):
+def ensure_iterable(inst: Any) -> Iterable[Any]:
"""
Wraps scalars or string types as a list, or returns the iterable instance.
"""
if isinstance(inst, str) or not isinstance(inst, Iterable):
return [inst]
else:
- return inst
+ return cast("Iterable[Any]", inst)
-def sanitize_regex_param(param):
+def sanitize_regex_param(param: Any) -> list[ResourcePattern]:
return [re_fix(x) for x in ensure_iterable(param)]
-def serialize_options(opts):
+def _resolve_pattern(pattern: ResourcePattern, *, ignore_case: bool) -> ResourcePattern:
+ """Resolve a single raw pattern once, at configuration time.
+
+ A regex-like string is compiled to a ``re.Pattern`` (with the correct
+ case-sensitivity baked in); a literal string and an already-compiled
+ pattern are returned unchanged. An invalid regular expression raises a
+ ``ValueError`` so the misconfiguration fails fast at setup time rather than
+ silently never matching.
"""
- A helper method to serialize and processes the options dictionary.
+ if isinstance(pattern, str) and probably_regex(pattern):
+ try:
+ return re.compile(pattern, re.IGNORECASE if ignore_case else 0)
+ except re.error as exc:
+ raise ValueError(f"Invalid regular expression in Flask-CORS configuration: {pattern!r}") from exc
+ return pattern
+
+
+def _resolve_patterns(patterns: Iterable[ResourcePattern], *, ignore_case: bool) -> list[ResourcePattern]:
+ """Resolve a list of patterns (see :func:`_resolve_pattern`).
+
+ After this, the request path can simply test ``isinstance(p, re.Pattern)``
+ instead of re-running the :func:`probably_regex` heuristic on every request.
"""
- options = (opts or {}).copy()
+ return [_resolve_pattern(p, ignore_case=ignore_case) for p in patterns]
+
- for key in opts.keys():
- if key not in DEFAULT_OPTIONS:
+def serialize_options(opts: Mapping[str, Any]) -> _ComputedCorsOptions:
+ """
+ Normalize a raw options mapping into a strongly-typed :class:`_ComputedCorsOptions`.
+
+ This is the single boundary where loosely-typed user input (arbitrary
+ keyword arguments, app config, resource dictionaries) is parsed into the
+ concrete, per-field types the rest of the package relies on.
+ """
+ for key in opts:
+ if key not in _KNOWN_OPTIONS:
LOG.warning("Unknown option passed to Flask-CORS: %s", key)
- # Ensure origins is a list of allowed origins with at least one entry.
- options["origins"] = sanitize_regex_param(options.get("origins"))
- options["allow_headers"] = sanitize_regex_param(options.get("allow_headers"))
+ # Ensure origins is a list of allowed origins with at least one entry. The
+ # wildcard is detected before the patterns are compiled (afterwards ``.*``
+ # is just another compiled regex, indistinguishable from a real one).
+ sanitized_origins = sanitize_regex_param(opts.get("origins"))
+ allow_all_origins = r".*" in sanitized_origins
+ supports_credentials = bool(opts.get("supports_credentials"))
+ send_wildcard = bool(opts.get("send_wildcard"))
# This is expressly forbidden by the spec. Raise a value error so people
# don't get burned in production.
- if r".*" in options["origins"] and options["supports_credentials"] and options["send_wildcard"]:
+ if allow_all_origins and supports_credentials and send_wildcard:
raise ValueError(
"Cannot use supports_credentials in conjunction with"
"an origin string of '*'. See: "
"http://www.w3.org/TR/cors/#resource-requests"
)
- serialize_option(options, "expose_headers")
- serialize_option(options, "methods", upper=True)
+ origins = _resolve_patterns(sanitized_origins, ignore_case=True)
+ allow_headers = _resolve_patterns(sanitize_regex_param(opts.get("allow_headers")), ignore_case=True)
- if isinstance(options.get("max_age"), timedelta):
- options["max_age"] = str(int(options["max_age"].total_seconds()))
+ methods = flexible_str(opts.get("methods"))
+ if methods is not None:
+ methods = methods.upper()
- return options
+ max_age = opts.get("max_age")
+ if isinstance(max_age, timedelta):
+ max_age = str(int(max_age.total_seconds()))
+
+ return _ComputedCorsOptions(
+ origins=origins,
+ allow_headers=allow_headers,
+ allow_all_origins=allow_all_origins,
+ methods=methods,
+ expose_headers=flexible_str(opts.get("expose_headers")),
+ max_age=max_age,
+ supports_credentials=supports_credentials,
+ send_wildcard=send_wildcard,
+ automatic_options=bool(opts.get("automatic_options")),
+ vary_header=bool(opts.get("vary_header")),
+ intercept_exceptions=bool(opts.get("intercept_exceptions")),
+ always_send=bool(opts.get("always_send")),
+ allow_private_network=bool(opts.get("allow_private_network")),
+ )
diff --git a/contrib/python/Flask-Cors/py3/flask_cors/decorator.py b/contrib/python/Flask-Cors/py3/flask_cors/decorator.py
index f8915b62d59..d3c883d4bae 100644
--- a/contrib/python/Flask-Cors/py3/flask_cors/decorator.py
+++ b/contrib/python/Flask-Cors/py3/flask_cors/decorator.py
@@ -1,14 +1,23 @@
+from __future__ import annotations
+
import logging
+from collections.abc import Callable
from functools import update_wrapper
+from typing import TYPE_CHECKING, Any, TypeVar, cast
+
+from flask import Response, current_app, make_response, request
-from flask import current_app, make_response, request
+from .core import FLASK_CORS_EVALUATED, CrossOriginOptionsInput, get_cors_options, set_cors_headers
-from .core import FLASK_CORS_EVALUATED, get_cors_options, set_cors_headers
+if TYPE_CHECKING:
+ from typing_extensions import Unpack
LOG = logging.getLogger(__name__)
+F = TypeVar("F", bound=Callable[..., Any])
+
-def cross_origin(*args, **kwargs):
+def cross_origin(*args: Any, **kwargs: Unpack[CrossOriginOptionsInput]) -> Callable[[F], F]:
"""
This function is the decorator which is used to wrap a Flask route with.
In the simplest case, simply use the default parameters to allow all
@@ -96,7 +105,7 @@ def cross_origin(*args, **kwargs):
"""
_options = kwargs
- def decorator(f):
+ def decorator(f: F) -> F:
LOG.debug("Enabling %s for cross_origin using options:%s", f, _options)
# If True, intercept OPTIONS requests by modifying the view function,
@@ -107,15 +116,20 @@ def cross_origin(*args, **kwargs):
# decorator (which is actually wraps the function object we return)
# intercepts OPTIONS handling, and requests will not have CORS headers
if _options.get("automatic_options", True):
- f.required_methods = getattr(f, "required_methods", set())
- f.required_methods.add("OPTIONS")
- f.provide_automatic_options = False
+ # ``f`` is a plain view function; these attributes are read by
+ # Flask's routing. Use an ``Any`` alias so the dynamic attribute
+ # assignment type-checks.
+ view_func: Any = f
+ required_methods = set(getattr(f, "required_methods", set()))
+ required_methods.add("OPTIONS")
+ view_func.required_methods = required_methods
+ view_func.provide_automatic_options = False
- def wrapped_function(*args, **kwargs):
+ def wrapped_function(*args: Any, **kwargs: Any) -> Response:
# Handle setting of Flask-Cors parameters
options = get_cors_options(current_app, _options)
- if options.get("automatic_options") and request.method == "OPTIONS":
+ if options.automatic_options and request.method == "OPTIONS":
resp = current_app.make_default_options_response()
else:
resp = make_response(f(*args, **kwargs))
@@ -124,6 +138,6 @@ def cross_origin(*args, **kwargs):
setattr(resp, FLASK_CORS_EVALUATED, True)
return resp
- return update_wrapper(wrapped_function, f)
+ return cast(F, update_wrapper(wrapped_function, f))
return decorator
diff --git a/contrib/python/Flask-Cors/py3/flask_cors/extension.py b/contrib/python/Flask-Cors/py3/flask_cors/extension.py
index 434f65eaa20..1e172142c43 100644
--- a/contrib/python/Flask-Cors/py3/flask_cors/extension.py
+++ b/contrib/python/Flask-Cors/py3/flask_cors/extension.py
@@ -1,9 +1,29 @@
+from __future__ import annotations
+
import logging
+from typing import TYPE_CHECKING, Any
from urllib.parse import unquote
-from flask import request
+from flask import Blueprint, Flask, Response, request
+
+from .core import (
+ ACL_ORIGIN,
+ CorsOptionsInput,
+ ResourcePattern,
+ _ComputedCorsOptions,
+ get_cors_options,
+ get_regexp_pattern,
+ merge_options,
+ parse_resources,
+ serialize_options,
+ set_cors_headers,
+ try_match_pattern,
+)
+
+if TYPE_CHECKING:
+ from collections.abc import Callable
-from .core import ACL_ORIGIN, get_cors_options, get_regexp_pattern, parse_resources, set_cors_headers, try_match_pattern
+ from typing_extensions import Unpack
LOG = logging.getLogger(__name__)
@@ -140,50 +160,61 @@ class CORS:
:type allow_private_network: bool
"""
- def __init__(self, app=None, **kwargs):
+ def __init__(self, app: Flask | Blueprint | None = None, **kwargs: Unpack[CorsOptionsInput]) -> None:
self._options = kwargs
if app is not None:
self.init_app(app, **kwargs)
- def init_app(self, app, **kwargs):
+ def init_app(self, app: Flask | Blueprint, **kwargs: Unpack[CorsOptionsInput]) -> None:
# The resources and options may be specified in the App Config, the CORS constructor
# or the kwargs to the call to init_app.
- options = get_cors_options(app, self._options, kwargs)
+ merged = merge_options(app, self._options, kwargs)
+ options = serialize_options(merged)
# Flatten our resources into a list of the form
- # (pattern_or_regexp, dictionary_of_options)
- resources = parse_resources(options.get("resources"))
+ # (pattern_or_regexp, dictionary_of_raw_options). ``resources`` is a raw
+ # spec consumed only here, so it is read from the merged mapping rather
+ # than carried on the resolved options.
+ resources = parse_resources(merged.get("resources", r"/*"))
- # Compute the options for each resource by combining the options from
- # the app's configuration, the constructor, the kwargs to init_app, and
- # finally the options specified in the resources dictionary.
- resources = [(pattern, get_cors_options(app, options, opts)) for (pattern, opts) in resources]
+ # Compute the options for each resource from the raw inputs (defaults,
+ # app config, constructor kwargs, init_app kwargs) plus the per-resource
+ # overrides. Re-merging the raw inputs rather than the already-resolved
+ # ``options`` avoids re-normalizing compiled regexes.
+ resolved_resources: list[tuple[ResourcePattern, _ComputedCorsOptions]] = [
+ (pattern, get_cors_options(app, self._options, kwargs, opts)) for (pattern, opts) in resources
+ ]
# Create a human-readable form of these resources by converting the compiled
# regular expressions into strings.
- resources_human = {get_regexp_pattern(pattern): opts for (pattern, opts) in resources}
+ resources_human = {get_regexp_pattern(pattern): opts for (pattern, opts) in resolved_resources}
LOG.debug("Configuring CORS with resources: %s", resources_human)
- cors_after_request = make_after_request_function(resources)
+ cors_after_request = make_after_request_function(resolved_resources)
app.after_request(cors_after_request)
- # Wrap exception handlers with cross_origin
- # These error handlers will still respect the behavior of the route
- if options.get("intercept_exceptions", True):
+ # Wrap exception handlers with cross_origin so error responses also
+ # receive CORS headers. Blueprints have no ``handle_exception`` /
+ # ``make_response``, so the ``hasattr`` guard skips them; the ``Any``
+ # alias lets us read and reassign those Flask-only methods without
+ # tripping mypy's attr-defined / method-assign checks for ``Blueprint``.
+ if options.intercept_exceptions and hasattr(app, "handle_exception"):
+ app_any: Any = app
- def _after_request_decorator(f):
- def wrapped_function(*args, **kwargs):
- return cors_after_request(app.make_response(f(*args, **kwargs)))
+ def _after_request_decorator(f: Callable[..., Any]) -> Callable[..., Response]:
+ def wrapped_function(*args: Any, **kwargs: Any) -> Response:
+ return cors_after_request(app_any.make_response(f(*args, **kwargs)))
return wrapped_function
- if hasattr(app, "handle_exception"):
- app.handle_exception = _after_request_decorator(app.handle_exception)
- app.handle_user_exception = _after_request_decorator(app.handle_user_exception)
+ app_any.handle_exception = _after_request_decorator(app_any.handle_exception)
+ app_any.handle_user_exception = _after_request_decorator(app_any.handle_user_exception)
-def make_after_request_function(resources):
- def cors_after_request(resp):
+def make_after_request_function(
+ resources: list[tuple[ResourcePattern, _ComputedCorsOptions]],
+) -> Callable[[Response], Response]:
+ def cors_after_request(resp: Response) -> Response:
# If CORS headers are set in a view decorator, pass
if resp.headers is not None and resp.headers.get(ACL_ORIGIN):
LOG.debug("CORS have been already evaluated, skipping")
diff --git a/contrib/python/Flask-Cors/py3/flask_cors/py.typed b/contrib/python/Flask-Cors/py3/flask_cors/py.typed
new file mode 100644
index 00000000000..e69de29bb2d
--- /dev/null
+++ b/contrib/python/Flask-Cors/py3/flask_cors/py.typed
diff --git a/contrib/python/Flask-Cors/py3/ya.make b/contrib/python/Flask-Cors/py3/ya.make
index 4c888cf5fde..4e0f3d25720 100644
--- a/contrib/python/Flask-Cors/py3/ya.make
+++ b/contrib/python/Flask-Cors/py3/ya.make
@@ -2,7 +2,7 @@
PY3_LIBRARY()
-VERSION(6.0.3)
+VERSION(6.0.5)
LICENSE(MIT)
@@ -25,6 +25,7 @@ RESOURCE_FILES(
PREFIX contrib/python/Flask-Cors/py3/
.dist-info/METADATA
.dist-info/top_level.txt
+ flask_cors/py.typed
)
END()
diff --git a/contrib/python/clickhouse-connect/.dist-info/METADATA b/contrib/python/clickhouse-connect/.dist-info/METADATA
index cb9928f01a9..cf511761a8d 100644
--- a/contrib/python/clickhouse-connect/.dist-info/METADATA
+++ b/contrib/python/clickhouse-connect/.dist-info/METADATA
@@ -1,6 +1,6 @@
Metadata-Version: 2.4
Name: clickhouse-connect
-Version: 1.1.1
+Version: 1.3.0
Summary: ClickHouse Database Core Driver for Python, Pandas, and Superset
Home-page: https://github.com/ClickHouse/clickhouse-connect
Author: ClickHouse Inc.
diff --git a/contrib/python/clickhouse-connect/clickhouse_connect/_version.py b/contrib/python/clickhouse-connect/clickhouse_connect/_version.py
index 64c8c21b112..d28b3ddc3b1 100644
--- a/contrib/python/clickhouse-connect/clickhouse_connect/_version.py
+++ b/contrib/python/clickhouse-connect/clickhouse_connect/_version.py
@@ -1 +1 @@
-version = "1.1.1"
+version = "1.3.0"
diff --git a/contrib/python/clickhouse-connect/clickhouse_connect/cc_sqlalchemy/alembic/impl.py b/contrib/python/clickhouse-connect/clickhouse_connect/cc_sqlalchemy/alembic/impl.py
index 771692ad015..1f942979c07 100644
--- a/contrib/python/clickhouse-connect/clickhouse_connect/cc_sqlalchemy/alembic/impl.py
+++ b/contrib/python/clickhouse-connect/clickhouse_connect/cc_sqlalchemy/alembic/impl.py
@@ -55,11 +55,20 @@ class ClickHouseImpl(DefaultImpl):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
+ self._add_integration_tag()
if self.context_opts.get("include_schemas") and not self.context_opts.get("version_table_schema") and self.connection is not None:
current_database = self.connection.execute(text("SELECT currentDatabase()")).scalar()
if current_database:
self.context_opts["version_table_schema"] = current_database
+ def _add_integration_tag(self) -> None:
+ if self.connection is None:
+ return
+ try:
+ self.connection.connection.driver_connection.client._add_integration_tag("alembic")
+ except Exception:
+ pass
+
def version_table_impl(
self,
*,
diff --git a/contrib/python/clickhouse-connect/clickhouse_connect/cc_sqlalchemy/datatypes/sqltypes.py b/contrib/python/clickhouse-connect/clickhouse_connect/cc_sqlalchemy/datatypes/sqltypes.py
index 94d8e67fb40..5356150545b 100644
--- a/contrib/python/clickhouse-connect/clickhouse_connect/cc_sqlalchemy/datatypes/sqltypes.py
+++ b/contrib/python/clickhouse-connect/clickhouse_connect/cc_sqlalchemy/datatypes/sqltypes.py
@@ -1,8 +1,18 @@
+import ipaddress
+import uuid
from collections.abc import Sequence
from enum import Enum as PyEnum
from sqlalchemy.exc import ArgumentError
from sqlalchemy.types import (
+ ARRAY,
+ Float,
+ Integer,
+ Interval,
+ Numeric,
+ UserDefinedType,
+)
+from sqlalchemy.types import (
Boolean as SqlaBoolean,
)
from sqlalchemy.types import (
@@ -12,17 +22,10 @@ from sqlalchemy.types import (
DateTime as SqlaDateTime,
)
from sqlalchemy.types import (
- Float,
- Integer,
- Interval,
- Numeric,
- UserDefinedType,
-)
-from sqlalchemy.types import (
String as SqlaString,
)
-from clickhouse_connect.cc_sqlalchemy.datatypes.base import ChSqlaType, schema_types
+from clickhouse_connect.cc_sqlalchemy.datatypes.base import ChSqlaType, schema_types, sqla_type_from_name
from clickhouse_connect.datatypes.base import EMPTY_TYPE_DEF, LC_TYPE_DEF, NULLABLE_TYPE_DEF, TypeDef
from clickhouse_connect.datatypes.numeric import Enum8 as ChEnum8
from clickhouse_connect.datatypes.numeric import Enum16 as ChEnum16
@@ -209,43 +212,43 @@ class FixedString(ChSqlaType, SqlaString):
class IPv4(ChSqlaType, UserDefinedType):
- python_type = None
+ python_type = ipaddress.IPv4Address
class IPv6(ChSqlaType, UserDefinedType):
- python_type = None
+ python_type = ipaddress.IPv6Address
class UUID(ChSqlaType, UserDefinedType):
- python_type = None
+ python_type = uuid.UUID
class Nothing(ChSqlaType, UserDefinedType):
- python_type = None
+ python_type = type(None)
class Point(ChSqlaType, UserDefinedType):
- python_type = None
+ python_type = tuple
class Ring(ChSqlaType, UserDefinedType):
- python_type = None
+ python_type = list
class Polygon(ChSqlaType, UserDefinedType):
- python_type = None
+ python_type = list
class MultiPolygon(ChSqlaType, UserDefinedType):
- python_type = None
+ python_type = list
class LineString(ChSqlaType, UserDefinedType):
- python_type = None
+ python_type = list
class MultiLineString(ChSqlaType, UserDefinedType):
- python_type = None
+ python_type = list
class Date(ChSqlaType, SqlaDate):
@@ -412,8 +415,9 @@ class LowCardinality:
return element.__class__(type_def=TypeDef(wrappers, orig.keys, orig.values))
-class Array(ChSqlaType, UserDefinedType):
+class Array(ChSqlaType, ARRAY):
python_type = list
+ dimensions = 1
def __init__(self, element: ChSqlaType | type[ChSqlaType] = None, type_def: TypeDef = None):
"""
@@ -425,7 +429,12 @@ class Array(ChSqlaType, UserDefinedType):
if callable(element):
element = element()
type_def = TypeDef(values=(element.name,))
- super().__init__(type_def)
+ ChSqlaType.__init__(self, type_def)
+ # Set item_type directly; calling ARRAY.__init__ would reject nested Array(Array(T)),
+ # which CH supports natively (CH expresses dimensions via nesting, not a dim count).
+ # as_tuple has no class-level default, so set it here to satisfy ARRAY result processing.
+ self.item_type = sqla_type_from_name(type_def.values[0])
+ self.as_tuple = False
class Map(ChSqlaType, UserDefinedType):
@@ -489,7 +498,7 @@ class JSON(ChSqlaType, UserDefinedType):
Note this isn't currently supported for insert/select, only table definitions
"""
- python_type = None
+ python_type = dict
class Nested(ChSqlaType, UserDefinedType):
@@ -497,11 +506,11 @@ class Nested(ChSqlaType, UserDefinedType):
Note this isn't currently supported for insert/select, only table definitions
"""
- python_type = None
+ python_type = list
class SimpleAggregateFunction(ChSqlaType, UserDefinedType):
- python_type = None
+ python_type = str
def __init__(
self,
@@ -532,7 +541,7 @@ class AggregateFunction(ChSqlaType, UserDefinedType):
Note this isn't currently supported for insert/select, only table definitions
"""
- python_type = None
+ python_type = str
def __init__(self, *params, type_def: TypeDef = None):
"""
diff --git a/contrib/python/clickhouse-connect/clickhouse_connect/cc_sqlalchemy/dialect.py b/contrib/python/clickhouse-connect/clickhouse_connect/cc_sqlalchemy/dialect.py
index 46eec9166bd..368e80422ba 100644
--- a/contrib/python/clickhouse-connect/clickhouse_connect/cc_sqlalchemy/dialect.py
+++ b/contrib/python/clickhouse-connect/clickhouse_connect/cc_sqlalchemy/dialect.py
@@ -5,7 +5,7 @@ from sqlalchemy.exc import NoResultFound, NoSuchTableError
from clickhouse_connect import dbapi
from clickhouse_connect.cc_sqlalchemy import dialect_name, ischema_names
-from clickhouse_connect.cc_sqlalchemy.inspector import ChInspector, get_table_metadata
+from clickhouse_connect.cc_sqlalchemy.inspector import ChInspector, get_columns, get_table_metadata
from clickhouse_connect.cc_sqlalchemy.sql import full_table
from clickhouse_connect.cc_sqlalchemy.sql.compiler import ChStatementCompiler
from clickhouse_connect.cc_sqlalchemy.sql.ddlcompiler import ChDDLCompiler
@@ -61,6 +61,11 @@ class ClickHouseDialect(DefaultDialect):
),
]
+ def __init__(self, server_side_params: bool = False, **kwargs):
+ # Set before super().__init__() so ChIdentifierPreparer can read it when built.
+ self.server_side_params = server_side_params
+ super().__init__(**kwargs)
+
# SQA 1 compatibility
@classmethod
@@ -89,6 +94,9 @@ class ClickHouseDialect(DefaultDialect):
cmd += " FROM " + quote_identifier(schema)
return [row.name for row in connection.execute(text(cmd))]
+ def get_columns(self, connection, table_name, schema=None, **kw):
+ return get_columns(connection, table_name, schema)
+
def get_primary_keys(self, connection, table_name, schema=None, **kw):
return []
diff --git a/contrib/python/clickhouse-connect/clickhouse_connect/cc_sqlalchemy/inspector.py b/contrib/python/clickhouse-connect/clickhouse_connect/cc_sqlalchemy/inspector.py
index 067c9fa029c..e566fec988c 100644
--- a/contrib/python/clickhouse-connect/clickhouse_connect/cc_sqlalchemy/inspector.py
+++ b/contrib/python/clickhouse-connect/clickhouse_connect/cc_sqlalchemy/inspector.py
@@ -4,7 +4,7 @@ from collections.abc import Collection
from typing import Any
import sqlalchemy.schema as sa_schema
-from sqlalchemy import text
+from sqlalchemy import String, bindparam, text
from sqlalchemy.engine.reflection import Inspector
from sqlalchemy.exc import NoResultFound
@@ -27,7 +27,9 @@ def _database_name(connection, schema: str | None) -> str:
def get_table_metadata(connection, table_name, schema=None):
database = _database_name(connection, schema)
result_set = connection.execute(
- text("SELECT engine, engine_full, comment FROM system.tables WHERE database = :database AND name = :table_name"),
+ text("SELECT engine, engine_full, comment FROM system.tables WHERE database = :database AND name = :table_name").bindparams(
+ bindparam("database", type_=String()), bindparam("table_name", type_=String())
+ ),
{"database": database, "table_name": table_name},
)
row = next(result_set, None)
@@ -119,6 +121,36 @@ def get_dictionary_metadata(connection, table_name: str, schema: str | None = No
return metadata
+def get_columns(connection, table_name: str, schema: str | None = None) -> list[dict[str, Any]]:
+ table_metadata = get_table_metadata(connection, table_name, schema)
+ if table_metadata.engine == "Dictionary":
+ return get_dictionary_columns(connection, table_name, schema)
+ table_id = full_table(table_name, schema)
+ result_set = connection.execute(text(f"DESCRIBE TABLE {table_id}"))
+ if not result_set:
+ raise NoResultFound(f"Table {table_id} does not exist")
+ columns = []
+ for row in result_set:
+ sqla_type = sqla_type_from_name(row.type.replace("\n", ""))
+ col = {
+ "name": row.name,
+ "type": sqla_type,
+ "nullable": sqla_type.nullable,
+ "autoincrement": False,
+ "comment": row.comment or None,
+ "clickhouse_codec": row.codec_expression or None,
+ "clickhouse_ttl": text(row.ttl_expression) if row.ttl_expression else None,
+ }
+ if row.default_type == "DEFAULT" and row.default_expression:
+ col["server_default"] = text(row.default_expression)
+ elif row.default_type == "MATERIALIZED" and row.default_expression:
+ col["clickhouse_materialized"] = text(row.default_expression)
+ elif row.default_type == "ALIAS" and row.default_expression:
+ col["clickhouse_alias"] = text(row.default_expression)
+ columns.append(col)
+ return columns
+
+
class ChInspector(Inspector):
def reflect_table(
self,
@@ -155,30 +187,4 @@ class ChInspector(Inspector):
table.kwargs["clickhouse_engine"] = table.engine
def get_columns(self, table_name, schema=None, **_kwargs):
- table_metadata = get_table_metadata(self.bind, table_name, schema)
- if table_metadata.engine == "Dictionary":
- return get_dictionary_columns(self.bind, table_name, schema)
- table_id = full_table(table_name, schema)
- result_set = self.bind.execute(text(f"DESCRIBE TABLE {table_id}"))
- if not result_set:
- raise NoResultFound(f"Table {table_id} does not exist")
- columns = []
- for row in result_set:
- sqla_type = sqla_type_from_name(row.type.replace("\n", ""))
- col = {
- "name": row.name,
- "type": sqla_type,
- "nullable": sqla_type.nullable,
- "autoincrement": False,
- "comment": row.comment or None,
- "clickhouse_codec": row.codec_expression or None,
- "clickhouse_ttl": text(row.ttl_expression) if row.ttl_expression else None,
- }
- if row.default_type == "DEFAULT" and row.default_expression:
- col["server_default"] = text(row.default_expression)
- elif row.default_type == "MATERIALIZED" and row.default_expression:
- col["clickhouse_materialized"] = text(row.default_expression)
- elif row.default_type == "ALIAS" and row.default_expression:
- col["clickhouse_alias"] = text(row.default_expression)
- columns.append(col)
- return columns
+ return get_columns(self.bind, table_name, schema)
diff --git a/contrib/python/clickhouse-connect/clickhouse_connect/cc_sqlalchemy/sql/compiler.py b/contrib/python/clickhouse-connect/clickhouse_connect/cc_sqlalchemy/sql/compiler.py
index 9dc41b21154..28a86cade19 100644
--- a/contrib/python/clickhouse-connect/clickhouse_connect/cc_sqlalchemy/sql/compiler.py
+++ b/contrib/python/clickhouse-connect/clickhouse_connect/cc_sqlalchemy/sql/compiler.py
@@ -1,10 +1,16 @@
+import re
+
from sqlalchemy.exc import CompileError
from sqlalchemy.sql import elements, sqltypes
from sqlalchemy.sql.compiler import SQLCompiler
+from sqlalchemy.util import memoized_property
from clickhouse_connect.cc_sqlalchemy.datatypes.base import ChSqlaType
from clickhouse_connect.cc_sqlalchemy.sql import format_table
+# The driver's external_bind_re only recognizes \w+ placeholder names.
+_bind_name_re = re.compile(r"\w+\Z")
+
def _find_outermost_marker(text, markers):
"""Earliest index in `text` where any of `markers` appears at paren depth 0, skipping
@@ -40,12 +46,8 @@ def _find_outermost_marker(text, markers):
return -1
-def _resolve_ch_type_name(sqla_type):
- """Resolve a SQLAlchemy type instance to a ClickHouse type name string.
-
- Handles both native ChSqlaType instances which carry their ClickHouse name
- directly and generic SQLAlchemy types by mapping to reasonable ClickHouse defaults.
- """
+def _ch_type_name(sqla_type):
+ """Map a SQLAlchemy type to a ClickHouse type name, or None if unmapped."""
if isinstance(sqla_type, ChSqlaType):
return sqla_type.name
# Order matters so we need to check subtypes before parent types
@@ -69,10 +71,93 @@ def _resolve_ch_type_name(sqla_type):
return "Date"
if isinstance(sqla_type, sqltypes.String):
return "String"
- return "String"
+ return None
+
+
+def _resolve_ch_type_name(sqla_type):
+ """ClickHouse type name for a VALUES column, falling back to String."""
+ return _ch_type_name(sqla_type) or "String"
+
+
+def _resolve_ch_bind_type(sqla_type):
+ """ClickHouse type name for a server-side bind, raising on unmapped types."""
+ if isinstance(sqla_type, sqltypes.TupleType):
+ return f"Tuple({', '.join(_resolve_ch_bind_type(t) for t in sqla_type.types)})"
+ name = _ch_type_name(sqla_type)
+ if name is None:
+ raise CompileError(f"server_side_params needs an explicit type for every bind; none resolved for {sqla_type!r}.")
+ return name
class ChStatementCompiler(SQLCompiler):
+ # SQLAlchemy 1.4 does not pass bindparam_type to bindparam_string, so stash it here.
+ _ch_bind_type = None
+
+ @property
+ def _server_side_params(self):
+ return getattr(self.dialect, "server_side_params", False)
+
+ @property
+ def _ch_array_binds(self):
+ return self.__dict__.setdefault("_ch_array_bind_names", set())
+
+ @memoized_property
+ def _bind_processors(self):
+ """Bind processors with array-bound params dropped; the driver formats those."""
+ processors = SQLCompiler._bind_processors.fget(self)
+ if self._ch_array_binds:
+ return {k: v for k, v in processors.items() if k not in self._ch_array_binds}
+ return processors
+
+ def _ch_check_bind_name(self, name):
+ if not _bind_name_re.match(name):
+ raise CompileError(
+ f"server_side_params cannot bind parameter {name!r}: ClickHouse server-side "
+ "parameter names must match [A-Za-z0-9_]. Rename the bind parameter."
+ )
+
+ def visit_bindparam(self, bindparam, **kw):
+ if not self._server_side_params:
+ return super().visit_bindparam(bindparam, **kw)
+ if bindparam.expanding and not kw.get("literal_binds") and not bindparam.literal_execute:
+ return self._ch_expanding_bindparam(bindparam, **kw)
+ prev = self._ch_bind_type
+ self._ch_bind_type = bindparam.type
+ try:
+ return super().visit_bindparam(bindparam, **kw)
+ finally:
+ self._ch_bind_type = prev
+
+ def _ch_has_bind_processor(self, sqla_type):
+ if isinstance(sqla_type, sqltypes.TupleType):
+ return any(self._ch_has_bind_processor(t) for t in sqla_type.types)
+ return sqla_type._cached_bind_processor(self.dialect) is not None
+
+ def _ch_expanding_bindparam(self, bindparam, **kw):
+ """Render an IN-family bind as a single {name:Array(Type)} placeholder."""
+ # Array binds skip per-element processors, so a type that needs one can't be bound.
+ if self._ch_has_bind_processor(bindparam.type):
+ raise CompileError(f"server_side_params cannot bind an IN list of {bindparam.type!r}: it needs a bind processor.")
+ # Drop the param from post-compile expansion so its list reaches the driver intact.
+ super().visit_bindparam(bindparam, **kw)
+ self.post_compile_params = self.post_compile_params.difference([bindparam])
+ name = self._truncate_bindparam(bindparam)
+ actual = self.escaped_bind_names.get(name, name) if self.escaped_bind_names else name
+ self._ch_check_bind_name(actual)
+ self._ch_array_binds.add(actual)
+ return "{" + actual + ":Array(" + _resolve_ch_bind_type(bindparam.type) + ")}"
+
+ def bindparam_string(self, name, **kw):
+ base = super().bindparam_string(name, **kw)
+ if not self._server_side_params or kw.get("post_compile") or kw.get("expanding"):
+ return base
+ actual = self.escaped_bind_names.get(name, name) if self.escaped_bind_names else name
+ self._ch_check_bind_name(actual)
+ ch_type = kw.get("bindparam_type") or self._ch_bind_type
+ if ch_type is None:
+ raise CompileError(f"server_side_params requires a typed bind parameter, but none was available for {name!r}.")
+ return "{" + actual + ":" + _resolve_ch_bind_type(ch_type) + "}"
+
def _raise_on_escape(self, binary, operator_name: str):
if binary.modifiers.get("escape") is not None:
raise CompileError(f"ClickHouse does not support the ESCAPE clause on {operator_name}")
diff --git a/contrib/python/clickhouse-connect/clickhouse_connect/cc_sqlalchemy/sql/preparer.py b/contrib/python/clickhouse-connect/clickhouse_connect/cc_sqlalchemy/sql/preparer.py
index b22c2b32c61..4521eb97dfa 100644
--- a/contrib/python/clickhouse-connect/clickhouse_connect/cc_sqlalchemy/sql/preparer.py
+++ b/contrib/python/clickhouse-connect/clickhouse_connect/cc_sqlalchemy/sql/preparer.py
@@ -6,5 +6,10 @@ from clickhouse_connect.driver.binding import quote_identifier
class ChIdentifierPreparer(IdentifierPreparer):
quote_identifier = staticmethod(quote_identifier)
+ def __init__(self, dialect, **kwargs):
+ super().__init__(dialect, **kwargs)
+ if getattr(dialect, "server_side_params", False):
+ self._double_percents = False
+
def _requires_quotes(self, _value):
return True
diff --git a/contrib/python/clickhouse-connect/clickhouse_connect/dbapi/cursor.py b/contrib/python/clickhouse-connect/clickhouse_connect/dbapi/cursor.py
index 9ad0431d6ed..af7f36f67b6 100644
--- a/contrib/python/clickhouse-connect/clickhouse_connect/dbapi/cursor.py
+++ b/contrib/python/clickhouse-connect/clickhouse_connect/dbapi/cursor.py
@@ -1,6 +1,6 @@
import logging
import re
-from collections.abc import Sequence
+from collections.abc import Mapping, Sequence
from clickhouse_connect.datatypes.registry import get_from_name
from clickhouse_connect.driver import Client
@@ -94,10 +94,19 @@ class Cursor:
op_columns = None
if "VALUES" not in temp.upper():
return False
- col_names = list(data[0].keys())
- if op_columns and {unescape_identifier(x) for x in op_columns} != set(col_names):
- return False # Data sent in doesn't match the columns in the insert statement
- data_values = [list(row.values()) for row in data]
+ first_row = data[0]
+ if isinstance(first_row, Mapping):
+ col_names = list(first_row.keys())
+ if op_columns and {unescape_identifier(x) for x in op_columns} != set(col_names):
+ return False # Data sent in doesn't match the columns in the insert statement
+ data_values = [list(row.values()) for row in data]
+ elif isinstance(first_row, Sequence) and not isinstance(first_row, (str, bytes)):
+ # PEP 249 also allows rows as sequences; take column names from the
+ # insert statement if present, otherwise insert into all columns
+ col_names = [unescape_identifier(x) for x in op_columns] if op_columns else "*"
+ data_values = data
+ else:
+ return False
self.client.insert(table, data_values, col_names)
self.data = []
return True
diff --git a/contrib/python/clickhouse-connect/clickhouse_connect/driver/__init__.py b/contrib/python/clickhouse-connect/clickhouse_connect/driver/__init__.py
index d0fe9f1d8f6..811286ad24c 100644
--- a/contrib/python/clickhouse-connect/clickhouse_connect/driver/__init__.py
+++ b/contrib/python/clickhouse-connect/clickhouse_connect/driver/__init__.py
@@ -1,8 +1,9 @@
from __future__ import annotations
+from collections.abc import Awaitable, Callable
from inspect import signature
from typing import TYPE_CHECKING, Any
-from urllib.parse import parse_qs, urlparse
+from urllib.parse import parse_qs, unquote, urlparse
import clickhouse_connect.driver.ctypes # noqa: F401 -- side-effect import
from clickhouse_connect.driver.client import Client
@@ -34,6 +35,11 @@ def default_port(interface: str, secure: bool) -> int:
raise ValueError("Unrecognized ClickHouse interface")
+def _unquote(value: str | None) -> str | None:
+ """Percent-decode a DSN component, passing through None/empty."""
+ return unquote(value) if value else value
+
+
def _parse_connection_params(
host: str | None,
username: str | None,
@@ -48,12 +54,12 @@ def _parse_connection_params(
"""Parse and normalize connection parameters including DSN parsing."""
if dsn:
parsed = urlparse(dsn)
- username = username or parsed.username
- password = password or parsed.password
+ username = username or _unquote(parsed.username)
+ password = password or _unquote(parsed.password) or ""
host = host or parsed.hostname
port = port or parsed.port
if parsed.path and (not database or database == "__default__"):
- database = parsed.path[1:].split("/")[0]
+ database = unquote(parsed.path[1:].split("/")[0])
database = database or parsed.path
for k, v in parse_qs(parsed.query).items():
kwargs[k] = v[0]
@@ -75,10 +81,26 @@ def _parse_connection_params(
return host, username, password, port, database, interface
-def _validate_access_token(access_token: str | None, username: str | None, password: str) -> None:
- """Validate that access_token and username/password are not both provided."""
- if access_token and (username or password != ""):
- raise ProgrammingError("Cannot use both access_token and username/password")
+def _validate_access_token(access_token: str | None, token_provider: Callable[[], str] | None, username: str | None, password: str) -> None:
+ """Validate that token-based and username/password auth are not mixed."""
+ if (access_token or token_provider) and (username or password):
+ raise ProgrammingError("Cannot use both token authentication and username/password")
+ if access_token and token_provider:
+ raise ProgrammingError("Cannot use both access_token and token_provider")
+
+
+def _pop_headers_arg(headers: Any | None, kwargs: dict[str, Any]) -> Any | None:
+ """Hoist headers parsed through generic kwargs while preserving explicit headers."""
+ if "headers" in kwargs:
+ kwargs_headers = kwargs.pop("headers")
+ if headers is None:
+ headers = kwargs_headers
+ return headers
+
+
+def _validate_headers(headers: Any | None) -> None:
+ if headers is not None and not isinstance(headers, dict):
+ raise ProgrammingError("headers must be a dictionary of HTTP header names and values")
def create_client(
@@ -87,12 +109,14 @@ def create_client(
username: str | None = None,
password: str = "",
access_token: str | None = None,
+ token_provider: Callable[[], str] | None = None,
database: str = "__default__",
interface: str | None = None,
port: int = 0,
secure: bool | str = False,
dsn: str | None = None,
settings: dict[str, Any] | None = None,
+ headers: dict[str, str] | None = None,
generic_args: dict[str, Any] | None = None,
**kwargs,
) -> Client:
@@ -106,6 +130,9 @@ def create_client(
Should not be set if `access_token` is used.
:param access_token: JWT access token (ClickHouse Cloud feature).
Should not be set if `username`/`password` are used.
+ :param token_provider: A callable returning a JWT access token (ClickHouse Cloud feature). Called for the initial token and
+ again to refresh it whenever the server rejects the current one.
+ Should not be set if `access_token` or `username`/`password` are used.
:param database: The default database for the connection. If not set, ClickHouse Connect will use the
default database for username.
:param interface: Must be http or https. Defaults to http, or to https if port is set to 8443 or 443
@@ -115,6 +142,9 @@ def create_client(
:param dsn: A string in standard DSN (Data Source Name) format. Other connection values (such as host or user)
will be extracted from this string if not set otherwise.
:param settings: ClickHouse server settings to be used with the session/every request
+ :param headers: Additional HTTP headers to send with every request. This can be used for proxy or gateway
+ authentication, such as Cloudflare Access service token headers. These headers are applied after driver defaults,
+ so they can intentionally override headers such as Authorization or User-Agent.
:param generic_args: Used internally to parse DBAPI connection strings into keyword arguments and ClickHouse settings.
It is not recommended to use this parameter externally.
@@ -154,22 +184,27 @@ def create_client(
match the server's column definition which means timezone-aware when the column defines a timezone and naive
for bare DateTime columns.
:param autogenerate_session_id If set, this will override the 'autogenerate_session_id' common setting.
- :param form_encode_query_params If True, query parameters will be sent as form-encoded data in the request body
- instead of as URL parameters. This is useful for queries with large parameter sets that might exceed URL length
- limits. Only available for query operations (not inserts). Default: False
+ :param form_encode_query_params If True, always send query parameters as form-encoded data in the request body
+ instead of as URL parameters. When False, large parameter payloads are still automatically sent as form data to
+ avoid exceeding URL length limits, except for queries using binary parameter binds, which are only form-encoded
+ when this is True. Only available for query operations (not inserts). Default: False
:return: ClickHouse Connect Client instance
"""
host, username, password, port, database, interface = _parse_connection_params(
host, username, password, port, database, interface, secure, dsn, kwargs
)
- _validate_access_token(access_token, username, password)
+ headers = _pop_headers_arg(headers, kwargs)
+ _validate_access_token(access_token, token_provider, username, password)
settings = settings or {}
if interface.startswith("http"):
if generic_args:
client_params = signature(HttpClient).parameters
for name, value in generic_args.items():
- if name in client_params:
+ if name == "headers":
+ if headers is None:
+ headers = value
+ elif name in client_params:
kwargs[name] = value
elif name == "compression":
if "compress" not in kwargs:
@@ -178,7 +213,26 @@ def create_client(
if name.startswith("ch_"):
name = name[3:]
settings[name] = value
- return HttpClient(interface, host, port, username, password, database, access_token, settings=settings, **kwargs)
+ # token auth may also arrive via generic_args (DB-API connect_args); pop both so neither is passed twice
+ generic_access = kwargs.pop("access_token", None)
+ generic_token = kwargs.pop("token_provider", None)
+ access_token = access_token or generic_access
+ token_provider = token_provider or generic_token
+ _validate_access_token(access_token, token_provider, username, password)
+ _validate_headers(headers)
+ return HttpClient(
+ interface,
+ host,
+ port,
+ username,
+ password,
+ database,
+ access_token,
+ token_provider=token_provider,
+ settings=settings,
+ headers=headers,
+ **kwargs,
+ )
raise ProgrammingError(f"Unrecognized client type {interface}")
@@ -188,12 +242,14 @@ async def create_async_client(
username: str | None = None,
password: str = "",
access_token: str | None = None,
+ token_provider: Callable[[], str | Awaitable[str]] | None = None,
database: str = "__default__",
interface: str | None = None,
port: int = 0,
secure: bool | str = False,
dsn: str | None = None,
settings: dict[str, Any] | None = None,
+ headers: dict[str, str] | None = None,
generic_args: dict[str, Any] | None = None,
connector_limit: int = 100,
connector_limit_per_host: int = 20,
@@ -212,6 +268,9 @@ async def create_async_client(
:param username: The ClickHouse username. If not set, the default ClickHouse user will be used.
:param password: The password for username.
:param access_token: JWT access token.
+ :param token_provider: A callable returning a JWT access token. Called for the initial token and
+ again to refresh it whenever the server rejects the current one. Because multiple in-flight requests
+ may each trigger a refresh concurrently, the callable must be safe to invoke in parallel.
:param database: The default database for the connection. If not set, ClickHouse Connect will use the
default database for username.
:param interface: Must be http or https. Defaults to http, or to https if port is set to 8443 or 443
@@ -221,6 +280,9 @@ async def create_async_client(
:param dsn: A string in standard DSN (Data Source Name) format. Other connection values (such as host or user)
will be extracted from this string if not set otherwise.
:param settings: ClickHouse server settings to be used with the session/every request
+ :param headers: Additional HTTP headers to send with every request. This can be used for proxy or gateway
+ authentication, such as Cloudflare Access service token headers. These headers are applied after driver defaults,
+ so they can intentionally override headers such as Authorization or User-Agent.
:param generic_args: Used internally to parse DBAPI connection strings into keyword arguments and ClickHouse settings.
It is not recommended to use this parameter externally
:param connector_limit: Maximum number of allowable connections to the server
@@ -259,9 +321,10 @@ async def create_async_client(
match the server's column definition which means timezone-aware when the column defines a timezone and naive
for bare DateTime columns.
:param autogenerate_session_id If set, this will override the 'autogenerate_session_id' common setting.
- :param form_encode_query_params If True, query parameters will be sent as form-encoded data in the request body
- instead of as URL parameters. This is useful for queries with large parameter sets that might exceed URL length
- limits. Only available for query operations (not inserts). Default: False
+ :param form_encode_query_params If True, always send query parameters as form-encoded data in the request body
+ instead of as URL parameters. When False, large parameter payloads are still automatically sent as form data to
+ avoid exceeding URL length limits, except for queries using binary parameter binds, which are only form-encoded
+ when this is True. Only available for query operations (not inserts). Default: False
:return: ClickHouse Connect AsyncClient instance
"""
try:
@@ -280,13 +343,17 @@ async def create_async_client(
host, username, password, port, database, interface = _parse_connection_params(
host, username, password, port, database, interface, secure, dsn, kwargs
)
- _validate_access_token(access_token, username, password)
+ headers = _pop_headers_arg(headers, kwargs)
+ _validate_access_token(access_token, token_provider, username, password)
settings = settings or {}
if generic_args:
client_params = signature(_AsyncClient).parameters
for name, value in generic_args.items():
- if name in client_params:
+ if name == "headers":
+ if headers is None:
+ headers = value
+ elif name in client_params:
kwargs[name] = value
elif name == "compression":
if "compress" not in kwargs:
@@ -299,6 +366,13 @@ async def create_async_client(
if "autogenerate_session_id" not in kwargs:
kwargs["autogenerate_session_id"] = False
+ # token auth may also arrive via generic_args (DB-API connect_args); pop both so neither is passed twice
+ generic_access = kwargs.pop("access_token", None)
+ generic_token = kwargs.pop("token_provider", None)
+ access_token = access_token or generic_access
+ token_provider = token_provider or generic_token
+ _validate_access_token(access_token, token_provider, username, password)
+ _validate_headers(headers)
client = _AsyncClient(
interface=interface,
host=host,
@@ -307,7 +381,9 @@ async def create_async_client(
password=password,
database=database,
access_token=access_token,
+ token_provider=token_provider,
settings=settings,
+ headers=headers,
connector_limit=connector_limit,
connector_limit_per_host=connector_limit_per_host,
keepalive_timeout=keepalive_timeout,
diff --git a/contrib/python/clickhouse-connect/clickhouse_connect/driver/asyncclient.py b/contrib/python/clickhouse-connect/clickhouse_connect/driver/asyncclient.py
index 1cadab3e907..b0301df84ca 100644
--- a/contrib/python/clickhouse-connect/clickhouse_connect/driver/asyncclient.py
+++ b/contrib/python/clickhouse-connect/clickhouse_connect/driver/asyncclient.py
@@ -2,6 +2,7 @@ from __future__ import annotations
import asyncio
import gzip
+import inspect
import io
import json
import logging
@@ -34,13 +35,20 @@ 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
+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
+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
@@ -60,6 +68,7 @@ from clickhouse_connect.driver.transform import NativeTransform
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:
@@ -197,6 +206,7 @@ class AsyncClient(Client):
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,
@@ -224,6 +234,7 @@ class AsyncClient(Client):
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().
@@ -243,6 +254,8 @@ class AsyncClient(Client):
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:
@@ -329,6 +342,8 @@ class AsyncClient(Client):
self._reported_libs = set()
self._last_pool_reset = 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
@@ -382,6 +397,9 @@ class AsyncClient(Client):
if self._initialized:
return
+ if self._token_provider:
+ self.set_access_token(await self._resolve_token())
+
try:
tz_source = self._deferred_tz_source
@@ -533,6 +551,15 @@ class AsyncClient(Client):
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, 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"):
@@ -560,13 +587,14 @@ class AsyncClient(Client):
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)
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 self.form_encode_query_params:
+ if use_form:
files = {}
if context.external_data:
params.update(context.external_data.query_params)
@@ -625,7 +653,7 @@ class AsyncClient(Client):
files = None
data = None
- if self.form_encode_query_params:
+ if use_form:
fields = {"query": final_query}
fields.update(context.bind_params)
@@ -1105,10 +1133,11 @@ class AsyncClient(Client):
files = None
body = None
- if external_data and not self.form_encode_query_params and isinstance(final_query, bytes):
+ 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 self.form_encode_query_params:
+ 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():
@@ -1870,26 +1899,29 @@ class AsyncClient(Client):
"""
try:
body = ""
+ full_body = ""
try:
raw_body = await response.read()
encoding = response.headers.get("Content-Encoding")
+ loop = asyncio.get_running_loop()
if encoding:
- loop = asyncio.get_running_loop()
def decompress_and_decode():
- decompressed = decompress_response(raw_body, encoding)
- return common.format_error(decompressed.decode(errors="backslashreplace")).strip()
+ return decompress_response(raw_body, encoding).decode(errors="backslashreplace")
- body = await loop.run_in_executor(None, decompress_and_decode)
+ full_body = await loop.run_in_executor(None, decompress_and_decode)
else:
- loop = asyncio.get_running_loop()
- body = await loop.run_in_executor(None, lambda: common.format_error(raw_body.decode(errors="backslashreplace")).strip())
+ 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:
- err_code = response.headers.get(ex_header)
if err_code:
err_str = f"Received ClickHouse exception, code: {err_code}"
else:
@@ -1905,7 +1937,8 @@ class AsyncClient(Client):
finally:
response.close()
- raise OperationalError(err_str) if retried else DatabaseError(err_str) from None
+ err_type = OperationalError if retried else DatabaseError
+ raise err_type(err_str, code=code, name=name) from None
async def _raw_request(
self,
@@ -1950,6 +1983,7 @@ class AsyncClient(Client):
req_headers["Host"] = self.server_host_name
query_session = final_params.get("session_id")
attempts = 0
+ auth_retried = False
while True:
attempts += 1
@@ -2018,6 +2052,17 @@ class AsyncClient(Client):
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:
diff --git a/contrib/python/clickhouse-connect/clickhouse_connect/driver/binding.py b/contrib/python/clickhouse-connect/clickhouse_connect/driver/binding.py
index 3734589a2e2..b4f848dc77a 100644
--- a/contrib/python/clickhouse-connect/clickhouse_connect/driver/binding.py
+++ b/contrib/python/clickhouse-connect/clickhouse_connect/driver/binding.py
@@ -6,6 +6,7 @@ from collections.abc import Sequence
from datetime import date, datetime, timezone, tzinfo
from enum import Enum
from typing import Any
+from urllib.parse import quote, urlencode
from clickhouse_connect import common
from clickhouse_connect.driver import tzutil
@@ -68,22 +69,22 @@ def finalize_query(query: str, parameters: Sequence | dict[str, Any] | None, ser
return query % tuple(format_query_value(v, server_tz) for v in parameters)
-def _extract_tz_from_type(type_str: str) -> tzinfo | None:
- """Extract timezone from a ClickHouse type hint like DateTime64(6, 'UTC').
-
- Handles LowCardinality/Nullable wrappers and container types
- (Array, Tuple, Map). Returns None if no timezone is found or parsing fails.
- """
- try:
- base = type_str.strip()
- if base.startswith("LowCardinality"):
- base = base[15:-1]
- if base.startswith("Nullable"):
- base = base[9:-1]
+def _unwrap_outer(type_str: str) -> tuple[str, tuple]:
+ """Strip LowCardinality/Nullable wrappers and return (base_name, args)"""
+ base = type_str.strip()
+ if base[:15].lower() == "lowcardinality(":
+ base = base[15:-1]
+ if base[:9].lower() == "nullable(":
+ base = base[9:-1]
+ base_name, values, _ = parse_callable(base)
+ return base_name, values
- base_name, values, _ = parse_callable(base)
- if base_name in ("DateTime", "DateTime64"):
+def _extract_tz_from_type(type_str: str) -> tzinfo | None:
+ """Resolve the timezone named in a ClickHouse type hint."""
+ try:
+ base_name, values = _unwrap_outer(type_str)
+ if base_name.lower() in ("datetime", "datetime64"):
for v in values:
if isinstance(v, str) and v.startswith("'") and v.endswith("'"):
try:
@@ -104,6 +105,25 @@ def _extract_tz_from_type(type_str: str) -> tzinfo | None:
return None
+def _promote_datetime64(type_str: str, value):
+ """Wrap values bound to a DateTime64 hint in DT64Param to preserve precision."""
+ if value is None or "datetime64" not in type_str.lower():
+ return value
+ try:
+ base_name, values = _unwrap_outer(type_str)
+ base_name = base_name.lower()
+ if base_name == "datetime64":
+ return DT64Param(value) if isinstance(value, datetime) else value
+ if base_name == "array" and values and isinstance(value, (list, tuple)):
+ inner = str(values[0])
+ return type(value)(_promote_datetime64(inner, x) for x in value)
+ if base_name == "tuple" and isinstance(value, tuple) and len(values) == len(value):
+ return tuple(_promote_datetime64(str(t), x) for t, x in zip(values, value))
+ return value
+ except Exception:
+ return value
+
+
def bind_query(
query: str,
parameters: Sequence | dict[str, Any] | None,
@@ -121,9 +141,13 @@ def bind_query(
for key in binary_binds.keys():
del params_copy[key]
+ matches = external_bind_re.findall(query)
+ placeholder_names = {name for name, _ in matches}
final_params = {}
for k, v in params_copy.items():
- if k.endswith("_64"):
+ # The _64 suffix is a precision hint, not part of the name, unless the
+ # query binds the full name itself.
+ if k.endswith("_64") and k not in placeholder_names:
if isinstance(v, datetime):
k = k[:-3]
v = DT64Param(v)
@@ -131,7 +155,6 @@ def bind_query(
k = k[:-3]
v = [DT64Param(x) for x in v]
final_params[k] = v
- matches = external_bind_re.findall(query)
if not matches:
query, bound_params = finalize_query(query, final_params, server_tz), {}
else:
@@ -147,6 +170,7 @@ def bind_query(
hint_tz = _extract_tz_from_type(type_str)
if hint_tz is not None:
tz = hint_tz
+ v = _promote_datetime64(type_str, v)
bound_params[f"param_{k}"] = format_bind_value(v, tz)
else:
query, bound_params = finalize_query(query, parameters, server_tz), {}
@@ -172,6 +196,30 @@ def bind_query(
return query, bound_params
+# Server-side bind parameters are urlencoded into the request URL. Once the encoded length
+# passes this budget the client routes them through multipart form data instead, keeping
+# oversized payloads out of the URL where proxies (nginx, ALB, CloudFront) reject them with
+# HTTP 414. The threshold leaves ample headroom under common request line limits.
+MAX_URL_BIND_PARAM_LENGTH = 4096
+
+
+def use_form_encoding(query: str | bytes, bind_params: dict[str, str], force_form: bool = False) -> bool:
+ if force_form:
+ return True
+ # Binary binds embed bytes into the query, which the form path cannot round-trip; leave
+ # those on the default path unless form encoding is explicitly requested.
+ if isinstance(query, bytes):
+ return False
+ if not bind_params:
+ return False
+ # Raw length is a lower bound on the encoded length, so large payloads short-circuit
+ # without materializing the encoded string.
+ if sum(len(k) + len(str(v)) for k, v in bind_params.items()) > MAX_URL_BIND_PARAM_LENGTH:
+ return True
+ # Measure with quote so spaces count as %20, matching the longer of the two client encodings.
+ return len(urlencode(bind_params, quote_via=quote)) > MAX_URL_BIND_PARAM_LENGTH
+
+
def format_str(value: str):
return f"'{escape_str(value)}'"
@@ -180,6 +228,10 @@ def escape_str(value: str):
return "".join(f"{BS}{c}" if c in must_escape else c for c in value)
+def escape_bytes(value):
+ return "".join(f"{BS}x{b:02x}" for b in value)
+
+
def format_query_value(value: Any, server_tz: tzinfo = timezone.utc):
"""
Format Python values in a ClickHouse query
@@ -191,6 +243,8 @@ def format_query_value(value: Any, server_tz: tzinfo = timezone.utc):
return "NULL"
if isinstance(value, str):
return format_str(value)
+ if isinstance(value, (bytes, bytearray)):
+ return f"'{escape_bytes(value)}'"
if isinstance(value, DT64Param):
return value.format(server_tz, False)
if isinstance(value, datetime):
@@ -238,6 +292,10 @@ def format_bind_value(value: Any, server_tz: tzinfo = timezone.utc, top_level: b
# At the top levels, strings must not be surrounded by quotes
return escape_str(value)
return format_str(value)
+ if isinstance(value, (bytes, bytearray)):
+ if top_level:
+ return escape_bytes(value)
+ return f"'{escape_bytes(value)}'"
if isinstance(value, DT64Param):
return value.format(server_tz, top_level)
if isinstance(value, datetime):
@@ -261,4 +319,8 @@ def format_bind_value(value: Any, server_tz: tzinfo = timezone.utc, top_level: b
return f"{{{', '.join(pairs)}}}"
if isinstance(value, Enum):
return recurse(value.value)
+ if isinstance(value, (uuid.UUID, ipaddress.IPv4Address, ipaddress.IPv6Address)):
+ if top_level:
+ return str(value)
+ return f"'{value}'"
return str(value)
diff --git a/contrib/python/clickhouse-connect/clickhouse_connect/driver/exceptions.py b/contrib/python/clickhouse-connect/clickhouse_connect/driver/exceptions.py
index 84009a319ab..cddcac55f59 100644
--- a/contrib/python/clickhouse-connect/clickhouse_connect/driver/exceptions.py
+++ b/contrib/python/clickhouse-connect/clickhouse_connect/driver/exceptions.py
@@ -4,6 +4,26 @@ how useful that naming convention is, but the convention is used for potential i
libraries. In most cases docstring are taken from the DBIApi 2.0 documentation
"""
+import re
+
+_error_name_re = re.compile(r"\(([A-Z][A-Z0-9_]+)\)")
+
+
+def error_code_from_header(header_value: str | None) -> int | None:
+ """Parse the numeric ClickHouse error code from the exception header value."""
+ if not header_value:
+ return None
+ try:
+ return int(header_value)
+ except (TypeError, ValueError):
+ return None
+
+
+def error_name_from_body(body: str | None) -> str | None:
+ """Extract the symbolic ClickHouse error name (e.g. UNKNOWN_TABLE) from a response body."""
+ matches = _error_name_re.findall(body or "")
+ return matches[-1] if matches else None
+
class ClickHouseError(Exception):
"""Exception related to operation with ClickHouse."""
@@ -16,7 +36,16 @@ class Warning(Warning, ClickHouseError): # noqa: N818
class Error(ClickHouseError):
"""Exception that is the base class of all other error exceptions
- (not Warning)."""
+ (not Warning).
+
+ `code` is the numeric ClickHouse server error code when known, `name` the symbolic
+ name. Both are None when unavailable, e.g. transport errors or suppressed error detail.
+ """
+
+ def __init__(self, *args, code: int | None = None, name: str | None = None):
+ super().__init__(*args)
+ self.code = code
+ self.name = name
class InterfaceError(Error):
diff --git a/contrib/python/clickhouse-connect/clickhouse_connect/driver/httpclient.py b/contrib/python/clickhouse-connect/clickhouse_connect/driver/httpclient.py
index 8040cd27c5b..170c2ab6ab8 100644
--- a/contrib/python/clickhouse-connect/clickhouse_connect/driver/httpclient.py
+++ b/contrib/python/clickhouse-connect/clickhouse_connect/driver/httpclient.py
@@ -19,12 +19,18 @@ from urllib3.response import HTTPResponse
from clickhouse_connect import common
from clickhouse_connect.datatypes import registry
from clickhouse_connect.datatypes.base import ClickHouseType
-from clickhouse_connect.driver.binding import bind_query, quote_identifier
+from clickhouse_connect.driver.binding import bind_query, quote_identifier, use_form_encoding
from clickhouse_connect.driver.client import Client
from clickhouse_connect.driver.common import coerce_bool, coerce_int, dict_add, dict_copy
from clickhouse_connect.driver.compression import available_compression
from clickhouse_connect.driver.ctypes import RespBuffCls
-from clickhouse_connect.driver.exceptions import DatabaseError, OperationalError, ProgrammingError
+from clickhouse_connect.driver.exceptions import (
+ DatabaseError,
+ OperationalError,
+ ProgrammingError,
+ error_code_from_header,
+ error_name_from_body,
+)
from clickhouse_connect.driver.external import ExternalData
from clickhouse_connect.driver.httputil import (
ResponseSource,
@@ -44,6 +50,7 @@ from clickhouse_connect.driver.transform import NativeTransform
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"
_REMOTE_CLOSE_ERRORS = (ConnectionResetError, BrokenPipeError)
@@ -79,6 +86,7 @@ class HttpClient(Client):
password: str,
database: str,
access_token: str | None = None,
+ token_provider: Callable[[], str] | None = None,
compress: bool | str = True,
query_limit: int = 0,
query_retries: int = 2,
@@ -104,6 +112,7 @@ class HttpClient(Client):
proxy_path: str = "",
form_encode_query_params: bool = False,
rename_response_column: str | None = None,
+ headers: dict[str, str] | None = None,
):
"""
Create an HTTP ClickHouse Connect client
@@ -150,6 +159,9 @@ class HttpClient(Client):
else:
self.http = default_pool_manager()
+ self._token_provider = token_provider
+ if token_provider:
+ access_token = token_provider()
if access_token:
self.headers["Authorization"] = f"Bearer {access_token}"
elif (not client_cert or tls_mode in ("strict", "proxy")) and username:
@@ -157,6 +169,8 @@ class HttpClient(Client):
self._reported_libs = set()
self.headers["User-Agent"] = common.build_client_name(client_name)
+ if headers:
+ self.headers.update(headers)
self._read_format = self._write_format = "Native"
self._transform = NativeTransform()
@@ -260,10 +274,11 @@ class HttpClient(Client):
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)
if not context.is_insert and columns_only_re.search(context.uncommented_query):
# Mirror normal query behavior for form encoding and external data
fmt_json_query = f"{context.final_query}\n FORMAT JSON"
- if self.form_encode_query_params:
+ if use_form:
fields = {"query": fmt_json_query}
fields.update(context.bind_params)
if context.external_data: # Deal with form encoding + external data
@@ -303,7 +318,7 @@ class HttpClient(Client):
final_query = self._prep_query(context)
fields = {}
# Setup additional query parameters and body
- if self.form_encode_query_params:
+ if use_form:
body = b""
fields["query"] = final_query
fields.update(context.bind_params)
@@ -490,14 +505,19 @@ class HttpClient(Client):
"""
try:
body = ""
+ full_body = ""
try:
raw_body = get_response_data(response)
- body = common.format_error(raw_body.decode(errors="backslashreplace")).strip()
+ full_body = 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:
- err_code = response.headers.get(ex_header)
if err_code:
err_str = f"Received ClickHouse exception, code: {err_code}"
else:
@@ -513,7 +533,8 @@ class HttpClient(Client):
finally:
response.close()
- raise OperationalError(err_str) if retried else DatabaseError(err_str) from None
+ err_type = OperationalError if retried else DatabaseError
+ raise err_type(err_str, code=code, name=name) from None
def _raw_request(
self,
@@ -532,6 +553,7 @@ class HttpClient(Client):
data = data.encode()
headers = dict_copy(self.headers, headers)
attempts = 0
+ auth_retried = False
final_params = {}
if server_wait:
final_params["wait_end_of_query"] = "1"
@@ -606,6 +628,17 @@ class HttpClient(Client):
if attempts > retries:
self._error_handler(response, True)
logger.debug("Retrying requests with status code %d", response.status)
+ elif self._token_provider and not auth_retried and response.headers.get(ex_header) == auth_failed_ex_code:
+ body = kwargs.get("body")
+ if retry_body is None and not (body is None or isinstance(body, (bytes, bytearray, str))):
+ self._error_handler(response) # non-replayable body, surface the auth error instead of retrying
+ auth_retried = True
+ self.set_access_token(self._token_provider())
+ headers["Authorization"] = self.headers["Authorization"]
+ if retry_body is not None:
+ kwargs["body"] = retry_body()
+ response.close()
+ logger.debug("Refreshing access token after authentication failure")
elif error_handler:
error_handler(response)
else:
@@ -667,11 +700,12 @@ class HttpClient(Client):
if use_database and self.database:
params["database"] = self.database
fields = {}
+ use_form = use_form_encoding(final_query, bind_params, self.form_encode_query_params)
# Setup query body
- if external_data and not self.form_encode_query_params and isinstance(final_query, bytes):
+ 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.")
# Setup additional query parameters and body
- if self.form_encode_query_params:
+ if use_form:
body = b""
fields["query"] = final_query
fields.update(bind_params)
@@ -739,7 +773,12 @@ class HttpClient(Client):
See BaseClient doc_string for this method
"""
try:
- response = self.http.request("GET", f"{self.url}/ping", timeout=3, preload_content=True)
+ headers = dict_copy(self.headers)
+ kwargs = {"headers": headers, "timeout": 3, "preload_content": True}
+ if self.server_host_name:
+ kwargs["assert_same_host"] = False
+ headers["Host"] = self.server_host_name
+ response = self.http.request("GET", f"{self.url}/ping", **kwargs)
return 200 <= response.status < 300
except HTTPError:
logger.debug("ping failed", exc_info=True)
diff --git a/contrib/python/clickhouse-connect/clickhouse_connect/driver/query.py b/contrib/python/clickhouse-connect/clickhouse_connect/driver/query.py
index c17cea1c485..9338e6ff572 100644
--- a/contrib/python/clickhouse-connect/clickhouse_connect/driver/query.py
+++ b/contrib/python/clickhouse-connect/clickhouse_connect/driver/query.py
@@ -403,7 +403,7 @@ class QueryResult(Closable):
self._block_gen = None
-comment_re = re.compile(r"(\".*?\"|\'.*?\')|(/\*.*?\*/|(--\s)[^\n]*$)", re.MULTILINE | re.DOTALL)
+comment_re = re.compile(r"(\".*?\"|\'.*?\')|(/\*.*?\*/|(--)[^\n]*$)", re.MULTILINE | re.DOTALL)
def remove_sql_comments(sql: str) -> str:
diff --git a/contrib/python/clickhouse-connect/ya.make b/contrib/python/clickhouse-connect/ya.make
index 82a5eb6d189..34bf63b5b78 100644
--- a/contrib/python/clickhouse-connect/ya.make
+++ b/contrib/python/clickhouse-connect/ya.make
@@ -2,7 +2,7 @@
PY3_LIBRARY()
-VERSION(1.1.1)
+VERSION(1.3.0)
LICENSE(Apache-2.0)
diff --git a/contrib/python/cryptography/next/rust/.yandex_meta/build.ym b/contrib/python/cryptography/next/rust/.yandex_meta/build.ym
index 27c37abb849..1a9f1137767 100644
--- a/contrib/python/cryptography/next/rust/.yandex_meta/build.ym
+++ b/contrib/python/cryptography/next/rust/.yandex_meta/build.ym
@@ -26,6 +26,7 @@ _rust
_openssl
{% endblock %}
+{% block rust_version %}nightly-2026-06-04{% endblock %}
{% block current_version %}41.0.6{% endblock %}
{% block current_url %}https://github.com/pyca/cryptography/archive/refs/tags/{{self.version().strip()}}.tar.gz{% endblock %}
@@ -46,17 +47,21 @@ export CC_aarch64_apple_darwin=mac-target-cc-arm
export DEP_OPENSSL_INCLUDE={{arcadia_root}}/contrib/libs/openssl/include
export PYO3_CROSS_PYTHON_VERSION=3.12
+export RUSTFLAGS="${RUSTFLAGS:-} -Cembed-bitcode=no"
rm -rf docs vectors tests
cd src/rust
{{super()}}
+find ${BUILD} -name '*.a' -type f | while read l; do
+ echo "_rust_eh_personality {{self.mod_name().strip()}}__rust_eh_personality" >> prog
+ llvm-objcopy --redefine-syms=prog ${l}
+ rm prog
+done
{% for p in self.msan_platforms().strip().split('\n') %}
export CC_{{p.replace('-', '_')}}=target-cc-msan
{% endfor %}
-export RUSTFLAGS="-Zsanitizer=memory -Zsanitizer-memory-track-origins=2 -Ctarget-feature=-crt-static"
-
-export RUSTC_BOOTSTRAP=1
+export RUSTFLAGS="${RUSTFLAGS:-} -Zsanitizer=memory -Zsanitizer-memory-track-origins=2 -Ctarget-feature=-crt-static -Cunsafe-allow-abi-mismatch=sanitizer -Ccodegen-units=1"
{% for arch in self.msan_platforms().strip().split('\n') %}
LD_PRELOAD=${BIN}/getrandom.so cargo {{self.cargo_flags().strip()}} build {{self.cargo_build_flags().strip()}} --release --target {{arch}} {{self.binary_type().strip()}} --target-dir ${BUILD}-msan \
@@ -69,6 +74,7 @@ find ${BUILD}-msan -name '*.a' -type f | while read l; do
llvm-nm --extern-only --defined-only -A ${l} | sed -e 's|.* ||' | grep -v 'PyInit_' | sort | while read s; do
echo "${s} {{self.mod_name().strip()}}_${s}" >> prog
done
+ echo "_rust_eh_personality {{self.mod_name().strip()}}__rust_eh_personality" >> prog
llvm-objcopy --redefine-syms=prog ${l}
rm prog
done
diff --git a/contrib/python/ydb/py3/.dist-info/METADATA b/contrib/python/ydb/py3/.dist-info/METADATA
index 3c0337ed5e0..809298530a7 100644
--- a/contrib/python/ydb/py3/.dist-info/METADATA
+++ b/contrib/python/ydb/py3/.dist-info/METADATA
@@ -1,6 +1,6 @@
Metadata-Version: 2.1
Name: ydb
-Version: 3.28.4
+Version: 3.29.6
Summary: YDB Python SDK
Home-page: http://github.com/ydb-platform/ydb-python-sdk
Author: Yandex LLC
@@ -21,6 +21,8 @@ Requires-Dist: grpcio >=1.42.0
Requires-Dist: packaging
Requires-Dist: protobuf <7.0.0,>=3.13.0
Requires-Dist: aiohttp <4
+Provides-Extra: opentelemetry
+Requires-Dist: opentelemetry-api >=1.0.0 ; extra == 'opentelemetry'
Provides-Extra: yc
Requires-Dist: yandexcloud ; extra == 'yc'
@@ -31,6 +33,7 @@ YDB Python SDK
[![API Reference](https://img.shields.io/badge/API-Reference-lightgreen.svg)](https://ydb-platform.github.io/ydb-python-sdk)
[![Functional tests](https://github.com/ydb-platform/ydb-python-sdk/actions/workflows/tests.yaml/badge.svg)](https://github.com/ydb-platform/ydb-python-sdk/actions/workflows/tests.yaml)
[![Style checks](https://github.com/ydb-platform/ydb-python-sdk/actions/workflows/style.yaml/badge.svg)](https://github.com/ydb-platform/ydb-python-sdk/actions/workflows/style.yaml)
+[![codecov](https://codecov.io/gh/ydb-platform/ydb-python-sdk/graph/badge.svg)](https://codecov.io/gh/ydb-platform/ydb-python-sdk)
Officially supported Python client for YDB.
diff --git a/contrib/python/ydb/py3/README.md b/contrib/python/ydb/py3/README.md
index 7842bd1d910..eeda8db3b42 100644
--- a/contrib/python/ydb/py3/README.md
+++ b/contrib/python/ydb/py3/README.md
@@ -5,6 +5,7 @@ YDB Python SDK
[![API Reference](https://img.shields.io/badge/API-Reference-lightgreen.svg)](https://ydb-platform.github.io/ydb-python-sdk)
[![Functional tests](https://github.com/ydb-platform/ydb-python-sdk/actions/workflows/tests.yaml/badge.svg)](https://github.com/ydb-platform/ydb-python-sdk/actions/workflows/tests.yaml)
[![Style checks](https://github.com/ydb-platform/ydb-python-sdk/actions/workflows/style.yaml/badge.svg)](https://github.com/ydb-platform/ydb-python-sdk/actions/workflows/style.yaml)
+[![codecov](https://codecov.io/gh/ydb-platform/ydb-python-sdk/graph/badge.svg)](https://codecov.io/gh/ydb-platform/ydb-python-sdk)
Officially supported Python client for YDB.
diff --git a/contrib/python/ydb/py3/patches/03-change-inport-protobufs.patch b/contrib/python/ydb/py3/patches/03-change-inport-protobufs.patch
index a865f95a382..156c6d7799e 100644
--- a/contrib/python/ydb/py3/patches/03-change-inport-protobufs.patch
+++ b/contrib/python/ydb/py3/patches/03-change-inport-protobufs.patch
@@ -87,10 +87,11 @@
from ..common.protos import ydb_topic_pb2
--- contrib/python/ydb/py3/ydb/aio/connection.py (index)
+++ contrib/python/ydb/py3/ydb/aio/connection.py (working tree)
-@@ -27,11 +27,10 @@ from ydb.driver import DriverConfig
+@@ -28,12 +28,11 @@ from ydb import issues
from ydb.settings import BaseRequestSettings
from ydb import issues
-
+ from ydb.opentelemetry.tracing import get_trace_metadata
+
-# Workaround for good IDE and universal for runtime
-if TYPE_CHECKING:
- from ydb._grpc.v4 import ydb_topic_v1_pb2_grpc
diff --git a/contrib/python/ydb/py3/ya.make b/contrib/python/ydb/py3/ya.make
index b75adf12a55..5c39099e85f 100644
--- a/contrib/python/ydb/py3/ya.make
+++ b/contrib/python/ydb/py3/ya.make
@@ -2,7 +2,7 @@
PY3_LIBRARY()
-VERSION(3.28.4)
+VERSION(3.29.6)
LICENSE(Apache-2.0)
@@ -16,6 +16,7 @@ PEERDIR(
NO_LINT()
NO_CHECK_IMPORTS(
+ ydb.opentelemetry.plugin
ydb.public.api.grpc
ydb.public.api.grpc.*
)
@@ -104,6 +105,9 @@ PY_SRCS(
ydb/oauth2_token_exchange/__init__.py
ydb/oauth2_token_exchange/token_exchange.py
ydb/oauth2_token_exchange/token_source.py
+ ydb/opentelemetry/__init__.py
+ ydb/opentelemetry/plugin.py
+ ydb/opentelemetry/tracing.py
ydb/operation.py
ydb/pool.py
ydb/query/__init__.py
diff --git a/contrib/python/ydb/py3/ydb/_topic_reader/topic_reader_asyncio.py b/contrib/python/ydb/py3/ydb/_topic_reader/topic_reader_asyncio.py
index f4e5a4f88a5..ee988a1ec3b 100644
--- a/contrib/python/ydb/py3/ydb/_topic_reader/topic_reader_asyncio.py
+++ b/contrib/python/ydb/py3/ydb/_topic_reader/topic_reader_asyncio.py
@@ -217,6 +217,7 @@ class ReaderReconnector:
_stream_reader: Optional["ReaderStream"]
_first_error: asyncio.Future[YdbError]
_tx_to_batches_map: Dict[str, typing.List[datatypes.PublicBatch]]
+ _closed: bool
def __init__(
self,
@@ -233,6 +234,7 @@ class ReaderReconnector:
self._state_changed = asyncio.Event()
self._stream_reader = None
+ self._closed = False
self._background_tasks.add(asyncio.create_task(self._connection_loop()))
self._first_error = asyncio.get_running_loop().create_future()
@@ -241,6 +243,8 @@ class ReaderReconnector:
async def _connection_loop(self):
attempt = 0
while True:
+ if self._closed:
+ return
try:
logger.debug("reader %s connect attempt %s", self._id, attempt)
self._stream_reader = await ReaderStream.create(self._id, self._driver, self._settings)
@@ -266,8 +270,12 @@ class ReaderReconnector:
# noinspection PyBroadException
try:
await self._stream_reader.close(flush=False)
- except BaseException:
- # supress any error on close stream reader
+ except asyncio.CancelledError:
+ # propagate cancellation (e.g. from reader.close()) so the loop stops
+ # instead of swallowing it and reconnecting into a zombie stream
+ raise
+ except Exception:
+ # suppress any error on close stream reader
pass
async def wait_message(self):
@@ -323,14 +331,44 @@ class ReaderReconnector:
tx._add_callback(TxEvent.AFTER_COMMIT, self._handle_after_tx_commit, self._loop)
tx._add_callback(TxEvent.AFTER_ROLLBACK, self._handle_after_tx_rollback, self._loop)
+ def _batch_partition_session_expired(self, batch: datatypes.PublicBatch) -> bool:
+ # A batch is expired if the reader reconnected after it was received: its partition
+ # session no longer belongs to the current stream. Mirrors the guard in
+ # ReaderStream.commit() for the non-transactional commit path.
+ stream = self._stream_reader
+ partition_session = batch._partition_session
+ return (
+ stream is None
+ or partition_session.reader_stream_id != stream._id
+ or partition_session.id not in stream._partition_sessions
+ )
+
async def _commit_batches_with_tx(self, tx: "BaseQueryTxContext"):
tx_id = tx.tx_id
if tx_id is None:
raise TopicReaderError("Transaction ID is None")
+
+ batches = self._tx_to_batches_map[tx_id]
+
+ if any(self._batch_partition_session_expired(batch) for batch in batches):
+ # The reader reconnected between receive_batch_with_tx() and tx.commit(), so
+ # these offsets belong to a partition session that no longer exists. Committing
+ # them would send a stale/gapped range (server "Gap", issue_code 2011) while the
+ # client believes the commit succeeded. Fail the tx instead (retriable) without
+ # sending the request; the AFTER_COMMIT handler then reconnects to reset the
+ # read-ahead state, and the pool re-reads from the committed offset.
+ err = issues.ClientInternalError(
+ "Topic reader partition session expired before tx commit; "
+ "offsets were not committed, the transaction will be retried"
+ )
+ tx._set_external_error(err)
+ del self._tx_to_batches_map[tx_id]
+ return
+
grouped_batches: Dict[str, Dict[int, typing.List[datatypes.PublicBatch]]] = defaultdict(
lambda: defaultdict(list)
)
- for batch in self._tx_to_batches_map[tx_id]:
+ for batch in batches:
grouped_batches[batch._partition_session.topic_path][batch._partition_session.partition_id].append(batch)
consumer = self._settings.consumer
@@ -401,8 +439,16 @@ class ReaderReconnector:
async def close(self, flush: bool):
logger.debug("reader reconnector %s close", self._id)
+ # Mark closed so the connection loop won't start a new stream, then close the
+ # current stream with the requested flush before cancelling the loop. On a normal
+ # close this flushes pending commits; cancelling the loop first would let it close
+ # the stream with flush=False instead and skip the flush.
+ self._closed = True
if self._stream_reader:
await self._stream_reader.close(flush)
+ # Wake any pending wait_message() waiter (e.g. a concurrent receive) so it doesn't
+ # hang if the loop was reconnecting when close() cancelled it.
+ self._set_first_error(TopicReaderStreamClosedError())
for task in self._background_tasks:
task.cancel()
@@ -469,6 +515,11 @@ class ReaderStream:
self._id = ReaderStream._static_id_counter.inc_and_get()
self._reader_reconnector_id = reader_reconnector_id
self._session_id = "not initialized"
+ self._log_prefix = "reader %s stream %s session=%s" % (
+ self._reader_reconnector_id,
+ self._id,
+ self._session_id,
+ )
self._stream = None
self._started = False
self._background_tasks = set()
@@ -503,17 +554,28 @@ class ReaderStream:
settings: topic_reader.PublicReaderSettings,
) -> "ReaderStream":
stream = GrpcWrapperAsyncIO(StreamReadMessage.FromServer.from_proto)
+ reader = None
+ try:
+ await stream.start(driver, _apis.TopicService.Stub, _apis.TopicService.StreamRead)
- await stream.start(driver, _apis.TopicService.Stub, _apis.TopicService.StreamRead)
-
- creds = driver._credentials
- reader = ReaderStream(
- reader_reconnector_id,
- settings,
- get_token_function=creds.get_auth_token if creds else None,
- )
- await reader._start(stream, settings._init_message())
- logger.debug("reader stream %s started session=%s", reader._id, reader._session_id)
+ creds = driver._credentials
+ reader = ReaderStream(
+ reader_reconnector_id,
+ settings,
+ get_token_function=creds.get_auth_token if creds else None,
+ )
+ await reader._start(stream, settings._init_message())
+ except BaseException:
+ # If create() is interrupted (e.g. reader.close() cancels the connection loop
+ # mid-reconnect) the in-flight stream is not yet assigned to the reconnector, so
+ # its finally cannot reach it. Close it here to avoid a zombie gRPC read session
+ # that keeps holding the consumer's partition on the server.
+ if reader is not None:
+ await reader.close(flush=False)
+ else:
+ stream.close()
+ raise
+ logger.debug("%s started", reader._log_prefix)
return reader
async def _start(self, stream: IGrpcWrapperAsyncIO, init_message: StreamReadMessage.InitRequest):
@@ -522,7 +584,7 @@ class ReaderStream:
self._started = True
self._stream = stream
- logger.debug("reader stream %s send init request", self._id)
+ logger.debug("%s send init request", self._log_prefix)
stream.write(StreamReadMessage.FromClient(client_message=init_message))
try:
@@ -534,7 +596,12 @@ class ReaderStream:
if isinstance(init_response.server_message, StreamReadMessage.InitResponse):
self._session_id = init_response.server_message.session_id
- logger.debug("reader stream %s initialized session=%s", self._id, self._session_id)
+ self._log_prefix = "reader %s stream %s session=%s" % (
+ self._reader_reconnector_id,
+ self._id,
+ self._session_id,
+ )
+ logger.debug("%s initialized", self._log_prefix)
else:
raise TopicReaderError("Unexpected message after InitRequest: %s" % init_response)
@@ -668,7 +735,7 @@ class ReaderStream:
async def _read_messages_loop(self):
try:
- logger.debug("reader stream %s start read loop", self._id)
+ logger.debug("%s start read loop", self._log_prefix)
self._stream.write(
StreamReadMessage.FromClient(
client_message=StreamReadMessage.ReadRequest(
@@ -682,7 +749,7 @@ class ReaderStream:
_process_response(message.server_status)
if isinstance(message.server_message, StreamReadMessage.ReadResponse):
- logger.debug("reader stream %s read %s bytes", self._id, message.server_message.bytes_size)
+ logger.debug("%s read %s bytes", self._log_prefix, message.server_message.bytes_size)
self._on_read_response(message.server_message)
elif isinstance(message.server_message, StreamReadMessage.CommitOffsetResponse):
@@ -693,8 +760,8 @@ class ReaderStream:
StreamReadMessage.StartPartitionSessionRequest,
):
logger.debug(
- "reader stream %s start partition %s",
- self._id,
+ "%s start partition %s",
+ self._log_prefix,
message.server_message.partition_session.partition_session_id,
)
await self._on_start_partition_session(message.server_message)
@@ -704,8 +771,8 @@ class ReaderStream:
StreamReadMessage.StopPartitionSessionRequest,
):
logger.debug(
- "reader stream %s stop partition %s",
- self._id,
+ "%s stop partition %s",
+ self._log_prefix,
message.server_message.partition_session_id,
)
self._on_partition_session_stop(message.server_message)
@@ -715,8 +782,8 @@ class ReaderStream:
StreamReadMessage.EndPartitionSession,
):
logger.debug(
- "reader stream %s end partition %s",
- self._id,
+ "%s end partition %s",
+ self._log_prefix,
message.server_message.partition_session_id,
)
self._on_end_partition_session(message.server_message)
@@ -733,12 +800,12 @@ class ReaderStream:
self._state_changed.set()
except asyncio.CancelledError as e:
- logger.debug("reader stream %s error: %s", self._id, e)
+ logger.debug("%s error: %s", self._log_prefix, e)
if not self._closed:
self._set_first_error(issues.ConnectionLost("gRPC stream cancelled"))
raise
except Exception as e:
- logger.debug("reader stream %s error: %s", self._id, e)
+ logger.debug("%s error: %s", self._log_prefix, e)
self._set_first_error(e)
return
@@ -908,7 +975,7 @@ class ReaderStream:
async def _decode_batches_loop(self):
while True:
batch = await self._batches_to_decode.get()
- logger.debug("reader stream %s decode batch %s messages", self._id, len(batch.messages))
+ logger.debug("%s decode batch %s messages", self._log_prefix, len(batch.messages))
await self._decode_batch_inplace(batch)
self._add_batch_to_queue(batch)
self._state_changed.set()
@@ -918,8 +985,8 @@ class ReaderStream:
if part_sess_id in self._message_batches:
self._message_batches[part_sess_id]._extend(batch)
logger.debug(
- "reader stream %s extend batch partition=%s size=%s",
- self._id,
+ "%s extend batch partition=%s size=%s",
+ self._log_prefix,
part_sess_id,
len(batch.messages),
)
@@ -927,8 +994,8 @@ class ReaderStream:
self._message_batches[part_sess_id] = batch
logger.debug(
- "reader stream %s new batch partition=%s size=%s",
- self._id,
+ "%s new batch partition=%s size=%s",
+ self._log_prefix,
part_sess_id,
len(batch.messages),
)
@@ -979,7 +1046,7 @@ class ReaderStream:
return
self._closed = True
- logger.debug("reader stream %s close", self._id)
+ logger.debug("%s close", self._log_prefix)
if flush:
await self.flush()
@@ -999,4 +1066,4 @@ class ReaderStream:
if self._background_tasks:
await asyncio.wait(self._background_tasks)
- logger.debug("reader stream %s was closed", self._id)
+ logger.debug("%s was closed", self._log_prefix)
diff --git a/contrib/python/ydb/py3/ydb/_topic_writer/topic_writer_asyncio.py b/contrib/python/ydb/py3/ydb/_topic_writer/topic_writer_asyncio.py
index 5e55917d830..ff9c59f7989 100644
--- a/contrib/python/ydb/py3/ydb/_topic_writer/topic_writer_asyncio.py
+++ b/contrib/python/ydb/py3/ydb/_topic_writer/topic_writer_asyncio.py
@@ -833,7 +833,8 @@ class WriterAsyncIOStream:
self._update_token_task.cancel()
await asyncio.wait([self._update_token_task])
- self._stream.close()
+ if getattr(self, "_stream", None) is not None:
+ self._stream.close()
logger.debug("writer stream %s was closed", self._id)
@staticmethod
@@ -845,15 +846,27 @@ class WriterAsyncIOStream:
) -> "WriterAsyncIOStream":
stream = GrpcWrapperAsyncIO(StreamWriteMessage.FromServer.from_proto)
- await stream.start(driver, _apis.TopicService.Stub, _apis.TopicService.StreamWrite)
+ writer = None
+ try:
+ await stream.start(driver, _apis.TopicService.Stub, _apis.TopicService.StreamWrite)
- creds = driver._credentials
- writer = WriterAsyncIOStream(
- update_token_interval=update_token_interval,
- get_token_function=creds.get_auth_token if creds else lambda: "",
- tx_identity=tx_identity,
- )
- await writer._start(stream, init_request)
+ creds = driver._credentials
+ writer = WriterAsyncIOStream(
+ update_token_interval=update_token_interval,
+ get_token_function=creds.get_auth_token if creds else lambda: "",
+ tx_identity=tx_identity,
+ )
+ await writer._start(stream, init_request)
+ except BaseException:
+ # If create() fails after stream.start() (e.g. the connection is lost while
+ # waiting for the init response), the stream is not yet handed to the
+ # reconnector, so its finally cannot reach it. Close it here to avoid a
+ # stranded gRPC consumption thread that blocks forever on the request queue.
+ if writer is not None:
+ await writer.close()
+ else:
+ stream.close()
+ raise
logger.debug(
"writer stream %s started seqno=%s",
writer._id,
@@ -875,6 +888,10 @@ class WriterAsyncIOStream:
raise Exception("Unknown message while read writer answers: %s" % item)
async def _start(self, stream: IGrpcWrapperAsyncIO, init_message: StreamWriteMessage.InitRequest):
+ # Assign before the init handshake (mirrors ReaderStream._start) so that if _start
+ # fails mid-handshake, close() can still reach the stream and release its gRPC thread.
+ self._stream = stream
+
logger.debug("writer stream %s send init request", self._id)
stream.write(StreamWriteMessage.FromClient(init_message))
@@ -895,8 +912,6 @@ class WriterAsyncIOStream:
self.last_seqno,
)
- self._stream = stream
-
if self._update_token_interval is not None:
self._update_token_event.set()
self._update_token_task = asyncio.create_task(self._update_token_loop())
diff --git a/contrib/python/ydb/py3/ydb/aio/connection.py b/contrib/python/ydb/py3/ydb/aio/connection.py
index 3cb6290e1d4..02320bf9afc 100644
--- a/contrib/python/ydb/py3/ydb/aio/connection.py
+++ b/contrib/python/ydb/py3/ydb/aio/connection.py
@@ -26,6 +26,7 @@ from ydb.connection import (
from ydb.driver import DriverConfig
from ydb.settings import BaseRequestSettings
from ydb import issues
+from ydb.opentelemetry.tracing import get_trace_metadata
try:
from ydb.public.api.grpc import ydb_topic_v1_pb2_grpc
@@ -70,6 +71,9 @@ async def _construct_metadata(
metadata.append((YDB_REQUEST_TYPE_HEADER, settings.request_type))
metadata.append(_utilities.x_ydb_sdk_build_info_header(getattr(driver_config, "_additional_sdk_headers", ())))
+
+ metadata.extend(get_trace_metadata())
+
return metadata
@@ -148,6 +152,9 @@ class Connection:
"closing",
"endpoint_key",
"node_id",
+ "peer_address",
+ "peer_port",
+ "peer_location",
)
def __init__(
@@ -156,10 +163,12 @@ class Connection:
driver_config: Optional[DriverConfig] = None,
endpoint_options: Optional[EndpointOptions] = None,
) -> None:
- global _stubs_list
self.endpoint = endpoint
self.endpoint_key = EndpointKey(self.endpoint, getattr(endpoint_options, "node_id", None))
self.node_id = getattr(endpoint_options, "node_id", None)
+ self.peer_address = getattr(endpoint_options, "address", None)
+ self.peer_port = getattr(endpoint_options, "port", None)
+ self.peer_location = getattr(endpoint_options, "location", None)
self._channel = channel_factory(self.endpoint, driver_config, grpc.aio, endpoint_options=endpoint_options)
self._driver_config = driver_config
@@ -248,8 +257,12 @@ class Connection:
:param grace:
:return: None
"""
- if hasattr(self, "_channel") and hasattr(self._channel, "close"):
- await self._channel.close(grace)
+ channel = getattr(self, "_channel", None)
+ if channel is not None and hasattr(channel, "close"):
+ await channel.close(grace)
+
+ self._stub_instances.clear()
+ self._channel = None
def add_cleanup_callback(self, callback: Callable[["Connection"], None]) -> None:
self._cleanup_callbacks.append(callback)
diff --git a/contrib/python/ydb/py3/ydb/aio/driver.py b/contrib/python/ydb/py3/ydb/aio/driver.py
index 405e5fcbf37..88221e947db 100644
--- a/contrib/python/ydb/py3/ydb/aio/driver.py
+++ b/contrib/python/ydb/py3/ydb/aio/driver.py
@@ -69,6 +69,7 @@ class Driver(pool.ConnectionPool):
root_certificates,
credentials,
config_class=DriverConfig,
+ **kwargs,
)
super(Driver, self).__init__(config)
diff --git a/contrib/python/ydb/py3/ydb/aio/pool.py b/contrib/python/ydb/py3/ydb/aio/pool.py
index fe7091332ce..4d952086c55 100644
--- a/contrib/python/ydb/py3/ydb/aio/pool.py
+++ b/contrib/python/ydb/py3/ydb/aio/pool.py
@@ -6,6 +6,7 @@ import random
from typing import Any, Callable, Optional, Tuple, TYPE_CHECKING
from ydb import issues
+from ydb.opentelemetry.tracing import SpanName, create_ydb_span
from ydb.pool import ConnectionsCache as _ConnectionsCache, IConnectionPool
from .connection import Connection, EndpointKey
@@ -247,17 +248,36 @@ class ConnectionPool(IConnectionPool):
self._store = ConnectionsCache(driver_config.use_all_nodes)
self._grpc_init = Connection(self._driver_config.endpoint, self._driver_config)
self._stopped = False
+ self._stopping = False
self._discovery: Optional[Discovery] = None
self._discovery_task: "asyncio.Task[None]"
if driver_config.disable_discovery:
# If discovery is disabled, just add the initial endpoint to the store
async def init_connection() -> None:
- ready_connection = Connection(self._driver_config.endpoint, self._driver_config)
- await ready_connection.connection_ready(
- ready_timeout=getattr(self._driver_config, "discovery_request_timeout", 10)
- )
- self._store.add(ready_connection)
+ ready_timeout = getattr(self._driver_config, "discovery_request_timeout", 10)
+ while not self._stopping:
+ ready_connection = Connection(self._driver_config.endpoint, self._driver_config)
+ try:
+ await ready_connection.connection_ready(ready_timeout=ready_timeout)
+ except asyncio.CancelledError:
+ try:
+ await ready_connection.close()
+ except Exception:
+ logger.debug("Failed to close cancelled initial connection", exc_info=True)
+ raise
+ except Exception:
+ logger.debug("Initial connection attempt failed", exc_info=True)
+ try:
+ await ready_connection.close()
+ except Exception:
+ logger.debug("Failed to close unsuccessful initial connection", exc_info=True)
+ if not self._stopping:
+ await asyncio.sleep(1)
+ continue
+
+ self._store.add(ready_connection)
+ return
# Create and schedule the task to initialize the connection
self._discovery_task = asyncio.get_event_loop().create_task(init_connection())
@@ -267,6 +287,7 @@ class ConnectionPool(IConnectionPool):
self._discovery_task = asyncio.get_event_loop().create_task(self._discovery.run())
async def stop(self, timeout: int = 10) -> None: # type: ignore[override] # async override of sync method
+ self._stopping = True
if self._discovery:
self._discovery.stop()
await self._grpc_init.close()
@@ -274,6 +295,12 @@ class ConnectionPool(IConnectionPool):
await asyncio.wait_for(self._discovery_task, timeout=timeout)
except asyncio.TimeoutError:
self._discovery_task.cancel()
+ try:
+ await self._discovery_task
+ except asyncio.CancelledError:
+ pass
+ if self._discovery is None:
+ await self._store.cleanup()
self._stopped = True
def _on_disconnected(self, connection: Connection) -> Callable[[], Any]:
@@ -285,7 +312,8 @@ class ConnectionPool(IConnectionPool):
return __wrapper__
async def wait(self, timeout: Optional[float] = 7.0, fail_fast: bool = False) -> None: # type: ignore[override] # async override of sync method
- await self._store.get(fast_fail=fail_fast, wait_timeout=timeout if timeout is not None else 7.0)
+ with create_ydb_span(SpanName.DRIVER_INITIALIZE, self._driver_config, kind="internal").attach_context():
+ await self._store.get(fast_fail=fail_fast, wait_timeout=timeout if timeout is not None else 7.0)
def discovery_debug_details(self) -> str:
if self._discovery:
diff --git a/contrib/python/ydb/py3/ydb/aio/query/base.py b/contrib/python/ydb/py3/ydb/aio/query/base.py
index a1d6e5d74fc..6c13dfd400a 100644
--- a/contrib/python/ydb/py3/ydb/aio/query/base.py
+++ b/contrib/python/ydb/py3/ydb/aio/query/base.py
@@ -2,9 +2,12 @@ from .. import _utilities
class AsyncResponseContextIterator(_utilities.AsyncResponseIterator):
- def __init__(self, it, wrapper, on_error=None):
+ """Async ExecuteQuery result stream."""
+
+ def __init__(self, it, wrapper, on_error=None, on_finish=None):
super().__init__(it, wrapper)
self._on_error = on_error
+ self._on_finish = on_finish
async def __aenter__(self) -> "AsyncResponseContextIterator":
return self
@@ -15,6 +18,7 @@ class AsyncResponseContextIterator(_utilities.AsyncResponseIterator):
except StopAsyncIteration:
# Normal stream termination is not an error and must not invalidate
# the session.
+ self._call_on_finish()
raise
except BaseException as e:
# BaseException (not Exception) because asyncio.CancelledError
@@ -25,8 +29,17 @@ class AsyncResponseContextIterator(_utilities.AsyncResponseIterator):
# reply with SessionBusy.
if self._on_error:
self._on_error(e)
+ self._call_on_finish(e)
raise
+ def _call_on_finish(self, exception=None):
+ if self._on_finish is not None:
+ self._on_finish(exception)
+ self._on_finish = None
+
+ def __del__(self):
+ self._call_on_finish()
+
async def __aexit__(self, exc_type, exc_val, exc_tb):
# To close stream on YDB it is necessary to scroll through it to the end.
# Errors that happen during the cleanup drain have already been reported
@@ -39,3 +52,4 @@ class AsyncResponseContextIterator(_utilities.AsyncResponseIterator):
pass
except BaseException:
pass
+ self._call_on_finish()
diff --git a/contrib/python/ydb/py3/ydb/aio/query/session.py b/contrib/python/ydb/py3/ydb/aio/query/session.py
index a565b266e82..b776b638268 100644
--- a/contrib/python/ydb/py3/ydb/aio/query/session.py
+++ b/contrib/python/ydb/py3/ydb/aio/query/session.py
@@ -19,6 +19,7 @@ from ..._grpc.grpcwrapper import ydb_query_public_types as _ydb_query_public
from ...query import base
from ...query.session import BaseQuerySession
+from ...opentelemetry.tracing import SpanName, create_ydb_span, set_peer_attributes, span_finish_callback
from ..._constants import DEFAULT_INITIAL_RESPONSE_TIMEOUT
@@ -105,8 +106,10 @@ class QuerySession(BaseQuerySession["AsyncDriver"]):
if self._closed:
raise RuntimeError("Session is already closed")
- await self._create_call(settings=settings)
- await self._attach()
+ with create_ydb_span(SpanName.CREATE_SESSION, self._driver_config).attach_context() as span:
+ await self._create_call(settings=settings)
+ set_peer_attributes(span, self._peer)
+ await self._attach()
return self
@@ -159,20 +162,27 @@ class QuerySession(BaseQuerySession["AsyncDriver"]):
"""
self._check_session_ready_to_use()
- stream_it = await self._execute_call(
- query=query,
- parameters=parameters,
- commit_tx=True,
- syntax=syntax,
- exec_mode=exec_mode,
- stats_mode=stats_mode,
- schema_inclusion_mode=schema_inclusion_mode,
- result_set_format=result_set_format,
- arrow_format_settings=arrow_format_settings,
- concurrent_result_sets=concurrent_result_sets,
- settings=settings,
+ span = create_ydb_span(
+ SpanName.EXECUTE_QUERY,
+ self._driver_config,
+ node_id=self._node_id,
+ peer=self._peer,
)
+ with span.attach_context(end_on_exit=False):
+ stream_it = await self._execute_call(
+ query=query,
+ parameters=parameters,
+ commit_tx=True,
+ syntax=syntax,
+ exec_mode=exec_mode,
+ stats_mode=stats_mode,
+ schema_inclusion_mode=schema_inclusion_mode,
+ result_set_format=result_set_format,
+ arrow_format_settings=arrow_format_settings,
+ concurrent_result_sets=concurrent_result_sets,
+ settings=settings,
+ )
return AsyncResponseContextIterator(
it=stream_it,
wrapper=lambda resp: base.wrap_execute_query_response(
@@ -182,6 +192,7 @@ class QuerySession(BaseQuerySession["AsyncDriver"]):
settings=self._settings,
),
on_error=self._on_execute_stream_error,
+ on_finish=span_finish_callback(span),
)
async def explain(
diff --git a/contrib/python/ydb/py3/ydb/aio/query/transaction.py b/contrib/python/ydb/py3/ydb/aio/query/transaction.py
index c31d79fb088..a05d91f2349 100644
--- a/contrib/python/ydb/py3/ydb/aio/query/transaction.py
+++ b/contrib/python/ydb/py3/ydb/aio/query/transaction.py
@@ -12,6 +12,7 @@ from ...query.transaction import (
BaseQueryTxContext,
QueryTxStateEnum,
)
+from ...opentelemetry.tracing import SpanName, create_ydb_span, span_finish_callback
if TYPE_CHECKING:
from .session import QuerySession
@@ -87,7 +88,13 @@ class QueryTxContext(BaseQueryTxContext["AsyncDriver"]):
:return: None or exception if begin is failed
"""
- await self._begin_call(settings)
+ with create_ydb_span(
+ SpanName.BEGIN_TRANSACTION,
+ self._driver_config,
+ node_id=self.session.node_id,
+ peer=getattr(self.session, "_peer", None),
+ ).attach_context():
+ await self._begin_call(settings)
return self
async def commit(self, settings: Optional[BaseRequestSettings] = None) -> None:
@@ -109,13 +116,19 @@ class QueryTxContext(BaseQueryTxContext["AsyncDriver"]):
await self._ensure_prev_stream_finished()
- try:
- await self._execute_callbacks_async(base.TxEvent.BEFORE_COMMIT)
- await self._commit_call(settings)
- await self._execute_callbacks_async(base.TxEvent.AFTER_COMMIT, exc=None)
- except BaseException as e:
- await self._execute_callbacks_async(base.TxEvent.AFTER_COMMIT, exc=e)
- raise e
+ with create_ydb_span(
+ SpanName.COMMIT,
+ self._driver_config,
+ node_id=self.session.node_id,
+ peer=getattr(self.session, "_peer", None),
+ ).attach_context():
+ try:
+ await self._execute_callbacks_async(base.TxEvent.BEFORE_COMMIT)
+ await self._commit_call(settings)
+ await self._execute_callbacks_async(base.TxEvent.AFTER_COMMIT, exc=None)
+ except BaseException as e:
+ await self._execute_callbacks_async(base.TxEvent.AFTER_COMMIT, exc=e)
+ raise e
async def rollback(self, settings: Optional[BaseRequestSettings] = None) -> None:
"""Calls rollback on a transaction if it is open otherwise is no-op. If transaction execution
@@ -136,13 +149,19 @@ class QueryTxContext(BaseQueryTxContext["AsyncDriver"]):
await self._ensure_prev_stream_finished()
- try:
- await self._execute_callbacks_async(base.TxEvent.BEFORE_ROLLBACK)
- await self._rollback_call(settings)
- await self._execute_callbacks_async(base.TxEvent.AFTER_ROLLBACK, exc=None)
- except BaseException as e:
- await self._execute_callbacks_async(base.TxEvent.AFTER_ROLLBACK, exc=e)
- raise e
+ with create_ydb_span(
+ SpanName.ROLLBACK,
+ self._driver_config,
+ node_id=self.session.node_id,
+ peer=getattr(self.session, "_peer", None),
+ ).attach_context():
+ try:
+ await self._execute_callbacks_async(base.TxEvent.BEFORE_ROLLBACK)
+ await self._rollback_call(settings)
+ await self._execute_callbacks_async(base.TxEvent.AFTER_ROLLBACK, exc=None)
+ except BaseException as e:
+ await self._execute_callbacks_async(base.TxEvent.AFTER_ROLLBACK, exc=e)
+ raise e
async def execute(
self,
@@ -190,20 +209,27 @@ class QueryTxContext(BaseQueryTxContext["AsyncDriver"]):
"""
await self._ensure_prev_stream_finished()
- stream_it = await self._execute_call(
- query=query,
- parameters=parameters,
- commit_tx=commit_tx,
- syntax=syntax,
- exec_mode=exec_mode,
- stats_mode=stats_mode,
- schema_inclusion_mode=schema_inclusion_mode,
- result_set_format=result_set_format,
- arrow_format_settings=arrow_format_settings,
- concurrent_result_sets=concurrent_result_sets,
- settings=settings,
+ span = create_ydb_span(
+ SpanName.EXECUTE_QUERY,
+ self._driver_config,
+ node_id=self.session.node_id,
+ peer=getattr(self.session, "_peer", None),
)
+ with span.attach_context(end_on_exit=False):
+ stream_it = await self._execute_call(
+ query=query,
+ parameters=parameters,
+ commit_tx=commit_tx,
+ syntax=syntax,
+ exec_mode=exec_mode,
+ stats_mode=stats_mode,
+ schema_inclusion_mode=schema_inclusion_mode,
+ result_set_format=result_set_format,
+ arrow_format_settings=arrow_format_settings,
+ concurrent_result_sets=concurrent_result_sets,
+ settings=settings,
+ )
self._prev_stream = AsyncResponseContextIterator(
it=stream_it,
wrapper=lambda resp: base.wrap_execute_query_response(
@@ -215,5 +241,6 @@ class QueryTxContext(BaseQueryTxContext["AsyncDriver"]):
settings=self.session._settings,
),
on_error=self.session._on_execute_stream_error,
+ on_finish=span_finish_callback(span),
)
return self._prev_stream
diff --git a/contrib/python/ydb/py3/ydb/aio/table.py b/contrib/python/ydb/py3/ydb/aio/table.py
index 0d14ba2f11b..8d5e02c180d 100644
--- a/contrib/python/ydb/py3/ydb/aio/table.py
+++ b/contrib/python/ydb/py3/ydb/aio/table.py
@@ -462,7 +462,8 @@ async def retry_operation(callee, retry_settings=None, *args, **kwargs): # pyli
opt_generator = ydb.retry_operation_impl(callee, retry_settings, *args, **kwargs)
for next_opt in opt_generator:
if isinstance(next_opt, ydb.YdbRetryOperationSleepOpt):
- await asyncio.sleep(next_opt.timeout)
+ if next_opt.timeout > 0:
+ await asyncio.sleep(next_opt.timeout)
else:
try:
return await next_opt.result
diff --git a/contrib/python/ydb/py3/ydb/connection.py b/contrib/python/ydb/py3/ydb/connection.py
index 98fbd5aab31..7e5ad3675fe 100644
--- a/contrib/python/ydb/py3/ydb/connection.py
+++ b/contrib/python/ydb/py3/ydb/connection.py
@@ -24,6 +24,7 @@ from google.protobuf import text_format
import grpc
from . import issues, _apis, _utilities
from . import default_pem
+from .opentelemetry.tracing import get_trace_metadata
_stubs_list = (
_apis.TableService.Stub,
@@ -179,6 +180,9 @@ def _construct_metadata(driver_config, settings):
metadata.extend(getattr(settings, "headers", []))
metadata.append(_utilities.x_ydb_sdk_build_info_header(getattr(driver_config, "_additional_sdk_headers", ())))
+
+ metadata.extend(get_trace_metadata())
+
return metadata
@@ -194,11 +198,14 @@ def _get_request_timeout(settings):
class EndpointOptions(object):
- __slots__ = ("ssl_target_name_override", "node_id")
+ __slots__ = ("ssl_target_name_override", "node_id", "address", "port", "location")
- def __init__(self, ssl_target_name_override=None, node_id=None):
+ def __init__(self, ssl_target_name_override=None, node_id=None, address=None, port=None, location=None):
self.ssl_target_name_override = ssl_target_name_override
self.node_id = node_id
+ self.address = address
+ self.port = port
+ self.location = location
def _construct_channel_options(driver_config, endpoint_options=None):
@@ -405,6 +412,9 @@ class Connection(object):
"closing",
"endpoint_key",
"node_id",
+ "peer_address",
+ "peer_port",
+ "peer_location",
)
def __init__(
@@ -419,9 +429,11 @@ class Connection(object):
discovered by the YDB endpoint discovery mechanism
:param driver_config: A driver config instance to be used for RPC call interception
"""
- global _stubs_list
self.endpoint = endpoint
self.node_id = getattr(endpoint_options, "node_id", None)
+ self.peer_address = getattr(endpoint_options, "address", None)
+ self.peer_port = getattr(endpoint_options, "port", None)
+ self.peer_location = getattr(endpoint_options, "location", None)
self.endpoint_key = EndpointKey(endpoint, getattr(endpoint_options, "node_id", None))
self._channel = channel_factory(self.endpoint, driver_config, endpoint_options=endpoint_options)
self._driver_config = driver_config
@@ -590,8 +602,12 @@ class Connection(object):
self.destroy()
def destroy(self):
- if hasattr(self, "_channel") and hasattr(self._channel, "close"):
- self._channel.close()
+ channel = getattr(self, "_channel", None)
+ if channel is not None and hasattr(channel, "close"):
+ channel.close()
+
+ self._stub_instances.clear()
+ self._channel = None
def ready_future(self):
"""
diff --git a/contrib/python/ydb/py3/ydb/convert.py b/contrib/python/ydb/py3/ydb/convert.py
index b54e8bddc8d..89f28ecc597 100644
--- a/contrib/python/ydb/py3/ydb/convert.py
+++ b/contrib/python/ydb/py3/ydb/convert.py
@@ -24,11 +24,21 @@ _initialize()
class _DotDict(dict):
+ # A lazy __dict__ is declared on purpose: it is not materialized until a row
+ # is written to (or its __dict__ is introspected), so untouched read-only
+ # rows avoid the per-instance dict overhead while callers can still attach
+ # their own attributes (ORM-style row.extra = ...), which materializes the
+ # dict for that row alone.
+ __slots__ = ("__dict__",)
+
def __init__(self, *args, **kwargs):
super(_DotDict, self).__init__(*args, **kwargs)
def __getattr__(self, item):
- return self[item]
+ try:
+ return self[item]
+ except KeyError:
+ raise AttributeError(item) from None
def _is_decimal_signed(hi_value):
@@ -83,7 +93,7 @@ def _pb_to_dict(type_pb, value_pb, table_client_settings):
class _Struct(_DotDict):
- pass
+ __slots__ = ()
def _pb_to_struct(type_pb, value_pb, table_client_settings):
@@ -351,6 +361,16 @@ def _unwrap_optionality(column):
return _to_native_map.get(current_type), c_type
+def _detach_columns(columns):
+ # The columns container references the source protobuf message, which keeps
+ # the whole arena (including already-parsed message.rows) alive for as long
+ # as the result set is held. Copy the schema into a standalone message so the
+ # source arena can be released right after conversion.
+ holder = _apis.ydb_value.ResultSet()
+ holder.columns.extend(columns)
+ return holder.columns
+
+
class _ResultSet(object):
__slots__ = ("columns", "rows", "truncated", "snapshot", "index", "format", "arrow_format_meta", "data")
@@ -375,12 +395,17 @@ class _ResultSet(object):
for column in message.columns:
column_parsers.append(_unwrap_optionality(column))
+ # Names are the only per-row metadata we need. Storing this shared tuple
+ # (instead of the proto columns) keeps rows detached from the source
+ # protobuf arena, so it can be freed once conversion is done.
+ column_names = tuple(column.name for column in message.columns)
+
for row_proto in message.rows:
- row = _Row(message.columns)
- for column, value, column_info in zip(message.columns, row_proto.items, column_parsers):
+ row = _Row(column_names)
+ for name, value, column_info in zip(column_names, row_proto.items, column_parsers):
v_type = value.WhichOneof("value")
if v_type == "null_flag_value":
- row[column.name] = None
+ row[name] = None
continue
while v_type == "nested_value":
@@ -388,7 +413,7 @@ class _ResultSet(object):
v_type = value.WhichOneof("value")
column_parser, unwrapped_type = column_info
- row[column.name] = column_parser(unwrapped_type, value, table_client_settings)
+ row[name] = column_parser(unwrapped_type, value, table_client_settings)
rows.append(row)
from ydb.query import QueryResultSetFormat, ArrowFormatMeta
@@ -401,12 +426,17 @@ class _ResultSet(object):
data = message.data if message.data else None
- return cls(message.columns, rows, message.truncated, snapshot, index, result_format, arrow_meta, data)
+ return cls(
+ _detach_columns(message.columns), rows, message.truncated, snapshot, index, result_format, arrow_meta, data
+ )
@classmethod
def lazy_from_message(cls, message, table_client_settings=None, snapshot=None):
from ydb.query import QueryResultSetFormat, ArrowFormatMeta
+ # No _detach_columns here on purpose: _LazyRows defers parsing and keeps
+ # message.rows, so the source arena is pinned regardless — detaching the
+ # schema would only add a copy without freeing anything.
rows = _LazyRows(message.rows, table_client_settings, message.columns)
result_format = message.format if message.format else QueryResultSetFormat.VALUE
@@ -422,15 +452,17 @@ ResultSet = _ResultSet
class _Row(_DotDict):
+ __slots__ = ("_columns",)
+
def __init__(self, columns):
super(_Row, self).__init__()
self._columns = columns
def __getitem__(self, key):
if isinstance(key, int):
- return self[self._columns[key].name]
+ return self[self._columns[key]]
elif isinstance(key, slice):
- return tuple(map(lambda x: self[x.name], self._columns[key]))
+ return tuple(self[name] for name in self._columns[key])
else:
return super(_Row, self).__getitem__(key)
@@ -455,10 +487,11 @@ class _LazyRowItem:
class _LazyRow(_DotDict):
+ __slots__ = ("_columns",)
+
def __init__(self, columns, proto_row, table_client_settings, parsers):
super(_LazyRow, self).__init__()
self._columns = columns
- self._table_client_settings = table_client_settings
for i, (column, row_item) in enumerate(zip(self._columns, proto_row.items)):
super(_LazyRow, self).__setitem__(
column.name,
diff --git a/contrib/python/ydb/py3/ydb/opentelemetry/__init__.py b/contrib/python/ydb/py3/ydb/opentelemetry/__init__.py
new file mode 100644
index 00000000000..fc058d0d866
--- /dev/null
+++ b/contrib/python/ydb/py3/ydb/opentelemetry/__init__.py
@@ -0,0 +1,36 @@
+"""Public OpenTelemetry entrypoints for YDB."""
+
+
+def enable_tracing(tracer=None):
+ """Enable OpenTelemetry trace context propagation and span creation for all YDB gRPC calls.
+
+ This call is **idempotent**: if tracing is already enabled, later calls do nothing
+ (including passing a different ``tracer``). Call :func:`disable_tracing` first to
+ reconfigure or turn instrumentation off.
+
+ Args:
+ tracer: Optional OTel tracer to use. If not provided, the default tracer named
+ ``ydb.sdk`` from the global tracer provider will be used.
+ """
+ try:
+ from ydb.opentelemetry.plugin import _enable_tracing
+ except ImportError:
+ raise ImportError(
+ "OpenTelemetry packages are required for tracing support. "
+ "Install them with: pip install ydb[opentelemetry]"
+ ) from None
+
+ _enable_tracing(tracer)
+
+
+def disable_tracing():
+ """Disable YDB OpenTelemetry hooks and allow :func:`enable_tracing` to run again."""
+ try:
+ from ydb.opentelemetry.plugin import _disable_tracing
+ except ImportError:
+ return
+
+ _disable_tracing()
+
+
+__all__ = ["disable_tracing", "enable_tracing"]
diff --git a/contrib/python/ydb/py3/ydb/opentelemetry/plugin.py b/contrib/python/ydb/py3/ydb/opentelemetry/plugin.py
new file mode 100644
index 00000000000..76942789f52
--- /dev/null
+++ b/contrib/python/ydb/py3/ydb/opentelemetry/plugin.py
@@ -0,0 +1,134 @@
+"""OpenTelemetry bridge for YDB."""
+
+from opentelemetry import context as otel_context
+from opentelemetry import trace
+from opentelemetry.propagate import inject
+from opentelemetry.trace import StatusCode
+
+from ydb import issues
+from ydb.issues import StatusCode as YdbStatusCode
+from ydb.opentelemetry.tracing import _registry
+
+# YDB client transport StatusCode values (401xxx band) -> OTel error.type transport_error.
+_TRANSPORT_STATUSES = frozenset(
+ {
+ YdbStatusCode.CONNECTION_LOST,
+ YdbStatusCode.CONNECTION_FAILURE,
+ YdbStatusCode.DEADLINE_EXCEEDED,
+ YdbStatusCode.CLIENT_INTERNAL_ERROR,
+ YdbStatusCode.UNIMPLEMENTED,
+ }
+)
+
+_tracer = None
+_enabled = False
+
+_KIND_MAP = {
+ "client": trace.SpanKind.CLIENT,
+ "internal": trace.SpanKind.INTERNAL,
+}
+
+
+def _otel_metadata_hook():
+ """Inject W3C Trace Context into outgoing gRPC metadata using the active OTel context."""
+ headers = {}
+ inject(headers)
+ return list(headers.items())
+
+
+def _set_error_on_span(span, exception):
+ if isinstance(exception, issues.Error) and exception.status is not None:
+ span.set_attribute("db.response.status_code", exception.status.name)
+ error_type = "transport_error" if exception.status in _TRANSPORT_STATUSES else "ydb_error"
+ else:
+ error_type = type(exception).__qualname__
+
+ span.set_attribute("error.type", error_type)
+ span.set_status(StatusCode.ERROR, str(exception))
+ span.record_exception(exception)
+
+
+class _AttachContext:
+ """Make a span the active OTel context for a ``with`` block.
+
+ When ``end_on_exit=True`` (default) the span is ended on exit — used for
+ single-shot RPCs. When ``end_on_exit=False`` the span is only ended on
+ exception — used for streaming RPCs where the result iterator owns ``end()``.
+ """
+
+ def __init__(self, span, end_on_exit):
+ self._span = span
+ self._end_on_exit = end_on_exit
+ self._token = None
+
+ def __enter__(self):
+ ctx = trace.set_span_in_context(self._span._span)
+ self._token = otel_context.attach(ctx)
+ return self._span
+
+ def __exit__(self, exc_type, exc_val, exc_tb):
+ if self._token is not None:
+ otel_context.detach(self._token)
+ self._token = None
+ if exc_val is not None:
+ self._span.set_error(exc_val)
+ self._span.end()
+ elif self._end_on_exit:
+ self._span.end()
+ return False
+
+
+class TracingSpan:
+ """Wrapper around an OTel span.
+
+ Use :meth:`attach_context` as a context manager around any RPC call.
+ The default (``end_on_exit=True``) is for single-shot operations; pass
+ ``end_on_exit=False`` for streaming RPCs where the result iterator owns
+ ``end()``.
+ """
+
+ def __init__(self, span):
+ self._span = span
+
+ def set_error(self, exception):
+ _set_error_on_span(self._span, exception)
+
+ def set_attribute(self, key, value):
+ self._span.set_attribute(key, value)
+
+ def end(self):
+ self._span.end()
+
+ def attach_context(self, end_on_exit=True):
+ return _AttachContext(self, end_on_exit)
+
+
+def _create_span(name, attributes=None, kind=None):
+ span = _tracer.start_span(
+ name,
+ kind=_KIND_MAP.get(kind, trace.SpanKind.CLIENT),
+ attributes=attributes or {},
+ )
+ return TracingSpan(span)
+
+
+def _enable_tracing(tracer=None):
+ global _enabled, _tracer
+
+ if _enabled:
+ return
+
+ _tracer = tracer if tracer is not None else trace.get_tracer("ydb.sdk")
+ _enabled = True
+ _registry.set_metadata_hook(_otel_metadata_hook)
+ _registry.set_create_span(_create_span)
+
+
+def _disable_tracing():
+ """Clear hooks and tracer; after this, :func:`~ydb.opentelemetry.enable_tracing` may be called again."""
+ global _enabled, _tracer
+
+ _registry.set_create_span(None)
+ _registry.set_metadata_hook(None)
+ _enabled = False
+ _tracer = None
diff --git a/contrib/python/ydb/py3/ydb/opentelemetry/tracing.py b/contrib/python/ydb/py3/ydb/opentelemetry/tracing.py
new file mode 100644
index 00000000000..1d0995df8d5
--- /dev/null
+++ b/contrib/python/ydb/py3/ydb/opentelemetry/tracing.py
@@ -0,0 +1,165 @@
+"""Internal SDK tracing helpers and registry."""
+
+import enum
+from typing import Optional, Tuple
+
+
+class SpanName(str, enum.Enum):
+ """Canonical span names used across the YDB SDK."""
+
+ CREATE_SESSION = "ydb.CreateSession"
+ EXECUTE_QUERY = "ydb.ExecuteQuery"
+ BEGIN_TRANSACTION = "ydb.BeginTransaction"
+ COMMIT = "ydb.Commit"
+ ROLLBACK = "ydb.Rollback"
+ DRIVER_INITIALIZE = "ydb.Driver.Initialize"
+ RUN_WITH_RETRY = "ydb.RunWithRetry"
+ TRY = "ydb.Try"
+
+
+class _NoopCtx:
+ __slots__ = ("_span",)
+
+ def __init__(self, span):
+ self._span = span
+
+ def __enter__(self):
+ return self._span
+
+ def __exit__(self, exc_type, exc_val, exc_tb):
+ return False
+
+
+class _NoopSpan:
+ """Returned by create_ydb_span when tracing is disabled."""
+
+ def set_error(self, exception):
+ pass
+
+ def set_attribute(self, key, value):
+ pass
+
+ def end(self):
+ pass
+
+ def attach_context(self, end_on_exit=True):
+ return _NoopCtx(self)
+
+
+_NOOP_SPAN = _NoopSpan()
+
+
+class OtelTracingRegistry:
+ """Singleton registry for OpenTelemetry tracing.
+
+ By default everything is no-op until :func:`~ydb.opentelemetry.enable_tracing` is called.
+ """
+
+ def __init__(self):
+ self._metadata_hook = None
+ self._create_span_func = None
+
+ def is_active(self) -> bool:
+ return self._create_span_func is not None
+
+ def create_span(self, name, attributes=None, kind=None):
+ if self._create_span_func is None:
+ return _NOOP_SPAN
+ return self._create_span_func(name, attributes, kind=kind)
+
+ def get_trace_metadata(self):
+ if self._metadata_hook is not None:
+ return self._metadata_hook()
+ return []
+
+ def set_metadata_hook(self, hook):
+ self._metadata_hook = hook
+
+ def set_create_span(self, func):
+ self._create_span_func = func
+
+
+_registry = OtelTracingRegistry()
+
+
+def get_trace_metadata():
+ """Return tracing metadata for gRPC calls."""
+ return _registry.get_trace_metadata()
+
+
+def _split_endpoint(endpoint: Optional[str]) -> Tuple[str, int]:
+ ep = endpoint or ""
+ if ep.startswith("grpcs://"):
+ ep = ep[len("grpcs://") :]
+ elif ep.startswith("grpc://"):
+ ep = ep[len("grpc://") :]
+
+ if ep.startswith("["):
+ close = ep.find("]")
+ if close != -1 and len(ep) > close + 1 and ep[close + 1] == ":":
+ host = ep[: close + 1]
+ port_s = ep[close + 2 :]
+ return host, int(port_s) if port_s.isdigit() else 0
+
+ host, sep, port_s = ep.rpartition(":")
+ if not sep:
+ return ep, 0
+ return host, int(port_s) if port_s.isdigit() else 0
+
+
+def _build_ydb_attrs(driver_config, node_id=None, peer=None):
+ host, port = _split_endpoint(getattr(driver_config, "endpoint", None))
+ attrs = {
+ "db.system.name": "ydb",
+ "db.namespace": getattr(driver_config, "database", None) or "",
+ "server.address": host,
+ "server.port": port,
+ }
+ if peer is not None:
+ address, port_, location = peer
+ if address is not None:
+ attrs["network.peer.address"] = address
+ if port_ is not None:
+ attrs["network.peer.port"] = int(port_)
+ if location:
+ attrs["ydb.node.dc"] = location
+ if node_id is not None:
+ attrs["ydb.node.id"] = node_id
+ return attrs
+
+
+def create_span(name, attributes=None, kind="internal"):
+ """Create a span with no YDB-specific attributes (used for SDK-internal operations)."""
+ return _registry.create_span(name, attributes=attributes, kind=kind).attach_context()
+
+
+def create_ydb_span(name, driver_config, node_id=None, kind=None, peer=None):
+ """Create a span pre-filled with standard YDB attributes."""
+ if not _registry.is_active():
+ return _NOOP_SPAN
+ attrs = _build_ydb_attrs(driver_config, node_id, peer)
+ return _registry.create_span(name, attributes=attrs, kind=kind)
+
+
+def set_peer_attributes(span, peer):
+ """Fill in network.peer.* and ydb.node.dc on an existing span once the peer is known."""
+ if peer is None:
+ return
+ address, port, location = peer
+ if address is not None:
+ span.set_attribute("network.peer.address", address)
+ if port is not None:
+ span.set_attribute("network.peer.port", int(port))
+ if location:
+ span.set_attribute("ydb.node.dc", location)
+
+
+def span_finish_callback(span):
+ """Return an on_finish callable that ends *span* when a streaming result iterator completes."""
+
+ def _finish(exception=None):
+ if exception is not None:
+ span.set_error(exception)
+ span.end()
+
+ return _finish
diff --git a/contrib/python/ydb/py3/ydb/pool.py b/contrib/python/ydb/py3/ydb/pool.py
index 2901c573435..f1a4bdf940b 100644
--- a/contrib/python/ydb/py3/ydb/pool.py
+++ b/contrib/python/ydb/py3/ydb/pool.py
@@ -10,6 +10,7 @@ import random
from typing import Any, Callable, ContextManager, List, Optional, Set, Tuple, TYPE_CHECKING
from . import connection as connection_impl, issues, resolver, _utilities, tracing
+from .opentelemetry.tracing import SpanName, create_ydb_span
from abc import abstractmethod
from .connection import Connection, EndpointKey
@@ -400,23 +401,41 @@ class ConnectionPool(IConnectionPool):
self._store = ConnectionsCache(driver_config.use_all_nodes, driver_config.tracer)
self.tracer = driver_config.tracer
self._grpc_init = connection_impl.Connection(self._driver_config.endpoint, self._driver_config)
+ self._stopped = False
+ self._stop_guard = threading.Lock()
+ self._stop_event = threading.Event()
+ self._init_thread: Optional[threading.Thread] = None
if driver_config.disable_discovery:
- # If discovery is disabled, just add the initial endpoint to the store
- ready_connection = connection_impl.Connection.ready_factory(
- self._driver_config.endpoint,
- self._driver_config,
- ready_timeout=getattr(self._driver_config, "discovery_request_timeout", 10),
- )
- self._store.add(ready_connection)
+ # If discovery is disabled, establish the initial connection in a
+ # background thread, retrying until it succeeds or the pool is stopped.
+ # Doing this off the constructor keeps wait(timeout) as the blocking
+ # point and lets stop() interrupt the retry loop.
self._discovery_thread = None
+ self._init_thread = threading.Thread(
+ name="ydb_driver_initial_connection",
+ target=self._init_connection,
+ daemon=True,
+ )
+ self._init_thread.start()
else:
# Start discovery thread as usual
self._discovery_thread = Discovery(self._store, self._driver_config)
self._discovery_thread.start()
- self._stopped = False
- self._stop_guard = threading.Lock()
+ def _init_connection(self) -> None:
+ ready_timeout = getattr(self._driver_config, "discovery_request_timeout", 10)
+ while not self._stopped:
+ ready_connection = connection_impl.Connection.ready_factory(
+ self._driver_config.endpoint,
+ self._driver_config,
+ ready_timeout=ready_timeout,
+ )
+ if self._store.add(ready_connection):
+ return
+
+ logger.debug("Initial connection attempt failed")
+ self._stop_event.wait(1)
def stop(self, timeout: int = 10) -> None:
"""
@@ -430,11 +449,16 @@ class ConnectionPool(IConnectionPool):
return
self._stopped = True
+ self._stop_event.set()
if self._discovery_thread:
self._discovery_thread.stop()
self._grpc_init.close()
if self._discovery_thread:
self._discovery_thread.join(timeout)
+ if self._init_thread:
+ self._init_thread.join(timeout)
+ if self._discovery_thread is None:
+ self._store.cleanup()
def async_wait(self, fail_fast: bool = False) -> "futures.Future[None]":
"""
@@ -453,10 +477,11 @@ class ConnectionPool(IConnectionPool):
:param timeout: A timeout to wait in seconds
:return: None
"""
- if fail_fast:
- self._store.add_fast_fail().result(timeout)
- else:
- self._store.subscribe().result(timeout)
+ with create_ydb_span(SpanName.DRIVER_INITIALIZE, self._driver_config, kind="internal").attach_context():
+ if fail_fast:
+ self._store.add_fast_fail().result(timeout)
+ else:
+ self._store.subscribe().result(timeout)
def _on_disconnected(self, connection: Connection) -> None:
"""
diff --git a/contrib/python/ydb/py3/ydb/query/base.py b/contrib/python/ydb/py3/ydb/query/base.py
index 7fea6cd0e8c..093c7d55452 100644
--- a/contrib/python/ydb/py3/ydb/query/base.py
+++ b/contrib/python/ydb/py3/ydb/query/base.py
@@ -27,7 +27,6 @@ from .. import _apis
from ydb._topic_common.common import CallFromSyncToAsync, _get_shared_event_loop
from ydb._grpc.grpcwrapper.common_utils import to_thread
-
if typing.TYPE_CHECKING:
from .transaction import BaseQueryTxContext
from .session import BaseQuerySession
@@ -73,9 +72,12 @@ class QueryResultSetFormat(enum.IntEnum):
class SyncResponseContextIterator(_utilities.SyncResponseIterator):
- def __init__(self, it, wrapper, on_error=None):
+ """Streams ExecuteQuery results."""
+
+ def __init__(self, it, wrapper, on_error=None, on_finish=None):
super().__init__(it, wrapper)
self._on_error = on_error
+ self._on_finish = on_finish
def __enter__(self) -> "SyncResponseContextIterator":
return self
@@ -86,6 +88,7 @@ class SyncResponseContextIterator(_utilities.SyncResponseIterator):
except StopIteration:
# Normal stream termination is not an error and must not invalidate
# the session.
+ self._call_on_finish()
raise
except BaseException as e:
# BaseException (not Exception) for parity with the async iterator:
@@ -95,8 +98,17 @@ class SyncResponseContextIterator(_utilities.SyncResponseIterator):
# SessionBusy.
if self._on_error:
self._on_error(e)
+ self._call_on_finish(e)
raise
+ def _call_on_finish(self, exception=None):
+ if self._on_finish is not None:
+ self._on_finish(exception)
+ self._on_finish = None
+
+ def __del__(self):
+ self._call_on_finish()
+
def __exit__(self, exc_type, exc_val, exc_tb):
# To close stream on YDB it is necessary to scroll through it to the end.
# Errors during the cleanup drain have already been reported to _on_error
@@ -107,6 +119,7 @@ class SyncResponseContextIterator(_utilities.SyncResponseIterator):
pass
except BaseException:
pass
+ self._call_on_finish()
class QueryClientSettings:
diff --git a/contrib/python/ydb/py3/ydb/query/session.py b/contrib/python/ydb/py3/ydb/query/session.py
index f2099f8cdbf..a9c1b4a50d9 100644
--- a/contrib/python/ydb/py3/ydb/query/session.py
+++ b/contrib/python/ydb/py3/ydb/query/session.py
@@ -18,6 +18,7 @@ from . import base
from .base import QueryExplainResultFormat
from .. import _apis, issues, _utilities
+from ..opentelemetry.tracing import SpanName, create_ydb_span, set_peer_attributes, span_finish_callback
from ..settings import BaseRequestSettings
from ..connection import _RpcState as RpcState, EndpointKey
from .._grpc.grpcwrapper import common_utils
@@ -30,7 +31,7 @@ from .transaction import QueryTxContext
from .._constants import DEFAULT_INITIAL_RESPONSE_TIMEOUT, DEFAULT_LONG_STREAM_TIMEOUT
if TYPE_CHECKING:
- from ..driver import Driver as SyncDriver
+ from ..driver import Driver as SyncDriver, DriverConfig
from ..aio.driver import Driver as AsyncDriver
@@ -46,9 +47,30 @@ def wrapper_create_session(
issues._process_response(message.status)
session._session_id = message.session_id
session._node_id = message.node_id
+ session._peer = _resolve_peer(session._driver, message.node_id)
return session
+def _resolve_peer(driver, node_id):
+ """Look up network.peer.* / ydb.node.dc for a node in the driver's endpoint map."""
+ if node_id is None:
+ return None
+ store = getattr(driver, "_store", None)
+ if store is None:
+ return None
+ by_node = getattr(store, "connections_by_node_id", None)
+ if not by_node:
+ return None
+ connection = by_node.get(node_id)
+ if connection is None:
+ return None
+ return (
+ getattr(connection, "peer_address", None),
+ getattr(connection, "peer_port", None),
+ getattr(connection, "peer_location", None),
+ )
+
+
def wrapper_delete_session(
rpc_state: RpcState,
response_pb: _apis.ydb_query.DeleteSessionResponse,
@@ -69,6 +91,7 @@ class BaseQuerySession(abc.ABC, Generic[DriverT]):
# Session data
_session_id: Optional[str] = None
_node_id: Optional[int] = None
+ _peer: Optional[tuple] = None
_closed: bool = False
_invalidated: bool = False
@@ -85,6 +108,10 @@ class BaseQuerySession(abc.ABC, Generic[DriverT]):
self._last_query_stats = None
@property
+ def _driver_config(self) -> Optional["DriverConfig"]:
+ return getattr(self._driver, "_driver_config", None)
+
+ @property
def session_id(self) -> Optional[str]:
return self._session_id
@@ -391,8 +418,10 @@ class QuerySession(BaseQuerySession["SyncDriver"]):
if self._closed:
raise RuntimeError("Session is already closed.")
- self._create_call(settings=settings)
- self._attach()
+ with create_ydb_span(SpanName.CREATE_SESSION, self._driver_config).attach_context() as span:
+ self._create_call(settings=settings)
+ set_peer_attributes(span, self._peer)
+ self._attach()
return self
@@ -458,20 +487,27 @@ class QuerySession(BaseQuerySession["SyncDriver"]):
"""
self._check_session_ready_to_use()
- stream_it = self._execute_call(
- query=query,
- parameters=parameters,
- commit_tx=True,
- syntax=syntax,
- exec_mode=exec_mode,
- stats_mode=stats_mode,
- schema_inclusion_mode=schema_inclusion_mode,
- result_set_format=result_set_format,
- arrow_format_settings=arrow_format_settings,
- concurrent_result_sets=concurrent_result_sets,
- settings=settings,
+ span = create_ydb_span(
+ SpanName.EXECUTE_QUERY,
+ self._driver_config,
+ node_id=self._node_id,
+ peer=self._peer,
)
+ with span.attach_context(end_on_exit=False):
+ stream_it = self._execute_call(
+ query=query,
+ parameters=parameters,
+ commit_tx=True,
+ syntax=syntax,
+ exec_mode=exec_mode,
+ stats_mode=stats_mode,
+ schema_inclusion_mode=schema_inclusion_mode,
+ result_set_format=result_set_format,
+ arrow_format_settings=arrow_format_settings,
+ concurrent_result_sets=concurrent_result_sets,
+ settings=settings,
+ )
return base.SyncResponseContextIterator(
stream_it,
lambda resp: base.wrap_execute_query_response(
@@ -481,6 +517,7 @@ class QuerySession(BaseQuerySession["SyncDriver"]):
settings=self._settings,
),
on_error=self._on_execute_stream_error,
+ on_finish=span_finish_callback(span),
)
def explain(
diff --git a/contrib/python/ydb/py3/ydb/query/transaction.py b/contrib/python/ydb/py3/ydb/query/transaction.py
index fdcefb0b801..1d278ac2fee 100644
--- a/contrib/python/ydb/py3/ydb/query/transaction.py
+++ b/contrib/python/ydb/py3/ydb/query/transaction.py
@@ -17,6 +17,7 @@ from .. import (
_apis,
issues,
)
+from ..opentelemetry.tracing import SpanName, create_ydb_span, span_finish_callback
from .._grpc.grpcwrapper import ydb_topic as _ydb_topic
from .._grpc.grpcwrapper import ydb_query as _ydb_query
from ..connection import _RpcState as RpcState
@@ -245,6 +246,10 @@ class BaseQueryTxContext(base.CallbackHandler, Generic[DriverT]):
self._last_query_stats = None
@property
+ def _driver_config(self):
+ return getattr(self._driver, "_driver_config", None)
+
+ @property
def session_id(self) -> Optional[str]:
"""
A transaction's session id
@@ -523,7 +528,13 @@ class QueryTxContext(BaseQueryTxContext["SyncDriver"]):
:return: Transaction object or exception if begin is failed
"""
- self._begin_call(settings)
+ with create_ydb_span(
+ SpanName.BEGIN_TRANSACTION,
+ self._driver_config,
+ node_id=self.session.node_id,
+ peer=getattr(self.session, "_peer", None),
+ ).attach_context():
+ self._begin_call(settings)
return self
@@ -545,13 +556,19 @@ class QueryTxContext(BaseQueryTxContext["SyncDriver"]):
self._ensure_prev_stream_finished()
- try:
- self._execute_callbacks_sync(base.TxEvent.BEFORE_COMMIT)
- self._commit_call(settings)
- self._execute_callbacks_sync(base.TxEvent.AFTER_COMMIT, exc=None)
- except BaseException as e: # TODO: probably should be less wide
- self._execute_callbacks_sync(base.TxEvent.AFTER_COMMIT, exc=e)
- raise e
+ with create_ydb_span(
+ SpanName.COMMIT,
+ self._driver_config,
+ node_id=self.session.node_id,
+ peer=getattr(self.session, "_peer", None),
+ ).attach_context():
+ try:
+ self._execute_callbacks_sync(base.TxEvent.BEFORE_COMMIT)
+ self._commit_call(settings)
+ self._execute_callbacks_sync(base.TxEvent.AFTER_COMMIT, exc=None)
+ except BaseException as e: # TODO: probably should be less wide
+ self._execute_callbacks_sync(base.TxEvent.AFTER_COMMIT, exc=e)
+ raise e
def rollback(self, settings: Optional[BaseRequestSettings] = None) -> None:
"""Calls rollback on a transaction if it is open otherwise is no-op. If transaction execution
@@ -571,13 +588,19 @@ class QueryTxContext(BaseQueryTxContext["SyncDriver"]):
self._ensure_prev_stream_finished()
- try:
- self._execute_callbacks_sync(base.TxEvent.BEFORE_ROLLBACK)
- self._rollback_call(settings)
- self._execute_callbacks_sync(base.TxEvent.AFTER_ROLLBACK, exc=None)
- except BaseException as e: # TODO: probably should be less wide
- self._execute_callbacks_sync(base.TxEvent.AFTER_ROLLBACK, exc=e)
- raise e
+ with create_ydb_span(
+ SpanName.ROLLBACK,
+ self._driver_config,
+ node_id=self.session.node_id,
+ peer=getattr(self.session, "_peer", None),
+ ).attach_context():
+ try:
+ self._execute_callbacks_sync(base.TxEvent.BEFORE_ROLLBACK)
+ self._rollback_call(settings)
+ self._execute_callbacks_sync(base.TxEvent.AFTER_ROLLBACK, exc=None)
+ except BaseException as e: # TODO: probably should be less wide
+ self._execute_callbacks_sync(base.TxEvent.AFTER_ROLLBACK, exc=e)
+ raise e
def execute(
self,
@@ -626,20 +649,27 @@ class QueryTxContext(BaseQueryTxContext["SyncDriver"]):
"""
self._ensure_prev_stream_finished()
- stream_it = self._execute_call(
- query=query,
- commit_tx=commit_tx,
- syntax=syntax,
- exec_mode=exec_mode,
- stats_mode=stats_mode,
- schema_inclusion_mode=schema_inclusion_mode,
- result_set_format=result_set_format,
- arrow_format_settings=arrow_format_settings,
- parameters=parameters,
- concurrent_result_sets=concurrent_result_sets,
- settings=settings,
+ span = create_ydb_span(
+ SpanName.EXECUTE_QUERY,
+ self._driver_config,
+ node_id=self.session.node_id,
+ peer=getattr(self.session, "_peer", None),
)
+ with span.attach_context(end_on_exit=False):
+ stream_it = self._execute_call(
+ query=query,
+ commit_tx=commit_tx,
+ syntax=syntax,
+ exec_mode=exec_mode,
+ stats_mode=stats_mode,
+ schema_inclusion_mode=schema_inclusion_mode,
+ result_set_format=result_set_format,
+ arrow_format_settings=arrow_format_settings,
+ parameters=parameters,
+ concurrent_result_sets=concurrent_result_sets,
+ settings=settings,
+ )
self._prev_stream = base.SyncResponseContextIterator(
stream_it,
lambda resp: base.wrap_execute_query_response(
@@ -651,5 +681,6 @@ class QueryTxContext(BaseQueryTxContext["SyncDriver"]):
settings=self.session._settings,
),
on_error=self.session._on_execute_stream_error,
+ on_finish=span_finish_callback(span),
)
return self._prev_stream
diff --git a/contrib/python/ydb/py3/ydb/resolver.py b/contrib/python/ydb/py3/ydb/resolver.py
index e9e8e6f93f3..1402e542abc 100644
--- a/contrib/python/ydb/py3/ydb/resolver.py
+++ b/contrib/python/ydb/py3/ydb/resolver.py
@@ -53,7 +53,11 @@ class EndpointInfo(object):
ssl_target_name_override = self.address
endpoint_options = conn_impl.EndpointOptions(
- ssl_target_name_override=ssl_target_name_override, node_id=self.node_id
+ ssl_target_name_override=ssl_target_name_override,
+ node_id=self.node_id,
+ address=self.address,
+ port=self.port,
+ location=self.location,
)
if self.ipv6_addrs or self.ipv4_addrs:
diff --git a/contrib/python/ydb/py3/ydb/retries.py b/contrib/python/ydb/py3/ydb/retries.py
index c151e3d2146..4b7c137f39e 100644
--- a/contrib/python/ydb/py3/ydb/retries.py
+++ b/contrib/python/ydb/py3/ydb/retries.py
@@ -7,6 +7,11 @@ from typing import Any, Callable, Generator, Optional, Union
from . import issues
from ._errors import check_retriable_error
+from .opentelemetry.tracing import SpanName, create_span as _create_span
+
+
+def _try_span_attrs(backoff_ms: Optional[int]):
+ return {"ydb.retry.backoff_ms": backoff_ms} if backoff_ms is not None else None
class BackoffSettings:
@@ -129,19 +134,18 @@ def retry_operation_impl(
if not retriable_info.is_retriable:
raise
- skip_yield_error_types = [
+ skip_yield_error_types = (
issues.Aborted,
issues.BadSession,
issues.NotFound,
issues.InternalError,
- ]
+ )
- yield_sleep = True
- for t in skip_yield_error_types:
- if isinstance(e, t):
- yield_sleep = False
-
- if yield_sleep:
+ if isinstance(e, skip_yield_error_types):
+ # Skip the inter-attempt sleep but still emit a marker so consumers
+ # advance per-attempt bookkeeping (e.g. ``ydb.Try`` spans get backoff=0).
+ yield YdbRetryOperationSleepOpt(0.0)
+ else:
yield YdbRetryOperationSleepOpt(retriable_info.sleep_timeout_seconds)
except Exception as e:
@@ -159,12 +163,21 @@ def retry_operation_sync(
*args: Any,
**kwargs: Any,
) -> Any:
- opt_generator = retry_operation_impl(callee, retry_settings, *args, **kwargs)
- for next_opt in opt_generator:
- if isinstance(next_opt, YdbRetryOperationSleepOpt):
- time.sleep(next_opt.timeout)
- else:
- return next_opt.result
+ backoff_ms: Optional[int] = None
+
+ @functools.wraps(callee)
+ def traced_callee(*a: Any, **kw: Any) -> Any:
+ with _create_span(SpanName.TRY, _try_span_attrs(backoff_ms)):
+ return callee(*a, **kw)
+
+ with _create_span(SpanName.RUN_WITH_RETRY):
+ for next_opt in retry_operation_impl(traced_callee, retry_settings, *args, **kwargs):
+ if isinstance(next_opt, YdbRetryOperationSleepOpt):
+ backoff_ms = int(next_opt.timeout * 1000)
+ if next_opt.timeout > 0:
+ time.sleep(next_opt.timeout)
+ else:
+ return next_opt.result
return None
@@ -186,15 +199,20 @@ async def retry_operation_async( # pylint: disable=W1113
Returns awaitable result of coroutine. If retries are not succussful exception is raised.
"""
- opt_generator = retry_operation_impl(callee, retry_settings, *args, **kwargs)
- for next_opt in opt_generator:
- if isinstance(next_opt, YdbRetryOperationSleepOpt):
- await asyncio.sleep(next_opt.timeout)
- else:
- try:
- return await next_opt.result
- except BaseException as e: # pylint: disable=W0703
- next_opt.set_exception(e)
+ backoff_ms: Optional[int] = None
+ with _create_span(SpanName.RUN_WITH_RETRY):
+ for next_opt in retry_operation_impl(callee, retry_settings, *args, **kwargs):
+ if isinstance(next_opt, YdbRetryOperationSleepOpt):
+ backoff_ms = int(next_opt.timeout * 1000)
+ if next_opt.timeout > 0:
+ await asyncio.sleep(next_opt.timeout)
+ else:
+ with _create_span(SpanName.TRY, _try_span_attrs(backoff_ms)) as try_span:
+ try:
+ return await next_opt.result
+ except BaseException as e: # pylint: disable=W0703
+ try_span.set_error(e)
+ next_opt.set_exception(e)
return None
diff --git a/contrib/python/ydb/py3/ydb/ydb_version.py b/contrib/python/ydb/py3/ydb/ydb_version.py
index d7d1d1869be..989938e1be0 100644
--- a/contrib/python/ydb/py3/ydb/ydb_version.py
+++ b/contrib/python/ydb/py3/ydb/ydb_version.py
@@ -1 +1 @@
-VERSION = "3.28.4"
+VERSION = "3.29.6"