smart-data-engine-sdk 0.1.0.dev0__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/model.py ADDED
@@ -0,0 +1,482 @@
1
+ """Turning declarations into a canonical model, and the model into a version.
2
+
3
+ The canonical IR is the artefact every library has to agree on byte for byte. Its shape is described
4
+ in ``docs/format-contract.md`` and pinned by ``conformance/vectors/model/*``. Two rules from the
5
+ contract show up all over this file and are worth naming before you read it:
6
+
7
+ * Arrays are sorted wherever order carries no meaning - entities by name, fields by name, relations
8
+ by ``(from, name, to)``. Nothing may depend on the order the client happened to declare things in,
9
+ because that order is not part of their model.
10
+ * Where order *does* carry meaning, it is recorded as an explicit ``position`` inside each element
11
+ rather than as a position in the array. Composite keys are the case that matters: ``(tenant, id)``
12
+ and ``(id, tenant)`` are different keys, and a reader must not have to know that array order is
13
+ significant here but not three lines above.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from collections.abc import Mapping
19
+ from dataclasses import dataclass, replace
20
+ from typing import Any, get_args, get_origin, get_type_hints
21
+
22
+ from .canonical import canonical_bytes, digest16
23
+ from .entity import EntityDecl, Ref, registry
24
+ from .errors import DeclarationError
25
+ from .logging import log
26
+ from .types import resolve_type
27
+
28
+ __all__ = [
29
+ "CONTRACT",
30
+ "EntitySpec",
31
+ "FieldSpec",
32
+ "LogicalModel",
33
+ "RelationSpec",
34
+ "assemble",
35
+ "build_model",
36
+ ]
37
+
38
+ CONTRACT = 1
39
+ """The **IR's** format version, and the one the conformance vectors are pinned to.
40
+
41
+ Separate from :data:`sde.placement.MAP_CONTRACT` since the placement map gained a key the IR did
42
+ not, and that separation is a correction rather than a convenience. One counter for two artefacts
43
+ means every artefact's version moves when any one of them changes - and because ``contract`` is
44
+ *inside* the IR, and ``model_version`` is a digest of the IR, bumping it for a change to the map
45
+ format would give every client a new model version, invalidate every issued map, and re-bless every
46
+ model vector. For a key in a different document.
47
+
48
+ The evidence that the split is right is the hand-written vector: ``model/001-single-entity`` was
49
+ typed out from the format contract to prove the document is implementable, and its digest is pinned
50
+ in CI. Adding ``also_write`` to the map does not move it, because nothing about the IR changed.
51
+ """
52
+ """Format contract version. Bumped when the IR shape or the encoding rules change."""
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class FieldSpec:
57
+ name: str
58
+ type: str
59
+ nullable: bool
60
+
61
+
62
+ @dataclass(frozen=True)
63
+ class RelationSpec:
64
+ name: str
65
+ source: str
66
+ target: str
67
+
68
+
69
+ @dataclass(frozen=True)
70
+ class EntitySpec:
71
+ name: str
72
+ fields: tuple[FieldSpec, ...]
73
+ key: tuple[str, ...]
74
+ pii: tuple[str, ...]
75
+ residency: str | None
76
+
77
+
78
+ @dataclass(frozen=True)
79
+ class LogicalModel:
80
+ entities: tuple[EntitySpec, ...]
81
+ relations: tuple[RelationSpec, ...]
82
+ atomic: tuple[tuple[str, ...], ...]
83
+ cost_ceiling: Mapping[str, str] | None
84
+ ir: Mapping[str, Any]
85
+ version: str
86
+
87
+ def entity(self, name: str) -> EntitySpec:
88
+ for spec in self.entities:
89
+ if spec.name == name:
90
+ return spec
91
+ raise KeyError(name)
92
+
93
+ @property
94
+ def ir_bytes(self) -> bytes:
95
+ return canonical_bytes(self.ir)
96
+
97
+
98
+ def _resolve_hints(decl: EntityDecl) -> dict[str, Any]:
99
+ try:
100
+ return get_type_hints(decl.cls, localns=dict(decl.localns), include_extras=True)
101
+ except NameError as exc:
102
+ raise DeclarationError(
103
+ f"{decl.name} refers to a name that is not defined yet ({exc}). Entities may reference "
104
+ "each other in any order, but every name has to exist by the time build_model() runs."
105
+ ) from exc
106
+
107
+
108
+ def _split_fields(
109
+ decl: EntityDecl, hints: Mapping[str, Any], known: set[str]
110
+ ) -> tuple[list[FieldSpec], list[RelationSpec]]:
111
+ fields: list[FieldSpec] = []
112
+ relations: list[RelationSpec] = []
113
+ for name, annotation in hints.items():
114
+ if name.startswith("_"):
115
+ continue
116
+ if get_origin(annotation) is Ref:
117
+ args = get_args(annotation)
118
+ if len(args) != 1 or not isinstance(args[0], type):
119
+ raise DeclarationError(
120
+ f"{decl.name}.{name} is a Ref without a single entity target"
121
+ )
122
+ target = args[0].__name__
123
+ if target not in known:
124
+ raise DeclarationError(
125
+ f"{decl.name}.{name} points at {target}, which is not a declared entity. Add "
126
+ "@sde.entity to it, or include it in the model you are building."
127
+ )
128
+ relations.append(RelationSpec(name=name, source=decl.name, target=target))
129
+ continue
130
+ neutral, nullable = resolve_type(annotation, field=name, entity=decl.name)
131
+ fields.append(FieldSpec(name=name, type=neutral, nullable=nullable))
132
+ return fields, relations
133
+
134
+
135
+ def _resolve_key(decl: EntityDecl, fields: list[FieldSpec]) -> tuple[str, ...]:
136
+ names = {f.name for f in fields}
137
+ if decl.key is not None:
138
+ missing = [k for k in decl.key if k not in names]
139
+ if missing:
140
+ raise DeclarationError(
141
+ f"{decl.name}.Meta.key names {missing}, which are not fields of {decl.name}. A "
142
+ "relation cannot be part of a key: the key has to be storable in the entity itself."
143
+ )
144
+ if not decl.key:
145
+ raise DeclarationError(f"{decl.name}.Meta.key is empty")
146
+ return decl.key
147
+ if "id" in names:
148
+ return ("id",)
149
+ raise DeclarationError(
150
+ f"{decl.name} has no 'id' field and no Meta.key. Every entity needs a key: without one "
151
+ "there "
152
+ "is no way to address a row, no way to migrate it and no way to verify a migration moved "
153
+ "it."
154
+ )
155
+
156
+
157
+ def _normalise_atomic(
158
+ decls: tuple[EntityDecl, ...], known: set[str]
159
+ ) -> tuple[tuple[str, ...], ...]:
160
+ """Turn pairwise ``atomic_with`` declarations into merged, sorted groups.
161
+
162
+ ``atomic_with`` is symmetric even when written on one side only: if A must change atomically
163
+ with B then B must change atomically with A, so a client declaring it once is enough and
164
+ declaring it twice must not produce two different groups. Merging is transitive for the same
165
+ reason - if A is atomic with B and B with C, all three have to commit together, and nothing else
166
+ would be implementable on a single engine's transaction.
167
+ """
168
+ parent: dict[str, str] = {d.name: d.name for d in decls}
169
+
170
+ def find(x: str) -> str:
171
+ while parent[x] != x:
172
+ parent[x] = parent[parent[x]]
173
+ x = parent[x]
174
+ return x
175
+
176
+ def union(a: str, b: str) -> None:
177
+ ra, rb = find(a), find(b)
178
+ if ra != rb:
179
+ parent[max(ra, rb)] = min(ra, rb)
180
+
181
+ touched: set[str] = set()
182
+ for decl in decls:
183
+ for other in decl.atomic_with:
184
+ if other not in known:
185
+ raise DeclarationError(
186
+ f"{decl.name}.Meta.atomic_with names {other!r}, which is not a declared entity"
187
+ )
188
+ if other == decl.name:
189
+ raise DeclarationError(
190
+ f"{decl.name}.Meta.atomic_with names itself, which says nothing"
191
+ )
192
+ union(decl.name, other)
193
+ touched.add(decl.name)
194
+ touched.add(other)
195
+
196
+ groups: dict[str, list[str]] = {}
197
+ for name in sorted(touched):
198
+ groups.setdefault(find(name), []).append(name)
199
+ return tuple(sorted(tuple(sorted(members)) for members in groups.values()))
200
+
201
+
202
+ def build_model(
203
+ *entities: type,
204
+ cost_ceiling: Mapping[str, str] | None = None,
205
+ ) -> LogicalModel:
206
+ """Resolve declarations into a :class:`LogicalModel`.
207
+
208
+ With no arguments, everything decorated with :func:`~sde.entity.entity` so far is used. Passing
209
+ entities explicitly is what tests do, because a global registry and test isolation do not mix.
210
+
211
+ ``cost_ceiling`` is ``{"amount": "500.00", "currency": "EUR"}`` - the amount is a *string* on
212
+ purpose. It is money, so it is a decimal, and a decimal in the IR would have to be a float,
213
+ which the canonical encoding refuses for good reason.
214
+ """
215
+ decls: tuple[EntityDecl, ...]
216
+ if entities:
217
+ collected: list[EntityDecl] = []
218
+ for cls in entities:
219
+ decl = getattr(cls, "__sde_decl__", None)
220
+ if decl is None:
221
+ raise DeclarationError(
222
+ f"{cls.__name__} is not an entity. Decorate it with @sde.entity."
223
+ )
224
+ collected.append(decl)
225
+ decls = tuple(collected)
226
+ else:
227
+ decls = registry()
228
+
229
+ if not decls:
230
+ raise DeclarationError("no entities declared, so there is no model to build")
231
+
232
+ known = {d.name for d in decls}
233
+
234
+ specs: list[EntitySpec] = []
235
+ relations: list[RelationSpec] = []
236
+ for decl in decls:
237
+ hints = _resolve_hints(decl)
238
+ fields, rels = _split_fields(decl, hints, known)
239
+ # "no fields" and "pii names something that is not a field" used to be checked here and
240
+ # not on the neutral-JSON path. They are in `assemble` now, which both paths go through: a
241
+ # guarantee that holds at one of two front doors holds for one of two callers.
242
+ specs.append(
243
+ EntitySpec(
244
+ name=decl.name,
245
+ fields=tuple(sorted(fields, key=lambda f: f.name)),
246
+ # Not resolved for an entity with no fields: `assemble` refuses that first (§8a),
247
+ # and resolving here would refuse a relation-only entity for having no key - which
248
+ # sends the reader looking for a Meta.key when the problem is that nothing is
249
+ # stored.
250
+ key=_resolve_key(decl, fields) if fields else (),
251
+ pii=tuple(sorted(decl.pii)),
252
+ residency=decl.residency,
253
+ )
254
+ )
255
+ relations.extend(rels)
256
+
257
+ atomic = _normalise_atomic(decls, known)
258
+
259
+ if cost_ceiling is not None:
260
+ missing = {"amount", "currency"} - set(cost_ceiling)
261
+ if missing:
262
+ raise DeclarationError(
263
+ f"cost_ceiling is missing {sorted(missing)}; it is "
264
+ '{"amount": "500.00", "currency": "EUR"} with the amount as a string'
265
+ )
266
+
267
+ return assemble(
268
+ entities=tuple(specs),
269
+ relations=tuple(relations),
270
+ atomic=atomic,
271
+ cost_ceiling=cost_ceiling,
272
+ )
273
+
274
+
275
+ def neutral_declaration(model: LogicalModel) -> dict[str, Any]:
276
+ """The model as the neutral form of format-contract §4a.
277
+
278
+ The inverse of :func:`sde.testing.loader.model_from_neutral`, and it exists because the control
279
+ plane takes a client's model as *that* document while a client declares it in whichever way
280
+ their language is comfortable with. Without this, a client using the decorator API has to write
281
+ their model a second time by hand for `declare`, and the two copies then drift.
282
+
283
+ **The IR is not this document.** They look close enough to be confused - our own tests declared
284
+ ``model.ir`` and got away with it because nothing read the stored file back - and they differ
285
+ exactly where it matters: the IR records key order as ``[{"field": "id", "position": 0}]``
286
+ because array order is not load-bearing anywhere else in it, and the neutral form states a key
287
+ as ``["id"]`` because that is what a person writes. Feeding the IR to the loader is refused, and
288
+ ``test_neutral_declaration.py`` pins that as well as the round trip.
289
+ """
290
+ entities: list[dict[str, Any]] = []
291
+ for spec in model.entities:
292
+ entity: dict[str, Any] = {
293
+ "name": spec.name,
294
+ "fields": [
295
+ {"name": f.name, "type": f.type, **({"nullable": True} if f.nullable else {})}
296
+ for f in spec.fields
297
+ ],
298
+ "key": list(spec.key),
299
+ }
300
+ if spec.pii:
301
+ entity["pii"] = list(spec.pii)
302
+ if spec.residency is not None:
303
+ entity["residency"] = spec.residency
304
+ entities.append(entity)
305
+
306
+ document: dict[str, Any] = {"entities": entities}
307
+ if model.relations:
308
+ document["relations"] = [
309
+ {"name": r.name, "from": r.source, "to": r.target} for r in model.relations
310
+ ]
311
+ if model.atomic:
312
+ document["atomic"] = [list(group) for group in model.atomic]
313
+ if model.cost_ceiling is not None:
314
+ document["cost_ceiling"] = dict(model.cost_ceiling)
315
+ return document
316
+
317
+ def _refuse_a_declaration_that_is_not_a_model(
318
+ entities: tuple[EntitySpec, ...],
319
+ relations: tuple[RelationSpec, ...],
320
+ ) -> None:
321
+ """The seven refusals of format-contract §4a, in the order that section writes them.
322
+
323
+ They live here rather than at each front door because there are two front doors - the
324
+ decorator path and the neutral-JSON path the conformance vectors use - and until this function
325
+ existed each of them enforced a different subset. The vectors therefore ran a *weaker*
326
+ validator than any application does, which is the suite's own stated failure mode: a vector
327
+ that passes without reaching the code it describes takes the place of one that would have.
328
+
329
+ Every one of these was measured against a third implementation written from the contract alone.
330
+ Three of them were accepted by both of our libraries and refused by that one, and one - an empty
331
+ ``key`` list - produced two different ``model_version`` values here, because Python spelled the
332
+ invented default ``or`` and TypeScript spelled it ``??``.
333
+ """
334
+ if not entities:
335
+ raise DeclarationError("no entities declared, so there is no model to build")
336
+
337
+ seen: dict[str, EntitySpec] = {}
338
+ for spec in entities:
339
+ if not spec.fields:
340
+ raise DeclarationError(
341
+ f"{spec.name} has no fields. An entity that stores nothing cannot be placed, so "
342
+ "there is nothing for a map to say about it."
343
+ )
344
+ if spec.name in seen:
345
+ raise DeclarationError(
346
+ f"two entities are called {spec.name!r}. Entity names reach the canonical IR and "
347
+ "the colocation graph, so a duplicate makes 'which entity is this' unanswerable in "
348
+ "the document whose job is to answer it."
349
+ )
350
+ seen[spec.name] = spec
351
+
352
+ relations_of: dict[str, set[str]] = {}
353
+ for rel in relations:
354
+ for side in (rel.source, rel.target):
355
+ if side not in seen:
356
+ raise DeclarationError(
357
+ f"relation {rel.name!r} names unknown entity {side!r}"
358
+ )
359
+ named = relations_of.setdefault(rel.source, set())
360
+ if rel.name in named:
361
+ raise DeclarationError(
362
+ f"{rel.source}.{rel.name} is declared twice. Two relations of one name on one "
363
+ "entity are two edges the colocation graph cannot tell apart."
364
+ )
365
+ named.add(rel.name)
366
+
367
+ for spec in entities:
368
+ fields = [f.name for f in spec.fields]
369
+ duplicates = sorted({name for name in fields if fields.count(name) > 1})
370
+ if duplicates:
371
+ raise DeclarationError(
372
+ f"{spec.name} declares the fields {duplicates} more than once. The layout would "
373
+ "have two columns of one name, and the refusal would arrive from the client's "
374
+ "engine at CREATE TABLE."
375
+ )
376
+ if not spec.key:
377
+ raise DeclarationError(
378
+ f"{spec.name} declares no key. A key is what makes a row addressable, migratable "
379
+ "and verifiable - a backfill compares rows by it - so an entity without one is a "
380
+ "group that cannot be moved, and that is worth knowing when the model is declared "
381
+ "rather than in the middle of a migration. No key is invented for you."
382
+ )
383
+ missing = [k for k in spec.key if k not in set(fields)]
384
+ if missing:
385
+ raise DeclarationError(
386
+ f"{spec.name}: key names {missing}, which are not fields of {spec.name}"
387
+ )
388
+ repeated = sorted({k for k in spec.key if list(spec.key).count(k) > 1})
389
+ if repeated:
390
+ raise DeclarationError(
391
+ f"{spec.name}: key names {repeated} more than once, which is a composite key with "
392
+ "one column in two positions."
393
+ )
394
+ bad_pii = [pii for pii in spec.pii if pii not in set(fields)]
395
+ if bad_pii:
396
+ raise DeclarationError(
397
+ f"{spec.name}: pii names {bad_pii}, which are not fields of {spec.name}. A pii "
398
+ "entry that is not a field silently protects nothing, and the exclusion of "
399
+ "personal data from a derived copy is meant to be readable rather than trusted."
400
+ )
401
+
402
+
403
+ def assemble(
404
+ *,
405
+ entities: tuple[EntitySpec, ...],
406
+ relations: tuple[RelationSpec, ...],
407
+ atomic: tuple[tuple[str, ...], ...],
408
+ cost_ceiling: Mapping[str, str] | None,
409
+ ) -> LogicalModel:
410
+ """Build the IR and the version from already-resolved specs.
411
+
412
+ Both entry points come through here: the decorator path in :func:`build_model`, and the neutral
413
+ JSON path in :mod:`sde.testing.loader` that the conformance vectors use. That is not tidiness -
414
+ if the vectors exercised a second implementation of this encoding, they would be verifying
415
+ something no application ever runs, which is the most expensive kind of green test.
416
+ """
417
+ _refuse_a_declaration_that_is_not_a_model(entities, relations)
418
+
419
+ specs = tuple(sorted(entities, key=lambda s: s.name))
420
+ rels = tuple(sorted(relations, key=lambda r: (r.source, r.name, r.target)))
421
+
422
+ # Fields and pii are sorted **here**, by the name that ends up in the IR - not left in whatever
423
+ # order the caller supplied. Both callers below happen to supply them sorted already, so for
424
+ # years this was a no-op and the ordering rule lived in a convention rather than in the code.
425
+ # Hashing broke the convention: it rebuilds an entity with digests for names while keeping the
426
+ # original sequence, so the IR came out ordered by the *real* field names. Two consequences, one
427
+ # of them a leak: the hashed IR encoded the alphabetical order of names it is supposed to hide,
428
+ # and no library that had never seen those names could reproduce the bytes. TypeScript sorted,
429
+ # Python did not, and the hashing vector is what made the disagreement visible.
430
+ def ordered(spec: EntitySpec) -> EntitySpec:
431
+ return replace(
432
+ spec,
433
+ fields=tuple(sorted(spec.fields, key=lambda f: f.name)),
434
+ # `key` is deliberately not sorted: its order is positional and carries meaning, which
435
+ # is why the IR writes an explicit index for it.
436
+ pii=tuple(sorted(spec.pii)),
437
+ )
438
+
439
+ specs = tuple(ordered(spec) for spec in specs)
440
+
441
+ ir: dict[str, Any] = {
442
+ "contract": CONTRACT,
443
+ "entities": [
444
+ {
445
+ "name": s.name,
446
+ "fields": [
447
+ {"name": f.name, "type": f.type, "nullable": f.nullable} for f in s.fields
448
+ ],
449
+ # Explicit position: array order is not load-bearing anywhere else in the IR, so it
450
+ # must not be here either.
451
+ "key": [{"field": k, "position": i} for i, k in enumerate(s.key)],
452
+ "pii": list(s.pii),
453
+ "residency": s.residency,
454
+ }
455
+ for s in specs
456
+ ],
457
+ "relations": [
458
+ {"name": r.name, "from": r.source, "to": r.target} for r in rels
459
+ ],
460
+ "atomic": [list(group) for group in atomic],
461
+ "cost_ceiling": dict(cost_ceiling) if cost_ceiling is not None else None,
462
+ }
463
+
464
+ model = LogicalModel(
465
+ entities=specs,
466
+ relations=rels,
467
+ atomic=atomic,
468
+ cost_ceiling=dict(cost_ceiling) if cost_ceiling is not None else None,
469
+ ir=ir,
470
+ version=digest16(ir),
471
+ )
472
+ # Once per process. `model_version` is the identifier every other artefact is keyed on - a map
473
+ # naming a different one is refused, and that refusal is the most common thing anybody will
474
+ # ever ask us about - so the version this process computed has to be readable from the client's
475
+ # own log rather than reconstructed from their source.
476
+ log(
477
+ "sde.model.built",
478
+ model_version=model.version,
479
+ entities=len(specs),
480
+ groups=len(atomic),
481
+ )
482
+ return model