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,476 @@
|
|
|
1
|
+
"""Three-way merge engine (design 6.8 to 6.10): orchestration and resolution application.
|
|
2
|
+
|
|
3
|
+
``merge`` finds the merge base (a virtual one for criss-cross histories, AC-13), merges the
|
|
4
|
+
schema, then the data under the merged schema, then checks consistency. The outcome is a
|
|
5
|
+
``MergedTree`` ready for the commit pipeline, or a ``MergeAttempt`` listing Conflicts.
|
|
6
|
+
``apply_resolutions`` re-runs the merge with the resolver's decisions.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
from collections.abc import Callable, Iterable, Mapping
|
|
13
|
+
from dataclasses import dataclass, field, replace
|
|
14
|
+
from enum import StrEnum
|
|
15
|
+
|
|
16
|
+
from lorebase_core.merge.conflicts import (
|
|
17
|
+
AutoResolution,
|
|
18
|
+
Conflict,
|
|
19
|
+
ConflictKind,
|
|
20
|
+
Resolution,
|
|
21
|
+
ResolutionChoice,
|
|
22
|
+
ResolutionError,
|
|
23
|
+
Side,
|
|
24
|
+
UnresolvedConflictsError,
|
|
25
|
+
)
|
|
26
|
+
from lorebase_core.merge.consistency import check_consistency
|
|
27
|
+
from lorebase_core.merge.data_merge import DataMerge, SideChanges, merge_data, side_changes
|
|
28
|
+
from lorebase_core.merge.index import TreeIndexView
|
|
29
|
+
from lorebase_core.merge.objects import (
|
|
30
|
+
ObjectStore,
|
|
31
|
+
SchemaLoader,
|
|
32
|
+
data_object_type,
|
|
33
|
+
tree_schema_loader,
|
|
34
|
+
)
|
|
35
|
+
from lorebase_core.merge.policies import ChangeStamp, MergePolicy
|
|
36
|
+
from lorebase_core.merge.resolve import emit_groups
|
|
37
|
+
from lorebase_core.merge.schema_merge import SchemaMerge, known_static_issues, merge_schema
|
|
38
|
+
from lorebase_core.merge.state import SIDES, Context, Directives, check_choice
|
|
39
|
+
from lorebase_core.revision import (
|
|
40
|
+
ChangesetEntry,
|
|
41
|
+
MergeStatus,
|
|
42
|
+
RevisionGraph,
|
|
43
|
+
RevisionStats,
|
|
44
|
+
changeset_between,
|
|
45
|
+
changeset_stats,
|
|
46
|
+
merge_base,
|
|
47
|
+
)
|
|
48
|
+
from lorebase_core.tree import (
|
|
49
|
+
PARTITION_SCHEMA,
|
|
50
|
+
NodeStore,
|
|
51
|
+
TreeChange,
|
|
52
|
+
TreeParams,
|
|
53
|
+
tree_apply,
|
|
54
|
+
tree_diff,
|
|
55
|
+
tree_entries,
|
|
56
|
+
)
|
|
57
|
+
from lorebase_core.validate import IndexView, SchemaRules
|
|
58
|
+
|
|
59
|
+
__all__ = [
|
|
60
|
+
"IndexViewFactory",
|
|
61
|
+
"LineageLoader",
|
|
62
|
+
"MergeAttempt",
|
|
63
|
+
"MergeInput",
|
|
64
|
+
"MergeOutcome",
|
|
65
|
+
"MergeResult",
|
|
66
|
+
"MergedTree",
|
|
67
|
+
"ProvenanceLoader",
|
|
68
|
+
"apply_resolutions",
|
|
69
|
+
"merge",
|
|
70
|
+
]
|
|
71
|
+
|
|
72
|
+
IndexViewFactory = Callable[[bytes], IndexView]
|
|
73
|
+
"""Returns the ``IndexView`` of the target head, given the target head Revision id."""
|
|
74
|
+
|
|
75
|
+
ProvenanceLoader = Callable[[bytes, str, str], Mapping[str, str]]
|
|
76
|
+
"""``(revision id, partition, key) -> {attribute id: provenance id}`` of an element."""
|
|
77
|
+
|
|
78
|
+
LineageLoader = Callable[[bytes, str, str], Mapping[str, ChangeStamp]]
|
|
79
|
+
"""``(revision id, partition, key) -> {attribute id: change stamp}``: the provenance stamp of
|
|
80
|
+
the last change of each Attribute of an element at a Revision. Read by the ``authority``
|
|
81
|
+
and ``newest_wins`` policies for ``ours`` and ``theirs``; an element without Lineage maps to
|
|
82
|
+
an empty mapping."""
|
|
83
|
+
|
|
84
|
+
_VIRTUAL_LABEL = b"lb1\x00virtual-base\x00"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass(frozen=True)
|
|
88
|
+
class MergeInput:
|
|
89
|
+
"""Everything a merge reads. ``theirs`` (source) is merged into ``ours`` (target).
|
|
90
|
+
|
|
91
|
+
``base`` overrides the merge base search. Without ``schema_loader`` schemas are read from
|
|
92
|
+
the ``schema`` partition; without ``index_view`` the target head is read from its tree.
|
|
93
|
+
``resolutions`` are matched to Conflicts by fingerprint. ``schema_rules`` are the options
|
|
94
|
+
of the static checks of the merged schema (the defaults when unset). ``lineage`` gives the
|
|
95
|
+
``authority`` and ``newest_wins`` policies the provenance entries of both sides; without
|
|
96
|
+
it those policies never decide.
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
store: NodeStore
|
|
100
|
+
objects: ObjectStore
|
|
101
|
+
graph: RevisionGraph
|
|
102
|
+
revision_root: Callable[[bytes], bytes]
|
|
103
|
+
ours: bytes
|
|
104
|
+
theirs: bytes
|
|
105
|
+
base: bytes | None = None
|
|
106
|
+
schema_loader: SchemaLoader | None = None
|
|
107
|
+
index_view: IndexViewFactory | None = None
|
|
108
|
+
provenance: ProvenanceLoader | None = None
|
|
109
|
+
lineage: LineageLoader | None = None
|
|
110
|
+
policy: MergePolicy = field(default_factory=MergePolicy)
|
|
111
|
+
tree_params: TreeParams = field(default_factory=TreeParams)
|
|
112
|
+
resolutions: tuple[Resolution, ...] = ()
|
|
113
|
+
schema_rules: SchemaRules | None = None
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class MergeResult(StrEnum):
|
|
117
|
+
UP_TO_DATE = "up_to_date"
|
|
118
|
+
"""Theirs is already contained in ours: the target does not change."""
|
|
119
|
+
FAST_FORWARD = "fast_forward"
|
|
120
|
+
"""The target moves to theirs."""
|
|
121
|
+
MERGED = "merged"
|
|
122
|
+
"""A merge Revision with parents ``(ours, theirs)`` is to be written."""
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@dataclass(frozen=True)
|
|
126
|
+
class MergedTree:
|
|
127
|
+
"""A clean merge: the new tree and its changeset against ours (the first parent)."""
|
|
128
|
+
|
|
129
|
+
result: MergeResult
|
|
130
|
+
root: bytes
|
|
131
|
+
ours: bytes
|
|
132
|
+
theirs: bytes
|
|
133
|
+
base: bytes | None
|
|
134
|
+
virtual_base: bool
|
|
135
|
+
parents: tuple[bytes, ...]
|
|
136
|
+
changes: tuple[ChangesetEntry, ...]
|
|
137
|
+
stats: RevisionStats
|
|
138
|
+
auto_resolutions: tuple[AutoResolution, ...] = ()
|
|
139
|
+
resolutions: tuple[Resolution, ...] = ()
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@dataclass(frozen=True)
|
|
143
|
+
class MergeAttempt:
|
|
144
|
+
"""A merge with Conflicts, kept until every Conflict is resolved."""
|
|
145
|
+
|
|
146
|
+
base: bytes | None
|
|
147
|
+
ours: bytes
|
|
148
|
+
theirs: bytes
|
|
149
|
+
virtual_base: bool
|
|
150
|
+
conflicts: tuple[Conflict, ...]
|
|
151
|
+
auto_resolutions: tuple[AutoResolution, ...] = ()
|
|
152
|
+
resolutions: tuple[Resolution, ...] = ()
|
|
153
|
+
input: MergeInput | None = field(default=None, repr=False, compare=False)
|
|
154
|
+
#: Full member keys of grouped Conflicts, by fingerprint.
|
|
155
|
+
members: Mapping[str, tuple[str, ...]] = field(default_factory=dict, repr=False)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
MergeOutcome = MergedTree | MergeAttempt
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def merge(merge_input: MergeInput) -> MergeOutcome:
|
|
162
|
+
"""Merge ``theirs`` into ``ours`` (design 6.8 steps 0 to 5)."""
|
|
163
|
+
return _Engine(merge_input).run()
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def apply_resolutions(attempt: MergeAttempt, resolutions: Iterable[Resolution]) -> MergeOutcome:
|
|
167
|
+
"""Apply resolutions to an attempt and merge again.
|
|
168
|
+
|
|
169
|
+
Every Conflict of the attempt needs a resolution with an accepted choice; grouped
|
|
170
|
+
Conflicts resolved with per-member values need a value for every member. The merge is
|
|
171
|
+
recomputed, so resolutions can surface new Conflicts.
|
|
172
|
+
"""
|
|
173
|
+
if attempt.input is None:
|
|
174
|
+
raise ResolutionError("the merge attempt does not carry its merge input")
|
|
175
|
+
conflicts = {c.fingerprint: c for c in attempt.conflicts}
|
|
176
|
+
given: dict[str, Resolution] = {}
|
|
177
|
+
for resolution in resolutions:
|
|
178
|
+
conflict = conflicts.get(resolution.fingerprint)
|
|
179
|
+
if conflict is None:
|
|
180
|
+
raise ResolutionError(f"no conflict with fingerprint {resolution.fingerprint}")
|
|
181
|
+
check_choice(conflict, resolution)
|
|
182
|
+
_check_members(attempt, conflict, resolution)
|
|
183
|
+
given[resolution.fingerprint] = resolution
|
|
184
|
+
unresolved = conflicts.keys() - given.keys()
|
|
185
|
+
if unresolved:
|
|
186
|
+
raise UnresolvedConflictsError(unresolved)
|
|
187
|
+
combined = {r.fingerprint: r for r in attempt.resolutions}
|
|
188
|
+
combined.update(given)
|
|
189
|
+
return merge(replace(attempt.input, resolutions=tuple(combined.values())))
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _check_members(attempt: MergeAttempt, conflict: Conflict, resolution: Resolution) -> None:
|
|
193
|
+
if resolution.choice is not ResolutionChoice.CUSTOM or resolution.values is None:
|
|
194
|
+
return
|
|
195
|
+
if conflict.kind in (ConflictKind.UNIQUENESS, ConflictKind.REQUIRED_RELATION):
|
|
196
|
+
# A new value for some of the Records, or Links for some of them, can be enough.
|
|
197
|
+
return
|
|
198
|
+
members = attempt.members.get(conflict.fingerprint, ())
|
|
199
|
+
missing = [m for m in members if m not in resolution.values]
|
|
200
|
+
if missing and resolution.value is None:
|
|
201
|
+
raise ResolutionError(
|
|
202
|
+
f"custom values of {conflict.fingerprint} miss {len(missing)} member(s), "
|
|
203
|
+
f"for example {missing[0]!r}"
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
@dataclass
|
|
208
|
+
class _Run:
|
|
209
|
+
ctx: Context
|
|
210
|
+
schema: SchemaMerge
|
|
211
|
+
data: DataMerge
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
class _Engine:
|
|
215
|
+
def __init__(self, merge_input: MergeInput) -> None:
|
|
216
|
+
self.input = merge_input
|
|
217
|
+
self.store = merge_input.store
|
|
218
|
+
self.loader = merge_input.schema_loader or tree_schema_loader(
|
|
219
|
+
merge_input.store, merge_input.objects
|
|
220
|
+
)
|
|
221
|
+
self.virtual_roots: dict[bytes, bytes] = {}
|
|
222
|
+
self.lineage_cache: dict[tuple[Side, str, str], Mapping[str, ChangeStamp]] = {}
|
|
223
|
+
|
|
224
|
+
def root_of(self, revision: bytes | None) -> bytes | None:
|
|
225
|
+
if revision is None:
|
|
226
|
+
return None
|
|
227
|
+
found = self.virtual_roots.get(revision)
|
|
228
|
+
return found if found is not None else self.input.revision_root(revision)
|
|
229
|
+
|
|
230
|
+
# Orchestration -------------------------------------------------------------------
|
|
231
|
+
|
|
232
|
+
def run(self) -> MergeOutcome:
|
|
233
|
+
merge_input = self.input
|
|
234
|
+
ours, theirs = merge_input.ours, merge_input.theirs
|
|
235
|
+
if merge_input.base is not None:
|
|
236
|
+
base: bytes | None = merge_input.base
|
|
237
|
+
status = (
|
|
238
|
+
MergeStatus.UP_TO_DATE
|
|
239
|
+
if base == theirs
|
|
240
|
+
else MergeStatus.FAST_FORWARD
|
|
241
|
+
if base == ours
|
|
242
|
+
else MergeStatus.MERGE
|
|
243
|
+
)
|
|
244
|
+
virtual = False
|
|
245
|
+
else:
|
|
246
|
+
found = merge_base(merge_input.graph, ours, theirs, self.build_virtual)
|
|
247
|
+
base, status, virtual = found.base, found.status, found.virtual
|
|
248
|
+
ours_root = self.root_of(ours)
|
|
249
|
+
theirs_root = self.root_of(theirs)
|
|
250
|
+
assert ours_root is not None
|
|
251
|
+
assert theirs_root is not None
|
|
252
|
+
if status is MergeStatus.UP_TO_DATE:
|
|
253
|
+
return self.unchanged(ours_root, base)
|
|
254
|
+
if status is MergeStatus.FAST_FORWARD and merge_input.policy.fast_forward:
|
|
255
|
+
return self.fast_forward(ours_root, theirs_root, base)
|
|
256
|
+
return self.three_way(base, virtual)
|
|
257
|
+
|
|
258
|
+
def unchanged(self, root: bytes, base: bytes | None) -> MergedTree:
|
|
259
|
+
merge_input = self.input
|
|
260
|
+
return MergedTree(
|
|
261
|
+
result=MergeResult.UP_TO_DATE,
|
|
262
|
+
root=root,
|
|
263
|
+
ours=merge_input.ours,
|
|
264
|
+
theirs=merge_input.theirs,
|
|
265
|
+
base=base,
|
|
266
|
+
virtual_base=False,
|
|
267
|
+
parents=(),
|
|
268
|
+
changes=(),
|
|
269
|
+
stats=changeset_stats(()),
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
def fast_forward(self, ours_root: bytes, theirs_root: bytes, base: bytes | None) -> MergedTree:
|
|
273
|
+
merge_input = self.input
|
|
274
|
+
changes = tuple(
|
|
275
|
+
replace(entry, provenance=self.provenance(merge_input.theirs, entry))
|
|
276
|
+
for entry in changeset_between(self.store, ours_root, theirs_root)
|
|
277
|
+
)
|
|
278
|
+
return MergedTree(
|
|
279
|
+
result=MergeResult.FAST_FORWARD,
|
|
280
|
+
root=theirs_root,
|
|
281
|
+
ours=merge_input.ours,
|
|
282
|
+
theirs=merge_input.theirs,
|
|
283
|
+
base=base,
|
|
284
|
+
virtual_base=False,
|
|
285
|
+
parents=(),
|
|
286
|
+
changes=changes,
|
|
287
|
+
stats=changeset_stats(changes),
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
def three_way(self, base: bytes | None, virtual: bool) -> MergeOutcome:
|
|
291
|
+
merge_input = self.input
|
|
292
|
+
roots = {
|
|
293
|
+
Side.BASE: self.root_of(base),
|
|
294
|
+
Side.OURS: self.root_of(merge_input.ours),
|
|
295
|
+
Side.THEIRS: self.root_of(merge_input.theirs),
|
|
296
|
+
}
|
|
297
|
+
resolutions = {r.fingerprint: r for r in merge_input.resolutions}
|
|
298
|
+
factory = merge_input.index_view
|
|
299
|
+
index: IndexView = (
|
|
300
|
+
factory(merge_input.ours)
|
|
301
|
+
if factory is not None
|
|
302
|
+
else TreeIndexView(self.store, merge_input.objects, roots[Side.OURS])
|
|
303
|
+
)
|
|
304
|
+
directives = Directives()
|
|
305
|
+
known = None
|
|
306
|
+
# Each round applies the directives of newly matched resolutions; a resolution
|
|
307
|
+
# yields its directives once, so the rounds are bounded by their number.
|
|
308
|
+
for _ in range(len(resolutions) + 1):
|
|
309
|
+
current = self.execute(roots, resolutions, directives, index=index, known=known)
|
|
310
|
+
known = current.ctx.known_issues
|
|
311
|
+
if not directives.absorb(current.ctx.out):
|
|
312
|
+
break
|
|
313
|
+
ctx = current.ctx
|
|
314
|
+
auto = tuple(ctx.auto)
|
|
315
|
+
if ctx.conflicts:
|
|
316
|
+
return MergeAttempt(
|
|
317
|
+
base=base,
|
|
318
|
+
ours=merge_input.ours,
|
|
319
|
+
theirs=merge_input.theirs,
|
|
320
|
+
virtual_base=virtual,
|
|
321
|
+
conflicts=tuple(ctx.conflicts),
|
|
322
|
+
auto_resolutions=auto,
|
|
323
|
+
resolutions=merge_input.resolutions,
|
|
324
|
+
input=merge_input,
|
|
325
|
+
members=dict(ctx.members),
|
|
326
|
+
)
|
|
327
|
+
root, entries = self.write(current)
|
|
328
|
+
return MergedTree(
|
|
329
|
+
result=MergeResult.MERGED,
|
|
330
|
+
root=root,
|
|
331
|
+
ours=merge_input.ours,
|
|
332
|
+
theirs=merge_input.theirs,
|
|
333
|
+
base=base,
|
|
334
|
+
virtual_base=virtual,
|
|
335
|
+
parents=(merge_input.ours, merge_input.theirs),
|
|
336
|
+
changes=entries,
|
|
337
|
+
stats=changeset_stats(entries),
|
|
338
|
+
auto_resolutions=auto,
|
|
339
|
+
resolutions=merge_input.resolutions,
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
def execute(
|
|
343
|
+
self,
|
|
344
|
+
roots: dict[Side, bytes | None],
|
|
345
|
+
resolutions: Mapping[str, Resolution],
|
|
346
|
+
directives: Directives,
|
|
347
|
+
*,
|
|
348
|
+
index: IndexView | None,
|
|
349
|
+
known: set[tuple[object, tuple[object, ...]]] | None = None,
|
|
350
|
+
) -> _Run:
|
|
351
|
+
"""One run of steps 1 to 4 under the given directives."""
|
|
352
|
+
schemas = {
|
|
353
|
+
side: None if root is None else self.loader(root) for side, root in roots.items()
|
|
354
|
+
}
|
|
355
|
+
ctx = Context(
|
|
356
|
+
store=self.store,
|
|
357
|
+
objects=self.input.objects,
|
|
358
|
+
policy=self.input.policy,
|
|
359
|
+
params=self.input.tree_params,
|
|
360
|
+
schema_loader=self.loader,
|
|
361
|
+
roots=roots,
|
|
362
|
+
schemas=schemas,
|
|
363
|
+
resolutions=resolutions,
|
|
364
|
+
directives=directives.copy(),
|
|
365
|
+
virtual=index is None,
|
|
366
|
+
rules=self.input.schema_rules or SchemaRules(),
|
|
367
|
+
lineage=None if self.input.lineage is None else self.lineage_of,
|
|
368
|
+
)
|
|
369
|
+
ctx.schema_hashes = {side: _schema_hashes(self.store, root) for side, root in roots.items()}
|
|
370
|
+
if index is not None:
|
|
371
|
+
ctx.known_issues = (
|
|
372
|
+
known
|
|
373
|
+
if known is not None
|
|
374
|
+
else known_static_issues([schemas[Side.OURS], schemas[Side.THEIRS]], ctx.rules)
|
|
375
|
+
)
|
|
376
|
+
changes: dict[Side, SideChanges] = {
|
|
377
|
+
side: side_changes(tree_diff(self.store, roots[Side.BASE], roots[side]))
|
|
378
|
+
for side in SIDES
|
|
379
|
+
}
|
|
380
|
+
schema = merge_schema(ctx)
|
|
381
|
+
data = merge_data(ctx, schema, changes)
|
|
382
|
+
if index is not None:
|
|
383
|
+
check_consistency(ctx, schema.schema, data, index)
|
|
384
|
+
emit_groups(ctx, schema)
|
|
385
|
+
return _Run(ctx, schema, data)
|
|
386
|
+
|
|
387
|
+
def write(
|
|
388
|
+
self, run: _Run, *, with_provenance: bool = True
|
|
389
|
+
) -> tuple[bytes, tuple[ChangesetEntry, ...]]:
|
|
390
|
+
"""Write the merged tree; return its root and its changeset against ours."""
|
|
391
|
+
ctx, schema, data = run.ctx, run.schema, run.data
|
|
392
|
+
theirs = self.input.theirs if with_provenance else None
|
|
393
|
+
entries: list[ChangesetEntry] = []
|
|
394
|
+
ours_schema = ctx.schema_hashes[Side.OURS]
|
|
395
|
+
for key in sorted(ours_schema.keys() | schema.hashes.keys()):
|
|
396
|
+
old, new = ours_schema.get(key), schema.hashes.get(key)
|
|
397
|
+
if old != new:
|
|
398
|
+
whole = schema.origins.get(key)
|
|
399
|
+
lineage = (
|
|
400
|
+
self.provenance_of(theirs, PARTITION_SCHEMA, key)
|
|
401
|
+
if whole is Side.THEIRS
|
|
402
|
+
else {}
|
|
403
|
+
)
|
|
404
|
+
entries.append(ChangesetEntry(PARTITION_SCHEMA, key, old, new, lineage))
|
|
405
|
+
tree_changes: list[TreeChange] = []
|
|
406
|
+
for location in sorted(data.states):
|
|
407
|
+
state = data.states[location]
|
|
408
|
+
new = None if state is None else ctx.put(data_object_type(location[0]), state)
|
|
409
|
+
old = data.ours.get(location)
|
|
410
|
+
if old == new:
|
|
411
|
+
continue
|
|
412
|
+
tree_changes.append(TreeChange(location[0], location[1], new))
|
|
413
|
+
origin = data.origins.get(location)
|
|
414
|
+
provenance: Mapping[str, str] = {}
|
|
415
|
+
if origin is not None and (origin.whole is Side.THEIRS or origin.attributes):
|
|
416
|
+
full = self.provenance_of(theirs, *location)
|
|
417
|
+
if origin.whole is Side.THEIRS:
|
|
418
|
+
provenance = full
|
|
419
|
+
else:
|
|
420
|
+
provenance = {
|
|
421
|
+
a: p for a, p in full.items() if origin.attributes.get(a) is Side.THEIRS
|
|
422
|
+
}
|
|
423
|
+
entries.append(ChangesetEntry(location[0], location[1], old, new, provenance))
|
|
424
|
+
root = tree_apply(self.store, schema.root, tree_changes, self.input.tree_params)
|
|
425
|
+
entries.sort(key=lambda e: (e.partition.encode("utf-8"), e.key))
|
|
426
|
+
return root, tuple(entries)
|
|
427
|
+
|
|
428
|
+
# Provenance -----------------------------------------------------------------------
|
|
429
|
+
|
|
430
|
+
def provenance_of(self, revision: bytes | None, partition: str, key: str) -> Mapping[str, str]:
|
|
431
|
+
loader = self.input.provenance
|
|
432
|
+
if loader is None or revision is None:
|
|
433
|
+
return {}
|
|
434
|
+
return dict(loader(revision, partition, key))
|
|
435
|
+
|
|
436
|
+
def provenance(self, revision: bytes, entry: ChangesetEntry) -> Mapping[str, str]:
|
|
437
|
+
return self.provenance_of(revision, entry.partition, entry.key)
|
|
438
|
+
|
|
439
|
+
def lineage_of(self, side: Side, partition: str, key: str) -> Mapping[str, ChangeStamp]:
|
|
440
|
+
"""Lineage entries of an element on ``ours`` or ``theirs``, loaded once per merge."""
|
|
441
|
+
loader = self.input.lineage
|
|
442
|
+
if loader is None or side is Side.BASE:
|
|
443
|
+
return {}
|
|
444
|
+
found = self.lineage_cache.get((side, partition, key))
|
|
445
|
+
if found is None:
|
|
446
|
+
revision = self.input.ours if side is Side.OURS else self.input.theirs
|
|
447
|
+
found = self.lineage_cache[(side, partition, key)] = dict(
|
|
448
|
+
loader(revision, partition, key)
|
|
449
|
+
)
|
|
450
|
+
return found
|
|
451
|
+
|
|
452
|
+
# Virtual merge base (AC-13) -----------------------------------------------------------
|
|
453
|
+
|
|
454
|
+
def build_virtual(self, base: bytes | None, ours: bytes, theirs: bytes) -> bytes:
|
|
455
|
+
"""Merge two common ancestors into a virtual base Revision (never committed).
|
|
456
|
+
|
|
457
|
+
Conflicting changes keep the value of their own merge base, so that both sides of
|
|
458
|
+
the outer merge see them as changes.
|
|
459
|
+
"""
|
|
460
|
+
roots = {
|
|
461
|
+
Side.BASE: self.root_of(base),
|
|
462
|
+
Side.OURS: self.root_of(ours),
|
|
463
|
+
Side.THEIRS: self.root_of(theirs),
|
|
464
|
+
}
|
|
465
|
+
run = self.execute(roots, {}, Directives(), index=None)
|
|
466
|
+
root, _ = self.write(run, with_provenance=False)
|
|
467
|
+
digest = hashlib.sha256(_VIRTUAL_LABEL)
|
|
468
|
+
for part in (base or b"", ours, theirs, root):
|
|
469
|
+
digest.update(part)
|
|
470
|
+
virtual = digest.digest()
|
|
471
|
+
self.virtual_roots[virtual] = root
|
|
472
|
+
return virtual
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def _schema_hashes(store: NodeStore, root: bytes | None) -> dict[str, bytes]:
|
|
476
|
+
return dict(tree_entries(store, root, PARTITION_SCHEMA))
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""An ``IndexView`` read from a Revision tree, for targets without a materialized head.
|
|
2
|
+
|
|
3
|
+
Each partition is loaded on first use and indexed in memory, so lookups cost one scan of the
|
|
4
|
+
partitions they touch. A materialized head (design 6.11) answers the same questions with
|
|
5
|
+
indexed reads.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections import defaultdict
|
|
11
|
+
from collections.abc import Iterable
|
|
12
|
+
|
|
13
|
+
from pydantic import JsonValue
|
|
14
|
+
|
|
15
|
+
from lorebase_core.canonical import canonical_bytes
|
|
16
|
+
from lorebase_core.merge.objects import ObjectStore, load_object
|
|
17
|
+
from lorebase_core.tree import NodeStore, kind_partition, relation_partition, tree_entries
|
|
18
|
+
from lorebase_core.types import CanonicalValue
|
|
19
|
+
|
|
20
|
+
__all__ = ["TreeIndexView"]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class _Links:
|
|
24
|
+
def __init__(self) -> None:
|
|
25
|
+
self.pairs: set[tuple[str, str]] = set()
|
|
26
|
+
self.by_from: dict[str, set[str]] = defaultdict(set)
|
|
27
|
+
self.by_to: dict[str, set[str]] = defaultdict(set)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class TreeIndexView:
|
|
31
|
+
""":class:`~lorebase_core.validate.IndexView` over the tree at ``root``."""
|
|
32
|
+
|
|
33
|
+
def __init__(self, store: NodeStore, objects: ObjectStore, root: bytes | None) -> None:
|
|
34
|
+
self._store = store
|
|
35
|
+
self._objects = objects
|
|
36
|
+
self._root = root
|
|
37
|
+
self._records: dict[str, dict[str, dict[str, JsonValue]]] = {}
|
|
38
|
+
self._links: dict[str, _Links] = {}
|
|
39
|
+
|
|
40
|
+
def _kind(self, kind_id: str) -> dict[str, dict[str, JsonValue]]:
|
|
41
|
+
records = self._records.get(kind_id)
|
|
42
|
+
if records is None:
|
|
43
|
+
records = {}
|
|
44
|
+
for key, digest in tree_entries(self._store, self._root, kind_partition(kind_id)):
|
|
45
|
+
values = load_object(self._objects, digest).get("values")
|
|
46
|
+
records[key] = dict(values) if isinstance(values, dict) else {}
|
|
47
|
+
self._records[kind_id] = records
|
|
48
|
+
return records
|
|
49
|
+
|
|
50
|
+
def _relation(self, relation_id: str) -> _Links:
|
|
51
|
+
links = self._links.get(relation_id)
|
|
52
|
+
if links is None:
|
|
53
|
+
links = _Links()
|
|
54
|
+
for _, digest in tree_entries(self._store, self._root, relation_partition(relation_id)):
|
|
55
|
+
payload = load_object(self._objects, digest)
|
|
56
|
+
start, end = str(payload["from"]), str(payload["to"])
|
|
57
|
+
links.pairs.add((start, end))
|
|
58
|
+
links.by_from[start].add(end)
|
|
59
|
+
links.by_to[end].add(start)
|
|
60
|
+
self._links[relation_id] = links
|
|
61
|
+
return links
|
|
62
|
+
|
|
63
|
+
def record_exists(self, kind_id: str, key: str) -> bool:
|
|
64
|
+
return key in self._kind(kind_id)
|
|
65
|
+
|
|
66
|
+
def find_unique(self, kind_id: str, attribute_id: str, value: CanonicalValue) -> str | None:
|
|
67
|
+
wanted = canonical_bytes(value)
|
|
68
|
+
for key in sorted(self._kind(kind_id)):
|
|
69
|
+
found = self._kind(kind_id)[key].get(attribute_id)
|
|
70
|
+
if found is not None and canonical_bytes(found) == wanted:
|
|
71
|
+
return key
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
def keys_referencing(self, kind_id: str, attribute_id: str, key: str) -> Iterable[str]:
|
|
75
|
+
for record_key, values in sorted(self._kind(kind_id).items()):
|
|
76
|
+
value = values.get(attribute_id)
|
|
77
|
+
if value == key or (isinstance(value, list) and key in value):
|
|
78
|
+
yield record_key
|
|
79
|
+
|
|
80
|
+
def link_exists(self, relation_id: str, from_key: str, to_key: str) -> bool:
|
|
81
|
+
return (from_key, to_key) in self._relation(relation_id).pairs
|
|
82
|
+
|
|
83
|
+
def count_links(
|
|
84
|
+
self, relation_id: str, *, from_key: str | None = None, to_key: str | None = None
|
|
85
|
+
) -> int:
|
|
86
|
+
return len(list(self.links_of(relation_id, from_key=from_key, to_key=to_key)))
|
|
87
|
+
|
|
88
|
+
def links_of(
|
|
89
|
+
self, relation_id: str, *, from_key: str | None = None, to_key: str | None = None
|
|
90
|
+
) -> Iterable[tuple[str, str]]:
|
|
91
|
+
links = self._relation(relation_id)
|
|
92
|
+
if from_key is not None:
|
|
93
|
+
pairs = {(from_key, end) for end in links.by_from.get(from_key, ())}
|
|
94
|
+
elif to_key is not None:
|
|
95
|
+
pairs = {(start, to_key) for start in links.by_to.get(to_key, ())}
|
|
96
|
+
else:
|
|
97
|
+
pairs = set(links.pairs)
|
|
98
|
+
if from_key is not None and to_key is not None:
|
|
99
|
+
pairs = {p for p in pairs if p[1] == to_key}
|
|
100
|
+
return sorted(pairs)
|