ray-doris 1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
ray_doris/__init__.py ADDED
@@ -0,0 +1,51 @@
1
+ """Apache Doris integration for Ray Data."""
2
+
3
+ from ray_doris._api import read_doris
4
+ from ray_doris._errors import (
5
+ DorisAmbiguousWriteError,
6
+ DorisAuthenticationError,
7
+ DorisConfigurationError,
8
+ DorisError,
9
+ DorisLabelExistsError,
10
+ DorisMetadataError,
11
+ DorisPermissionError,
12
+ DorisPlanningError,
13
+ DorisReadError,
14
+ DorisSchemaError,
15
+ DorisTableCompatibilityError,
16
+ DorisWriteError,
17
+ )
18
+ from ray_doris.datasource import DorisDatasource
19
+ from ray_doris.write import (
20
+ DorisConnection,
21
+ DorisDatasink,
22
+ DorisTable,
23
+ DorisWriteOptions,
24
+ DorisWriteResult,
25
+ write_doris,
26
+ )
27
+
28
+ __all__ = [
29
+ "DorisAuthenticationError",
30
+ "DorisAmbiguousWriteError",
31
+ "DorisConfigurationError",
32
+ "DorisConnection",
33
+ "DorisDatasource",
34
+ "DorisDatasink",
35
+ "DorisError",
36
+ "DorisLabelExistsError",
37
+ "DorisMetadataError",
38
+ "DorisPermissionError",
39
+ "DorisPlanningError",
40
+ "DorisReadError",
41
+ "DorisSchemaError",
42
+ "DorisTable",
43
+ "DorisTableCompatibilityError",
44
+ "DorisWriteError",
45
+ "DorisWriteOptions",
46
+ "DorisWriteResult",
47
+ "read_doris",
48
+ "write_doris",
49
+ ]
50
+
51
+ __version__ = "1.0"
ray_doris/_api.py ADDED
@@ -0,0 +1,73 @@
1
+ """Convenience entry point mirroring Ray Data reader functions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Mapping, Optional, Sequence, cast
6
+
7
+ import ray.data
8
+ from ray.data import Dataset
9
+
10
+ from ray_doris._models import FlightScheme, HttpScheme, QueryPlanPolicy, Transport
11
+ from ray_doris.datasource import DorisDatasource
12
+
13
+
14
+ def read_doris(
15
+ *,
16
+ table: str,
17
+ host: str,
18
+ mysql_port: int = 9030,
19
+ http_port: int = 8030,
20
+ flight_port: int = 8070,
21
+ http_scheme: HttpScheme = "http",
22
+ flight_scheme: FlightScheme = "grpc",
23
+ user: str = "root",
24
+ password: str = "",
25
+ password_env: Optional[str] = None,
26
+ columns: Optional[Sequence[str]] = None,
27
+ filter: Optional[str] = None,
28
+ transport: Transport = "mysql",
29
+ on_query_plan_error: QueryPlanPolicy = "single_task",
30
+ tablet_size: int = 1,
31
+ batch_size: int = 10_000,
32
+ connect_timeout: float = 10.0,
33
+ query_plan_timeout: Optional[float] = None,
34
+ http_ca_file: Optional[str] = None,
35
+ client_kwargs: Optional[Mapping[str, Any]] = None,
36
+ flight_options: Optional[Mapping[str, Any]] = None,
37
+ concurrency: Optional[int] = None,
38
+ override_num_blocks: Optional[int] = None,
39
+ ray_remote_args: Optional[Mapping[str, Any]] = None,
40
+ ) -> Dataset:
41
+ """Create a Ray Dataset by reading a Doris table in streaming tablet splits."""
42
+ datasource = DorisDatasource(
43
+ table=table,
44
+ host=host,
45
+ mysql_port=mysql_port,
46
+ http_port=http_port,
47
+ flight_port=flight_port,
48
+ http_scheme=http_scheme,
49
+ flight_scheme=flight_scheme,
50
+ user=user,
51
+ password=password,
52
+ password_env=password_env,
53
+ columns=columns,
54
+ filter=filter,
55
+ transport=transport,
56
+ on_query_plan_error=on_query_plan_error,
57
+ tablet_size=tablet_size,
58
+ batch_size=batch_size,
59
+ connect_timeout=connect_timeout,
60
+ query_plan_timeout=query_plan_timeout,
61
+ http_ca_file=http_ca_file,
62
+ client_kwargs=client_kwargs,
63
+ flight_options=flight_options,
64
+ )
65
+ return cast(
66
+ Dataset,
67
+ ray.data.read_datasource(
68
+ datasource,
69
+ concurrency=concurrency,
70
+ override_num_blocks=override_num_blocks,
71
+ ray_remote_args=dict(ray_remote_args) if ray_remote_args is not None else None,
72
+ ),
73
+ )
ray_doris/_compat.py ADDED
@@ -0,0 +1,117 @@
1
+ """Small, explicit compatibility layer for supported Ray V1 releases."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import inspect
6
+ import re
7
+ from dataclasses import dataclass
8
+ from typing import Any, Callable, Iterable, Literal, Mapping, Optional
9
+
10
+ import pyarrow as pa
11
+ import ray
12
+ from ray.data.block import BlockMetadata
13
+ from ray.data.datasource import Datasink, ReadTask
14
+
15
+ from ray_doris._errors import DorisConfigurationError
16
+
17
+ _MIN_RAY_VERSION = (2, 49, 2)
18
+ _MAX_RAY_VERSION = (2, 57, 0)
19
+ _RAY_VERSION = ray.__version__
20
+ _FINAL_RELEASE = re.compile(r"^(\d+)\.(\d+)\.(\d+)(?:\+[A-Za-z0-9]+(?:[._-][A-Za-z0-9]+)*)?$")
21
+
22
+
23
+ def ensure_supported_ray_version() -> None:
24
+ """Reject Ray releases outside the compatibility matrix before network access."""
25
+ match = _FINAL_RELEASE.fullmatch(_RAY_VERSION)
26
+ release = tuple(int(part) for part in match.groups()) if match is not None else ()
27
+ if not _MIN_RAY_VERSION <= release < _MAX_RAY_VERSION:
28
+ raise DorisConfigurationError(
29
+ "ray-doris supports final Ray releases >=2.49.2,<2.57; "
30
+ "PEP 440 local build suffixes are allowed, but prerelease and post-release builds "
31
+ f"are unsupported; found Ray {_RAY_VERSION!r}"
32
+ )
33
+
34
+
35
+ def make_read_task(
36
+ read_fn: Callable[[], Iterable[pa.Table]],
37
+ metadata: BlockMetadata,
38
+ schema: pa.Schema,
39
+ per_task_row_limit: Optional[int],
40
+ ) -> ReadTask:
41
+ """Construct a ReadTask without hiding version or read-function TypeErrors."""
42
+ ensure_supported_ray_version()
43
+ parameters = inspect.signature(ReadTask).parameters
44
+ kwargs: dict[str, Any] = {"read_fn": read_fn, "metadata": metadata}
45
+ if "schema" not in parameters:
46
+ raise DorisConfigurationError(
47
+ "installed Ray ReadTask lacks required schema support within the final "
48
+ "Ray >=2.49.2,<2.57 compatibility window"
49
+ )
50
+ kwargs["schema"] = schema
51
+ if "per_task_row_limit" in parameters:
52
+ kwargs["per_task_row_limit"] = per_task_row_limit
53
+ elif per_task_row_limit is not None:
54
+ raise DorisConfigurationError(
55
+ "this Ray version does not support non-null per_task_row_limit"
56
+ )
57
+ return ReadTask(**kwargs)
58
+
59
+
60
+ @dataclass(frozen=True)
61
+ class DatasinkCompatibility:
62
+ """Public Ray Datasink lifecycle facts for one supported Ray generation."""
63
+
64
+ on_write_start_has_schema: bool
65
+ on_write_start_on_empty_dataset: bool
66
+ schema_source: Literal["first_input_bundle", "worker_blocks"]
67
+
68
+
69
+ def _parse_final_release(version: str) -> tuple[int, int, int]:
70
+ match = _FINAL_RELEASE.fullmatch(version)
71
+ if match is None:
72
+ raise DorisConfigurationError(f"unsupported Ray release format: {version!r}")
73
+ return (int(match.group(1)), int(match.group(2)), int(match.group(3)))
74
+
75
+
76
+ def datasink_compatibility(version: Optional[str] = None) -> DatasinkCompatibility:
77
+ """Return the explicit Datasink callback contract for a supported Ray release."""
78
+ ensure_supported_ray_version()
79
+ release = _parse_final_release(_RAY_VERSION if version is None else version)
80
+ if release < (2, 53, 0):
81
+ return DatasinkCompatibility(False, True, "worker_blocks")
82
+ return DatasinkCompatibility(True, False, "first_input_bundle")
83
+
84
+
85
+ def validate_datasink_signature() -> DatasinkCompatibility:
86
+ """Validate the installed public Datasink signature against the release matrix."""
87
+ contract = datasink_compatibility()
88
+ parameters = inspect.signature(Datasink.on_write_start).parameters
89
+ has_schema = "schema" in parameters
90
+ if has_schema != contract.on_write_start_has_schema:
91
+ raise DorisConfigurationError(
92
+ "installed Ray Datasink.on_write_start signature does not match the supported "
93
+ f"Ray {_RAY_VERSION} compatibility contract"
94
+ )
95
+ return contract
96
+
97
+
98
+ def prepare_write_remote_args(
99
+ ray_remote_args: Optional[Mapping[str, Any]],
100
+ ) -> dict[str, Any]:
101
+ """Freeze write task options and reject configurations that can replay side effects."""
102
+ args = dict(ray_remote_args) if ray_remote_args is not None else {}
103
+ for name in ("max_retries", "max_task_retries"):
104
+ if name not in args:
105
+ continue
106
+ value = args[name]
107
+ if isinstance(value, bool) or not isinstance(value, int) or value != 0:
108
+ raise DorisConfigurationError(
109
+ "Doris writes require Ray write task retry options to be exactly zero"
110
+ )
111
+ if args.get("retry_exceptions"):
112
+ raise DorisConfigurationError(
113
+ "Doris writes do not support Ray exception retries because a request may already "
114
+ "have reached Doris"
115
+ )
116
+ args["max_retries"] = 0
117
+ return args
ray_doris/_errors.py ADDED
@@ -0,0 +1,49 @@
1
+ """Public exceptions raised by ray-doris."""
2
+
3
+
4
+ class DorisError(Exception):
5
+ """Base exception for ray-doris."""
6
+
7
+
8
+ class DorisConfigurationError(DorisError, ValueError):
9
+ """Raised when datasource configuration is invalid."""
10
+
11
+
12
+ class DorisSchemaError(DorisError):
13
+ """Raised when a Doris schema cannot be represented safely in Arrow."""
14
+
15
+
16
+ class DorisPlanningError(DorisError):
17
+ """Raised when tablet planning fails."""
18
+
19
+
20
+ class DorisAuthenticationError(DorisPlanningError):
21
+ """Raised when Doris rejects credentials."""
22
+
23
+
24
+ class DorisPermissionError(DorisPlanningError):
25
+ """Raised when Doris rejects an operation because permission is missing."""
26
+
27
+
28
+ class DorisReadError(DorisError):
29
+ """Raised when a planned split cannot be read."""
30
+
31
+
32
+ class DorisWriteError(DorisError):
33
+ """Raised when a Doris Stream Load request has a known failure."""
34
+
35
+
36
+ class DorisAmbiguousWriteError(DorisWriteError):
37
+ """Raised when a Stream Load request may have reached Doris with unknown outcome."""
38
+
39
+
40
+ class DorisLabelExistsError(DorisWriteError):
41
+ """Raised when Doris reports that a Stream Load label is already retained."""
42
+
43
+
44
+ class DorisMetadataError(DorisWriteError):
45
+ """Raised when target table metadata is absent or malformed."""
46
+
47
+
48
+ class DorisTableCompatibilityError(DorisWriteError):
49
+ """Raised when the requested operation cannot safely target the table."""
ray_doris/_models.py ADDED
@@ -0,0 +1,292 @@
1
+ """Immutable data passed between the driver and Ray workers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ import os
7
+ import re
8
+ from collections.abc import Mapping as MappingABC
9
+ from copy import deepcopy
10
+ from dataclasses import dataclass, field
11
+ from typing import Any, Literal, Mapping, Optional, Tuple
12
+
13
+ import pyarrow as pa
14
+
15
+ from ray_doris._errors import DorisConfigurationError
16
+
17
+ Transport = Literal["auto", "mysql", "flight"]
18
+ QueryPlanPolicy = Literal["single_task", "error"]
19
+ HttpScheme = Literal["http", "https"]
20
+ FlightScheme = Literal["grpc", "grpc+tls"]
21
+
22
+ _ADBC_CONNECT_TIMEOUT_OPTION = "adbc.flight.sql.rpc.timeout_seconds.connect"
23
+ _MAX_CONNECT_TIMEOUT_SECONDS = 31_536_000
24
+ _ENVIRONMENT_VARIABLE_PATTERN = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
25
+
26
+ _RESERVED_MYSQL_OPTIONS = {
27
+ "charset",
28
+ "connect_timeout",
29
+ "cursorclass",
30
+ "database",
31
+ "db",
32
+ "host",
33
+ "password",
34
+ "passwd",
35
+ "port",
36
+ "user",
37
+ }
38
+ _RESERVED_FLIGHT_OPTIONS = {
39
+ _ADBC_CONNECT_TIMEOUT_OPTION,
40
+ "password",
41
+ "uri",
42
+ "username",
43
+ }
44
+
45
+
46
+ @dataclass(frozen=True)
47
+ class QualifiedTable:
48
+ """A validated two-part Doris table name."""
49
+
50
+ database: str
51
+ table: str
52
+
53
+
54
+ @dataclass(frozen=True)
55
+ class DorisInputSplit:
56
+ """A group of tablets, or an unpartitioned fallback query."""
57
+
58
+ tablet_ids: Optional[Tuple[int, ...]]
59
+
60
+
61
+ @dataclass(frozen=True)
62
+ class DorisPlan:
63
+ """The canonical schema and worker splits produced on the Ray driver."""
64
+
65
+ schema: pa.Schema
66
+ splits: Tuple[DorisInputSplit, ...]
67
+
68
+
69
+ @dataclass(frozen=True)
70
+ class DorisPlanningSnapshot:
71
+ """Schema and tablet discovery shared across Ray planning calls."""
72
+
73
+ schema: pa.Schema
74
+ tablet_ids: Optional[Tuple[int, ...]]
75
+
76
+
77
+ @dataclass(frozen=True)
78
+ class DorisReadConfig:
79
+ """Serializable configuration shared by planning and worker reads."""
80
+
81
+ host: str
82
+ table: QualifiedTable
83
+ user: str
84
+ password: str = field(default="", repr=False)
85
+ password_env: Optional[str] = field(default=None, repr=False)
86
+ mysql_port: int = 9030
87
+ http_port: int = 8030
88
+ flight_port: int = 8070
89
+ http_scheme: HttpScheme = "http"
90
+ http_ca_file: Optional[str] = field(default=None, repr=False)
91
+ flight_scheme: FlightScheme = "grpc"
92
+ columns: Optional[Tuple[str, ...]] = None
93
+ filter: Optional[str] = None
94
+ transport: Transport = "mysql"
95
+ tablet_size: int = 1
96
+ batch_size: int = 10_000
97
+ on_query_plan_error: QueryPlanPolicy = "single_task"
98
+ connect_timeout: float = 10.0
99
+ query_plan_timeout: Optional[float] = None
100
+ client_options: Tuple[Tuple[str, Any], ...] = field(default_factory=tuple, repr=False)
101
+ flight_options: Tuple[Tuple[str, Any], ...] = field(default_factory=tuple, repr=False)
102
+
103
+ def __post_init__(self) -> None:
104
+ """Validate scalar settings without performing network I/O."""
105
+ if not isinstance(self.host, str) or not self.host.strip():
106
+ raise DorisConfigurationError("host must not be empty")
107
+ if not isinstance(self.user, str) or not self.user:
108
+ raise DorisConfigurationError("user must not be empty")
109
+ if not isinstance(self.password, str):
110
+ raise DorisConfigurationError("password must be a string")
111
+ if self.password_env is not None and (
112
+ not isinstance(self.password_env, str)
113
+ or _ENVIRONMENT_VARIABLE_PATTERN.fullmatch(self.password_env) is None
114
+ ):
115
+ raise DorisConfigurationError("password_env must be a portable environment name")
116
+ if self.password and self.password_env is not None:
117
+ raise DorisConfigurationError("password and password_env are mutually exclusive")
118
+ for name, port in (
119
+ ("mysql_port", self.mysql_port),
120
+ ("http_port", self.http_port),
121
+ ("flight_port", self.flight_port),
122
+ ):
123
+ if isinstance(port, bool) or not isinstance(port, int) or not 1 <= port <= 65535:
124
+ raise DorisConfigurationError(f"{name} must be between 1 and 65535")
125
+ if (
126
+ isinstance(self.tablet_size, bool)
127
+ or not isinstance(self.tablet_size, int)
128
+ or self.tablet_size <= 0
129
+ ):
130
+ raise DorisConfigurationError("tablet_size must be a positive integer")
131
+ if (
132
+ isinstance(self.batch_size, bool)
133
+ or not isinstance(self.batch_size, int)
134
+ or self.batch_size <= 0
135
+ ):
136
+ raise DorisConfigurationError("batch_size must be a positive integer")
137
+ self._validate_timeout("connect_timeout", self.connect_timeout)
138
+ if self.query_plan_timeout is not None:
139
+ self._validate_timeout("query_plan_timeout", self.query_plan_timeout)
140
+ if self.transport not in ("auto", "mysql", "flight"):
141
+ raise DorisConfigurationError(f"unsupported transport: {self.transport!r}")
142
+ if self.http_scheme not in ("http", "https"):
143
+ raise DorisConfigurationError(f"unsupported HTTP scheme: {self.http_scheme!r}")
144
+ if self.http_ca_file is not None:
145
+ if not isinstance(self.http_ca_file, str) or not self.http_ca_file:
146
+ raise DorisConfigurationError("http_ca_file must be a non-empty string")
147
+ if self.http_scheme != "https":
148
+ raise DorisConfigurationError("http_ca_file requires http_scheme='https'")
149
+ if self.flight_scheme not in ("grpc", "grpc+tls"):
150
+ raise DorisConfigurationError(f"unsupported Flight SQL scheme: {self.flight_scheme!r}")
151
+ if self.on_query_plan_error not in ("single_task", "error"):
152
+ raise DorisConfigurationError(
153
+ f"unsupported on_query_plan_error: {self.on_query_plan_error!r}"
154
+ )
155
+ self._validate_options("client_options", self.client_options)
156
+ self._validate_options("flight_options", self.flight_options)
157
+ self._reject_reserved_options(
158
+ "client_options", self.client_options, _RESERVED_MYSQL_OPTIONS
159
+ )
160
+ self._reject_reserved_options(
161
+ "flight_options", self.flight_options, _RESERVED_FLIGHT_OPTIONS
162
+ )
163
+ self._validate_mysql_timeouts()
164
+ if any(not isinstance(value, str) for _, value in self.flight_options):
165
+ raise DorisConfigurationError("flight_options values must be strings")
166
+
167
+ @staticmethod
168
+ def _validate_timeout(name: str, value: object) -> None:
169
+ if (
170
+ isinstance(value, bool)
171
+ or not isinstance(value, (int, float))
172
+ or value <= 0
173
+ or value > _MAX_CONNECT_TIMEOUT_SECONDS
174
+ or not math.isfinite(value)
175
+ ):
176
+ raise DorisConfigurationError(
177
+ f"{name} must be finite, positive, and at most 31536000 seconds"
178
+ )
179
+
180
+ def _validate_mysql_timeouts(self) -> None:
181
+ options = dict(self.client_options)
182
+ for name in ("read_timeout", "write_timeout"):
183
+ if name not in options:
184
+ continue
185
+ value = options[name]
186
+ try:
187
+ finite = (
188
+ not isinstance(value, bool)
189
+ and isinstance(value, (int, float))
190
+ and math.isfinite(value)
191
+ )
192
+ except (TypeError, OverflowError):
193
+ finite = False
194
+ if not finite or value <= 0:
195
+ raise DorisConfigurationError(
196
+ f"client_options {name} must be a finite positive number"
197
+ )
198
+
199
+ @staticmethod
200
+ def _validate_options(name: str, options: Tuple[Tuple[str, Any], ...]) -> None:
201
+ seen = set()
202
+ for key, _ in options:
203
+ if not isinstance(key, str) or not key:
204
+ raise DorisConfigurationError(f"{name} keys must be non-empty strings")
205
+ if key in seen:
206
+ raise DorisConfigurationError(f"duplicate {name} key: {key!r}")
207
+ seen.add(key)
208
+
209
+ @staticmethod
210
+ def _reject_reserved_options(
211
+ name: str, options: Tuple[Tuple[str, Any], ...], reserved: set[str]
212
+ ) -> None:
213
+ overridden = sorted(key for key, _ in options if key in reserved)
214
+ if overridden:
215
+ raise DorisConfigurationError(
216
+ f"{name} must not override managed connection options: " + ", ".join(overridden)
217
+ )
218
+
219
+ @classmethod
220
+ def from_options(
221
+ cls,
222
+ *,
223
+ client_options: Optional[Mapping[str, Any]] = None,
224
+ flight_options: Optional[Mapping[str, Any]] = None,
225
+ **kwargs: Any,
226
+ ) -> "DorisReadConfig":
227
+ """Construct a config while snapshotting caller-owned option mappings."""
228
+ return cls(
229
+ client_options=cls._copy_options("client_options", client_options),
230
+ flight_options=cls._copy_options("flight_options", flight_options),
231
+ **kwargs,
232
+ )
233
+
234
+ @staticmethod
235
+ def _copy_options(
236
+ name: str, options: Optional[Mapping[str, Any]]
237
+ ) -> Tuple[Tuple[str, Any], ...]:
238
+ if options is None:
239
+ return ()
240
+ if not isinstance(options, MappingABC):
241
+ raise DorisConfigurationError(f"{name} must be a mapping")
242
+ try:
243
+ copied = deepcopy(dict(options))
244
+ except Exception:
245
+ raise DorisConfigurationError(f"{name} values must be copyable") from None
246
+ return tuple(copied.items())
247
+
248
+ def mysql_options(self) -> Mapping[str, Any]:
249
+ """Return a fresh mapping for a PyMySQL connection."""
250
+ return deepcopy(dict(self.client_options))
251
+
252
+ def resolve_password(self) -> str:
253
+ """Resolve a per-process credential without storing it in the config."""
254
+ if self.password_env is None:
255
+ return self.password
256
+ try:
257
+ return os.environ[self.password_env]
258
+ except KeyError:
259
+ raise DorisConfigurationError(
260
+ "configured password environment variable is unavailable"
261
+ ) from None
262
+
263
+ @property
264
+ def effective_query_plan_timeout(self) -> float:
265
+ """Return the configured query-plan I/O timeout."""
266
+ return self.connect_timeout if self.query_plan_timeout is None else self.query_plan_timeout
267
+
268
+ def adbc_options(self) -> Mapping[str, Any]:
269
+ """Return a fresh mapping for an ADBC Flight SQL connection."""
270
+ return deepcopy(dict(self.flight_options))
271
+
272
+ def __repr__(self) -> str:
273
+ """Return a representation that never exposes credentials or option values."""
274
+ rendered_filter = "None" if self.filter is None else "<redacted>"
275
+ rendered_password_env = "None" if self.password_env is None else "<configured>"
276
+ rendered_http_ca = "None" if self.http_ca_file is None else "<configured>"
277
+ return (
278
+ "DorisReadConfig("
279
+ f"host={self.host!r}, table={self.table!r}, user={self.user!r}, "
280
+ f"password=<redacted>, password_env={rendered_password_env}, "
281
+ f"mysql_port={self.mysql_port}, http_port={self.http_port}, "
282
+ f"flight_port={self.flight_port}, http_scheme={self.http_scheme!r}, "
283
+ f"http_ca_file={rendered_http_ca}, "
284
+ f"flight_scheme={self.flight_scheme!r}, columns={self.columns!r}, "
285
+ f"filter={rendered_filter}, transport={self.transport!r}, "
286
+ f"tablet_size={self.tablet_size}, batch_size={self.batch_size}, "
287
+ f"on_query_plan_error={self.on_query_plan_error!r}, "
288
+ f"connect_timeout={self.connect_timeout}, "
289
+ f"query_plan_timeout={self.query_plan_timeout}, "
290
+ f"client_options={tuple(key for key, _ in self.client_options)!r}, "
291
+ f"flight_options={tuple(key for key, _ in self.flight_options)!r})"
292
+ )