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
@@ -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))