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,522 @@
1
+ """Data merge (design 6.8 step 3): Records and Links under the merged schema.
2
+
3
+ Each side's version of a changed element is first lifted into the merged schema: values of
4
+ Attributes the merged schema no longer has are dropped (orphaned values when that side wrote
5
+ them) and values of Attributes whose type changed are cast (type coercion Conflicts when the
6
+ cast fails). The lifted versions then merge Attribute by Attribute against the lifted base.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections import defaultdict
12
+ from collections.abc import Iterable, Mapping
13
+ from dataclasses import dataclass, field
14
+
15
+ from pydantic import JsonValue
16
+
17
+ from lorebase_core.canonical import ObjectType
18
+ from lorebase_core.merge.conflicts import ConflictKind, ResolutionChoice, ResolutionError, Side
19
+ from lorebase_core.merge.objects import data_object_type, partition_element
20
+ from lorebase_core.merge.schema_merge import SchemaMerge, pick
21
+ from lorebase_core.merge.state import (
22
+ MISSING,
23
+ SIDES,
24
+ Context,
25
+ Payload,
26
+ Stage,
27
+ Value,
28
+ as_json,
29
+ same,
30
+ )
31
+ from lorebase_core.model import Attribute, Kind, Relation, Schema
32
+ from lorebase_core.tree import PARTITION_SCHEMA, DiffEntry, relation_partition, tree_get
33
+ from lorebase_core.types import CastError, CastRule, cast, cast_rule
34
+
35
+ __all__ = ["DataMerge", "Origin", "SideChanges", "merge_data", "side_changes"]
36
+
37
+ #: Changed data elements of one side against the base: ``(partition, key) -> (base, side)``.
38
+ SideChanges = dict[tuple[str, str], tuple[bytes | None, bytes | None]]
39
+
40
+
41
+ @dataclass
42
+ class Origin:
43
+ """Where the merged version of an element comes from, for provenance."""
44
+
45
+ whole: Side | None = None
46
+ attributes: dict[str, Side] = field(default_factory=dict)
47
+
48
+
49
+ @dataclass
50
+ class DataMerge:
51
+ """Merged states of every data element changed on either side."""
52
+
53
+ #: ``(partition, key) -> payload``, ``None`` when the element is deleted.
54
+ states: dict[tuple[str, str], Payload | None] = field(default_factory=dict)
55
+ #: Ours' object hash of each element in ``states``.
56
+ ours: dict[tuple[str, str], bytes | None] = field(default_factory=dict)
57
+ origins: dict[tuple[str, str], Origin] = field(default_factory=dict)
58
+
59
+ def present(self, partition: str | None = None) -> Iterable[tuple[tuple[str, str], Payload]]:
60
+ """Elements present after the merge, optionally of one partition, in key order."""
61
+ for location in sorted(self.states):
62
+ state = self.states[location]
63
+ if state is not None and (partition is None or location[0] == partition):
64
+ yield location, state
65
+
66
+
67
+ def side_changes(entries: Iterable[DiffEntry]) -> SideChanges:
68
+ """Data changes of a side from its diff against the base (the ``schema`` is left out)."""
69
+ return {
70
+ (e.partition, e.key): (e.hash_a, e.hash_b)
71
+ for e in entries
72
+ if e.partition != PARTITION_SCHEMA
73
+ }
74
+
75
+
76
+ def merge_data(ctx: Context, schema: SchemaMerge, changes: Mapping[Side, SideChanges]) -> DataMerge:
77
+ """Merge every data element changed on either side."""
78
+ result = DataMerge()
79
+ by_partition: dict[str, dict[str, bytes | None]] = defaultdict(dict)
80
+ for side in SIDES:
81
+ for (partition, key), (base_hash, _) in changes[side].items():
82
+ by_partition[partition][key] = base_hash
83
+ for partition in sorted(by_partition):
84
+ merger = _PartitionMerge(ctx, schema, changes, partition, result)
85
+ merger.run(by_partition[partition])
86
+ _apply_directives(ctx, result)
87
+ if ctx.policy.apply_defaults and not ctx.virtual:
88
+ _apply_defaults(ctx, schema, result)
89
+ return result
90
+
91
+
92
+ class _PartitionMerge:
93
+ def __init__(
94
+ self,
95
+ ctx: Context,
96
+ schema: SchemaMerge,
97
+ changes: Mapping[Side, SideChanges],
98
+ partition: str,
99
+ result: DataMerge,
100
+ ) -> None:
101
+ self.ctx = ctx
102
+ self.schema = schema
103
+ self.changes = changes
104
+ self.partition = partition
105
+ self.result = result
106
+ element = partition_element(partition)
107
+ self.element_id = element
108
+ self.is_link = element is not None and partition == relation_partition(element)
109
+ self.merged = self._element(schema.schema, element)
110
+ self.sides: dict[Side, Kind | Relation | None] = {
111
+ side: self._element(ctx.schemas[side], element)
112
+ for side in (Side.BASE, Side.OURS, Side.THEIRS)
113
+ }
114
+ merged_hash = None if element is None else schema.hashes.get(element)
115
+ self.lift_needed = {
116
+ side: element is not None
117
+ and (
118
+ ctx.schema_hashes[side].get(element) != merged_hash
119
+ or any(
120
+ s is side and p == partition for s, p, _ in ctx.directives.ignored_attributes
121
+ )
122
+ )
123
+ for side in (Side.BASE, Side.OURS, Side.THEIRS)
124
+ }
125
+ self.failures: set[tuple[Side, str]] = set()
126
+ self.bases: Mapping[str, bytes | None] = {}
127
+
128
+ def _element(self, schema: Schema | None, element: str | None) -> Kind | Relation | None:
129
+ if element is None or schema is None:
130
+ return None
131
+ if self.is_link:
132
+ return schema.relations.get(element)
133
+ return schema.kinds.get(element)
134
+
135
+ # Hashes ---------------------------------------------------------------------
136
+
137
+ def side_hash(self, side: Side, key: str, base: bytes | None) -> bytes | None:
138
+ if (side, self.partition) in self.ctx.directives.ignored_partitions:
139
+ return base
140
+ change = self.changes[side].get((self.partition, key))
141
+ return base if change is None else change[1]
142
+
143
+ # Entry point ----------------------------------------------------------------
144
+
145
+ def run(self, keys: Mapping[str, bytes | None]) -> None:
146
+ self.bases = keys
147
+ if self.element_id is None:
148
+ for key, base in sorted(keys.items()):
149
+ self.store(key, self.ctx.document(self.side_hash(Side.THEIRS, key, base)))
150
+ return
151
+ if self.merged is None:
152
+ self.orphans(keys)
153
+ return
154
+ for key, base in sorted(keys.items()):
155
+ self.merge_key(key, base)
156
+
157
+ def store(self, key: str, state: Payload | None, origin: Origin | None = None) -> None:
158
+ location = (self.partition, key)
159
+ ours = self.changes[Side.OURS].get(location)
160
+ self.result.states[location] = state
161
+ self.result.ours[location] = self.bases[key] if ours is None else ours[1]
162
+ self.result.origins[location] = origin or Origin()
163
+
164
+ # Orphaned data --------------------------------------------------------------
165
+
166
+ def orphans(self, keys: Mapping[str, bytes | None]) -> None:
167
+ """The Kind or Relation is gone from the merged schema: its data is dropped."""
168
+ assert self.element_id is not None
169
+ for side in SIDES:
170
+ if self.sides[side] is None:
171
+ continue
172
+ members = [key for key, base in keys.items() if self.side_hash(side, key, base) != base]
173
+ if members and self.partition not in self.ctx.blocked:
174
+ group = self.ctx.group(
175
+ ConflictKind.ORPHANED_DATA,
176
+ self.partition,
177
+ None,
178
+ None,
179
+ stage=Stage.DECISION,
180
+ element=self.element_id,
181
+ data_side=side,
182
+ message=(
183
+ f"{self.element_id} was removed on one side; {len(members)} element(s) "
184
+ f"of it changed on {side.value}"
185
+ ),
186
+ )
187
+ group.members.update(dict.fromkeys(members))
188
+ for key in sorted(keys):
189
+ self.store(key, None)
190
+
191
+ # Elements -------------------------------------------------------------------
192
+
193
+ def merge_key(self, key: str, base_hash: bytes | None) -> None:
194
+ ours_hash = self.side_hash(Side.OURS, key, base_hash)
195
+ theirs_hash = self.side_hash(Side.THEIRS, key, base_hash)
196
+ changed_ours = ours_hash != base_hash
197
+ changed_theirs = theirs_hash != base_hash
198
+ if ours_hash == theirs_hash and not self.lift_needed[Side.OURS]:
199
+ self.store(key, self.ctx.document(ours_hash))
200
+ return
201
+ if changed_theirs and not changed_ours and not self.lift_needed[Side.THEIRS]:
202
+ self.store(key, self.ctx.document(theirs_hash), Origin(whole=Side.THEIRS))
203
+ return
204
+ if changed_ours and not changed_theirs and not self.lift_needed[Side.OURS]:
205
+ self.store(key, self.ctx.document(ours_hash), Origin(whole=Side.OURS))
206
+ return
207
+ hashes = (base_hash, ours_hash, theirs_hash)
208
+ payloads = [self.ctx.document(h) for h in hashes]
209
+ base_values = _values(payloads[0])
210
+ lifted_base = self.lift(Side.BASE, key, base_values, base_values)
211
+ lifted: dict[Side, dict[str, JsonValue] | None] = {}
212
+ for side, payload in zip(SIDES, payloads[1:], strict=True):
213
+ lifted[side] = (
214
+ None if payload is None else self.lift(side, key, _values(payload), base_values)
215
+ )
216
+ for failed_side, attribute in self.failures:
217
+ values = lifted[side]
218
+ if failed_side is side and values is not None:
219
+ values.pop(attribute, None)
220
+ if attribute in lifted_base:
221
+ values[attribute] = lifted_base[attribute]
222
+ self.failures.clear()
223
+ template = next((p for p in (payloads[1], payloads[2], payloads[0]) if p is not None), None)
224
+ if template is None:
225
+ self.store(key, None)
226
+ return
227
+ if changed_ours and changed_theirs:
228
+ if payloads[1] is None and payloads[2] is None:
229
+ self.store(key, None)
230
+ elif payloads[0] is not None and (payloads[1] is None or payloads[2] is None):
231
+ self.delete_modify(key, hashes, template, lifted_base, lifted)
232
+ else:
233
+ values, origin = self.merge_values(
234
+ key, hashes, lifted_base, lifted[Side.OURS] or {}, lifted[Side.THEIRS] or {}
235
+ )
236
+ self.store(key, _with_values(template, values), origin)
237
+ return
238
+ side = Side.THEIRS if changed_theirs else Side.OURS
239
+ values_of_side = lifted[side]
240
+ state = None if values_of_side is None else _with_values(template, values_of_side)
241
+ self.store(key, state, Origin(whole=side) if changed_theirs or changed_ours else None)
242
+
243
+ def lift(
244
+ self, side: Side, key: str, values: dict[str, JsonValue], base: dict[str, JsonValue]
245
+ ) -> dict[str, JsonValue]:
246
+ """Values of one side expressed in the merged schema."""
247
+ merged = self.merged
248
+ assert merged is not None
249
+ side_element = self.sides[side]
250
+ side_attributes = merged.attributes if side_element is None else side_element.attributes
251
+ values = self.unignored(side, values, base)
252
+ lifted: dict[str, JsonValue] = {}
253
+ for attribute_id, value in values.items():
254
+ wrote = side is not Side.BASE and not same(value, base.get(attribute_id, MISSING))
255
+ target = merged.attributes.get(attribute_id)
256
+ if target is None:
257
+ if wrote and attribute_id in side_attributes:
258
+ self.problem(ConflictKind.ORPHANED_VALUE, side, attribute_id, key, value)
259
+ continue
260
+ source = side_attributes.get(attribute_id)
261
+ if source is None or source.type == target.type:
262
+ lifted[attribute_id] = value
263
+ continue
264
+ converted = self.convert(source, target, value)
265
+ if converted is MISSING:
266
+ if wrote:
267
+ self.problem(ConflictKind.TYPE_COERCION, side, attribute_id, key, value)
268
+ self.failures.add((side, attribute_id))
269
+ continue
270
+ lifted[attribute_id] = as_json(converted)
271
+ return lifted
272
+
273
+ def unignored(
274
+ self, side: Side, values: dict[str, JsonValue], base: dict[str, JsonValue]
275
+ ) -> dict[str, JsonValue]:
276
+ """``values`` with base values for the Attributes whose ``side`` changes are ignored."""
277
+ result = values
278
+ for ignored_side, partition, attribute_id in self.ctx.directives.ignored_attributes:
279
+ if ignored_side is not side or partition != self.partition:
280
+ continue
281
+ result = dict(result)
282
+ if attribute_id in base:
283
+ result[attribute_id] = base[attribute_id]
284
+ else:
285
+ result.pop(attribute_id, None)
286
+ return result
287
+
288
+ def convert(self, source: Attribute, target: Attribute, value: JsonValue) -> Value:
289
+ rule = cast_rule(source.type, target.type)
290
+ allowed = rule is CastRule.SAFE or (
291
+ rule is CastRule.CHECKED and self.ctx.policy.allow_checked_casts
292
+ )
293
+ if not allowed:
294
+ return MISSING
295
+ try:
296
+ return cast(source.type, target.type, value)
297
+ except CastError:
298
+ return MISSING
299
+
300
+ def problem(
301
+ self, kind: ConflictKind, side: Side, attribute_id: str, key: str, value: JsonValue
302
+ ) -> None:
303
+ if self.partition in self.ctx.blocked:
304
+ return
305
+ assert self.element_id is not None
306
+ label = "removed" if kind is ConflictKind.ORPHANED_VALUE else "retyped"
307
+ group = self.ctx.group(
308
+ kind,
309
+ self.partition,
310
+ None,
311
+ attribute_id,
312
+ stage=Stage.DECISION,
313
+ element=self.element_id,
314
+ data_side=side,
315
+ message=f"{attribute_id} was {label} on one side and written on {side.value}",
316
+ )
317
+ group.members[key] = value
318
+
319
+ def delete_modify(
320
+ self,
321
+ key: str,
322
+ hashes: tuple[bytes | None, bytes | None, bytes | None],
323
+ template: Payload,
324
+ base: dict[str, JsonValue],
325
+ lifted: Mapping[Side, dict[str, JsonValue] | None],
326
+ ) -> None:
327
+ versions: list[Value] = [
328
+ base,
329
+ *(MISSING if lifted[s] is None else lifted[s] for s in SIDES),
330
+ ]
331
+ conflict = self.ctx.conflict(
332
+ ConflictKind.DELETE_MODIFY,
333
+ self.partition,
334
+ key,
335
+ None,
336
+ (base, lifted[Side.OURS], lifted[Side.THEIRS]),
337
+ hashes,
338
+ message=f"{key} was deleted on one side and modified on the other",
339
+ )
340
+ resolution = (
341
+ None
342
+ if self.partition in self.ctx.frozen
343
+ else self.ctx.decide(conflict, self.element_id, None)
344
+ )
345
+ if resolution is None:
346
+ if self.partition not in self.ctx.frozen:
347
+ self.ctx.report(conflict)
348
+ self.ctx.open_keys.add((self.partition, key))
349
+ self.store(key, _with_values(template, base))
350
+ return
351
+ chosen = pick(resolution, versions[0], versions[1], versions[2])
352
+ if chosen is MISSING or chosen is None:
353
+ self.store(key, None, _origin_of(resolution.choice))
354
+ return
355
+ if not isinstance(chosen, dict):
356
+ raise ResolutionError(f"custom value of {conflict.fingerprint} must be a value map")
357
+ self.store(key, _with_values(template, chosen), _origin_of(resolution.choice))
358
+
359
+ def merge_values(
360
+ self,
361
+ key: str,
362
+ hashes: tuple[bytes | None, bytes | None, bytes | None],
363
+ base: dict[str, JsonValue],
364
+ ours: dict[str, JsonValue],
365
+ theirs: dict[str, JsonValue],
366
+ ) -> tuple[dict[str, JsonValue], Origin]:
367
+ """Attribute-level three-way merge; arrays compare as whole values."""
368
+ kind = ConflictKind.ADD_ADD if hashes[0] is None else ConflictKind.ATTRIBUTE_VALUE
369
+ merged: dict[str, JsonValue] = {}
370
+ origin = Origin()
371
+ for attribute_id in sorted(base.keys() | ours.keys() | theirs.keys()):
372
+ b = base.get(attribute_id, MISSING)
373
+ o = ours.get(attribute_id, MISSING)
374
+ t = theirs.get(attribute_id, MISSING)
375
+ value: Value
376
+ if same(o, t):
377
+ value = o
378
+ elif same(o, b):
379
+ value = t
380
+ origin.attributes[attribute_id] = Side.THEIRS
381
+ elif same(t, b):
382
+ value = o
383
+ origin.attributes[attribute_id] = Side.OURS
384
+ else:
385
+ value = self.value_conflict(kind, key, attribute_id, hashes, (b, o, t), origin)
386
+ if value is not MISSING and value is not None:
387
+ merged[attribute_id] = value
388
+ return merged, origin
389
+
390
+ def value_conflict(
391
+ self,
392
+ kind: ConflictKind,
393
+ key: str,
394
+ attribute_id: str,
395
+ hashes: tuple[bytes | None, bytes | None, bytes | None],
396
+ values: tuple[Value, Value, Value],
397
+ origin: Origin,
398
+ ) -> Value:
399
+ b, o, t = values
400
+ conflict = self.ctx.conflict(
401
+ kind,
402
+ self.partition,
403
+ key,
404
+ attribute_id,
405
+ (as_json(b), as_json(o), as_json(t)),
406
+ hashes,
407
+ message=f"{attribute_id} of {key} changed differently on both sides",
408
+ )
409
+ if self.partition in self.ctx.frozen:
410
+ self.ctx.open_keys.add((self.partition, key))
411
+ return b
412
+ target = None if self.merged is None else self.merged.attributes.get(attribute_id)
413
+ resolution = self.ctx.decide(
414
+ conflict,
415
+ self.element_id,
416
+ attribute_id,
417
+ attribute_authority=None if target is None else target.authority,
418
+ )
419
+ if resolution is None:
420
+ self.ctx.report(conflict)
421
+ self.ctx.open_keys.add((self.partition, key))
422
+ return b
423
+ side = _origin_of(resolution.choice).whole
424
+ if side is not None:
425
+ origin.attributes[attribute_id] = side
426
+ return pick(resolution, b, o, t)
427
+
428
+
429
+ def _origin_of(choice: ResolutionChoice) -> Origin:
430
+ if choice is ResolutionChoice.THEIRS:
431
+ return Origin(whole=Side.THEIRS)
432
+ if choice is ResolutionChoice.OURS:
433
+ return Origin(whole=Side.OURS)
434
+ return Origin()
435
+
436
+
437
+ def _values(payload: Payload | None) -> dict[str, JsonValue]:
438
+ if payload is None:
439
+ return {}
440
+ values = payload.get("values")
441
+ return dict(values) if isinstance(values, dict) else {}
442
+
443
+
444
+ def _with_values(template: Payload, values: Mapping[str, JsonValue]) -> Payload:
445
+ return {**template, "values": {k: v for k, v in values.items() if v is not None}}
446
+
447
+
448
+ def _apply_directives(ctx: Context, result: DataMerge) -> None:
449
+ """Data overrides from resolutions: whole states, then single values."""
450
+ directives = ctx.directives
451
+ for location, state in sorted(directives.states.items(), key=lambda i: i[0]):
452
+ _ensure(ctx, result, location)
453
+ result.states[location] = state
454
+ result.origins[location] = Origin()
455
+ for (partition, key, attribute_id), value in sorted(
456
+ directives.values.items(), key=lambda i: i[0]
457
+ ):
458
+ location = (partition, key)
459
+ _ensure(ctx, result, location)
460
+ state = result.states[location]
461
+ if state is None:
462
+ continue
463
+ values = _values(state)
464
+ if value is MISSING or value is None:
465
+ values.pop(attribute_id, None)
466
+ else:
467
+ values[attribute_id] = value
468
+ result.states[location] = _with_values(state, values)
469
+ result.origins[location].attributes.pop(attribute_id, None)
470
+
471
+
472
+ def _ensure(ctx: Context, result: DataMerge, location: tuple[str, str]) -> None:
473
+ """Bring an element not changed on either side into the merge set (ours' version)."""
474
+ if location in result.states:
475
+ return
476
+ ours_root = ctx.roots[Side.OURS]
477
+ digest = tree_get(ctx.store, ours_root, location[0], location[1])
478
+ result.states[location] = ctx.document(digest)
479
+ result.ours[location] = digest
480
+ result.origins[location] = Origin()
481
+
482
+
483
+ def _apply_defaults(ctx: Context, schema: SchemaMerge, result: DataMerge) -> None:
484
+ """Missing values of required Attributes take their default (recorded as resolutions)."""
485
+ applied: dict[tuple[str, str], list[str]] = defaultdict(list)
486
+ for (partition, key), state in list(result.present()):
487
+ element = partition_element(partition)
488
+ if element is None or (partition, key) in ctx.open_keys:
489
+ continue
490
+ owner: Kind | Relation | None
491
+ if data_object_type(partition) is ObjectType.LINK:
492
+ owner = schema.schema.relations.get(element)
493
+ else:
494
+ owner = schema.schema.kinds.get(element)
495
+ if owner is None:
496
+ continue
497
+ values = _values(state)
498
+ changed = False
499
+ for attribute in owner.attributes.values():
500
+ if (
501
+ attribute.required
502
+ and not attribute.is_derived
503
+ and attribute.default is not None
504
+ and values.get(attribute.id) is None
505
+ ):
506
+ values[attribute.id] = attribute.default
507
+ applied[(partition, attribute.id)].append(key)
508
+ changed = True
509
+ if changed:
510
+ result.states[(partition, key)] = _with_values(state, values)
511
+ for (partition, attribute_id), keys in sorted(applied.items()):
512
+ element = partition_element(partition)
513
+ conflict = ctx.conflict(
514
+ ConflictKind.MISSING_VALUE,
515
+ partition,
516
+ None,
517
+ attribute_id,
518
+ ctx.element_values(element, attribute_id),
519
+ ctx.element_hashes(element),
520
+ members=keys,
521
+ )
522
+ ctx.record_auto(conflict, ResolutionChoice.CUSTOM, "policy:default")