lorebase-core 0.2.3__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 (94) hide show
  1. lorebase_core/__init__.py +1 -0
  2. lorebase_core/canonical/__init__.py +35 -0
  3. lorebase_core/canonical/hashing.py +128 -0
  4. lorebase_core/canonical/jcs.py +162 -0
  5. lorebase_core/expr/__init__.py +118 -0
  6. lorebase_core/expr/ast.py +443 -0
  7. lorebase_core/expr/checker.py +428 -0
  8. lorebase_core/expr/compiler.py +87 -0
  9. lorebase_core/expr/context.py +82 -0
  10. lorebase_core/expr/derivations.py +306 -0
  11. lorebase_core/expr/environment.py +176 -0
  12. lorebase_core/expr/errors.py +98 -0
  13. lorebase_core/expr/evaluator.py +414 -0
  14. lorebase_core/expr/functions.py +898 -0
  15. lorebase_core/expr/limits.py +52 -0
  16. lorebase_core/expr/operations.py +479 -0
  17. lorebase_core/expr/ops.py +243 -0
  18. lorebase_core/expr/resolver.py +96 -0
  19. lorebase_core/expr/rows.py +148 -0
  20. lorebase_core/expr/sensitivity.py +337 -0
  21. lorebase_core/expr/types.py +227 -0
  22. lorebase_core/expr/validation.py +51 -0
  23. lorebase_core/expr/values.py +169 -0
  24. lorebase_core/expr/visibility.py +363 -0
  25. lorebase_core/infer/__init__.py +36 -0
  26. lorebase_core/infer/columns.py +621 -0
  27. lorebase_core/infer/engine.py +546 -0
  28. lorebase_core/infer/keys.py +164 -0
  29. lorebase_core/infer/names.py +145 -0
  30. lorebase_core/infer/options.py +290 -0
  31. lorebase_core/infer/profile.py +54 -0
  32. lorebase_core/infer/proposal.py +122 -0
  33. lorebase_core/infer/references.py +314 -0
  34. lorebase_core/infer/suggestions.py +731 -0
  35. lorebase_core/infer/tables.py +81 -0
  36. lorebase_core/merge/__init__.py +89 -0
  37. lorebase_core/merge/conflicts.py +262 -0
  38. lorebase_core/merge/consistency.py +440 -0
  39. lorebase_core/merge/data_merge.py +522 -0
  40. lorebase_core/merge/engine.py +476 -0
  41. lorebase_core/merge/index.py +100 -0
  42. lorebase_core/merge/objects.py +204 -0
  43. lorebase_core/merge/policies.py +322 -0
  44. lorebase_core/merge/resolve.py +321 -0
  45. lorebase_core/merge/schema_merge.py +429 -0
  46. lorebase_core/merge/state.py +443 -0
  47. lorebase_core/model/__init__.py +245 -0
  48. lorebase_core/model/attribute.py +72 -0
  49. lorebase_core/model/base.py +61 -0
  50. lorebase_core/model/derivation.py +95 -0
  51. lorebase_core/model/domain.py +71 -0
  52. lorebase_core/model/embedding.py +193 -0
  53. lorebase_core/model/ids.py +78 -0
  54. lorebase_core/model/kind.py +85 -0
  55. lorebase_core/model/lineage.py +114 -0
  56. lorebase_core/model/operation.py +161 -0
  57. lorebase_core/model/paths.py +141 -0
  58. lorebase_core/model/record.py +127 -0
  59. lorebase_core/model/relation.py +83 -0
  60. lorebase_core/model/schema.py +219 -0
  61. lorebase_core/model/source.py +204 -0
  62. lorebase_core/model/trait.py +37 -0
  63. lorebase_core/model/vault.py +43 -0
  64. lorebase_core/model/yaml_schema.py +1058 -0
  65. lorebase_core/query/__init__.py +97 -0
  66. lorebase_core/query/dsl.py +227 -0
  67. lorebase_core/query/resolve.py +794 -0
  68. lorebase_core/revision/__init__.py +59 -0
  69. lorebase_core/revision/ancestry.py +272 -0
  70. lorebase_core/revision/changeset.py +62 -0
  71. lorebase_core/revision/revision.py +121 -0
  72. lorebase_core/tree/__init__.py +76 -0
  73. lorebase_core/tree/diff.py +119 -0
  74. lorebase_core/tree/hamt.py +333 -0
  75. lorebase_core/tree/node.py +280 -0
  76. lorebase_core/tree/params.py +33 -0
  77. lorebase_core/tree/root.py +70 -0
  78. lorebase_core/tree/store.py +98 -0
  79. lorebase_core/types/__init__.py +71 -0
  80. lorebase_core/types/casts.py +271 -0
  81. lorebase_core/types/constraints.py +299 -0
  82. lorebase_core/types/encoding.py +538 -0
  83. lorebase_core/types/errors.py +41 -0
  84. lorebase_core/types/matching.py +40 -0
  85. lorebase_core/types/types.py +187 -0
  86. lorebase_core/validate/__init__.py +49 -0
  87. lorebase_core/validate/changes.py +70 -0
  88. lorebase_core/validate/changeset.py +551 -0
  89. lorebase_core/validate/data_rules.py +291 -0
  90. lorebase_core/validate/issues.py +123 -0
  91. lorebase_core/validate/schema_rules.py +1304 -0
  92. lorebase_core-0.2.3.dist-info/METADATA +9 -0
  93. lorebase_core-0.2.3.dist-info/RECORD +94 -0
  94. lorebase_core-0.2.3.dist-info/WHEEL +4 -0
@@ -0,0 +1,429 @@
1
+ """Schema merge (design 6.8 step 2): the ``schema`` partition is merged first.
2
+
3
+ Elements changed on one side are taken; elements changed differently on both sides go
4
+ through a structural three-way merge of their JSON documents: objects (such as a Kind's
5
+ ``attributes``, keyed by id) merge entry by entry, scalars and ordered lists conflict when
6
+ both sides changed them differently. The merged schema is then checked statically; problems
7
+ that neither side had (api name collisions, broken references, Trait conformance) become
8
+ Conflicts.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from collections.abc import Callable
14
+ from dataclasses import dataclass, field
15
+
16
+ from pydantic import JsonValue, ValidationError
17
+
18
+ from lorebase_core.merge.conflicts import (
19
+ ConflictKind,
20
+ Resolution,
21
+ ResolutionChoice,
22
+ ResolutionError,
23
+ Side,
24
+ )
25
+ from lorebase_core.merge.objects import element_model, schema_object_type
26
+ from lorebase_core.merge.state import (
27
+ MISSING,
28
+ Context,
29
+ Payload,
30
+ Stage,
31
+ Value,
32
+ as_json,
33
+ same,
34
+ )
35
+ from lorebase_core.model import IdPrefix, Schema, id_prefix
36
+ from lorebase_core.tree import (
37
+ PARTITION_SCHEMA,
38
+ TreeChange,
39
+ kind_partition,
40
+ relation_partition,
41
+ tree_apply,
42
+ )
43
+ from lorebase_core.validate import IssueCode, SchemaRules, validate_schema
44
+
45
+ __all__ = [
46
+ "SchemaMerge",
47
+ "at_path",
48
+ "element_partition",
49
+ "known_static_issues",
50
+ "merge_document",
51
+ "merge_schema",
52
+ "pick",
53
+ ]
54
+
55
+ _CODE_KINDS: dict[IssueCode, ConflictKind] = {
56
+ IssueCode.SCHEMA_API_NAME_DUPLICATE: ConflictKind.NAMING,
57
+ IssueCode.SCHEMA_TRAIT_DUPLICATE: ConflictKind.TRAIT_CONFORMANCE,
58
+ IssueCode.SCHEMA_TRAIT_MAPPING_MISSING: ConflictKind.TRAIT_CONFORMANCE,
59
+ IssueCode.SCHEMA_TRAIT_MAPPING_TYPE: ConflictKind.TRAIT_CONFORMANCE,
60
+ IssueCode.SCHEMA_TRAIT_MAPPING_REQUIRED: ConflictKind.TRAIT_CONFORMANCE,
61
+ IssueCode.SCHEMA_TRAIT_UNKNOWN: ConflictKind.BROKEN_REFERENCE,
62
+ IssueCode.SCHEMA_TRAIT_MAPPING_INVALID: ConflictKind.BROKEN_REFERENCE,
63
+ IssueCode.SCHEMA_PRIMARY_KEY_UNKNOWN: ConflictKind.BROKEN_REFERENCE,
64
+ IssueCode.SCHEMA_TITLE_ATTRIBUTE_UNKNOWN: ConflictKind.BROKEN_REFERENCE,
65
+ IssueCode.SCHEMA_TYPE_REFERENCE_UNKNOWN: ConflictKind.BROKEN_REFERENCE,
66
+ IssueCode.SCHEMA_RELATION_ENDPOINT_UNKNOWN: ConflictKind.BROKEN_REFERENCE,
67
+ IssueCode.SCHEMA_FOREIGN_KEY_INVALID: ConflictKind.BROKEN_REFERENCE,
68
+ IssueCode.SCHEMA_AUTHORITY_UNKNOWN: ConflictKind.BROKEN_REFERENCE,
69
+ IssueCode.SCHEMA_OPERATION_REFERENCE_UNKNOWN: ConflictKind.BROKEN_REFERENCE,
70
+ IssueCode.SCHEMA_DERIVATION_REFERENCE_UNKNOWN: ConflictKind.BROKEN_REFERENCE,
71
+ IssueCode.SCHEMA_SOURCE_REFERENCE_UNKNOWN: ConflictKind.BROKEN_REFERENCE,
72
+ }
73
+
74
+ ConflictHandler = Callable[[tuple[str, ...], Value, Value, Value], Value]
75
+ """Decides a conflicting property: ``(path, base, ours, theirs) -> merged value``."""
76
+
77
+
78
+ @dataclass
79
+ class SchemaMerge:
80
+ """The merged ``schema`` partition."""
81
+
82
+ schema: Schema
83
+ #: Merged ``schema`` partition: element key -> object hash.
84
+ hashes: dict[str, bytes]
85
+ #: Ours' tree with the merged ``schema`` partition.
86
+ root: bytes
87
+ #: Elements taken whole from one side, for provenance.
88
+ origins: dict[str, Side] = field(default_factory=dict)
89
+
90
+
91
+ def element_partition(key: str) -> str | None:
92
+ """Data partition owned by a schema element: Kinds and Relations have one."""
93
+ prefix = id_prefix(key)
94
+ if prefix is IdPrefix.KIND:
95
+ return kind_partition(key)
96
+ if prefix is IdPrefix.RELATION:
97
+ return relation_partition(key)
98
+ return None
99
+
100
+
101
+ def merge_document(
102
+ base: Value,
103
+ ours: Value,
104
+ theirs: Value,
105
+ on_conflict: ConflictHandler,
106
+ atomic: frozenset[str],
107
+ path: tuple[str, ...] = (),
108
+ ) -> Value:
109
+ """Three-way merge of JSON documents. Objects merge entry by entry unless atomic."""
110
+ if same(ours, theirs):
111
+ return ours
112
+ if same(ours, base):
113
+ return theirs
114
+ if same(theirs, base):
115
+ return ours
116
+ mergeable = (
117
+ isinstance(ours, dict)
118
+ and isinstance(theirs, dict)
119
+ and (isinstance(base, dict) or base is MISSING)
120
+ and not (path and path[-1] in atomic)
121
+ )
122
+ if not mergeable:
123
+ return on_conflict(path, base, ours, theirs)
124
+ assert isinstance(ours, dict)
125
+ assert isinstance(theirs, dict)
126
+ base_map = base if isinstance(base, dict) else {}
127
+ merged: dict[str, JsonValue] = {}
128
+ for key in sorted(base_map.keys() | ours.keys() | theirs.keys()):
129
+ value = merge_document(
130
+ base_map.get(key, MISSING),
131
+ ours.get(key, MISSING),
132
+ theirs.get(key, MISSING),
133
+ on_conflict,
134
+ atomic,
135
+ (*path, key),
136
+ )
137
+ if value is not MISSING and value is not None:
138
+ merged[key] = value
139
+ return merged
140
+
141
+
142
+ def at_path(document: Value, path: tuple[str | int, ...]) -> Value:
143
+ """The value at ``path`` inside a JSON document, or ``MISSING``."""
144
+ current = document
145
+ for part in path:
146
+ if isinstance(current, dict) and isinstance(part, str):
147
+ if part not in current:
148
+ return MISSING
149
+ current = current[part]
150
+ elif isinstance(current, list) and isinstance(part, int) and 0 <= part < len(current):
151
+ current = current[part]
152
+ else:
153
+ return MISSING
154
+ return current
155
+
156
+
157
+ def pick(resolution: Resolution, base: Value, ours: Value, theirs: Value) -> Value:
158
+ """The value a resolution picks among three sides; ``custom`` takes its own value."""
159
+ choice = resolution.choice
160
+ if choice is ResolutionChoice.OURS:
161
+ return ours
162
+ if choice is ResolutionChoice.THEIRS:
163
+ return theirs
164
+ if choice is ResolutionChoice.BASE:
165
+ return base
166
+ value = resolution.value
167
+ return MISSING if value is None else value
168
+
169
+
170
+ def merge_schema(ctx: Context) -> SchemaMerge:
171
+ """Merge the ``schema`` partition and load the merged Schema."""
172
+ hashes = ctx.schema_hashes
173
+ directives = ctx.directives
174
+ patched = (
175
+ set(directives.elements)
176
+ | {key for key, _ in directives.properties}
177
+ | {kind for kind, _ in directives.trait_removals}
178
+ | {kind for kind, _ in directives.trait_mappings}
179
+ )
180
+ keys = sorted(set().union(*(h.keys() for h in hashes.values())) | patched)
181
+ merged: dict[str, bytes] = {}
182
+ origins: dict[str, Side] = {}
183
+ for key in keys:
184
+ b, o, t = ctx.element_hashes(key)
185
+ if key not in patched:
186
+ if o == t:
187
+ if o is not None:
188
+ merged[key] = o
189
+ continue
190
+ if o == b or t == b:
191
+ chosen, side = (t, Side.THEIRS) if o == b else (o, Side.OURS)
192
+ if chosen is not None:
193
+ merged[key] = chosen
194
+ origins[key] = side
195
+ continue
196
+ document = _merge_element(ctx, key)
197
+ if document is not None:
198
+ merged[key] = ctx.put(schema_object_type(key), document)
199
+ changes = [
200
+ TreeChange(PARTITION_SCHEMA, key, merged.get(key))
201
+ for key in keys
202
+ if merged.get(key) != hashes[Side.OURS].get(key)
203
+ ]
204
+ ours_root = ctx.roots[Side.OURS]
205
+ assert ours_root is not None
206
+ root = tree_apply(ctx.store, ours_root, changes, ctx.params)
207
+ schema = ctx.schema_loader(root)
208
+ result = SchemaMerge(schema=schema, hashes=merged, root=root, origins=origins)
209
+ if not ctx.virtual:
210
+ _static_conflicts(ctx, result)
211
+ return result
212
+
213
+
214
+ def _merge_element(ctx: Context, key: str) -> Payload | None:
215
+ """Merge one element changed on both sides, then apply directives and validate it."""
216
+ b, o, t = ctx.element_hashes(key)
217
+ base_doc, ours_doc, theirs_doc = (ctx.document(h) for h in (b, o, t))
218
+ partition = element_partition(key)
219
+ document: Payload | None
220
+ if key in ctx.directives.elements:
221
+ document = ctx.directives.elements[key]
222
+ elif o in (t, b) or t == b:
223
+ document = ours_doc if t in (o, b) else theirs_doc
224
+ elif base_doc is not None and (ours_doc is None or theirs_doc is None):
225
+ document = _delete_modify(ctx, key, base_doc, ours_doc, theirs_doc)
226
+ else:
227
+
228
+ def on_conflict(path: tuple[str, ...], bv: Value, ov: Value, tv: Value) -> Value:
229
+ conflict = ctx.conflict(
230
+ ConflictKind.SCHEMA_PROPERTY,
231
+ PARTITION_SCHEMA,
232
+ key,
233
+ ".".join(path),
234
+ (as_json(bv), as_json(ov), as_json(tv)),
235
+ (b, o, t),
236
+ message=f"{'.'.join(path)} of {key} changed differently on both sides",
237
+ )
238
+ resolution = ctx.decide(conflict, key, None)
239
+ if resolution is not None:
240
+ return pick(resolution, bv, ov, tv)
241
+ ctx.report(conflict)
242
+ _block(ctx, key, partition)
243
+ return bv
244
+
245
+ value = merge_document(
246
+ MISSING if base_doc is None else base_doc,
247
+ MISSING if ours_doc is None else ours_doc,
248
+ MISSING if theirs_doc is None else theirs_doc,
249
+ on_conflict,
250
+ ctx.policy.atomic_properties,
251
+ )
252
+ document = value if isinstance(value, dict) else None
253
+ if document is None:
254
+ return None
255
+ document = _apply_directives(ctx, key, document)
256
+ return _validated(ctx, key, document, (base_doc, ours_doc, theirs_doc))
257
+
258
+
259
+ def _delete_modify(
260
+ ctx: Context, key: str, base: Payload, ours: Payload | None, theirs: Payload | None
261
+ ) -> Payload | None:
262
+ conflict = ctx.conflict(
263
+ ConflictKind.SCHEMA_DELETE_MODIFY,
264
+ PARTITION_SCHEMA,
265
+ key,
266
+ None,
267
+ (base, ours, theirs),
268
+ ctx.element_hashes(key),
269
+ message=f"{key} was deleted on one side and modified on the other",
270
+ )
271
+ resolution = ctx.decide(conflict, key, None)
272
+ if resolution is None:
273
+ ctx.report(conflict)
274
+ partition = _block(ctx, key, element_partition(key))
275
+ if partition is not None:
276
+ ctx.frozen.add(partition)
277
+ return base
278
+ chosen = pick(
279
+ resolution, base, MISSING if ours is None else ours, MISSING if theirs is None else theirs
280
+ )
281
+ if chosen is MISSING:
282
+ return None
283
+ if not isinstance(chosen, dict):
284
+ raise ResolutionError(f"custom value of {conflict.fingerprint} must be an element document")
285
+ return chosen
286
+
287
+
288
+ def _block(ctx: Context, key: str, partition: str | None) -> str | None:
289
+ ctx.open_elements.add(key)
290
+ if partition is not None:
291
+ ctx.blocked.add(partition)
292
+ return partition
293
+
294
+
295
+ def _apply_directives(ctx: Context, key: str, document: Payload) -> Payload:
296
+ directives = ctx.directives
297
+ result: Payload = document
298
+ for (element, path), value in sorted(directives.properties.items(), key=lambda i: i[0]):
299
+ if element == key:
300
+ result = _set_path(result, path, value)
301
+ removals = {trait for kind, trait in directives.trait_removals if kind == key}
302
+ mappings = {
303
+ trait: mapping
304
+ for (kind, trait), mapping in directives.trait_mappings.items()
305
+ if kind == key
306
+ }
307
+ if removals or mappings:
308
+ traits = result.get("traits")
309
+ if isinstance(traits, list):
310
+ updated: list[JsonValue] = []
311
+ for item in traits:
312
+ trait = item.get("trait_id") if isinstance(item, dict) else None
313
+ if isinstance(trait, str) and trait in removals:
314
+ continue
315
+ if isinstance(item, dict) and isinstance(trait, str) and trait in mappings:
316
+ item = {**item, "attribute_map": dict(mappings[trait])}
317
+ updated.append(item)
318
+ result = {**result, "traits": updated}
319
+ return result
320
+
321
+
322
+ def _set_path(document: Payload, path: tuple[str | int, ...], value: Value) -> Payload:
323
+ updated = _set_in(document, path, value)
324
+ return updated if isinstance(updated, dict) else document
325
+
326
+
327
+ def _set_in(current: JsonValue, path: tuple[str | int, ...], value: Value) -> JsonValue:
328
+ """A copy of ``current`` with ``path`` set to ``value`` (``MISSING`` removes it)."""
329
+ if not path:
330
+ return as_json(value)
331
+ head, rest = path[0], path[1:]
332
+ if isinstance(head, int):
333
+ items = list(current) if isinstance(current, list) else []
334
+ if not 0 <= head < len(items):
335
+ return current
336
+ items[head] = _set_in(items[head], rest, value)
337
+ return items
338
+ result = dict(current) if isinstance(current, dict) else {}
339
+ if rest:
340
+ result[head] = _set_in(result.get(head), rest, value)
341
+ elif value is MISSING or value is None:
342
+ result.pop(head, None)
343
+ else:
344
+ result[head] = value
345
+ return result
346
+
347
+
348
+ def _validated(
349
+ ctx: Context,
350
+ key: str,
351
+ document: Payload,
352
+ sides: tuple[Payload | None, Payload | None, Payload | None],
353
+ ) -> Payload | None:
354
+ """The canonical document of a valid element; an invalid merge result is a Conflict."""
355
+ model = element_model(key)
356
+ try:
357
+ return model.model_validate(document).content()
358
+ except ValidationError as exc:
359
+ conflict = ctx.conflict(
360
+ ConflictKind.SCHEMA_PROPERTY,
361
+ PARTITION_SCHEMA,
362
+ key,
363
+ None,
364
+ sides,
365
+ ctx.element_hashes(key),
366
+ message=f"the merged definition of {key} is invalid",
367
+ details={"errors": [str(e["msg"]) for e in exc.errors()]},
368
+ )
369
+ resolution = ctx.decide(conflict, key, None)
370
+ if resolution is None:
371
+ ctx.report(conflict)
372
+ _block(ctx, key, element_partition(key))
373
+ chosen: Value = MISSING if sides[0] is None else sides[0]
374
+ else:
375
+ chosen = pick(resolution, *(MISSING if s is None else s for s in sides))
376
+ if not isinstance(chosen, dict):
377
+ return None
378
+ return model.model_validate(chosen).content()
379
+
380
+
381
+ def known_static_issues(
382
+ schemas: list[Schema | None], rules: SchemaRules | None = None
383
+ ) -> set[tuple[object, tuple[object, ...]]]:
384
+ """``(code, path)`` of the static errors of the given schemas."""
385
+ known: set[tuple[object, tuple[object, ...]]] = set()
386
+ for schema in schemas:
387
+ if schema is not None:
388
+ known.update((i.code, i.path) for i in validate_schema(schema, rules) if i.is_error)
389
+ return known
390
+
391
+
392
+ def _static_conflicts(ctx: Context, merged: SchemaMerge) -> None:
393
+ """Conflicts for static problems of the merged schema that neither side had."""
394
+ known = ctx.known_issues
395
+ for issue in validate_schema(merged.schema, ctx.rules):
396
+ if not issue.is_error or (issue.code, issue.path) in known or len(issue.path) < 2:
397
+ continue
398
+ element = str(issue.path[1])
399
+ if element in ctx.open_elements:
400
+ continue
401
+ rest = tuple(issue.path[2:])
402
+ kind = _CODE_KINDS.get(issue.code, ConflictKind.SCHEMA_PROPERTY)
403
+ attribute = ".".join(map(str, rest)) or None
404
+ merged_doc = ctx.document(merged.hashes.get(element))
405
+ details: dict[str, JsonValue] = {
406
+ "code": issue.code.value,
407
+ "path": [str(p) if isinstance(p, str) else p for p in rest],
408
+ }
409
+ reference = at_path(merged_doc, rest)
410
+ if isinstance(reference, str):
411
+ details["reference"] = reference
412
+ if rest[:1] == ("traits",) and len(rest) > 1:
413
+ trait = at_path(merged_doc, (*rest[:2], "trait_id"))
414
+ if isinstance(trait, str):
415
+ details["trait"] = trait
416
+ group = ctx.group(
417
+ kind,
418
+ PARTITION_SCHEMA,
419
+ element,
420
+ attribute,
421
+ stage=Stage.CHECK,
422
+ element=element,
423
+ message=issue.message,
424
+ details=details,
425
+ )
426
+ group.values = tuple( # type: ignore[assignment]
427
+ as_json(at_path(ctx.element_document(side, element), rest))
428
+ for side in (Side.BASE, Side.OURS, Side.THEIRS)
429
+ )