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.
- sde/__init__.py +318 -0
- sde/_cutover_project.py +179 -0
- sde/_local_state.py +188 -0
- sde/_operator_deadline.py +50 -0
- sde/_usage.py +314 -0
- sde/bulk.py +79 -0
- sde/canonical.py +141 -0
- sde/capabilities.py +62 -0
- sde/cutover.py +286 -0
- sde/engines/__init__.py +0 -0
- sde/engines/_clickhouse_connection.py +224 -0
- sde/engines/_index_build.py +294 -0
- sde/engines/_operator.py +394 -0
- sde/engines/_staging.py +222 -0
- sde/engines/_storage.py +22 -0
- sde/engines/_write_fences.py +271 -0
- sde/engines/clickhouse.py +1115 -0
- sde/engines/orderbook.py +457 -0
- sde/engines/postgres.py +967 -0
- sde/entity.py +170 -0
- sde/errors.py +103 -0
- sde/explain.py +300 -0
- sde/frozen_verification.py +152 -0
- sde/generation.py +131 -0
- sde/groups.py +97 -0
- sde/hashing.py +242 -0
- sde/index_build.py +313 -0
- sde/index_operator.py +347 -0
- sde/infer.py +461 -0
- sde/inspection.py +62 -0
- sde/internal.py +90 -0
- sde/layout.py +669 -0
- sde/local_cutover.py +801 -0
- sde/logging.py +143 -0
- sde/migration.py +856 -0
- sde/model.py +482 -0
- sde/physical.py +531 -0
- sde/placement.py +1010 -0
- sde/provisioning.py +63 -0
- sde/py.typed +0 -0
- sde/query.py +521 -0
- sde/routing.py +85 -0
- sde/schema.py +466 -0
- sde/session.py +993 -0
- sde/shapes.py +153 -0
- sde/staging.py +264 -0
- sde/staging_operator.py +393 -0
- sde/telemetry.py +1087 -0
- sde/testing/__init__.py +14 -0
- sde/testing/loader.py +175 -0
- sde/testing/memory.py +331 -0
- sde/types.py +228 -0
- sde/verification.py +220 -0
- sde/watermark.py +222 -0
- sde/write_fence.py +283 -0
- sde_demo/__init__.py +1 -0
- sde_demo/__main__.py +183 -0
- sde_demo/diagnostics.py +92 -0
- sde_demo/model.py +75 -0
- sde_demo/project.py +312 -0
- sde_demo/py.typed +0 -0
- sde_demo/query_count.py +301 -0
- sde_demo/resources.py +969 -0
- sde_demo/runtime.py +419 -0
- sde_demo/verification.py +242 -0
- sde_operator/__init__.py +1 -0
- sde_operator/__main__.py +210 -0
- smart_data_engine_sdk-0.1.0.dist-info/METADATA +174 -0
- smart_data_engine_sdk-0.1.0.dist-info/RECORD +73 -0
- smart_data_engine_sdk-0.1.0.dist-info/WHEEL +4 -0
- smart_data_engine_sdk-0.1.0.dist-info/entry_points.txt +3 -0
- smart_data_engine_sdk-0.1.0.dist-info/licenses/LICENSE +201 -0
- 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
|
sde/engines/__init__.py
ADDED
|
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
|