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/placement.py ADDED
@@ -0,0 +1,1010 @@
1
+ """The placement map: where every group lives, and where every operation goes.
2
+
3
+ The map is the only instruction this library takes from outside. It says which engine holds each
4
+ group, what the physical layout there is, and - optionally - which materialisation each operation
5
+ shape should be routed to. Because it decides where data is written, it is the one input that
6
+ refuses rather than degrades: a bad signature, a mismatched model version or an unknown contract
7
+ version all stop the library from starting.
8
+
9
+ Two things about signing are worth stating plainly, because open-sourcing the library changes how
10
+ they read.
11
+
12
+ Publishing this code does not weaken the map. The public key lives here, the private key lives in
13
+ the control plane, and a reader seeing that maps are verified is reassured rather than informed of a
14
+ weakness. A signature is not a secret.
15
+
16
+ And a map with no signature at all is a *valid* map. That is the no-account mode: write a map by
17
+ hand, point the library at it, and everything works with no key, no account and no network. It is a
18
+ documented, supported way to use this library rather than a gap - which is also the honest answer to
19
+ anyone asking what happens if they stop paying us. What is refused is the middle case: a map that
20
+ carries a signature, thereby claiming to come from us, when we have no key to check that claim
21
+ against.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import base64
27
+ import hashlib
28
+ import re
29
+ import unicodedata
30
+ from collections.abc import Mapping, Sequence
31
+ from copy import deepcopy
32
+ from dataclasses import dataclass, field, replace
33
+ from typing import Any, NoReturn, TypeVar
34
+
35
+ from .canonical import CanonicalError, canonical_bytes
36
+ from .errors import MapError
37
+ from .generation import DRAIN_TABLE, EPOCH_COLUMN, MAX_EPOCH, json_numbers
38
+ from .logging import log
39
+ from .model import LogicalModel
40
+ from .physical import (
41
+ PHYSICAL_DESIGN_SINCE,
42
+ check_against_model,
43
+ parse_indexes,
44
+ parse_key_order,
45
+ parse_partition_by,
46
+ )
47
+
48
+ __all__ = [
49
+ "BACKFILL_TABLE",
50
+ "RESERVED_TABLES",
51
+ "GroupPlacement",
52
+ "Materialization",
53
+ "PhysicalLayout",
54
+ "PlacementMap",
55
+ "load_map",
56
+ ]
57
+
58
+
59
+ @dataclass(frozen=True)
60
+ class PhysicalLayout:
61
+ """What the group looks like inside one engine.
62
+
63
+ This is ours to choose and ours to change, which is the entire reason the client's code never
64
+ names a table. Everything here is derived by the planner and simply obeyed by the library.
65
+ """
66
+
67
+ tables: Mapping[str, str]
68
+ columns: Mapping[str, Mapping[str, str]]
69
+ indexes: Sequence[Mapping[str, Any]] = field(default_factory=tuple)
70
+ partition_by: Mapping[str, Mapping[str, str]] = field(default_factory=dict)
71
+ """Entity -> ``{"field", "granularity"}``, contract 5. See :mod:`sde.physical`."""
72
+ key_order: Mapping[str, Sequence[str]] = field(default_factory=dict)
73
+ """Entity -> its key columns in physical order, contract 5. Absent: the declared order."""
74
+
75
+ def table_for(self, entity: str) -> str:
76
+ try:
77
+ return self.tables[entity]
78
+ except KeyError:
79
+ raise MapError(
80
+ f"the layout has no table for {entity!r}. The map claims to place a group that "
81
+ "contains this entity, so this is a defect in the map rather than something the "
82
+ "library can work around."
83
+ ) from None
84
+
85
+
86
+ @dataclass(frozen=True)
87
+ class Materialization:
88
+ """One physical copy of a group in one engine."""
89
+
90
+ id: str
91
+ engine: str
92
+ layout: PhysicalLayout
93
+ lag_budget_ms: int | None = None
94
+
95
+ @property
96
+ def is_source(self) -> bool:
97
+ return self.lag_budget_ms is None
98
+
99
+
100
+ @dataclass(frozen=True)
101
+ class GroupPlacement:
102
+ group: str
103
+ source: Materialization
104
+ derived: tuple[Materialization, ...] = ()
105
+ also_write: tuple[Materialization, ...] = ()
106
+ """Derived copies that writes are **also** sent to, additionally and never authoritatively.
107
+
108
+ This is how a migration reaches the library, and the reason there is no phase name anywhere in
109
+ the map. A library does not need to know what ``DUAL_WRITE`` means; it needs to know where
110
+ writes go and where reads go, and both of those were already things a map says. Putting the
111
+ phase in the document as well would be a second representation of a fact the fan-out and the
112
+ routing table already carry - and a signed document with two representations of one fact is one
113
+ that can contradict itself.
114
+
115
+ Empty for every map that is not mid-migration, and the key is absent rather than empty then, for
116
+ the reason ``partition_by`` is: absent says "not doing this", and an empty list says "considered
117
+ and chose none", which is a stronger claim and a false one.
118
+ """
119
+
120
+ write_epoch: int | None = None
121
+
122
+ def all(self) -> tuple[Materialization, ...]:
123
+ return (self.source, *self.derived)
124
+
125
+ def by_id(self, mat_id: str) -> Materialization:
126
+ for mat in self.all():
127
+ if mat.id == mat_id:
128
+ return mat
129
+ raise MapError(
130
+ f"group {self.group!r} has no materialisation {mat_id!r}, but the routing table points "
131
+ "at it. The map is internally inconsistent."
132
+ )
133
+
134
+
135
+ @dataclass(frozen=True)
136
+ class PlacementMap:
137
+ contract: int
138
+ model_version: str
139
+ map_version: int
140
+ groups: Mapping[str, GroupPlacement]
141
+ routing: Mapping[str, str]
142
+ signed: bool
143
+ verified_with: str | None = None
144
+ """Which of the caller's public keys verified this map, by the caller's own name for it.
145
+
146
+ ``None`` for an unsigned map and also for one verified against a bare key, where there was no
147
+ name to report; :attr:`signed` tells those apart. A default is allowed here because this field
148
+ is *reported* and never acted on - nothing routes or writes differently because of it - unlike
149
+ the dialect in a materialisation plan, which has no default precisely because a wrong one
150
+ produces a document that verifies and cannot be applied.
151
+
152
+ Public because a rotation needs it. Adding a key is safe and reversible; **removing** one is
153
+ the irreversible half, and a client cannot know when the old key is safe to drop without
154
+ seeing which key the maps they are actually receiving were signed with.
155
+ """
156
+
157
+ project_id: str | None = None
158
+ fingerprint: str | None = field(default=None, init=False)
159
+
160
+ def placement_of(self, group: str) -> GroupPlacement:
161
+ try:
162
+ return self.groups[group]
163
+ except KeyError:
164
+ raise MapError(
165
+ f"no placement for group {group!r}. Every group in the model needs one: a group "
166
+ "with "
167
+ "nowhere to live is not a slow path, it is an unanswerable operation."
168
+ ) from None
169
+
170
+
171
+ MAP_CONTRACT = 5
172
+ """The placement map's format version, which is not the IR's - see :data:`sde.model.CONTRACT`.
173
+
174
+ Five since 23 September 2026, for the physical design vocabulary (:mod:`sde.physical`): the order
175
+ of a key, a time partition, and index methods. An earlier library would ignore ``"method": "brin"``
176
+ and build a B-tree from the same document, which is a loosening and therefore a bump. A contract-5
177
+ library reads contracts 1 to 4 with their meaning unchanged, and refuses the new keys in them.
178
+
179
+ Two was for ``also_write``, which a contract-1 library would ignore while a contract-2 one honours
180
+ it: the same document, two different sets of engines written to, and the difference decided by
181
+ which version happens to be installed. That is a loosening, and section 11 of the contract says a
182
+ loosening bumps the number.
183
+
184
+ Three since 7 September 2026, and the reason is not a new key. The rendered ClickHouse timestamp
185
+ moved from `DateTime64(3)` to `DateTime64(6)` so that it matches PostgreSQL, which changed what
186
+ this library believes the two dialects keep - and therefore whether it accepts a `also_write` map
187
+ between them. A contract-2 library refuses a fan-out that a contract-3 one performs, on the same
188
+ document, which is the loosening section 11 says bumps the number.
189
+ """
190
+
191
+ ALSO_WRITE_SINCE = 2
192
+ """The contract that introduced ``also_write``.
193
+
194
+ A document declaring an earlier contract and carrying the key is refused. That is a *tightening* -
195
+ no bump - and it exists because of who makes the mistake: a producer that grew the key and forgot to
196
+ raise the number, which is exactly what happened on the control-plane side of the very change that
197
+ added it. Honouring the key anyway would mean an old library ignoring the fan-out while a new one
198
+ performs it, on the same document.
199
+ """
200
+
201
+ MAP_CONTRACT_FLOOR = 1
202
+ """The oldest map format this library still reads.
203
+
204
+ **Backwards compatible, forwards strict**, and the asymmetry is knowledge rather than kindness: we
205
+ know exactly what contract 1 was, and every contract-1 document is a valid contract-2 one with a key
206
+ absent - which reads as "no dual write", a complete and correct meaning. What came *after* this
207
+ library cannot be known, so a higher number is refused rather than interpreted, which is the case
208
+ the original message argued for.
209
+
210
+ The alternative - strict equality, as this library did until contract 2 - couples a library upgrade
211
+ to a control-plane action: a client would have to be issued a new map before they could start. For
212
+ the no-account mode that is worse than inconvenient, because there is nobody to issue them one and
213
+ the promise was that hand-writing a map is enough.
214
+ """
215
+
216
+ WATERMARK_TABLE = "sde_map_state"
217
+ """The table this library keeps its map bookkeeping in - see :mod:`sde.watermark`.
218
+
219
+ Defined here rather than there because the reservation is a property of the map format, and because
220
+ ``watermark.py`` imports this module: a layout naming this table is refused when the map is loaded.
221
+
222
+ A client entity called ``SdeMapState`` would derive this table name from the model, and the refusal
223
+ below catches that too - at map load, which for a map we issue means at issuance, since `issue()`
224
+ parses its own output with this parser. The message names the fix, which is to rename the entity.
225
+ """
226
+
227
+ BACKFILL_TABLE = "sde_backfill_state"
228
+ """The table this library keeps its backfill progress in - see :mod:`sde.migration`.
229
+
230
+ The second of these, and the reason the refusal below is written over a set rather than over one
231
+ name: a bookkeeping table whose name is not reserved is a table a client's model can collide with,
232
+ and the collision is silent in the direction that matters - we would read their rows as progress and
233
+ write progress into their table.
234
+ """
235
+
236
+ RESERVED_TABLES: Mapping[str, str] = {
237
+ WATERMARK_TABLE: (
238
+ "the highest map version applied against an engine, which is what stops an older map "
239
+ "from being loaded over a newer one"
240
+ ),
241
+ BACKFILL_TABLE: (
242
+ "how many rows of each entity a migration has copied into an engine, which is what lets "
243
+ "an interrupted backfill resume instead of starting over"
244
+ ),
245
+ }
246
+ """Table names this library owns, and what each one holds.
247
+
248
+ A mapping rather than a set so the refusal can say what the collision would break. "This name is
249
+ reserved" sends the reader to our source; "this name holds the backfill marker" tells them why
250
+ their entity cannot have it.
251
+ """
252
+
253
+ _AUTO = object()
254
+
255
+
256
+ def _layout(raw: Mapping[str, Any], where: str, *, contract: int) -> PhysicalLayout | object:
257
+ """Parse a layout, or report that it asked to be derived.
258
+
259
+ ``{"auto": true}`` is what makes a hand-written map a few lines rather than a full schema
260
+ written out by hand. Without it the no-account mode from requirement 12.5 would be technically
261
+ true and practically unusable, which is the same as not having it.
262
+
263
+ It derives a **PostgreSQL** layout, always, and that is a limit rather than a default worth
264
+ changing quietly. A layout has no dialect in it and the map names an engine by *name*, not by
265
+ dialect - deliberately, since reasoning from an engine's name is what this library refuses
266
+ everywhere - so there is nothing here from which the right dialect could be known. A
267
+ hand-written map for ClickHouse or for the orderbook engine therefore needs an explicit layout,
268
+ and both of those engines refuse a PostgreSQL-derived one rather than applying it: ClickHouse
269
+ because it carries indexes it has no B-tree for, the orderbook engine because the table is not
270
+ the one name its storage has. Fails closed, in other words, but the message names the symptom.
271
+ Making ``auto`` dialect-aware means a new key in a signed document, which is a loosening of the
272
+ format and so a contract bump in every language at once - see section 11.
273
+ """
274
+ if not isinstance(raw, dict):
275
+ raise MapError(f"{where}: layout must be an object")
276
+ if raw.get("auto") is True:
277
+ if len(raw) != 1:
278
+ raise MapError(
279
+ f"{where}: a layout is either auto or explicit, not both. Two sources of truth for "
280
+ "a "
281
+ "schema is how a column ends up existing in one place and not the other."
282
+ )
283
+ return _AUTO
284
+ tables = raw.get("tables")
285
+ if not isinstance(tables, dict) or not tables:
286
+ raise MapError(
287
+ f"{where}: layout needs a non-empty 'tables' mapping entity -> table name, "
288
+ 'or {"auto": true} to have one derived from the model'
289
+ )
290
+ for name, holds in RESERVED_TABLES.items():
291
+ reserved = sorted(entity for entity, table in tables.items() if str(table) == name)
292
+ if reserved:
293
+ raise MapError(
294
+ f"{where}: {reserved} would be stored in a table called {name!r}, which this "
295
+ f"library keeps its own bookkeeping in - {holds}. A client table under that name "
296
+ f"would be read as bookkeeping and written to as bookkeeping. Rename the table; "
297
+ f"the name is yours to choose everywhere else."
298
+ )
299
+ columns = {k: dict(v) for k, v in (raw.get("columns") or {}).items()}
300
+ if contract < PHYSICAL_DESIGN_SINCE:
301
+ # Before contract 5 `partition_by` had no meaning, and the refusal is the whole point: the
302
+ # key was parsed, emitted by the control plane when non-empty, and **no renderer ever
303
+ # applied it** - a layout declaring it produced an unpartitioned table and said nothing.
304
+ # A contract-4 document may not start meaning something by it now (`errors/037`).
305
+ if raw.get("partition_by"):
306
+ raise MapError(
307
+ f"{where}: this layout declares partition_by in a document declaring map contract "
308
+ f"{contract}, where no library renders it - the table would be created "
309
+ f"unpartitioned and nothing would say so. Partitioning is map contract "
310
+ f"{PHYSICAL_DESIGN_SINCE}; remove the key or raise the contract."
311
+ )
312
+ if "key_order" in raw:
313
+ raise MapError(
314
+ f"{where}: key_order is map contract {PHYSICAL_DESIGN_SINCE} and this document "
315
+ f"declares {contract}. A library of that contract would ignore it and order the "
316
+ f"key as declared, so one document would build two different tables."
317
+ )
318
+ key_order: dict[str, tuple[str, ...]] = {}
319
+ partition_by: dict[str, dict[str, str]] = {}
320
+ else:
321
+ key_order = parse_key_order(raw.get("key_order", {}), where, tables=tables)
322
+ partition_by = parse_partition_by(raw.get("partition_by", {}), where, tables=tables)
323
+ return PhysicalLayout(
324
+ tables=dict(tables),
325
+ columns=columns,
326
+ indexes=parse_indexes(
327
+ raw.get("indexes"), where, tables=tables, columns=columns, contract=contract
328
+ ),
329
+ partition_by=partition_by,
330
+ key_order=key_order,
331
+ )
332
+
333
+
334
+ def _also_write(
335
+ raw: Any,
336
+ where: str,
337
+ *,
338
+ contract: int,
339
+ source: Materialization,
340
+ derived: tuple[Materialization, ...],
341
+ ) -> tuple[Materialization, ...]:
342
+ """The derived copies writes are additionally sent to. Four refusals, each with its failure.
343
+
344
+ Validated here rather than at the first write, for the reason the routing table was moved here:
345
+ a map is a document handed over once and obeyed for months, so a defect in it should be found
346
+ when it arrives and not by the request that happens to touch it. During a migration that request
347
+ is a write, and the failure mode is a write that goes to one engine when the map says two.
348
+ """
349
+ if raw is None:
350
+ return ()
351
+ if contract < ALSO_WRITE_SINCE:
352
+ raise MapError(
353
+ f"{where}: this map declares format contract {contract} and uses 'also_write', which "
354
+ f"contract {ALSO_WRITE_SINCE} introduced. Refused rather than honoured, and the reason "
355
+ f"is who makes this mistake: a producer that grew the key and forgot to raise the "
356
+ f"number. Honouring it would mean a contract-1 library ignoring the fan-out while a "
357
+ f"contract-2 one performs it - the same document, two sets of engines written to - "
358
+ f"which is the whole failure the version exists to prevent."
359
+ )
360
+ if not isinstance(raw, list) or not raw:
361
+ raise MapError(
362
+ f"{where}: 'also_write' is a non-empty list of materialisation ids, or absent. Absent "
363
+ f"means writes go to the source alone; an empty list would be a claim that fan-out was "
364
+ f"considered and none chosen, which is a stronger thing to say and not what the "
365
+ f"planner means when it omits the key."
366
+ )
367
+ by_id = {m.id: m for m in derived}
368
+ out: list[Materialization] = []
369
+ seen: set[str] = set()
370
+ for entry in raw:
371
+ if not isinstance(entry, str):
372
+ raise MapError(f"{where}: 'also_write' holds materialisation ids, not {entry!r}")
373
+ if entry == source.id:
374
+ raise MapError(
375
+ f"{where}: 'also_write' names the source {entry!r}. The source is where writes "
376
+ f"already land - listing it would either write the row twice or read as though the "
377
+ f"source were somehow optional, and both are worse than the refusal."
378
+ )
379
+ if entry in seen:
380
+ raise MapError(
381
+ f"{where}: 'also_write' names {entry!r} twice. Two identical fan-out targets is "
382
+ f"either a duplicated row or a document nobody meant to write."
383
+ )
384
+ if entry not in by_id:
385
+ raise MapError(
386
+ f"{where}: 'also_write' names {entry!r}, which is not a derived materialisation of "
387
+ f"this group. It has {sorted(by_id)}. A fan-out target that does not exist is a "
388
+ f"write with nowhere to go, and during a migration that is a row the copy never "
389
+ f"receives."
390
+ )
391
+ seen.add(entry)
392
+ out.append(by_id[entry])
393
+ return tuple(out)
394
+
395
+
396
+ def _materialization(
397
+ raw: Mapping[str, Any], where: str, *, source: bool, contract: int
398
+ ) -> Materialization:
399
+ for required in ("id", "engine", "layout"):
400
+ if required not in raw:
401
+ raise MapError(f"{where}: materialisation is missing {required!r}")
402
+ lag = raw.get("lag_budget_ms")
403
+ if source and lag is not None:
404
+ raise MapError(
405
+ f"{where}: the source materialisation cannot have a lag budget. The source is where "
406
+ "writes land, so it is by definition not behind anything."
407
+ )
408
+ if not source and lag is None:
409
+ raise MapError(
410
+ f"{where}: a derived materialisation needs lag_budget_ms. Without it nobody - not the "
411
+ "client, not the monitoring - can tell whether it is healthy or hours behind."
412
+ )
413
+ layout = _layout(raw["layout"], where, contract=contract)
414
+ return Materialization(
415
+ id=str(raw["id"]),
416
+ engine=str(raw["engine"]),
417
+ layout=layout, # type: ignore[arg-type]
418
+ lag_budget_ms=None if lag is None else int(lag),
419
+ )
420
+
421
+
422
+ def _key_set(public_key: bytes | Mapping[str, bytes]) -> tuple[tuple[str, bytes], ...]:
423
+ """Normalise what the caller supplied into named keys, refusing what cannot be a key.
424
+
425
+ A bare ``bytes`` is the ordinary case and keeps the empty name, because the caller gave us no
426
+ name to report back. A mapping is the rotation case, and the names are the caller's own: what a
427
+ rotation runbook needs is not a key but something to *remove*, and "remove k1" is only an
428
+ instruction if k1 is what they wrote in their configuration.
429
+
430
+ A key of the wrong length is refused here rather than left to fail verification. It is the
431
+ client's configuration, and a rotation that silently did not take effect - because the new key
432
+ was pasted a byte short - is the failure mode this whole mechanism exists to avoid. Before this
433
+ it raised a bare ``ValueError`` out of ``load_map`` on the application's start path.
434
+ """
435
+ pairs: tuple[tuple[str, bytes], ...]
436
+ if isinstance(public_key, (bytes, bytearray)):
437
+ pairs = (("", bytes(public_key)),)
438
+ else:
439
+ if not public_key:
440
+ raise MapError(
441
+ "an empty set of public keys was supplied. That is not the no-account mode - a map "
442
+ "with no signature is - it is a configuration that can verify nothing. Pass the "
443
+ "keys, or load an unsigned map."
444
+ )
445
+ pairs = tuple(sorted((str(k), bytes(v)) for k, v in public_key.items()))
446
+ for name, value in pairs:
447
+ if len(value) != 32:
448
+ raise MapError(
449
+ f"the public key {name or '(unnamed)'!r} is {len(value)} bytes and an Ed25519 "
450
+ f"public key is 32. Refused here rather than at verification: a key pasted a byte "
451
+ f"short would look like a map that does not verify, and the two have completely "
452
+ f"different fixes."
453
+ )
454
+ return pairs
455
+
456
+
457
+ def _verify_signature(
458
+ raw: Mapping[str, Any], public_key: bytes | Mapping[str, bytes]
459
+ ) -> str | None:
460
+ """Verify, and report **which** of the caller's keys did it.
461
+
462
+ Returns the caller's own name for the key that verified, or ``None`` when a bare key was
463
+ supplied and there was no name to report. That does not collide with an unsigned map:
464
+ :attr:`PlacementMap.signed` tells those two apart, and one field meaning two things is how a
465
+ value ends up standing for four (see the coordinator's three-state answer in the engine).
466
+
467
+ **Every key is tried, and ``key_id`` only decides the order.** The signature block is excluded
468
+ from the signed payload in its entirety, so ``key_id`` is not covered by the signature and
469
+ anybody can edit it. Making it authoritative would therefore be trusting an attacker's
470
+ annotation; making it *authenticated* would mean changing what the payload is, which recomputes
471
+ every signature ever issued and is a contract bump in every language at once. As an ordering it
472
+ costs nothing to be wrong about: a bad hint makes verification try the keys in a worse order
473
+ and reach the same answer.
474
+
475
+ The cost of accepting a set is real and belongs written down here rather than in a document:
476
+ **an old key verifies for as long as the client keeps it.** This library has no clock - it is
477
+ never told when a map was issued, and reads the wall clock exactly once in the whole package -
478
+ so it cannot expire a key, and that same absence is what makes "you stop paying and nothing
479
+ breaks" true. Revocation is therefore the client removing a key from their own configuration,
480
+ which is why the id that verified is reported: a client who cannot see which key is in use
481
+ cannot know when the old one is safe to drop.
482
+ """
483
+ try:
484
+ from cryptography.exceptions import InvalidSignature
485
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
486
+ except ImportError as exc: # pragma: no cover - depends on the install extra
487
+ raise MapError(
488
+ "this map is signed, but signature verification needs the 'signed' extra: "
489
+ "pip install 'smart-data-engine-sdk[signed]'. The base install stays dependency-free "
490
+ "on purpose, because a library that goes into someone's application should not drag in "
491
+ "cryptography unless it is actually verifying something."
492
+ ) from exc
493
+
494
+ signature = raw["signature"]
495
+ if not isinstance(signature, dict) or signature.get("alg") != "ed25519":
496
+ raise MapError("only ed25519 signatures are understood")
497
+ try:
498
+ value = base64.b64decode(signature["value"], validate=True)
499
+ except Exception as exc:
500
+ raise MapError("the signature is not valid base64") from exc
501
+
502
+ claimed = signature.get("key_id")
503
+ claimed = str(claimed) if isinstance(claimed, str) else None
504
+ keys = _key_set(public_key)
505
+ if claimed is None:
506
+ ordered = list(keys)
507
+ else:
508
+ hinted = [pair for pair in keys if pair[0] == claimed]
509
+ ordered = hinted + [pair for pair in keys if pair[0] != claimed]
510
+
511
+ payload = canonical_bytes({k: v for k, v in raw.items() if k != "signature"})
512
+ for name, key in ordered:
513
+ try:
514
+ Ed25519PublicKey.from_public_bytes(key).verify(value, payload)
515
+ except InvalidSignature:
516
+ continue
517
+ return name or None
518
+
519
+ tried = [name or "(unnamed)" for name, _ in ordered]
520
+ raise MapError(
521
+ f"the map's signature does not verify against any of the {len(ordered)} key(s) supplied "
522
+ f"({tried}); the map says it was signed with {claimed!r}. This is refused rather than "
523
+ f"warned about: the map decides where your data is written. If a rotation is in progress, "
524
+ f"the key named above is the one to add - and while both are configured, maps signed with "
525
+ f"either are accepted."
526
+ )
527
+
528
+
529
+ def load_map(
530
+ raw: Mapping[str, Any],
531
+ *,
532
+ model: LogicalModel | None = None,
533
+ public_key: bytes | Mapping[str, bytes] | None = None,
534
+ require_signature: bool = False,
535
+ ) -> PlacementMap:
536
+ """Parse and validate a placement map, and put the outcome on the record either way.
537
+
538
+ ``model`` is optional only so that the conformance vectors can exercise parsing on its own; in
539
+ an application it is always passed, because a map for a different model version has to be
540
+ refused rather than half-applied.
541
+
542
+ Both outcomes are logged, and the pair is the point. Until this wrapper existed the only line
543
+ a map load produced was ``sde.map.unsigned``, which fired **only** when the map had no
544
+ signature - so the one thing this library said about a map was said exclusively to clients
545
+ without an account. That is the shape requirement 12.6 forbids, arrived at from the other
546
+ direction: not a complaint anybody wrote, but a line that exists in one mode because nobody
547
+ wrote the other one.
548
+
549
+ So the event is the same in both modes and the mode is a **field**. That is what the closed
550
+ vocabulary is for - structured fields, and no interpolated values in the event name - and it
551
+ means the account-free mode emits no event the account mode does not. ``forward_only`` is the
552
+ single thing that actually differs; the sentence explaining it lives on
553
+ :attr:`Session.rollback_protection`, where a client can read it rather than grep for it.
554
+
555
+ A refusal is logged too, because ``MapError`` reaches the application while the log reaches the
556
+ operator, and those are different people at four in the morning. Its ``reason`` is the
557
+ engine-facing message, which in this library is always about structure - contract versions,
558
+ model versions, group, engine, table and materialisation names - and never about a value: there
559
+ is no code path by which one of the client's rows reaches a map.
560
+ """
561
+ try:
562
+ placement = _parse_map(
563
+ raw, model=model, public_key=public_key, require_signature=require_signature
564
+ )
565
+ except MapError as exc:
566
+ log("sde.map.rejected", error=type(exc).__name__, reason=str(exc)[:200])
567
+ raise
568
+ log(
569
+ "sde.map.loaded",
570
+ model_version=placement.model_version,
571
+ map_version=placement.map_version,
572
+ signed=placement.signed,
573
+ forward_only=placement.signed,
574
+ key=placement.verified_with,
575
+ )
576
+ return placement
577
+
578
+
579
+ def _require_canonical_text(value: Any) -> None:
580
+ """Reject ambiguous identifiers; normalizing here could silently choose another SQL object."""
581
+ if isinstance(value, str):
582
+ if value != unicodedata.normalize("NFC", value) or any(
583
+ 0xD800 <= ord(char) <= 0xDFFF for char in value
584
+ ):
585
+ raise MapError(
586
+ "placement map payload strings and member names must be Unicode scalar text in NFC"
587
+ )
588
+ elif isinstance(value, dict):
589
+ for key, child in value.items():
590
+ _require_canonical_text(key)
591
+ _require_canonical_text(child)
592
+ elif isinstance(value, (list, tuple)):
593
+ for child in value:
594
+ _require_canonical_text(child)
595
+
596
+
597
+ def _parse_map(
598
+ raw: Mapping[str, Any],
599
+ *,
600
+ model: LogicalModel | None = None,
601
+ public_key: bytes | Mapping[str, bytes] | None = None,
602
+ require_signature: bool = False,
603
+ ) -> PlacementMap:
604
+ """The parse itself. Separate so that the reporting above wraps every refusal in it, including
605
+ the ones added later by somebody who has not read this file."""
606
+ if not isinstance(raw, dict):
607
+ raise MapError("a placement map is an object")
608
+
609
+ raw = deepcopy(dict(raw))
610
+ candidate = raw.get("contract")
611
+ if isinstance(candidate, (int, float)) and candidate >= 4:
612
+ raw = json_numbers(raw)
613
+ contract = raw.get("contract")
614
+ if not isinstance(contract, int) or isinstance(contract, bool):
615
+ raise MapError(
616
+ f"this map declares format contract {contract!r}, which is not a version number. "
617
+ f"This library reads {MAP_CONTRACT_FLOOR} to {MAP_CONTRACT}."
618
+ )
619
+ if contract > MAP_CONTRACT:
620
+ raise MapError(
621
+ f"this map declares format contract {contract} and this library implements "
622
+ f"{MAP_CONTRACT}. Refusing rather than guessing: a version this library does not know "
623
+ f"may say something with a key it has never heard of, and the difference between two "
624
+ f"contract versions is exactly the kind of thing that would otherwise be interpreted "
625
+ f"as a missing field meaning zero. Upgrade the library."
626
+ )
627
+ if contract < MAP_CONTRACT_FLOOR:
628
+ raise MapError(
629
+ f"this map declares format contract {contract} and the oldest this library still "
630
+ f"reads is {MAP_CONTRACT_FLOOR}."
631
+ )
632
+
633
+ # Signatures/fingerprints normalize text, whereas database identifiers preserve its bytes.
634
+ # The instruction payload must already have the canonical spelling. The signature block is
635
+ # excluded: its key_id is an unauthenticated hint and cannot change the selected instruction.
636
+ _require_canonical_text({key: value for key, value in raw.items() if key != "signature"})
637
+
638
+ project_id = raw.get("project_id") if contract >= 4 else None
639
+ if contract >= 4 and (
640
+ not isinstance(project_id, str) or re.fullmatch("[0-9a-f]{32}", project_id) is None
641
+ ):
642
+ raise MapError("contract 4 requires a 32-digit lowercase hexadecimal project_id")
643
+ if contract < 4 and "project_id" in raw:
644
+ raise MapError("project_id requires placement map contract 4")
645
+
646
+ model_version = raw.get("model_version")
647
+ if not isinstance(model_version, str) or not model_version:
648
+ raise MapError("the map does not say which model version it is for")
649
+ if model is not None and model.version != model_version:
650
+ raise MapError(
651
+ f"this map is for model version {model_version} and your declared model is "
652
+ f"{model.version}. Something changed in your entities; ask for a new map rather than "
653
+ "running against this one, because the difference cannot be guessed."
654
+ )
655
+
656
+ map_version = raw.get("map_version")
657
+ if not isinstance(map_version, int) or isinstance(map_version, bool) or map_version < 1:
658
+ raise MapError(
659
+ f"this map declares map_version {map_version!r}, which is not a version number. It "
660
+ "used to be read as int(document.get('map_version', 0)), so a document without one "
661
+ "loaded as version 0 - and this is the number that decides whether an older map is "
662
+ "being replayed over a newer one against the client's own engines."
663
+ )
664
+
665
+ signature_present = "signature" in raw and raw["signature"] is not None
666
+ if require_signature and not signature_present:
667
+ raise MapError("a signature was required and this map has none")
668
+ if signature_present:
669
+ if public_key is None:
670
+ raise MapError(
671
+ "this map is signed, which is a claim that it came from us, and no public key was "
672
+ "provided to check that claim. Either pass the key, or use an unsigned map - an "
673
+ "unsigned map is a supported mode, an unverifiable claim is not."
674
+ )
675
+ verified_with = _verify_signature(raw, public_key)
676
+ else:
677
+ verified_with = None
678
+
679
+ groups_raw = raw.get("groups")
680
+ if not isinstance(groups_raw, dict) or not groups_raw:
681
+ raise MapError("the map places no groups")
682
+
683
+ groups: dict[str, GroupPlacement] = {}
684
+ # By name, not in the document's order. A map with two defects has to refuse the same way in
685
+ # every language (format-contract §8a): `errors/019` carries a reserved table name in one group
686
+ # and an auto-plus-explicit layout in another, and it pinned the first only because that group
687
+ # is written earlier in the file and both of our runtimes iterate an object in insertion order.
688
+ # A third implementation in Go, whose maps iterate in a randomised order, failed that vector in
689
+ # 5 of 20 runs. The routing loop below has sorted since it was written; this one had not.
690
+ for name in sorted(groups_raw):
691
+ body = groups_raw[name]
692
+ where = f"group {name!r}"
693
+ if not isinstance(body, dict) or "source" not in body:
694
+ raise MapError(f"{where}: needs a 'source' materialisation")
695
+ epoch = body.get("write_epoch") if contract >= 4 else None
696
+ if contract >= 4 and (type(epoch) is not int or not 1 <= epoch <= MAX_EPOCH):
697
+ raise MapError(f"{where}: contract 4 requires a positive safe write_epoch")
698
+ if contract < 4 and "write_epoch" in body:
699
+ raise MapError(f"{where}: write_epoch requires placement map contract 4")
700
+ source = _materialization(body["source"], f"{where}.source", source=True, contract=contract)
701
+ derived = tuple(
702
+ _materialization(d, f"{where}.derived[{i}]", source=False, contract=contract)
703
+ for i, d in enumerate(body.get("derived") or ())
704
+ )
705
+ ids = [m.id for m in (source, *derived)]
706
+ if len(set(ids)) != len(ids):
707
+ raise MapError(f"{where}: two materialisations share an id")
708
+
709
+ groups[name] = GroupPlacement(
710
+ write_epoch=epoch,
711
+ group=name,
712
+ source=source,
713
+ derived=derived,
714
+ also_write=_also_write(
715
+ body.get("also_write"),
716
+ where,
717
+ contract=contract,
718
+ source=source,
719
+ derived=derived,
720
+ ),
721
+ )
722
+
723
+ routing = raw.get("routing") or {}
724
+ if not isinstance(routing, dict):
725
+ raise MapError("'routing' must be a mapping from shape id to materialisation id")
726
+
727
+ if model is not None:
728
+ model_groups = {g.name: g for g in _model_groups(model)}
729
+ missing = sorted(set(model_groups) - set(groups))
730
+ if missing:
731
+ raise MapError(
732
+ f"the map does not place these groups: {missing}. Every group in the model needs a "
733
+ "home before anything can run."
734
+ )
735
+ # And the other direction. Checking only one of the two is how a map placing a group this
736
+ # model does not have reached `model_groups[name]` below and came out as a bare KeyError -
737
+ # a library exception with no explanation, on the path whose entire job is to explain.
738
+ unknown = sorted(set(groups) - set(model_groups))
739
+ if unknown:
740
+ raise MapError(
741
+ f"the map places groups this model does not have: {unknown}. The model version "
742
+ "matched, so this is not a stale map: the two sides derived colocation groups "
743
+ "differently, and a group nobody declared has no entities to hold."
744
+ )
745
+ groups = {
746
+ name: _resolve_auto(placement, model, model_groups[name])
747
+ for name, placement in groups.items()
748
+ }
749
+ for placement in groups.values():
750
+ _refuse_shadowing(placement)
751
+ for name in sorted(groups):
752
+ _check_physical(groups[name], model)
753
+ _check_routing_targets(routing, groups, model)
754
+ elif any(m.layout is _AUTO for p in groups.values() for m in p.all()):
755
+ raise MapError(
756
+ 'a layout asked to be derived with {"auto": true}, but no model was supplied to derive '
757
+ "it from. Pass model= to load_map()."
758
+ )
759
+ else:
760
+ _check_routing_targets(routing, groups, None)
761
+
762
+ if contract >= 4:
763
+ for spot in groups.values():
764
+ for materialization in spot.all():
765
+ if any(
766
+ EPOCH_COLUMN in fields for fields in materialization.layout.columns.values()
767
+ ):
768
+ raise MapError("the write-epoch column is reserved for the SDK")
769
+ if DRAIN_TABLE in materialization.layout.tables.values():
770
+ raise MapError("the write-fence drain log is reserved for the SDK")
771
+
772
+ try:
773
+ fingerprint = hashlib.sha256(
774
+ canonical_bytes({key: value for key, value in raw.items() if key != "signature"})
775
+ ).hexdigest()
776
+ except CanonicalError as exc:
777
+ if contract >= 4:
778
+ raise MapError("contract 4 requires a canonically encodable placement map") from exc
779
+ # An unsigned legacy map may carry noncanonical annotations. Preserve its old behavior;
780
+ # the optional bound-verification protocol refuses a map it cannot fingerprint.
781
+ fingerprint = None
782
+
783
+ frozen_groups = _FrozenDict({name: _freeze_group(spot) for name, spot in groups.items()})
784
+ result = PlacementMap(
785
+ project_id=project_id,
786
+ contract=contract,
787
+ model_version=model_version,
788
+ map_version=map_version,
789
+ groups=frozen_groups,
790
+ routing=_FrozenDict({str(k): str(v) for k, v in routing.items()}),
791
+ signed=signature_present,
792
+ verified_with=verified_with,
793
+ )
794
+ object.__setattr__(result, "fingerprint", fingerprint)
795
+ return result
796
+
797
+
798
+ def _check_physical(placement: GroupPlacement, model: LogicalModel) -> None:
799
+ """The physical design rules that need the model: key permutations and partitions on the key.
800
+
801
+ Materialisations in document order within the group, entities in name order within a layout -
802
+ the same ordering §8a gives every other refusal, so one document refuses the same way in every
803
+ language.
804
+ """
805
+ for material in placement.all():
806
+ layout = material.layout
807
+ where = f"group {placement.group!r}: {material.id!r}"
808
+ entities = sorted(set(layout.key_order) | set(layout.partition_by))
809
+ declared_entities = {spec.name for spec in model.entities}
810
+ for entity in entities:
811
+ if entity not in declared_entities:
812
+ raise MapError(
813
+ f"{where}: the physical design names {entity!r}, which this model does not "
814
+ f"declare, so there is no key to order or partition by."
815
+ )
816
+ spec = model.entity(entity)
817
+ check_against_model(
818
+ where,
819
+ entity=entity,
820
+ key=spec.key,
821
+ field_types={declared.name: declared.type for declared in spec.fields},
822
+ key_order=layout.key_order.get(entity),
823
+ partition=layout.partition_by.get(entity),
824
+ )
825
+
826
+
827
+ def _refuse_shadowing(placement: GroupPlacement) -> None:
828
+ """Refuse a derived copy that is secretly the source.
829
+
830
+ Two materialisations of one group in the same engine must not name the same tables. Found by a
831
+ test rather than by thinking: with an auto layout on both, the source and a derived copy derive
832
+ identical table names, so a copy placed in the same engine simply *is* the source. Reads would
833
+ appear to work, the lag would always measure zero, and the second copy would exist only in the
834
+ map. Refused rather than warned about, precisely because it looks like it works.
835
+
836
+ Checked after auto layouts are resolved, since before that there are no table names to compare.
837
+ """
838
+ source_tables = set(placement.source.layout.tables.values())
839
+ for candidate in placement.derived:
840
+ if candidate.engine != placement.source.engine:
841
+ continue
842
+ shared = sorted(source_tables & set(candidate.layout.tables.values()))
843
+ if shared:
844
+ raise MapError(
845
+ f"group {placement.group!r}: materialisation {candidate.id!r} is in the same "
846
+ "engine "
847
+ f"as the source and reuses its tables {shared}. That is not a copy of the group, "
848
+ "it is the original with a second name in the map, so its lag would always read as "
849
+ "zero and a read routed to it would silently be a read of the source."
850
+ )
851
+
852
+
853
+ def _resolve_auto(placement: GroupPlacement, model: LogicalModel, group: Any) -> GroupPlacement:
854
+ """Fill in any layout that asked to be derived.
855
+
856
+ Derivation lives in :mod:`sde.layout` and is the boring total function from a model to a schema
857
+ that stores it. The interesting choices - indexes worth their cost, partitioning, a second
858
+ materialisation - are the planner's and are not in this repository.
859
+ """
860
+ from .layout import default_layout
861
+
862
+ def fill(mat: Materialization) -> Materialization:
863
+ if mat.layout is not _AUTO:
864
+ return mat
865
+ return Materialization(
866
+ id=mat.id,
867
+ engine=mat.engine,
868
+ layout=default_layout(model, group),
869
+ lag_budget_ms=mat.lag_budget_ms,
870
+ )
871
+
872
+ # `replace` rather than a fresh GroupPlacement, and this is a fix rather than a preference.
873
+ # Reconstructing it listed the fields that existed when it was written, so `also_write` was
874
+ # dropped here the moment it was added: the map parsed, the fan-out came back empty, and writes
875
+ # went to one engine while the signed document said two. Silently, on the write path, during a
876
+ # migration - the worst failure this feature has. It is the third time in this project that a
877
+ # value has been computed and lost at a boundary (`provisional`, then `basis`), so the shape of
878
+ # the fix matters more than the fix: with `replace`, a field added later travels whether or not
879
+ # anybody remembers this function.
880
+ return replace(
881
+ placement,
882
+ source=fill(placement.source),
883
+ derived=tuple(fill(m) for m in placement.derived),
884
+ also_write=tuple(fill(m) for m in placement.also_write),
885
+ )
886
+
887
+
888
+ def _model_groups(model: LogicalModel) -> tuple[Any, ...]:
889
+ # Imported late: groups imports model, and model must not import groups.
890
+ from .groups import colocation_groups
891
+
892
+ return colocation_groups(model)
893
+
894
+
895
+ def _model_shapes(model: LogicalModel) -> tuple[Any, ...]:
896
+ # Late for the same reason: shapes imports groups, which imports model.
897
+ from .shapes import enumerate_shapes
898
+
899
+ return enumerate_shapes(model)
900
+
901
+
902
+ def _check_routing_targets(
903
+ routing: Mapping[str, Any],
904
+ groups: Mapping[str, GroupPlacement],
905
+ model: LogicalModel | None,
906
+ ) -> None:
907
+ """Every routing entry must name a materialisation that exists, in the shape's own group.
908
+
909
+ This used to be checked in ``GroupPlacement.by_id`` - that is, at the first read that routed
910
+ through the broken entry. Deferring it there turns a mistake in a document we hand over into an
911
+ error inside the client's request path, at a moment nobody can predict: only the shapes routing
912
+ through that entry fail, so a staging run that never issues those operations is green and the
913
+ map looks applied. Everything here is decidable at load, so it is decided at load.
914
+
915
+ Two levels, because ``model`` is optional. Without a model: the target has to be an id declared
916
+ somewhere in this map. With one: it has to be declared in the group the shape belongs to, which
917
+ is the check that matters - ids are only unique *within* a group, so a target that exists in
918
+ some other group would otherwise read the entity out of a copy that does not hold it.
919
+ """
920
+ declared = {name: {m.id for m in placement.all()} for name, placement in groups.items()}
921
+ everywhere = {mat_id for ids in declared.values() for mat_id in ids}
922
+
923
+ for shape_id, target in sorted(routing.items()):
924
+ if not isinstance(target, str):
925
+ raise MapError(
926
+ f"the routing entry for shape {shape_id!r} is not a materialisation id. Routing "
927
+ "maps a shape id to one id, and anything else is a table nobody can look up."
928
+ )
929
+ if target not in everywhere:
930
+ raise MapError(
931
+ f"the routing table sends shape {shape_id!r} to materialisation {target!r}, and no "
932
+ f"group in this map declares one with that id. The map is internally inconsistent: "
933
+ f"declared ids are {sorted(everywhere)}."
934
+ )
935
+
936
+ if model is None:
937
+ return
938
+
939
+ shapes = {shape.id: shape for shape in _model_shapes(model)}
940
+ for shape_id, target in sorted(routing.items()):
941
+ shape = shapes.get(shape_id)
942
+ if shape is None:
943
+ raise MapError(
944
+ f"the routing table has an entry for shape {shape_id!r}, and this model does not "
945
+ "produce that shape. The model version matched, so the two sides enumerated shapes "
946
+ "differently - which is the divergence that puts one library's write in a table "
947
+ "another library never looks at."
948
+ )
949
+ if target not in declared.get(shape.group, frozenset()):
950
+ raise MapError(
951
+ f"the routing table sends shape {shape_id!r} - which belongs to group "
952
+ f"{shape.group!r} - to materialisation {target!r}, which that group does not "
953
+ f"declare. Materialisation ids are unique only within a group, so this would read "
954
+ f"{shape.entity} out of a copy that does not hold it."
955
+ )
956
+
957
+
958
+ _K = TypeVar("_K")
959
+ _V = TypeVar("_V")
960
+
961
+
962
+ class _FrozenDict(dict[_K, _V]):
963
+ """Read-only through normal mapping operations, while retaining JSON/dict compatibility."""
964
+
965
+ @staticmethod
966
+ def _deny(*_args: Any, **_kwargs: Any) -> NoReturn:
967
+ raise TypeError("a loaded placement map is immutable; load a new document")
968
+
969
+ __setitem__ = _deny
970
+ __delitem__ = _deny
971
+ clear = _deny
972
+ pop = _deny
973
+ popitem = _deny
974
+ setdefault = _deny
975
+ update = _deny
976
+ __ior__ = _deny
977
+
978
+ def __deepcopy__(self, _memo: dict[int, Any]) -> _FrozenDict[_K, _V]:
979
+ return self
980
+
981
+
982
+ def _freeze(value: Any) -> Any:
983
+ if isinstance(value, dict):
984
+ return _FrozenDict({key: _freeze(item) for key, item in value.items()})
985
+ if isinstance(value, (list, tuple)):
986
+ return tuple(_freeze(item) for item in value)
987
+ return value
988
+
989
+
990
+ def _freeze_group(spot: GroupPlacement) -> GroupPlacement:
991
+ materials = {}
992
+ for material in spot.all():
993
+ layout = material.layout
994
+ materials[material.id] = replace(
995
+ material,
996
+ layout=replace(
997
+ layout,
998
+ tables=_freeze(dict(layout.tables)),
999
+ columns=_freeze(dict(layout.columns)),
1000
+ indexes=_freeze(layout.indexes),
1001
+ partition_by=_freeze(dict(layout.partition_by)),
1002
+ key_order=_freeze(dict(layout.key_order)),
1003
+ ),
1004
+ )
1005
+ return replace(
1006
+ spot,
1007
+ source=materials[spot.source.id],
1008
+ derived=tuple(materials[mat.id] for mat in spot.derived),
1009
+ also_write=tuple(materials[mat.id] for mat in spot.also_write),
1010
+ )