smart-data-engine-sdk 0.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.
Files changed (73) hide show
  1. sde/__init__.py +318 -0
  2. sde/_cutover_project.py +179 -0
  3. sde/_local_state.py +188 -0
  4. sde/_operator_deadline.py +50 -0
  5. sde/_usage.py +314 -0
  6. sde/bulk.py +79 -0
  7. sde/canonical.py +141 -0
  8. sde/capabilities.py +62 -0
  9. sde/cutover.py +286 -0
  10. sde/engines/__init__.py +0 -0
  11. sde/engines/_clickhouse_connection.py +224 -0
  12. sde/engines/_index_build.py +294 -0
  13. sde/engines/_operator.py +394 -0
  14. sde/engines/_staging.py +222 -0
  15. sde/engines/_storage.py +22 -0
  16. sde/engines/_write_fences.py +271 -0
  17. sde/engines/clickhouse.py +1115 -0
  18. sde/engines/orderbook.py +457 -0
  19. sde/engines/postgres.py +967 -0
  20. sde/entity.py +170 -0
  21. sde/errors.py +103 -0
  22. sde/explain.py +300 -0
  23. sde/frozen_verification.py +152 -0
  24. sde/generation.py +131 -0
  25. sde/groups.py +97 -0
  26. sde/hashing.py +242 -0
  27. sde/index_build.py +313 -0
  28. sde/index_operator.py +347 -0
  29. sde/infer.py +461 -0
  30. sde/inspection.py +62 -0
  31. sde/internal.py +90 -0
  32. sde/layout.py +669 -0
  33. sde/local_cutover.py +801 -0
  34. sde/logging.py +143 -0
  35. sde/migration.py +856 -0
  36. sde/model.py +482 -0
  37. sde/physical.py +531 -0
  38. sde/placement.py +1010 -0
  39. sde/provisioning.py +63 -0
  40. sde/py.typed +0 -0
  41. sde/query.py +521 -0
  42. sde/routing.py +85 -0
  43. sde/schema.py +466 -0
  44. sde/session.py +993 -0
  45. sde/shapes.py +153 -0
  46. sde/staging.py +264 -0
  47. sde/staging_operator.py +393 -0
  48. sde/telemetry.py +1087 -0
  49. sde/testing/__init__.py +14 -0
  50. sde/testing/loader.py +175 -0
  51. sde/testing/memory.py +331 -0
  52. sde/types.py +228 -0
  53. sde/verification.py +220 -0
  54. sde/watermark.py +222 -0
  55. sde/write_fence.py +283 -0
  56. sde_demo/__init__.py +1 -0
  57. sde_demo/__main__.py +183 -0
  58. sde_demo/diagnostics.py +92 -0
  59. sde_demo/model.py +75 -0
  60. sde_demo/project.py +312 -0
  61. sde_demo/py.typed +0 -0
  62. sde_demo/query_count.py +301 -0
  63. sde_demo/resources.py +969 -0
  64. sde_demo/runtime.py +419 -0
  65. sde_demo/verification.py +242 -0
  66. sde_operator/__init__.py +1 -0
  67. sde_operator/__main__.py +210 -0
  68. smart_data_engine_sdk-0.1.0.dist-info/METADATA +174 -0
  69. smart_data_engine_sdk-0.1.0.dist-info/RECORD +73 -0
  70. smart_data_engine_sdk-0.1.0.dist-info/WHEEL +4 -0
  71. smart_data_engine_sdk-0.1.0.dist-info/entry_points.txt +3 -0
  72. smart_data_engine_sdk-0.1.0.dist-info/licenses/LICENSE +201 -0
  73. smart_data_engine_sdk-0.1.0.dist-info/licenses/NOTICE +13 -0
sde/cutover.py ADDED
@@ -0,0 +1,286 @@
1
+ """Load a signed local cutover authorization without touching an engine or adopting a map."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import hashlib
7
+ import json
8
+ import re
9
+ from collections.abc import Mapping
10
+ from copy import deepcopy
11
+ from dataclasses import dataclass, field
12
+ from typing import Any, Literal
13
+
14
+ from .canonical import CanonicalError, canonical_bytes
15
+ from .errors import MapError, MigrationRefused
16
+ from .generation import GENERATIONS_SINCE, MAX_EPOCH, check_map_project, json_numbers
17
+ from .model import LogicalModel
18
+ from .placement import PlacementMap, _verify_signature, load_map
19
+ from .shapes import enumerate_shapes
20
+ from .verification import VerificationRequest
21
+
22
+ CUTOVER_PROTOCOL = 1
23
+ """A move: the maintained copy that takes over is in another engine binding than the source."""
24
+ CUTOVER_RELAYOUT_PROTOCOL = 2
25
+ """A relayout: the copy that takes over is in the source's own engine binding, under fresh names -
26
+ the cutover that follows a staging protocol 2 preparation. Separate for the reason given there:
27
+ each protocol stays strict, and an older operator refuses a relayout by its number."""
28
+ _FIELDS = {
29
+ "kind",
30
+ "protocol",
31
+ "plan_id",
32
+ "project_id",
33
+ "group",
34
+ "pause_budget_ms",
35
+ "query_impact_digest",
36
+ "verification",
37
+ "before",
38
+ "success",
39
+ "abort",
40
+ "signature",
41
+ }
42
+ Outcome = Literal["success", "abort"]
43
+
44
+
45
+ def _hex(value: Any, width: int, name: str, subject: str = "cutover") -> str:
46
+ if not isinstance(value, str) or re.fullmatch(f"[0-9a-f]{{{width}}}", value) is None:
47
+ raise MigrationRefused(f"{subject} {name} must be {width} lowercase hexadecimal digits")
48
+ return value
49
+
50
+
51
+ def _positive(value: Any, name: str) -> int:
52
+ if type(value) is not int or not 1 <= value <= MAX_EPOCH:
53
+ raise MigrationRefused(f"cutover {name} must be a positive safe integer")
54
+ return value
55
+
56
+
57
+ def _record(value: Any, name: str, subject: str = "cutover") -> dict[str, Any]:
58
+ if not isinstance(value, dict):
59
+ raise MigrationRefused(f"{subject} {name} must be an object")
60
+ return value
61
+
62
+
63
+ def _signature(record: dict[str, Any], subject: str = "cutover") -> None:
64
+ signature = _record(record.get("signature"), "signature", subject)
65
+ if set(signature) not in ({"alg", "value"}, {"alg", "value", "key_id"}):
66
+ raise MigrationRefused(f"{subject} signatures have missing or unknown fields")
67
+ value = signature.get("value")
68
+ malformed = f"{subject} signatures must use ed25519 and canonical base64"
69
+ if signature.get("alg") != "ed25519" or not isinstance(value, str):
70
+ raise MigrationRefused(malformed)
71
+ try:
72
+ decoded = base64.b64decode(value, validate=True)
73
+ except ValueError as exc:
74
+ raise MigrationRefused(malformed) from exc
75
+ if len(decoded) != 64 or base64.b64encode(decoded).decode() != value:
76
+ raise MigrationRefused(malformed)
77
+ if "key_id" in signature and not isinstance(signature["key_id"], str):
78
+ raise MigrationRefused(f"{subject} signature key_id must be a string")
79
+
80
+
81
+ @dataclass(frozen=True)
82
+ class CutoverPlan:
83
+ plan_id: str
84
+ project_id: str
85
+ group: str
86
+ before: PlacementMap
87
+ success: PlacementMap
88
+ abort: PlacementMap
89
+ verification: VerificationRequest
90
+ pause_budget_ms: int
91
+ query_impact_digest: str
92
+ verified_with: str | None
93
+ fingerprint: str | None = field(default=None, init=False)
94
+ _document: bytes = field(default=b"", init=False, repr=False)
95
+
96
+ def _loaded(self) -> None:
97
+ if self.fingerprint is None or not self._document:
98
+ raise MigrationRefused("cutover execution requires an immutable loaded plan")
99
+
100
+ @property
101
+ def source_epoch(self) -> int:
102
+ self._loaded()
103
+ value = self.before.placement_of(self.group).write_epoch
104
+ assert value is not None
105
+ return value
106
+
107
+ @property
108
+ def maintenance_epoch(self) -> int:
109
+ return self.source_epoch + 1
110
+
111
+ @property
112
+ def activation_epoch(self) -> int:
113
+ return self.source_epoch + 2
114
+
115
+ def as_record(self) -> dict[str, Any]:
116
+ self._loaded()
117
+ result: dict[str, Any] = json.loads(self._document)
118
+ return result
119
+
120
+ def candidate_payload(self, outcome: Outcome) -> bytes:
121
+ if outcome not in ("success", "abort"):
122
+ raise MigrationRefused("cutover outcome must be success or abort")
123
+ return canonical_bytes(self.as_record()[outcome])
124
+
125
+ def check_current(self, current: PlacementMap) -> None:
126
+ self._loaded()
127
+ check_map_project(current, self.project_id)
128
+ if not current.signed or current.fingerprint != self.before.fingerprint:
129
+ raise MigrationRefused("cutover plan does not name the current placement map")
130
+
131
+
132
+ def _load(
133
+ raw: Mapping[str, Any],
134
+ *,
135
+ model: LogicalModel,
136
+ project_id: str,
137
+ public_key: bytes | Mapping[str, bytes],
138
+ ) -> CutoverPlan:
139
+ body = _record(json_numbers(deepcopy(raw)), "plan")
140
+ if set(body) != _FIELDS:
141
+ raise MigrationRefused("cutover plan has missing or unknown fields")
142
+ if type(body["protocol"]) is not int or body["protocol"] not in (
143
+ CUTOVER_PROTOCOL,
144
+ CUTOVER_RELAYOUT_PROTOCOL,
145
+ ):
146
+ raise MigrationRefused("unsupported cutover plan protocol")
147
+ protocol: int = body["protocol"]
148
+ if body["kind"] != "sde-cutover":
149
+ raise MigrationRefused("unsupported cutover document kind")
150
+ identity = _hex(body["plan_id"], 32, "plan_id")
151
+ local = _hex(body["project_id"], 32, "project_id")
152
+ if local != project_id:
153
+ raise MigrationRefused("cutover plan belongs to another locally configured project")
154
+ budget = _positive(body["pause_budget_ms"], "pause_budget_ms")
155
+ approval = _hex(body["query_impact_digest"], 64, "query_impact_digest")
156
+ group = body["group"]
157
+ if not isinstance(group, str) or not group:
158
+ raise MigrationRefused("cutover group must be a nonempty string")
159
+ _signature(body)
160
+ verified_with = _verify_signature(body, public_key)
161
+ documents = {name: _record(body[name], name) for name in ("before", "success", "abort")}
162
+ maps: dict[str, PlacementMap] = {}
163
+ for name, document in documents.items():
164
+ _signature(document)
165
+ for raw_spot in _record(document.get("groups"), "groups").values():
166
+ raw_spot = _record(raw_spot, "group")
167
+ derived = raw_spot.get("derived", [])
168
+ if not isinstance(derived, list):
169
+ raise MigrationRefused("cutover derived copies must be an array")
170
+ for raw_material in (raw_spot.get("source"), *derived):
171
+ raw_material = _record(raw_material, "materialization")
172
+ layout = _record(raw_material.get("layout"), "layout")
173
+ if layout.get("auto"):
174
+ raise MigrationRefused("cutover maps require explicit physical layouts")
175
+ parsed = load_map(document, model=model, public_key=public_key, require_signature=True)
176
+ # Generations arrived in contract 4 and contract 5 keeps them. The three candidates share
177
+ # every top-level attribute, contract included, which the comparison below enforces.
178
+ if parsed.contract < GENERATIONS_SINCE:
179
+ raise MigrationRefused(
180
+ f"cutover protocol {protocol} requires placement map contract "
181
+ f"{GENERATIONS_SINCE} or later"
182
+ )
183
+ check_map_project(parsed, project_id)
184
+ _positive(parsed.map_version, "map_version")
185
+ maps[name] = parsed
186
+ before, success, abort = (maps[name] for name in ("before", "success", "abort"))
187
+ if not before.map_version < success.map_version < abort.map_version:
188
+ raise MigrationRefused("cutover versions must increase from before to success to abort")
189
+ request = VerificationRequest.from_record(body["verification"])
190
+ if not request.requires_signature:
191
+ raise MigrationRefused("cutover verification must require the signed before map")
192
+ request.check_session(before, project_id=project_id, group=group)
193
+ spot = before.placement_of(group)
194
+ if len(spot.derived) != 1 or spot.also_write != spot.derived:
195
+ raise MigrationRefused("cutover requires exactly one derived copy maintained by fan-out")
196
+ source, target = spot.source, spot.derived[0]
197
+ same = source.engine == target.engine
198
+ if protocol == CUTOVER_PROTOCOL and same:
199
+ raise MigrationRefused("cutover source and target must use different engine bindings")
200
+ if protocol == CUTOVER_RELAYOUT_PROTOCOL and not same:
201
+ raise MigrationRefused(
202
+ "a relayout (cutover protocol 2) activates a copy in the source's own engine binding"
203
+ )
204
+ epoch = spot.write_epoch
205
+ assert epoch is not None
206
+ if epoch > MAX_EPOCH - 2:
207
+ raise MigrationRefused("cutover needs two available write generations")
208
+ affected = {shape.id for shape in enumerate_shapes(model) if shape.group == group}
209
+ if any(before.routing.get(shape, source.id) != source.id for shape in affected):
210
+ raise MigrationRefused("cutover before-map reads must still use the source")
211
+ before_raw = documents["before"]
212
+ expected_routes = {
213
+ shape: value for shape, value in before.routing.items() if shape not in affected
214
+ }
215
+ stable = {
216
+ key: value
217
+ for key, value in before_raw.items()
218
+ if key not in {"signature", "map_version", "groups", "routing"}
219
+ }
220
+ for name, material, terminal_epoch in (
221
+ ("success", target, epoch + 2),
222
+ ("abort", source, epoch + 1),
223
+ ):
224
+ document, parsed = documents[name], maps[name]
225
+ unchanged = {
226
+ key: value
227
+ for key, value in document.items()
228
+ if key not in {"signature", "map_version", "groups", "routing"}
229
+ }
230
+ if canonical_bytes(unchanged) != canonical_bytes(stable) or set(parsed.groups) != set(
231
+ before.groups
232
+ ):
233
+ raise MigrationRefused("cutover cannot change other map attributes or groups")
234
+ if dict(parsed.routing) != expected_routes:
235
+ raise MigrationRefused("cutover terminal routing must preserve the unaffected groups")
236
+ for other in before.groups:
237
+ if other != group and canonical_bytes(document["groups"][other]) != canonical_bytes(
238
+ before_raw["groups"][other]
239
+ ):
240
+ raise MigrationRefused("cutover cannot change an unaffected group")
241
+ terminal = document["groups"][group]
242
+ if set(terminal) != {"source", "write_epoch"} or terminal["write_epoch"] != terminal_epoch:
243
+ raise MigrationRefused(
244
+ "cutover terminal group must contain only its source and decision generation"
245
+ )
246
+ candidate = terminal["source"]
247
+ if not isinstance(candidate.get("id"), str) or not candidate["id"]:
248
+ raise MigrationRefused("cutover terminal source must have a nonempty string id")
249
+ original = (
250
+ before_raw["groups"][group]["source"]
251
+ if name == "abort"
252
+ else before_raw["groups"][group]["derived"][0]
253
+ )
254
+ expected = {
255
+ key: value for key, value in original.items() if key not in {"id", "lag_budget_ms"}
256
+ }
257
+ actual = {key: value for key, value in candidate.items() if key != "id"}
258
+ if (
259
+ canonical_bytes(actual) != canonical_bytes(expected)
260
+ or parsed.groups[group].source.engine != material.engine
261
+ ):
262
+ raise MigrationRefused(
263
+ "cutover terminal map changed the authorized physical materialization"
264
+ )
265
+ plan = CutoverPlan(
266
+ identity, local, group, before, success, abort, request, budget, approval, verified_with
267
+ )
268
+ document_bytes = canonical_bytes(body)
269
+ unsigned = {key: value for key, value in body.items() if key != "signature"}
270
+ object.__setattr__(plan, "_document", document_bytes)
271
+ object.__setattr__(plan, "fingerprint", hashlib.sha256(canonical_bytes(unsigned)).hexdigest())
272
+ return plan
273
+
274
+
275
+ def load_cutover_plan(
276
+ raw: Mapping[str, Any],
277
+ *,
278
+ model: LogicalModel,
279
+ project_id: str,
280
+ public_key: bytes | Mapping[str, bytes],
281
+ ) -> CutoverPlan:
282
+ """Verify exact authorization and candidates. No I/O, reservation, activation or watermark."""
283
+ try:
284
+ return _load(raw, model=model, project_id=project_id, public_key=public_key)
285
+ except (MapError, CanonicalError) as exc:
286
+ raise MigrationRefused(f"cutover document refused: {exc}") from exc
File without changes
@@ -0,0 +1,224 @@
1
+ """Pure normalization of the portable ClickHouse DSN profile.
2
+
3
+ No sockets, CA reads or driver factories run here. The connection owner qualifies local trust
4
+ material before creating a client. Never include a raw DSN or its credentials in parser errors.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import ipaddress
10
+ import math
11
+ import re
12
+ from dataclasses import dataclass, field
13
+ from pathlib import Path
14
+ from typing import Literal
15
+ from urllib.parse import parse_qsl, unquote_to_bytes, urlsplit
16
+
17
+ from ..errors import EngineError
18
+
19
+ CONNECT_TIMEOUT_SECONDS = 10
20
+ HANDSHAKE_TIMEOUT_SECONDS = 15
21
+ MIN_TIMEOUT_SECONDS = 0.001
22
+ MAX_TIMEOUT_SECONDS = 2147483.647
23
+ _QUERY_KEYS = frozenset({"secure", "verify", "ca_cert", "connect_timeout", "send_receive_timeout"})
24
+ _SCHEMES = frozenset({"clickhouse", "clickhouses", "http", "https"})
25
+ _PERCENT = re.compile(r"%(?![0-9a-fA-F]{2})")
26
+ _DNS_LABEL = re.compile(r"[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?", re.ASCII)
27
+ _NUMERIC_HOST = re.compile(r"(?:[0-9]+|0x[0-9a-f]+)(?:\.(?:[0-9]+|0x[0-9a-f]+))*\.?", re.ASCII)
28
+ _SECONDS = re.compile(r"(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+)(?:[eE][+-]?[0-9]+)?", re.ASCII)
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class ConnectionParameters:
33
+ host: str = field(repr=False)
34
+ port: int
35
+ username: str = field(repr=False)
36
+ password: str = field(repr=False)
37
+ database: str = field(repr=False)
38
+ secure: bool
39
+ ca_cert: str | None = field(default=None, repr=False)
40
+ connect_timeout: float = CONNECT_TIMEOUT_SECONDS
41
+ send_receive_timeout: float = HANDSHAKE_TIMEOUT_SECONDS
42
+
43
+ @property
44
+ def interface(self) -> Literal["http", "https"]:
45
+ return "https" if self.secure else "http"
46
+
47
+
48
+ def _require(condition: bool, message: str) -> None:
49
+ if not condition:
50
+ raise EngineError(message)
51
+
52
+
53
+ def _no_controls(value: str) -> None:
54
+ _require(
55
+ all(ord(character) >= 32 and ord(character) != 127 for character in value),
56
+ "ClickHouse connection fields cannot contain control characters",
57
+ )
58
+
59
+
60
+ def _decode(value: str) -> str:
61
+ result = unquote_to_bytes(value).decode("utf-8", errors="strict")
62
+ _no_controls(result)
63
+ return result
64
+
65
+
66
+ def _host(value: str) -> str:
67
+ _require(
68
+ value.isascii() and "%" not in value, "ClickHouse host must be ASCII DNS or an IP address"
69
+ )
70
+ value = value.lower()
71
+ try:
72
+ address = ipaddress.ip_address(value)
73
+ except ValueError:
74
+ _require(
75
+ _NUMERIC_HOST.fullmatch(value) is None, "Legacy numeric IPv4 forms are not supported"
76
+ )
77
+ labels = value[:-1].split(".") if value.endswith(".") else value.split(".")
78
+ _require(
79
+ len(value) <= 253 and all(_DNS_LABEL.fullmatch(label) is not None for label in labels),
80
+ "ClickHouse host is not a valid DNS name or IP address",
81
+ )
82
+ return value
83
+ return str(address)
84
+
85
+
86
+ def _seconds(value: str | None, default: float) -> float:
87
+ if value is None:
88
+ return default
89
+ _require(
90
+ _SECONDS.fullmatch(value) is not None, "ClickHouse timeout must be positive finite seconds"
91
+ )
92
+ result = float(value)
93
+ _require(
94
+ math.isfinite(result) and MIN_TIMEOUT_SECONDS <= result <= MAX_TIMEOUT_SECONDS,
95
+ "ClickHouse timeout must be positive finite seconds",
96
+ )
97
+ return result
98
+
99
+
100
+ def parse_dsn(dsn: str) -> ConnectionParameters:
101
+ """Parse the shared URI profile once; errors name rules, never connection values."""
102
+ try:
103
+ _require(isinstance(dsn, str) and bool(dsn), "Provide a ClickHouse connection URI")
104
+ dsn.encode("utf-8", errors="strict")
105
+ _require(
106
+ not any(character.isspace() for character in dsn),
107
+ "Encode whitespace in connection URIs",
108
+ )
109
+ _no_controls(dsn)
110
+ _require(
111
+ "#" not in dsn and "\\" not in dsn and _PERCENT.search(dsn) is None,
112
+ "ClickHouse URI fragments, raw backslashes and malformed percent escapes are refused",
113
+ )
114
+ parsed = urlsplit(dsn)
115
+ scheme = parsed.scheme.lower()
116
+ _require(
117
+ scheme in _SCHEMES and bool(parsed.netloc),
118
+ "Unsupported ClickHouse URI scheme or authority",
119
+ )
120
+ _require(
121
+ parsed.hostname is not None and parsed.netloc.count("@") <= 1,
122
+ "ClickHouse URI needs one unambiguous host",
123
+ )
124
+ authority = parsed.netloc.rsplit("@", 1)[-1]
125
+ _require("\\" not in authority, "ClickHouse authority cannot contain a backslash")
126
+ if authority.startswith("["):
127
+ closing = authority.find("]")
128
+ _require(
129
+ closing > 0 and (len(authority) == closing + 1 or authority[closing + 1] == ":"),
130
+ "ClickHouse IPv6 authority is malformed",
131
+ )
132
+ declared_port = authority[closing + 2 :] if len(authority) > closing + 1 else None
133
+ else:
134
+ _require(authority.count(":") <= 1, "Enclose IPv6 hosts in URI brackets")
135
+ declared_port = authority.rsplit(":", 1)[1] if ":" in authority else None
136
+ port = None
137
+ if declared_port is not None:
138
+ _require(
139
+ re.fullmatch(r"[0-9]+", declared_port, flags=re.ASCII) is not None,
140
+ "ClickHouse port must be an explicit positive integer",
141
+ )
142
+ port = int(declared_port)
143
+ _require(0 < port <= 65535, "ClickHouse port is outside the valid range")
144
+ host = _host(parsed.hostname or "")
145
+ username = _decode(parsed.username) if parsed.username is not None else "default"
146
+ password = _decode(parsed.password) if parsed.password is not None else ""
147
+ _require(
148
+ bool(username) and ":" not in username,
149
+ "ClickHouse username must be nonempty and contain no colon",
150
+ )
151
+ _require(
152
+ parsed.path.startswith("/") and parsed.path.count("/") == 1 and "\\" not in parsed.path,
153
+ "ClickHouse URI needs one explicit database path segment",
154
+ )
155
+ database = _decode(parsed.path[1:])
156
+ _require(
157
+ bool(database) and "/" not in database and database not in (".", ".."),
158
+ "ClickHouse database must be a nonempty single segment",
159
+ )
160
+ options: dict[str, str] = {}
161
+ for name, value in parse_qsl(
162
+ parsed.query,
163
+ keep_blank_values=True,
164
+ strict_parsing=True,
165
+ encoding="utf-8",
166
+ errors="strict",
167
+ ):
168
+ _no_controls(name)
169
+ _no_controls(value)
170
+ _require(
171
+ name in _QUERY_KEYS and name not in options,
172
+ "ClickHouse query parameters must be known and appear once",
173
+ )
174
+ options[name] = value
175
+ requested = options.get("secure")
176
+ _require(
177
+ requested is None or requested in ("true", "false"),
178
+ "ClickHouse secure must be literal true or false",
179
+ )
180
+ secure = scheme in ("https", "clickhouses")
181
+ if scheme == "clickhouse":
182
+ _require(
183
+ requested is not None or port not in (443, 8443),
184
+ "Select TLS or explicit plain transport for an ambiguous ClickHouse port",
185
+ )
186
+ secure = requested == "true"
187
+ elif requested is not None:
188
+ _require(
189
+ (requested == "true") == secure, "ClickHouse scheme and secure setting disagree"
190
+ )
191
+ _require(
192
+ "verify" not in options or options["verify"] == "true",
193
+ "ClickHouse verification must remain enabled; use literal true or omit verify",
194
+ )
195
+ _require(
196
+ secure or not {"verify", "ca_cert"} & options.keys(),
197
+ "ClickHouse TLS options cannot apply to plain HTTP",
198
+ )
199
+ ca_cert = options.get("ca_cert")
200
+ _require(
201
+ ca_cert is None or (bool(ca_cert) and Path(ca_cert).is_absolute()),
202
+ "ClickHouse CA must be an absolute local file path",
203
+ )
204
+ if port is None:
205
+ port = {"http": 80, "https": 443}.get(scheme, 8443 if secure else 8123)
206
+ return ConnectionParameters(
207
+ host=host,
208
+ port=port,
209
+ username=username,
210
+ password=password,
211
+ database=database,
212
+ secure=secure,
213
+ ca_cert=ca_cert,
214
+ connect_timeout=_seconds(options.get("connect_timeout"), CONNECT_TIMEOUT_SECONDS),
215
+ send_receive_timeout=_seconds(
216
+ options.get("send_receive_timeout"), HANDSHAKE_TIMEOUT_SECONDS
217
+ ),
218
+ )
219
+ except EngineError:
220
+ raise
221
+ except (ValueError, TypeError, UnicodeError, AttributeError):
222
+ raise EngineError(
223
+ "Invalid ClickHouse URI encoding, authority or connection setting"
224
+ ) from None