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,59 @@
|
|
|
1
|
+
"""Revisions, changesets, ancestry and merge base (design 6.3, 6.4, 6.8)."""
|
|
2
|
+
|
|
3
|
+
from lorebase_core.revision.ancestry import (
|
|
4
|
+
InMemoryRevisionGraph,
|
|
5
|
+
MergeBaseError,
|
|
6
|
+
MergeBaseResult,
|
|
7
|
+
MergeStatus,
|
|
8
|
+
RevisionGraph,
|
|
9
|
+
UnknownRevisionError,
|
|
10
|
+
VirtualBaseBuilder,
|
|
11
|
+
VirtualRevision,
|
|
12
|
+
is_ancestor,
|
|
13
|
+
lowest_common_ancestors,
|
|
14
|
+
merge_base,
|
|
15
|
+
)
|
|
16
|
+
from lorebase_core.revision.changeset import (
|
|
17
|
+
ChangesetEntry,
|
|
18
|
+
changeset_between,
|
|
19
|
+
changeset_stats,
|
|
20
|
+
changeset_tree_changes,
|
|
21
|
+
)
|
|
22
|
+
from lorebase_core.revision.revision import (
|
|
23
|
+
ROOT_GENERATION,
|
|
24
|
+
Author,
|
|
25
|
+
Hash,
|
|
26
|
+
Origin,
|
|
27
|
+
Revision,
|
|
28
|
+
RevisionStats,
|
|
29
|
+
format_timestamp,
|
|
30
|
+
next_generation,
|
|
31
|
+
revision_id,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
__all__ = [
|
|
35
|
+
"ROOT_GENERATION",
|
|
36
|
+
"Author",
|
|
37
|
+
"ChangesetEntry",
|
|
38
|
+
"Hash",
|
|
39
|
+
"InMemoryRevisionGraph",
|
|
40
|
+
"MergeBaseError",
|
|
41
|
+
"MergeBaseResult",
|
|
42
|
+
"MergeStatus",
|
|
43
|
+
"Origin",
|
|
44
|
+
"Revision",
|
|
45
|
+
"RevisionGraph",
|
|
46
|
+
"RevisionStats",
|
|
47
|
+
"UnknownRevisionError",
|
|
48
|
+
"VirtualBaseBuilder",
|
|
49
|
+
"VirtualRevision",
|
|
50
|
+
"changeset_between",
|
|
51
|
+
"changeset_stats",
|
|
52
|
+
"changeset_tree_changes",
|
|
53
|
+
"format_timestamp",
|
|
54
|
+
"is_ancestor",
|
|
55
|
+
"lowest_common_ancestors",
|
|
56
|
+
"merge_base",
|
|
57
|
+
"next_generation",
|
|
58
|
+
"revision_id",
|
|
59
|
+
]
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
"""Revision ancestry: generation numbers, lowest common ancestors and merge base.
|
|
2
|
+
|
|
3
|
+
Design 6.4 and 6.8 step 0, AC-13.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import heapq
|
|
9
|
+
from collections.abc import Iterable, Sequence
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from enum import StrEnum
|
|
12
|
+
from typing import Protocol, runtime_checkable
|
|
13
|
+
|
|
14
|
+
from lorebase_core.revision.revision import Revision, next_generation, revision_id
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"InMemoryRevisionGraph",
|
|
18
|
+
"MergeBaseError",
|
|
19
|
+
"MergeBaseResult",
|
|
20
|
+
"MergeStatus",
|
|
21
|
+
"RevisionGraph",
|
|
22
|
+
"UnknownRevisionError",
|
|
23
|
+
"VirtualBaseBuilder",
|
|
24
|
+
"VirtualRevision",
|
|
25
|
+
"is_ancestor",
|
|
26
|
+
"lowest_common_ancestors",
|
|
27
|
+
"merge_base",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class UnknownRevisionError(KeyError):
|
|
32
|
+
"""Raised when a Revision id is not in the graph."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class MergeBaseError(ValueError):
|
|
36
|
+
"""Raised when a merge base cannot be determined."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@runtime_checkable
|
|
40
|
+
class RevisionGraph(Protocol):
|
|
41
|
+
"""Read access to the Revision DAG."""
|
|
42
|
+
|
|
43
|
+
def parents(self, revision_id: bytes) -> Sequence[bytes]: ...
|
|
44
|
+
|
|
45
|
+
def generation(self, revision_id: bytes) -> int: ...
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class InMemoryRevisionGraph:
|
|
49
|
+
"""Dictionary-backed :class:`RevisionGraph`."""
|
|
50
|
+
|
|
51
|
+
def __init__(self) -> None:
|
|
52
|
+
self._parents: dict[bytes, tuple[bytes, ...]] = {}
|
|
53
|
+
self._generations: dict[bytes, int] = {}
|
|
54
|
+
|
|
55
|
+
def add(self, revision: bytes, parents: Iterable[bytes] = ()) -> int:
|
|
56
|
+
"""Register a Revision whose parents are already known; return its generation."""
|
|
57
|
+
parent_ids = tuple(parents)
|
|
58
|
+
known = self._parents.get(revision)
|
|
59
|
+
if known is not None:
|
|
60
|
+
if known != parent_ids:
|
|
61
|
+
raise ValueError("revision already registered with different parents")
|
|
62
|
+
return self._generations[revision]
|
|
63
|
+
generation = next_generation(self.generation(p) for p in parent_ids)
|
|
64
|
+
self._parents[revision] = parent_ids
|
|
65
|
+
self._generations[revision] = generation
|
|
66
|
+
return generation
|
|
67
|
+
|
|
68
|
+
def add_revision(self, revision: Revision) -> bytes:
|
|
69
|
+
"""Register a Revision object and return its id."""
|
|
70
|
+
rev_id = revision_id(revision)
|
|
71
|
+
self.add(rev_id, revision.parents)
|
|
72
|
+
return rev_id
|
|
73
|
+
|
|
74
|
+
def parents(self, revision_id: bytes) -> Sequence[bytes]:
|
|
75
|
+
try:
|
|
76
|
+
return self._parents[revision_id]
|
|
77
|
+
except KeyError:
|
|
78
|
+
raise UnknownRevisionError(revision_id.hex()) from None
|
|
79
|
+
|
|
80
|
+
def generation(self, revision_id: bytes) -> int:
|
|
81
|
+
try:
|
|
82
|
+
return self._generations[revision_id]
|
|
83
|
+
except KeyError:
|
|
84
|
+
raise UnknownRevisionError(revision_id.hex()) from None
|
|
85
|
+
|
|
86
|
+
def __contains__(self, revision_id: object) -> bool:
|
|
87
|
+
return revision_id in self._parents
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def is_ancestor(graph: RevisionGraph, ancestor: bytes, descendant: bytes) -> bool:
|
|
91
|
+
"""True when ``ancestor`` is ``descendant`` or reachable from it through parents."""
|
|
92
|
+
if ancestor == descendant:
|
|
93
|
+
graph.generation(ancestor)
|
|
94
|
+
return True
|
|
95
|
+
floor = graph.generation(ancestor)
|
|
96
|
+
seen = {descendant}
|
|
97
|
+
stack = [descendant]
|
|
98
|
+
while stack:
|
|
99
|
+
for parent in graph.parents(stack.pop()):
|
|
100
|
+
if parent == ancestor:
|
|
101
|
+
return True
|
|
102
|
+
# Parents always have a lower generation, so nothing at or below
|
|
103
|
+
# the ancestor's generation can lead to it.
|
|
104
|
+
if parent not in seen and graph.generation(parent) > floor:
|
|
105
|
+
seen.add(parent)
|
|
106
|
+
stack.append(parent)
|
|
107
|
+
return False
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
_OURS = 1
|
|
111
|
+
_THEIRS = 2
|
|
112
|
+
_STALE = 4
|
|
113
|
+
_BOTH = _OURS | _THEIRS
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def lowest_common_ancestors(graph: RevisionGraph, a: bytes, b: bytes) -> tuple[bytes, ...]:
|
|
117
|
+
"""All lowest common ancestors of ``a`` and ``b``, ordered by (generation, id).
|
|
118
|
+
|
|
119
|
+
Walks both histories at once in decreasing generation order. Parents always
|
|
120
|
+
have a lower generation than their children, so when a Revision is visited
|
|
121
|
+
every path from ``a`` or ``b`` to it has already been seen: a Revision
|
|
122
|
+
reached from both sides is a common ancestor, and everything below it is
|
|
123
|
+
marked stale. The walk stops as soon as no unvisited, non-stale Revision
|
|
124
|
+
is reachable from both sides.
|
|
125
|
+
"""
|
|
126
|
+
if a == b:
|
|
127
|
+
graph.generation(a)
|
|
128
|
+
return (a,)
|
|
129
|
+
flags = {a: _OURS, b: _THEIRS}
|
|
130
|
+
heap = [(-graph.generation(a), a), (-graph.generation(b), b)]
|
|
131
|
+
heapq.heapify(heap)
|
|
132
|
+
pending: dict[int, set[bytes]] = {_OURS: {a}, _THEIRS: {b}}
|
|
133
|
+
visited: set[bytes] = set()
|
|
134
|
+
found: list[bytes] = []
|
|
135
|
+
while pending[_OURS] and pending[_THEIRS]:
|
|
136
|
+
_, current = heapq.heappop(heap)
|
|
137
|
+
if current in visited:
|
|
138
|
+
continue
|
|
139
|
+
visited.add(current)
|
|
140
|
+
pending[_OURS].discard(current)
|
|
141
|
+
pending[_THEIRS].discard(current)
|
|
142
|
+
state = flags[current]
|
|
143
|
+
if state & _BOTH == _BOTH and not state & _STALE:
|
|
144
|
+
found.append(current)
|
|
145
|
+
state |= _STALE
|
|
146
|
+
flags[current] = state
|
|
147
|
+
for parent in graph.parents(current):
|
|
148
|
+
old = flags.get(parent, 0)
|
|
149
|
+
new = old | state
|
|
150
|
+
if new == old:
|
|
151
|
+
continue
|
|
152
|
+
flags[parent] = new
|
|
153
|
+
heapq.heappush(heap, (-graph.generation(parent), parent))
|
|
154
|
+
for side in (_OURS, _THEIRS):
|
|
155
|
+
if new & side and not new & _STALE:
|
|
156
|
+
pending[side].add(parent)
|
|
157
|
+
else:
|
|
158
|
+
pending[side].discard(parent)
|
|
159
|
+
return tuple(sorted(found, key=lambda rev: (graph.generation(rev), rev)))
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class MergeStatus(StrEnum):
|
|
163
|
+
UP_TO_DATE = "up_to_date"
|
|
164
|
+
"""Theirs is already contained in ours: nothing to merge."""
|
|
165
|
+
FAST_FORWARD = "fast_forward"
|
|
166
|
+
"""Ours is an ancestor of theirs: the target can move to theirs."""
|
|
167
|
+
MERGE = "merge"
|
|
168
|
+
"""Histories diverged: a three-way merge against ``base`` is needed."""
|
|
169
|
+
UNRELATED = "unrelated"
|
|
170
|
+
"""No common ancestor: merge against the empty tree."""
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class VirtualBaseBuilder(Protocol):
|
|
174
|
+
"""Builds a synthetic merge of two Revisions and returns its Revision id.
|
|
175
|
+
|
|
176
|
+
Called with the merge base of the pair (``None`` when they share no
|
|
177
|
+
ancestor). Implemented by the merge engine; the result is never committed.
|
|
178
|
+
"""
|
|
179
|
+
|
|
180
|
+
def __call__(self, base: bytes | None, ours: bytes, theirs: bytes) -> bytes: ...
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@dataclass(frozen=True, slots=True)
|
|
184
|
+
class VirtualRevision:
|
|
185
|
+
"""A synthetic Revision produced while building a virtual merge base."""
|
|
186
|
+
|
|
187
|
+
id: bytes
|
|
188
|
+
parents: tuple[bytes, bytes]
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
@dataclass(frozen=True, slots=True)
|
|
192
|
+
class MergeBaseResult:
|
|
193
|
+
status: MergeStatus
|
|
194
|
+
base: bytes | None
|
|
195
|
+
"""Revision to use as merge base: a real ancestor, a virtual Revision, or ``None``."""
|
|
196
|
+
common_ancestors: tuple[bytes, ...]
|
|
197
|
+
"""The lowest common ancestors, ordered by (generation, id)."""
|
|
198
|
+
virtual_revisions: tuple[VirtualRevision, ...] = ()
|
|
199
|
+
"""Synthetic Revisions built for criss-cross histories, in creation order."""
|
|
200
|
+
|
|
201
|
+
@property
|
|
202
|
+
def virtual(self) -> bool:
|
|
203
|
+
return bool(self.virtual_revisions)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def merge_base(
|
|
207
|
+
graph: RevisionGraph,
|
|
208
|
+
ours: bytes,
|
|
209
|
+
theirs: bytes,
|
|
210
|
+
build_virtual: VirtualBaseBuilder | None = None,
|
|
211
|
+
) -> MergeBaseResult:
|
|
212
|
+
"""Determine how to merge ``theirs`` (source head) into ``ours`` (target head).
|
|
213
|
+
|
|
214
|
+
With several lowest common ancestors (criss-cross history) they are merged
|
|
215
|
+
pairwise and recursively into a virtual base through ``build_virtual``.
|
|
216
|
+
"""
|
|
217
|
+
common = lowest_common_ancestors(graph, ours, theirs)
|
|
218
|
+
if theirs in common:
|
|
219
|
+
return MergeBaseResult(MergeStatus.UP_TO_DATE, theirs, common)
|
|
220
|
+
if ours in common:
|
|
221
|
+
return MergeBaseResult(MergeStatus.FAST_FORWARD, ours, common)
|
|
222
|
+
if not common:
|
|
223
|
+
return MergeBaseResult(MergeStatus.UNRELATED, None, common)
|
|
224
|
+
if len(common) == 1:
|
|
225
|
+
return MergeBaseResult(MergeStatus.MERGE, common[0], common)
|
|
226
|
+
if build_virtual is None:
|
|
227
|
+
raise MergeBaseError("several lowest common ancestors and no virtual base builder")
|
|
228
|
+
overlay = _OverlayGraph(graph)
|
|
229
|
+
base = _virtual_base(overlay, common, build_virtual)
|
|
230
|
+
return MergeBaseResult(MergeStatus.MERGE, base, common, tuple(overlay.added))
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _virtual_base(
|
|
234
|
+
graph: _OverlayGraph, ancestors: Sequence[bytes], build: VirtualBaseBuilder
|
|
235
|
+
) -> bytes:
|
|
236
|
+
merged = ancestors[0]
|
|
237
|
+
for following in ancestors[1:]:
|
|
238
|
+
inner = lowest_common_ancestors(graph, merged, following)
|
|
239
|
+
if not inner:
|
|
240
|
+
base: bytes | None = None
|
|
241
|
+
elif len(inner) == 1:
|
|
242
|
+
base = inner[0]
|
|
243
|
+
else:
|
|
244
|
+
base = _virtual_base(graph, inner, build)
|
|
245
|
+
virtual = build(base, merged, following)
|
|
246
|
+
graph.add_virtual(virtual, (merged, following))
|
|
247
|
+
merged = virtual
|
|
248
|
+
return merged
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
class _OverlayGraph:
|
|
252
|
+
"""A graph extended with the virtual Revisions of one merge-base computation."""
|
|
253
|
+
|
|
254
|
+
def __init__(self, base: RevisionGraph) -> None:
|
|
255
|
+
self._base = base
|
|
256
|
+
self._virtual: dict[bytes, tuple[tuple[bytes, bytes], int]] = {}
|
|
257
|
+
self.added: list[VirtualRevision] = []
|
|
258
|
+
|
|
259
|
+
def add_virtual(self, revision: bytes, parents: tuple[bytes, bytes]) -> None:
|
|
260
|
+
if revision in self._virtual:
|
|
261
|
+
raise MergeBaseError("virtual base builder returned a duplicate Revision id")
|
|
262
|
+
generation = next_generation(self.generation(p) for p in parents)
|
|
263
|
+
self._virtual[revision] = (parents, generation)
|
|
264
|
+
self.added.append(VirtualRevision(revision, parents))
|
|
265
|
+
|
|
266
|
+
def parents(self, revision_id: bytes) -> Sequence[bytes]:
|
|
267
|
+
entry = self._virtual.get(revision_id)
|
|
268
|
+
return entry[0] if entry is not None else self._base.parents(revision_id)
|
|
269
|
+
|
|
270
|
+
def generation(self, revision_id: bytes) -> int:
|
|
271
|
+
entry = self._virtual.get(revision_id)
|
|
272
|
+
return entry[1] if entry is not None else self._base.generation(revision_id)
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Changesets: the delta of a Revision against its first parent (design 6.3)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable, Mapping
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from types import MappingProxyType
|
|
8
|
+
|
|
9
|
+
from lorebase_core.canonical import HASH_SIZE
|
|
10
|
+
from lorebase_core.revision.revision import RevisionStats
|
|
11
|
+
from lorebase_core.tree import NodeStore, TreeChange, tree_diff
|
|
12
|
+
|
|
13
|
+
__all__ = ["ChangesetEntry", "changeset_between", "changeset_stats", "changeset_tree_changes"]
|
|
14
|
+
|
|
15
|
+
_NO_PROVENANCE: Mapping[str, str] = MappingProxyType({})
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True, slots=True)
|
|
19
|
+
class ChangesetEntry:
|
|
20
|
+
"""One changed element: ``None`` hashes mean absent.
|
|
21
|
+
|
|
22
|
+
``provenance`` maps each changed Attribute id to its provenance entry id;
|
|
23
|
+
it is not part of any content hash (AC-08).
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
partition: str
|
|
27
|
+
key: str
|
|
28
|
+
old_hash: bytes | None
|
|
29
|
+
new_hash: bytes | None
|
|
30
|
+
provenance: Mapping[str, str] = field(default=_NO_PROVENANCE, compare=False)
|
|
31
|
+
|
|
32
|
+
def __post_init__(self) -> None:
|
|
33
|
+
if self.old_hash == self.new_hash:
|
|
34
|
+
raise ValueError("a changeset entry must change the element")
|
|
35
|
+
for h in (self.old_hash, self.new_hash):
|
|
36
|
+
if h is not None and len(h) != HASH_SIZE:
|
|
37
|
+
raise ValueError(f"hashes are {HASH_SIZE} bytes")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def changeset_between(
|
|
41
|
+
store: NodeStore, parent_root: bytes | None, root: bytes
|
|
42
|
+
) -> list[ChangesetEntry]:
|
|
43
|
+
"""Changeset of ``root`` against its first parent's root, without provenance."""
|
|
44
|
+
return [
|
|
45
|
+
ChangesetEntry(d.partition, d.key, d.hash_a, d.hash_b)
|
|
46
|
+
for d in tree_diff(store, parent_root, root)
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def changeset_tree_changes(entries: Iterable[ChangesetEntry]) -> list[TreeChange]:
|
|
51
|
+
"""Tree changes that turn the parent tree into the Revision tree."""
|
|
52
|
+
return [TreeChange(e.partition, e.key, e.new_hash) for e in entries]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def changeset_stats(entries: Iterable[ChangesetEntry]) -> RevisionStats:
|
|
56
|
+
"""Revision stats derived from a changeset."""
|
|
57
|
+
partitions: set[str] = set()
|
|
58
|
+
changes = 0
|
|
59
|
+
for entry in entries:
|
|
60
|
+
partitions.add(entry.partition)
|
|
61
|
+
changes += 1
|
|
62
|
+
return RevisionStats(partitions_changed=len(partitions), changes=changes)
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Revision objects and their content hash (design 6.4)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable
|
|
6
|
+
from datetime import UTC, datetime
|
|
7
|
+
from typing import Annotated
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel, BeforeValidator, ConfigDict, Field, field_validator
|
|
10
|
+
|
|
11
|
+
from lorebase_core.canonical import HASH_SIZE, from_hex, hex, revision_hash
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"ROOT_GENERATION",
|
|
15
|
+
"Author",
|
|
16
|
+
"Hash",
|
|
17
|
+
"Origin",
|
|
18
|
+
"Revision",
|
|
19
|
+
"RevisionStats",
|
|
20
|
+
"format_timestamp",
|
|
21
|
+
"next_generation",
|
|
22
|
+
"revision_id",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
ROOT_GENERATION = 1
|
|
26
|
+
"""Generation number of a Revision without parents."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _parse_hash(value: object) -> object:
|
|
30
|
+
if isinstance(value, str):
|
|
31
|
+
return from_hex(value)
|
|
32
|
+
if isinstance(value, bytes) and len(value) != HASH_SIZE:
|
|
33
|
+
raise ValueError(f"hashes are {HASH_SIZE} bytes")
|
|
34
|
+
return value
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
Hash = Annotated[bytes, BeforeValidator(_parse_hash)]
|
|
38
|
+
"""A 32-byte hash; accepts raw bytes or lowercase hex."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class _Frozen(BaseModel):
|
|
42
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class Author(_Frozen):
|
|
46
|
+
principal: str = Field(min_length=1)
|
|
47
|
+
on_behalf_of: str | None = None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class Origin(_Frozen):
|
|
51
|
+
type: str = Field(min_length=1)
|
|
52
|
+
ref: str | None = None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class RevisionStats(_Frozen):
|
|
56
|
+
partitions_changed: int = Field(ge=0)
|
|
57
|
+
changes: int = Field(ge=0)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class Revision(_Frozen):
|
|
61
|
+
"""An immutable snapshot: tree root, parents and metadata.
|
|
62
|
+
|
|
63
|
+
``parents[0]`` is the first parent (the target branch for merges).
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
parents: tuple[Hash, ...] = ()
|
|
67
|
+
root: Hash
|
|
68
|
+
domain: str = Field(min_length=1)
|
|
69
|
+
author: Author
|
|
70
|
+
message: str
|
|
71
|
+
origin: Origin
|
|
72
|
+
created_at: datetime
|
|
73
|
+
stats: RevisionStats
|
|
74
|
+
|
|
75
|
+
@field_validator("parents")
|
|
76
|
+
@classmethod
|
|
77
|
+
def _distinct_parents(cls, parents: tuple[bytes, ...]) -> tuple[bytes, ...]:
|
|
78
|
+
if len(set(parents)) != len(parents):
|
|
79
|
+
raise ValueError("parents must be distinct")
|
|
80
|
+
return parents
|
|
81
|
+
|
|
82
|
+
@field_validator("created_at")
|
|
83
|
+
@classmethod
|
|
84
|
+
def _utc(cls, value: datetime) -> datetime:
|
|
85
|
+
if value.tzinfo is None or value.utcoffset() is None:
|
|
86
|
+
raise ValueError("created_at must carry a time zone")
|
|
87
|
+
return value.astimezone(UTC)
|
|
88
|
+
|
|
89
|
+
def canonical_payload(self) -> dict[str, object]:
|
|
90
|
+
"""The JSON document whose canonical form is hashed into the Revision id."""
|
|
91
|
+
return {
|
|
92
|
+
"parents": [hex(p) for p in self.parents],
|
|
93
|
+
"root": hex(self.root),
|
|
94
|
+
"domain": self.domain,
|
|
95
|
+
"author": self.author.model_dump(),
|
|
96
|
+
"message": self.message,
|
|
97
|
+
"origin": self.origin.model_dump(),
|
|
98
|
+
"created_at": format_timestamp(self.created_at),
|
|
99
|
+
"stats": self.stats.model_dump(),
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def revision_id(revision: Revision) -> bytes:
|
|
104
|
+
"""Revision id: ``sha256("lb1\\x00revision\\x00" + canonical_json)``."""
|
|
105
|
+
return revision_hash(revision.canonical_payload())
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def next_generation(parent_generations: Iterable[int]) -> int:
|
|
109
|
+
"""Generation of a Revision: ``1 + max(parent generations)``, or 1 without parents."""
|
|
110
|
+
return 1 + max(parent_generations, default=ROOT_GENERATION - 1)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def format_timestamp(value: datetime) -> str:
|
|
114
|
+
"""RFC 3339 UTC with exactly six fractional digits."""
|
|
115
|
+
if value.tzinfo is None:
|
|
116
|
+
raise ValueError("timestamp must carry a time zone")
|
|
117
|
+
utc = value.astimezone(UTC)
|
|
118
|
+
return (
|
|
119
|
+
f"{utc.year:04d}-{utc.month:02d}-{utc.day:02d}"
|
|
120
|
+
f"T{utc.hour:02d}:{utc.minute:02d}:{utc.second:02d}.{utc.microsecond:06d}Z"
|
|
121
|
+
)
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Partitioned canonical HAMT: the Revision manifest (design 6.2, 6.7)."""
|
|
2
|
+
|
|
3
|
+
from lorebase_core.tree.diff import DiffEntry, tree_diff
|
|
4
|
+
from lorebase_core.tree.hamt import (
|
|
5
|
+
TreeChange,
|
|
6
|
+
empty_tree,
|
|
7
|
+
tree_apply,
|
|
8
|
+
tree_count,
|
|
9
|
+
tree_entries,
|
|
10
|
+
tree_get,
|
|
11
|
+
)
|
|
12
|
+
from lorebase_core.tree.node import (
|
|
13
|
+
BranchNode,
|
|
14
|
+
LeafNode,
|
|
15
|
+
Node,
|
|
16
|
+
NodeFormatError,
|
|
17
|
+
NodeType,
|
|
18
|
+
PartitionRef,
|
|
19
|
+
RootNode,
|
|
20
|
+
decode_node,
|
|
21
|
+
encode_node,
|
|
22
|
+
key_hash,
|
|
23
|
+
)
|
|
24
|
+
from lorebase_core.tree.params import DEFAULT_LEAF_SIZE, FANOUT, TreeParams
|
|
25
|
+
from lorebase_core.tree.root import (
|
|
26
|
+
LINK_KEY_SEPARATOR,
|
|
27
|
+
PARTITION_SCHEMA,
|
|
28
|
+
Tree,
|
|
29
|
+
kind_partition,
|
|
30
|
+
link_key,
|
|
31
|
+
relation_partition,
|
|
32
|
+
)
|
|
33
|
+
from lorebase_core.tree.store import (
|
|
34
|
+
InMemoryNodeStore,
|
|
35
|
+
MemoryNodeStore,
|
|
36
|
+
MissingNodeError,
|
|
37
|
+
NodeStore,
|
|
38
|
+
StagingNodeStore,
|
|
39
|
+
read_node,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
__all__ = [
|
|
43
|
+
"DEFAULT_LEAF_SIZE",
|
|
44
|
+
"FANOUT",
|
|
45
|
+
"LINK_KEY_SEPARATOR",
|
|
46
|
+
"PARTITION_SCHEMA",
|
|
47
|
+
"BranchNode",
|
|
48
|
+
"DiffEntry",
|
|
49
|
+
"InMemoryNodeStore",
|
|
50
|
+
"LeafNode",
|
|
51
|
+
"MemoryNodeStore",
|
|
52
|
+
"MissingNodeError",
|
|
53
|
+
"Node",
|
|
54
|
+
"NodeFormatError",
|
|
55
|
+
"NodeStore",
|
|
56
|
+
"NodeType",
|
|
57
|
+
"PartitionRef",
|
|
58
|
+
"RootNode",
|
|
59
|
+
"StagingNodeStore",
|
|
60
|
+
"Tree",
|
|
61
|
+
"TreeChange",
|
|
62
|
+
"TreeParams",
|
|
63
|
+
"decode_node",
|
|
64
|
+
"empty_tree",
|
|
65
|
+
"encode_node",
|
|
66
|
+
"key_hash",
|
|
67
|
+
"kind_partition",
|
|
68
|
+
"link_key",
|
|
69
|
+
"read_node",
|
|
70
|
+
"relation_partition",
|
|
71
|
+
"tree_apply",
|
|
72
|
+
"tree_count",
|
|
73
|
+
"tree_diff",
|
|
74
|
+
"tree_entries",
|
|
75
|
+
"tree_get",
|
|
76
|
+
]
|