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
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Wall-clock watchdog for the dedicated POSIX operator process, independent of driver progress."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import signal
|
|
6
|
+
import threading
|
|
7
|
+
from types import FrameType
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from .errors import MigrationRefused
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class DeadlineInterrupt(BaseException):
|
|
14
|
+
"""Interrupt the operator without being swallowed by adapter exception translation."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class OperatorDeadline:
|
|
18
|
+
"""Own SIGALRM only in a dedicated main thread with no pre-existing alarm."""
|
|
19
|
+
|
|
20
|
+
def __init__(self) -> None:
|
|
21
|
+
self.previous: Any = None
|
|
22
|
+
self.installed = False
|
|
23
|
+
|
|
24
|
+
def __enter__(self) -> OperatorDeadline:
|
|
25
|
+
if threading.current_thread() is not threading.main_thread():
|
|
26
|
+
raise MigrationRefused("local cutover runs in a dedicated process main thread")
|
|
27
|
+
if signal.getitimer(signal.ITIMER_REAL) != (0.0, 0.0):
|
|
28
|
+
raise MigrationRefused("local cutover cannot share an existing process alarm")
|
|
29
|
+
self.previous = signal.getsignal(signal.SIGALRM)
|
|
30
|
+
signal.signal(signal.SIGALRM, self._interrupt)
|
|
31
|
+
self.installed = True
|
|
32
|
+
return self
|
|
33
|
+
|
|
34
|
+
@staticmethod
|
|
35
|
+
def _interrupt(_signum: int, _frame: FrameType | None) -> None:
|
|
36
|
+
raise DeadlineInterrupt("local operator wall-clock deadline reached")
|
|
37
|
+
|
|
38
|
+
def arm(self, milliseconds: int) -> None:
|
|
39
|
+
if type(milliseconds) is not int or milliseconds < 1:
|
|
40
|
+
raise MigrationRefused("operator deadline must be a positive integer in milliseconds")
|
|
41
|
+
signal.setitimer(signal.ITIMER_REAL, milliseconds / 1000)
|
|
42
|
+
|
|
43
|
+
def cancel(self) -> None:
|
|
44
|
+
if self.installed:
|
|
45
|
+
signal.setitimer(signal.ITIMER_REAL, 0)
|
|
46
|
+
|
|
47
|
+
def __exit__(self, *_args: Any) -> None:
|
|
48
|
+
self.cancel()
|
|
49
|
+
signal.signal(signal.SIGALRM, self.previous)
|
|
50
|
+
self.installed = False
|
sde/_usage.py
ADDED
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
"""Local operation and transaction ownership; no database or process-global client state."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from collections.abc import Callable, Iterator
|
|
7
|
+
from contextlib import contextmanager
|
|
8
|
+
from contextvars import ContextVar
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from functools import wraps
|
|
11
|
+
from threading import Lock
|
|
12
|
+
from typing import Concatenate, ParamSpec, Protocol, TypeVar
|
|
13
|
+
|
|
14
|
+
from .errors import ResourceBusy, ResourceClosed
|
|
15
|
+
|
|
16
|
+
_session_owner: ContextVar[object | None] = ContextVar("sde_session_owner", default=None)
|
|
17
|
+
_operation: ContextVar[object | None] = ContextVar("sde_operation", default=None)
|
|
18
|
+
_transaction: ContextVar[TransactionScope | None] = ContextVar("sde_transaction", default=None)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(eq=False)
|
|
22
|
+
class TransactionScope:
|
|
23
|
+
gate: UsageGate
|
|
24
|
+
parent: TransactionScope | None
|
|
25
|
+
session: object | None
|
|
26
|
+
active: bool = True
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def check_scope() -> None:
|
|
30
|
+
scope = _transaction.get()
|
|
31
|
+
while scope is not None:
|
|
32
|
+
if not scope.active:
|
|
33
|
+
raise ResourceClosed("the inherited transaction scope has already ended")
|
|
34
|
+
scope = scope.parent
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@contextmanager
|
|
38
|
+
def session_owner(owner: object) -> Iterator[None]:
|
|
39
|
+
check_scope()
|
|
40
|
+
logical = _session_scope.get()
|
|
41
|
+
if logical is not None and logical.active and logical.usage.owner is not owner:
|
|
42
|
+
raise ResourceBusy("another session owns the current transaction scope")
|
|
43
|
+
scope = _transaction.get()
|
|
44
|
+
if scope is not None and scope.session is not owner:
|
|
45
|
+
raise ResourceBusy("another session owns the current transaction scope")
|
|
46
|
+
marker = _session_owner.set(owner)
|
|
47
|
+
try:
|
|
48
|
+
yield
|
|
49
|
+
finally:
|
|
50
|
+
_session_owner.reset(marker)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class UsageGate:
|
|
54
|
+
"""Synchronize admission with transaction claims; reject foreign use before native I/O."""
|
|
55
|
+
|
|
56
|
+
def __init__(self) -> None:
|
|
57
|
+
self.lock = Lock()
|
|
58
|
+
self.pid = os.getpid()
|
|
59
|
+
self.operation_owner: object | None = None
|
|
60
|
+
self.operation_depth = 0
|
|
61
|
+
self.transaction_owner: TransactionScope | None = None
|
|
62
|
+
|
|
63
|
+
def _process(self) -> None:
|
|
64
|
+
if self.pid != os.getpid():
|
|
65
|
+
raise ResourceClosed(
|
|
66
|
+
"create a fresh adapter after fork; the inherited connection is not usable"
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
def _check_transaction(self) -> None:
|
|
70
|
+
check_scope()
|
|
71
|
+
owned = self.transaction_owner
|
|
72
|
+
if owned is None:
|
|
73
|
+
return
|
|
74
|
+
scope = _transaction.get()
|
|
75
|
+
while scope is not None and scope.gate is not self:
|
|
76
|
+
scope = scope.parent
|
|
77
|
+
if scope is not owned or owned.session is not _session_owner.get():
|
|
78
|
+
raise ResourceBusy("the connection is owned by another active transaction scope")
|
|
79
|
+
|
|
80
|
+
@contextmanager
|
|
81
|
+
def operation(self) -> Iterator[None]:
|
|
82
|
+
self._process()
|
|
83
|
+
owner = _operation.get()
|
|
84
|
+
marker = None
|
|
85
|
+
if owner is None:
|
|
86
|
+
owner = object()
|
|
87
|
+
marker = _operation.set(owner)
|
|
88
|
+
admitted = False
|
|
89
|
+
failed = False
|
|
90
|
+
try:
|
|
91
|
+
with self.lock:
|
|
92
|
+
self._check_transaction()
|
|
93
|
+
if self.operation_owner is not None and self.operation_owner is not owner:
|
|
94
|
+
raise ResourceBusy("the connection already has an operation in progress")
|
|
95
|
+
self.operation_owner = owner
|
|
96
|
+
self.operation_depth += 1
|
|
97
|
+
admitted = True
|
|
98
|
+
try:
|
|
99
|
+
yield
|
|
100
|
+
except BaseException:
|
|
101
|
+
failed = True
|
|
102
|
+
raise
|
|
103
|
+
finally:
|
|
104
|
+
if admitted:
|
|
105
|
+
with self.lock:
|
|
106
|
+
self.operation_depth -= 1
|
|
107
|
+
if self.operation_depth == 0:
|
|
108
|
+
self.operation_owner = None
|
|
109
|
+
if not failed:
|
|
110
|
+
check_scope()
|
|
111
|
+
finally:
|
|
112
|
+
if marker is not None:
|
|
113
|
+
_operation.reset(marker)
|
|
114
|
+
|
|
115
|
+
@contextmanager
|
|
116
|
+
def transaction(self) -> Iterator[TransactionScope]:
|
|
117
|
+
self._process()
|
|
118
|
+
with self.lock:
|
|
119
|
+
self._check_transaction()
|
|
120
|
+
if self.operation_owner is not None:
|
|
121
|
+
raise ResourceBusy("a transaction cannot begin while an operation is in progress")
|
|
122
|
+
previous = self.transaction_owner
|
|
123
|
+
scope = TransactionScope(self, _transaction.get(), _session_owner.get())
|
|
124
|
+
self.transaction_owner = scope
|
|
125
|
+
marker = _transaction.set(scope)
|
|
126
|
+
try:
|
|
127
|
+
yield scope
|
|
128
|
+
finally:
|
|
129
|
+
with self.lock:
|
|
130
|
+
scope.active = False
|
|
131
|
+
self.transaction_owner = previous
|
|
132
|
+
_transaction.reset(marker)
|
|
133
|
+
|
|
134
|
+
def idle(self) -> None:
|
|
135
|
+
with self.lock:
|
|
136
|
+
if self.operation_owner is not None:
|
|
137
|
+
if self.transaction_owner is not None:
|
|
138
|
+
self.transaction_owner.active = False
|
|
139
|
+
raise ResourceBusy("finish all operations before leaving the transaction")
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class Guarded(Protocol):
|
|
143
|
+
@property
|
|
144
|
+
def _usage(self) -> UsageGate: ...
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
G = TypeVar("G", bound=Guarded)
|
|
148
|
+
P = ParamSpec("P")
|
|
149
|
+
R = TypeVar("R")
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def guarded(method: Callable[Concatenate[G, P], R]) -> Callable[Concatenate[G, P], R]:
|
|
153
|
+
@wraps(method)
|
|
154
|
+
def call(self: G, /, *args: P.args, **kwargs: P.kwargs) -> R:
|
|
155
|
+
with self._usage.operation():
|
|
156
|
+
return method(self, *args, **kwargs)
|
|
157
|
+
|
|
158
|
+
return call
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
@dataclass(eq=False)
|
|
162
|
+
class SessionScope:
|
|
163
|
+
usage: SessionUsage
|
|
164
|
+
group: str
|
|
165
|
+
active: bool = True
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
_session_scope: ContextVar[SessionScope | None] = ContextVar("sde_session_scope", default=None)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class SessionUsage:
|
|
172
|
+
def __init__(self, owner: object) -> None:
|
|
173
|
+
self.owner = owner
|
|
174
|
+
self.pid = os.getpid()
|
|
175
|
+
self.lock = Lock()
|
|
176
|
+
self.closed = False
|
|
177
|
+
self.busy = False
|
|
178
|
+
self.transaction_scope: SessionScope | None = None
|
|
179
|
+
|
|
180
|
+
def check(self) -> None:
|
|
181
|
+
if self.pid != os.getpid():
|
|
182
|
+
raise ResourceClosed("create a fresh session and adapters after fork")
|
|
183
|
+
check_scope()
|
|
184
|
+
context = _session_scope.get()
|
|
185
|
+
if self.closed or (context is not None and not context.active):
|
|
186
|
+
raise ResourceClosed("the session or inherited transaction scope has ended")
|
|
187
|
+
if self.transaction_scope is not None and context is not self.transaction_scope:
|
|
188
|
+
raise ResourceBusy("the session belongs to another active transaction context")
|
|
189
|
+
|
|
190
|
+
def group(self, name: str) -> None:
|
|
191
|
+
from .errors import ModelPlanningError
|
|
192
|
+
|
|
193
|
+
self.check()
|
|
194
|
+
if self.transaction_scope is not None and self.transaction_scope.group != name:
|
|
195
|
+
raise ModelPlanningError(
|
|
196
|
+
"the operation is outside the active transaction's colocation group"
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
@contextmanager
|
|
200
|
+
def operation(self) -> Iterator[None]:
|
|
201
|
+
self.check() # Refuse inherited locks before acquiring one in a child.
|
|
202
|
+
with self.lock:
|
|
203
|
+
self.check()
|
|
204
|
+
if self.busy:
|
|
205
|
+
raise ResourceBusy("the session already has an operation in progress")
|
|
206
|
+
self.busy = True
|
|
207
|
+
failed = False
|
|
208
|
+
try:
|
|
209
|
+
with session_owner(self.owner):
|
|
210
|
+
try:
|
|
211
|
+
yield
|
|
212
|
+
except BaseException:
|
|
213
|
+
failed = True
|
|
214
|
+
raise
|
|
215
|
+
finally:
|
|
216
|
+
if not failed:
|
|
217
|
+
self.check()
|
|
218
|
+
finally:
|
|
219
|
+
with self.lock:
|
|
220
|
+
self.busy = False
|
|
221
|
+
|
|
222
|
+
@contextmanager
|
|
223
|
+
def transaction(self, group: str) -> Iterator[None]:
|
|
224
|
+
self.check()
|
|
225
|
+
with self.lock:
|
|
226
|
+
self.group(group)
|
|
227
|
+
if self.busy:
|
|
228
|
+
raise ResourceBusy("finish the session operation before beginning a transaction")
|
|
229
|
+
previous = self.transaction_scope
|
|
230
|
+
scope = SessionScope(self, group)
|
|
231
|
+
self.transaction_scope = scope
|
|
232
|
+
marker = _session_scope.set(scope)
|
|
233
|
+
try:
|
|
234
|
+
with session_owner(self.owner):
|
|
235
|
+
yield
|
|
236
|
+
finally:
|
|
237
|
+
with self.lock:
|
|
238
|
+
scope.active = False
|
|
239
|
+
self.transaction_scope = previous
|
|
240
|
+
_session_scope.reset(marker)
|
|
241
|
+
|
|
242
|
+
def idle(self) -> None:
|
|
243
|
+
with self.lock:
|
|
244
|
+
if self.busy:
|
|
245
|
+
if self.transaction_scope is not None:
|
|
246
|
+
self.transaction_scope.active = False
|
|
247
|
+
raise ResourceBusy("finish all session operations before leaving the transaction")
|
|
248
|
+
|
|
249
|
+
def seal(self) -> None:
|
|
250
|
+
with self.lock:
|
|
251
|
+
if self.transaction_scope is not None:
|
|
252
|
+
self.transaction_scope.active = False
|
|
253
|
+
|
|
254
|
+
def close(self) -> None:
|
|
255
|
+
if self.pid != os.getpid():
|
|
256
|
+
raise ResourceClosed(
|
|
257
|
+
"create a fresh session after fork; do not close inherited adapters"
|
|
258
|
+
)
|
|
259
|
+
with self.lock:
|
|
260
|
+
if self.busy or self.transaction_scope is not None:
|
|
261
|
+
raise ResourceBusy(
|
|
262
|
+
"cannot close a session while its operation or transaction is active"
|
|
263
|
+
)
|
|
264
|
+
self.closed = True
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
class SessionGuarded(Protocol):
|
|
268
|
+
@property
|
|
269
|
+
def _session_usage(self) -> SessionUsage: ...
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
S = TypeVar("S", bound=SessionGuarded)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def session_call(method: Callable[Concatenate[S, P], R]) -> Callable[Concatenate[S, P], R]:
|
|
276
|
+
@wraps(method)
|
|
277
|
+
def call(self: S, /, *args: P.args, **kwargs: P.kwargs) -> R:
|
|
278
|
+
with self._session_usage.operation():
|
|
279
|
+
return method(self, *args, **kwargs)
|
|
280
|
+
|
|
281
|
+
return call
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _after_fork() -> None:
|
|
285
|
+
_session_owner.set(None)
|
|
286
|
+
_operation.set(None)
|
|
287
|
+
_transaction.set(None)
|
|
288
|
+
_session_scope.set(None)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
if hasattr(os, "register_at_fork"):
|
|
292
|
+
os.register_at_fork(after_in_child=_after_fork)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
Reader = TypeVar("Reader")
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def session_function(
|
|
299
|
+
method: Callable[Concatenate[Reader, P], R],
|
|
300
|
+
) -> Callable[Concatenate[Reader, P], R]:
|
|
301
|
+
"""Keep free migration helpers inside a real Session lease; inspection contexts are separate."""
|
|
302
|
+
|
|
303
|
+
@wraps(method)
|
|
304
|
+
def call(reader: Reader, /, *args: P.args, **kwargs: P.kwargs) -> R:
|
|
305
|
+
usage = getattr(reader, "_session_usage", None)
|
|
306
|
+
if isinstance(usage, SessionUsage):
|
|
307
|
+
usage.check()
|
|
308
|
+
if usage.transaction_scope is not None:
|
|
309
|
+
raise ResourceBusy("run migration helpers outside application transactions")
|
|
310
|
+
with usage.operation():
|
|
311
|
+
return method(reader, *args, **kwargs)
|
|
312
|
+
return method(reader, *args, **kwargs)
|
|
313
|
+
|
|
314
|
+
return call
|
sde/bulk.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Bounded application batches, independent of migration's idempotent copy protocol."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping, Sequence
|
|
6
|
+
from datetime import date, datetime
|
|
7
|
+
from decimal import Decimal
|
|
8
|
+
from typing import Any, Final, Protocol, cast
|
|
9
|
+
from uuid import UUID
|
|
10
|
+
|
|
11
|
+
from .errors import BulkWriteRefused
|
|
12
|
+
|
|
13
|
+
MAX_BATCH_ROWS: Final = 1000
|
|
14
|
+
MAX_BATCH_VALUES: Final = 60_000
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class BulkWritable(Protocol):
|
|
18
|
+
"""One native application insert, with no conflict suppression, splitting or retry."""
|
|
19
|
+
|
|
20
|
+
def insert_many(self, table: str, rows: Sequence[Mapping[str, Any]]) -> None: ...
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def bulk_writer(engine: object) -> BulkWritable:
|
|
24
|
+
if not callable(getattr(engine, "insert_many", None)):
|
|
25
|
+
raise BulkWriteRefused("this adapter does not support bulk writes (insert_many)")
|
|
26
|
+
return cast(BulkWritable, engine)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def batch_columns(rows: object, *, extra_columns: int = 0) -> list[str]:
|
|
30
|
+
"""Check the entire batch before an adapter can start work. Values are never in errors."""
|
|
31
|
+
if not isinstance(rows, Sequence) or isinstance(rows, (str, bytes, bytearray)):
|
|
32
|
+
raise BulkWriteRefused("a batch must be a sequence of row mappings")
|
|
33
|
+
if len(rows) > MAX_BATCH_ROWS:
|
|
34
|
+
raise BulkWriteRefused(f"a batch may contain at most {MAX_BATCH_ROWS} rows")
|
|
35
|
+
if not rows:
|
|
36
|
+
return []
|
|
37
|
+
columns: list[str] | None = None
|
|
38
|
+
for row in rows:
|
|
39
|
+
if not isinstance(row, Mapping) or not row or any(not isinstance(c, str) for c in row):
|
|
40
|
+
raise BulkWriteRefused("each batch row must be a nonempty mapping with string fields")
|
|
41
|
+
here = sorted(row)
|
|
42
|
+
if columns is None:
|
|
43
|
+
columns = here
|
|
44
|
+
if len(rows) * (len(columns) + extra_columns) > MAX_BATCH_VALUES:
|
|
45
|
+
raise BulkWriteRefused(
|
|
46
|
+
f"a batch may contain at most {MAX_BATCH_VALUES} values, including generation"
|
|
47
|
+
)
|
|
48
|
+
elif here != columns:
|
|
49
|
+
raise BulkWriteRefused("all batch rows must have the same fields")
|
|
50
|
+
assert columns is not None
|
|
51
|
+
return columns
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _snapshot(value: Any, active: set[int]) -> Any:
|
|
55
|
+
if value is None or isinstance(
|
|
56
|
+
value, (str, bool, int, float, Decimal, UUID, date, datetime, bytes)
|
|
57
|
+
):
|
|
58
|
+
return value
|
|
59
|
+
if isinstance(value, (bytearray, memoryview)):
|
|
60
|
+
return bytes(value)
|
|
61
|
+
identity = id(value)
|
|
62
|
+
if identity in active:
|
|
63
|
+
raise BulkWriteRefused("batch values must not contain cycles")
|
|
64
|
+
active.add(identity)
|
|
65
|
+
try:
|
|
66
|
+
if isinstance(value, Mapping) and all(isinstance(key, str) for key in value):
|
|
67
|
+
return {key: _snapshot(item, active) for key, item in value.items()}
|
|
68
|
+
if isinstance(value, (list, tuple)):
|
|
69
|
+
return [_snapshot(item, active) for item in value]
|
|
70
|
+
raise BulkWriteRefused("batch values must use the SDK scalar types or JSON containers")
|
|
71
|
+
finally:
|
|
72
|
+
active.remove(identity)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def snapshot_rows(rows: Sequence[Mapping[str, Any]]) -> tuple[dict[str, Any], ...]:
|
|
76
|
+
try:
|
|
77
|
+
return tuple({key: _snapshot(value, set()) for key, value in row.items()} for row in rows)
|
|
78
|
+
except RecursionError:
|
|
79
|
+
raise BulkWriteRefused("batch values are nested too deeply") from None
|
sde/canonical.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Canonical encoding: the one place where the cross-language contract lives.
|
|
2
|
+
|
|
3
|
+
Every SDE library has to produce byte-identical output from this. A definition amounting to
|
|
4
|
+
"whatever the Python implementation does" is not a definition, so the rules are spelled out here and
|
|
5
|
+
in ``docs/format-contract.md``, and the conformance vectors pin them.
|
|
6
|
+
|
|
7
|
+
The rules, in the order they are applied:
|
|
8
|
+
|
|
9
|
+
1. UTF-8, no byte order mark.
|
|
10
|
+
2. Object keys are NFC-normalised, then sorted by Unicode code point. Normalise first: sorting first
|
|
11
|
+
would order ``é`` (U+00E9) and ``e`` + U+0301 differently while both normalise to the same key.
|
|
12
|
+
3. No insignificant whitespace. ``{"a":1,"b":[2,3]}`` and nothing else.
|
|
13
|
+
4. Every string is NFC-normalised.
|
|
14
|
+
5. Escaping is minimal and exhaustive: only ``"``, ``\\`` and C0 control characters are escaped,
|
|
15
|
+
controls using the short forms where JSON defines them and ``\\u00XX`` otherwise. Everything else
|
|
16
|
+
is emitted as raw UTF-8. This matters because JSON writers disagree: some escape all non-ASCII,
|
|
17
|
+
some escape U+2028, some escape forward slashes. Any of those would change the hash.
|
|
18
|
+
6. Floating point values are rejected outright. Their textual form differs across languages and the
|
|
19
|
+
difference is unfixable after the fact. Note the distinction that cost a spec revision: a *field*
|
|
20
|
+
may have type ``float64``; the IR records the *name* of that type, which is a string. There is no
|
|
21
|
+
float literal anywhere in the encoding.
|
|
22
|
+
7. Integers are emitted as their shortest decimal form, no leading ``+``, no exponent.
|
|
23
|
+
|
|
24
|
+
Ordering of arrays is not this module's business. Where order carries no meaning the caller sorts;
|
|
25
|
+
where it does, the caller records an explicit index in each element rather than relying on position.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import hashlib
|
|
31
|
+
import unicodedata
|
|
32
|
+
from typing import Final
|
|
33
|
+
|
|
34
|
+
__all__ = ["CanonicalError", "canonical_bytes", "canonical_str", "digest16"]
|
|
35
|
+
|
|
36
|
+
# Short escape forms JSON defines. Everything else below 0x20 gets \u00XX.
|
|
37
|
+
_SHORT_ESCAPES: Final[dict[int, str]] = {
|
|
38
|
+
0x08: "\\b",
|
|
39
|
+
0x09: "\\t",
|
|
40
|
+
0x0A: "\\n",
|
|
41
|
+
0x0C: "\\f",
|
|
42
|
+
0x0D: "\\r",
|
|
43
|
+
0x22: '\\"',
|
|
44
|
+
0x5C: "\\\\",
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class CanonicalError(ValueError):
|
|
49
|
+
"""A value cannot be encoded canonically.
|
|
50
|
+
|
|
51
|
+
Raised rather than coerced on purpose: silently accepting a float, a NaN or a non-string key
|
|
52
|
+
would produce bytes that another implementation cannot reproduce, and the failure would surface
|
|
53
|
+
much later as two versions of the same model.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _escape(text: str) -> str:
|
|
58
|
+
normalised = unicodedata.normalize("NFC", text)
|
|
59
|
+
out: list[str] = ['"']
|
|
60
|
+
for char in normalised:
|
|
61
|
+
code = ord(char)
|
|
62
|
+
short = _SHORT_ESCAPES.get(code)
|
|
63
|
+
if short is not None:
|
|
64
|
+
out.append(short)
|
|
65
|
+
elif code < 0x20:
|
|
66
|
+
out.append(f"\\u{code:04x}")
|
|
67
|
+
else:
|
|
68
|
+
out.append(char)
|
|
69
|
+
out.append('"')
|
|
70
|
+
return "".join(out)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _encode(value: object, path: str) -> str:
|
|
74
|
+
# bool before int: bool is a subclass of int in Python and would otherwise encode as 1/0.
|
|
75
|
+
if value is True:
|
|
76
|
+
return "true"
|
|
77
|
+
if value is False:
|
|
78
|
+
return "false"
|
|
79
|
+
if value is None:
|
|
80
|
+
return "null"
|
|
81
|
+
if isinstance(value, str):
|
|
82
|
+
return _escape(value)
|
|
83
|
+
if isinstance(value, int):
|
|
84
|
+
return str(value)
|
|
85
|
+
if isinstance(value, float):
|
|
86
|
+
raise CanonicalError(
|
|
87
|
+
f"float at {path}: floating point is not representable in canonical form, "
|
|
88
|
+
"because its textual form differs between languages. Use an integer, or a decimal "
|
|
89
|
+
"string, or the name of a float type if you meant to describe a type."
|
|
90
|
+
)
|
|
91
|
+
if isinstance(value, (list, tuple)):
|
|
92
|
+
items = [_encode(item, f"{path}[{i}]") for i, item in enumerate(value)]
|
|
93
|
+
return "[" + ",".join(items) + "]"
|
|
94
|
+
if isinstance(value, dict):
|
|
95
|
+
pairs: list[tuple[str, object]] = []
|
|
96
|
+
for key, item in value.items():
|
|
97
|
+
if not isinstance(key, str):
|
|
98
|
+
raise CanonicalError(
|
|
99
|
+
f"non-string key {key!r} at {path}: object keys must be strings, because "
|
|
100
|
+
"key ordering is defined over Unicode code points"
|
|
101
|
+
)
|
|
102
|
+
pairs.append((unicodedata.normalize("NFC", key), item))
|
|
103
|
+
# Sort after normalising, on the normalised key.
|
|
104
|
+
pairs.sort(key=lambda pair: pair[0])
|
|
105
|
+
seen: set[str] = set()
|
|
106
|
+
parts: list[str] = []
|
|
107
|
+
for key, item in pairs:
|
|
108
|
+
if key in seen:
|
|
109
|
+
raise CanonicalError(
|
|
110
|
+
f"duplicate key {key!r} at {path} after NFC normalisation: two keys that "
|
|
111
|
+
"differ "
|
|
112
|
+
"only in Unicode composition are the same key here"
|
|
113
|
+
)
|
|
114
|
+
seen.add(key)
|
|
115
|
+
parts.append(_escape(key) + ":" + _encode(item, f"{path}.{key}"))
|
|
116
|
+
return "{" + ",".join(parts) + "}"
|
|
117
|
+
raise CanonicalError(
|
|
118
|
+
f"{type(value).__name__} at {path} has no canonical form. The canonical encoding accepts "
|
|
119
|
+
"only null, bool, int, str, list and dict; anything richer has to be reduced to those by "
|
|
120
|
+
"the caller, so that the reduction is visible and testable."
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def canonical_str(value: object) -> str:
|
|
125
|
+
"""Canonical form as text. Prefer :func:`canonical_bytes` for hashing."""
|
|
126
|
+
return _encode(value, "$")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def canonical_bytes(value: object) -> bytes:
|
|
130
|
+
"""Canonical form as UTF-8 bytes. This is what gets hashed and what vectors compare."""
|
|
131
|
+
return canonical_str(value).encode("utf-8")
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def digest16(value: object) -> str:
|
|
135
|
+
"""The identifier form used for ``model_version`` and ``shape.id``.
|
|
136
|
+
|
|
137
|
+
Lowercase hex, first 8 bytes of SHA-256 over the canonical bytes. Sixteen characters is short
|
|
138
|
+
enough to appear in logs and error messages without wrapping, and 64 bits of collision
|
|
139
|
+
resistance is ample for the number of models and shapes one application declares.
|
|
140
|
+
"""
|
|
141
|
+
return hashlib.sha256(canonical_bytes(value)).hexdigest()[:16]
|
sde/capabilities.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Asking an engine adapter whether it will answer a call, which is not the same as its type.
|
|
2
|
+
|
|
3
|
+
Two optional protocols decide whether an engine takes part in something: :class:`
|
|
4
|
+
sde.watermark.WatermarkStore` for the forward-only map check, and :class:`sde.migration.Migratable`
|
|
5
|
+
for a migration. Both were originally asked with ``isinstance(engine, Protocol)``, which is the
|
|
6
|
+
obvious spelling and the wrong question.
|
|
7
|
+
|
|
8
|
+
A ``runtime_checkable`` protocol resolves its members with ``hasattr`` up to Python 3.11 and with
|
|
9
|
+
:func:`inspect.getattr_static` from 3.12 onwards - and the second deliberately ignores
|
|
10
|
+
``__getattr__``. So an object that forwards to a wrapped adapter answers ``hasattr`` for every
|
|
11
|
+
member of the protocol, passes ``isinstance`` on 3.11, and fails it on 3.12. This library supports
|
|
12
|
+
and tests all three, which makes that **the same client, the same wrapper, and a different answer
|
|
13
|
+
per interpreter**.
|
|
14
|
+
|
|
15
|
+
Wrapping an engine adapter is an ordinary thing for a client to do - metrics, logging, a retry, a
|
|
16
|
+
connection pool - and the consequence was two wrong diagnoses shipped as helpful messages: "this
|
|
17
|
+
engine has nowhere to keep the bookkeeping, so you have no rollback protection", and "this engine
|
|
18
|
+
cannot take part in a migration". Both named the client's *engine* for a property of their own
|
|
19
|
+
wrapper, and the second refuses a migration outright.
|
|
20
|
+
|
|
21
|
+
Found from a test, not from review: a two-line proxy over the real ClickHouse adapter, written to
|
|
22
|
+
make one write fail, was refused as an engine with no row-level operations. The version dependence
|
|
23
|
+
came from CI on 3.11, which is the part no single machine would have shown.
|
|
24
|
+
|
|
25
|
+
So the question asked here is the one that matters - **will this object respond to these calls** -
|
|
26
|
+
and it is asked with ordinary attribute access, which honours every way Python has of providing an
|
|
27
|
+
attribute.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
from typing import Any
|
|
33
|
+
|
|
34
|
+
__all__ = ["members_of", "satisfies"]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def members_of(protocol: type) -> tuple[str, ...]:
|
|
38
|
+
"""The public members a protocol names: its methods and its annotated data attributes.
|
|
39
|
+
|
|
40
|
+
Both halves are needed and neither is enough. Methods have class attributes, so ``dir`` finds
|
|
41
|
+
them; an annotated attribute with no value - ``dialect: str`` - exists only in
|
|
42
|
+
``__annotations__``. Assembled from two public sources rather than from
|
|
43
|
+
``__protocol_attrs__``, which is an implementation detail that did not exist in every version
|
|
44
|
+
this library supports.
|
|
45
|
+
"""
|
|
46
|
+
named = {name for name in dir(protocol) if not name.startswith("_")}
|
|
47
|
+
named.update(
|
|
48
|
+
name
|
|
49
|
+
for name in getattr(protocol, "__annotations__", {})
|
|
50
|
+
if not name.startswith("_")
|
|
51
|
+
)
|
|
52
|
+
return tuple(sorted(named))
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def satisfies(obj: Any, protocol: type) -> bool:
|
|
56
|
+
"""Whether every member the protocol names can be reached on this object.
|
|
57
|
+
|
|
58
|
+
Presence rather than callability, deliberately. A member that exists and is not callable fails
|
|
59
|
+
at the call with a message naming it, which is a better failure than a capability check that
|
|
60
|
+
quietly answers "no" and sends the reader to look at their engine.
|
|
61
|
+
"""
|
|
62
|
+
return all(hasattr(obj, name) for name in members_of(protocol))
|