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.
- lorebase_core/__init__.py +1 -0
- lorebase_core/canonical/__init__.py +35 -0
- lorebase_core/canonical/hashing.py +128 -0
- lorebase_core/canonical/jcs.py +162 -0
- lorebase_core/expr/__init__.py +118 -0
- lorebase_core/expr/ast.py +443 -0
- lorebase_core/expr/checker.py +428 -0
- lorebase_core/expr/compiler.py +87 -0
- lorebase_core/expr/context.py +82 -0
- lorebase_core/expr/derivations.py +306 -0
- lorebase_core/expr/environment.py +176 -0
- lorebase_core/expr/errors.py +98 -0
- lorebase_core/expr/evaluator.py +414 -0
- lorebase_core/expr/functions.py +898 -0
- lorebase_core/expr/limits.py +52 -0
- lorebase_core/expr/operations.py +479 -0
- lorebase_core/expr/ops.py +243 -0
- lorebase_core/expr/resolver.py +96 -0
- lorebase_core/expr/rows.py +148 -0
- lorebase_core/expr/sensitivity.py +337 -0
- lorebase_core/expr/types.py +227 -0
- lorebase_core/expr/validation.py +51 -0
- lorebase_core/expr/values.py +169 -0
- lorebase_core/expr/visibility.py +363 -0
- lorebase_core/infer/__init__.py +36 -0
- lorebase_core/infer/columns.py +621 -0
- lorebase_core/infer/engine.py +546 -0
- lorebase_core/infer/keys.py +164 -0
- lorebase_core/infer/names.py +145 -0
- lorebase_core/infer/options.py +290 -0
- lorebase_core/infer/profile.py +54 -0
- lorebase_core/infer/proposal.py +122 -0
- lorebase_core/infer/references.py +314 -0
- lorebase_core/infer/suggestions.py +731 -0
- lorebase_core/infer/tables.py +81 -0
- lorebase_core/merge/__init__.py +89 -0
- lorebase_core/merge/conflicts.py +262 -0
- lorebase_core/merge/consistency.py +440 -0
- lorebase_core/merge/data_merge.py +522 -0
- lorebase_core/merge/engine.py +476 -0
- lorebase_core/merge/index.py +100 -0
- lorebase_core/merge/objects.py +204 -0
- lorebase_core/merge/policies.py +322 -0
- lorebase_core/merge/resolve.py +321 -0
- lorebase_core/merge/schema_merge.py +429 -0
- lorebase_core/merge/state.py +443 -0
- lorebase_core/model/__init__.py +245 -0
- lorebase_core/model/attribute.py +72 -0
- lorebase_core/model/base.py +61 -0
- lorebase_core/model/derivation.py +95 -0
- lorebase_core/model/domain.py +71 -0
- lorebase_core/model/embedding.py +193 -0
- lorebase_core/model/ids.py +78 -0
- lorebase_core/model/kind.py +85 -0
- lorebase_core/model/lineage.py +114 -0
- lorebase_core/model/operation.py +161 -0
- lorebase_core/model/paths.py +141 -0
- lorebase_core/model/record.py +127 -0
- lorebase_core/model/relation.py +83 -0
- lorebase_core/model/schema.py +219 -0
- lorebase_core/model/source.py +204 -0
- lorebase_core/model/trait.py +37 -0
- lorebase_core/model/vault.py +43 -0
- lorebase_core/model/yaml_schema.py +1058 -0
- lorebase_core/query/__init__.py +97 -0
- lorebase_core/query/dsl.py +227 -0
- lorebase_core/query/resolve.py +794 -0
- lorebase_core/revision/__init__.py +59 -0
- lorebase_core/revision/ancestry.py +272 -0
- lorebase_core/revision/changeset.py +62 -0
- lorebase_core/revision/revision.py +121 -0
- lorebase_core/tree/__init__.py +76 -0
- lorebase_core/tree/diff.py +119 -0
- lorebase_core/tree/hamt.py +333 -0
- lorebase_core/tree/node.py +280 -0
- lorebase_core/tree/params.py +33 -0
- lorebase_core/tree/root.py +70 -0
- lorebase_core/tree/store.py +98 -0
- lorebase_core/types/__init__.py +71 -0
- lorebase_core/types/casts.py +271 -0
- lorebase_core/types/constraints.py +299 -0
- lorebase_core/types/encoding.py +538 -0
- lorebase_core/types/errors.py +41 -0
- lorebase_core/types/matching.py +40 -0
- lorebase_core/types/types.py +187 -0
- lorebase_core/validate/__init__.py +49 -0
- lorebase_core/validate/changes.py +70 -0
- lorebase_core/validate/changeset.py +551 -0
- lorebase_core/validate/data_rules.py +291 -0
- lorebase_core/validate/issues.py +123 -0
- lorebase_core/validate/schema_rules.py +1304 -0
- lorebase_core-0.2.3.dist-info/METADATA +9 -0
- lorebase_core-0.2.3.dist-info/RECORD +94 -0
- 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
|
+
)
|