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,280 @@
|
|
|
1
|
+
"""Compact binary encoding of tree nodes (design 6.2).
|
|
2
|
+
|
|
3
|
+
Every node starts with a header::
|
|
4
|
+
|
|
5
|
+
version (u8) | node type (u8) | depth (u8) | entry count (varint) | subtree count (varint)
|
|
6
|
+
|
|
7
|
+
followed by a type-specific body:
|
|
8
|
+
|
|
9
|
+
- leaf: per entry ``varint(len(key)) | key utf-8 | object hash``, sorted by ``sha256(key)``;
|
|
10
|
+
- branch: a 256-bit occupancy bitmap (bit ``i`` set when the child for key-hash
|
|
11
|
+
byte ``i`` exists, most significant bit first) and the child hashes in byte order;
|
|
12
|
+
- root: per partition ``varint(len(id)) | id utf-8 | partition root hash | varint(count)``,
|
|
13
|
+
sorted by the UTF-8 bytes of the partition id.
|
|
14
|
+
|
|
15
|
+
Varints are unsigned LEB128. Decoding accepts only the canonical encoding, so a
|
|
16
|
+
node has exactly one byte representation and therefore exactly one hash.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import hashlib
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from enum import IntEnum
|
|
24
|
+
|
|
25
|
+
from lorebase_core.canonical import HASH_SIZE, node_hash
|
|
26
|
+
from lorebase_core.tree.params import BITS_PER_BYTE, FANOUT, NODE_FORMAT_VERSION
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"BranchNode",
|
|
30
|
+
"LeafNode",
|
|
31
|
+
"Node",
|
|
32
|
+
"NodeFormatError",
|
|
33
|
+
"NodeType",
|
|
34
|
+
"PartitionRef",
|
|
35
|
+
"RootNode",
|
|
36
|
+
"decode_node",
|
|
37
|
+
"encode_node",
|
|
38
|
+
"key_hash",
|
|
39
|
+
"node_hash",
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
_BITMAP_SIZE = FANOUT // BITS_PER_BYTE
|
|
43
|
+
_VARINT_PAYLOAD_BITS = 7
|
|
44
|
+
_VARINT_PAYLOAD_MASK = (1 << _VARINT_PAYLOAD_BITS) - 1
|
|
45
|
+
_VARINT_CONTINUATION = 1 << _VARINT_PAYLOAD_BITS
|
|
46
|
+
_HIGH_BIT = 1 << (BITS_PER_BYTE - 1)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class NodeType(IntEnum):
|
|
50
|
+
LEAF = 1
|
|
51
|
+
BRANCH = 2
|
|
52
|
+
ROOT = 3
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class NodeFormatError(ValueError):
|
|
56
|
+
"""Raised when node bytes are malformed, non-canonical or inconsistent."""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def key_hash(key: str) -> bytes:
|
|
60
|
+
"""Placement hash of an element key inside a partition: ``sha256(key)``."""
|
|
61
|
+
return hashlib.sha256(key.encode("utf-8")).digest()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(frozen=True, slots=True)
|
|
65
|
+
class LeafNode:
|
|
66
|
+
"""Leaf at ``depth`` holding ``(element_key, object_hash)`` entries sorted by key hash."""
|
|
67
|
+
|
|
68
|
+
depth: int
|
|
69
|
+
entries: tuple[tuple[str, bytes], ...]
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def count(self) -> int:
|
|
73
|
+
return len(self.entries)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass(frozen=True, slots=True)
|
|
77
|
+
class BranchNode:
|
|
78
|
+
"""Branch at ``depth``: ``(byte, child_hash)`` pairs in byte order and the subtree count."""
|
|
79
|
+
|
|
80
|
+
depth: int
|
|
81
|
+
children: tuple[tuple[int, bytes], ...]
|
|
82
|
+
count: int
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass(frozen=True, slots=True)
|
|
86
|
+
class PartitionRef:
|
|
87
|
+
"""Root hash and entry count of one non-empty partition."""
|
|
88
|
+
|
|
89
|
+
root: bytes
|
|
90
|
+
count: int
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclass(frozen=True, slots=True)
|
|
94
|
+
class RootNode:
|
|
95
|
+
"""Revision tree root: non-empty partitions sorted by the UTF-8 bytes of their id."""
|
|
96
|
+
|
|
97
|
+
partitions: tuple[tuple[str, PartitionRef], ...]
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def count(self) -> int:
|
|
101
|
+
return sum(ref.count for _, ref in self.partitions)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
Node = LeafNode | BranchNode | RootNode
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def encode_node(node: Node) -> bytes:
|
|
108
|
+
"""Encode a node to its canonical bytes, validating its invariants."""
|
|
109
|
+
out = bytearray()
|
|
110
|
+
if isinstance(node, LeafNode):
|
|
111
|
+
_check_depth(node.depth)
|
|
112
|
+
if not node.entries:
|
|
113
|
+
raise NodeFormatError("a leaf holds at least one entry")
|
|
114
|
+
previous = b""
|
|
115
|
+
for key, value in node.entries:
|
|
116
|
+
current = key_hash(key)
|
|
117
|
+
if current <= previous:
|
|
118
|
+
raise NodeFormatError("leaf entries must be strictly sorted by key hash")
|
|
119
|
+
previous = current
|
|
120
|
+
_write_bytes(out, _utf8(key))
|
|
121
|
+
_write_hash(out, value)
|
|
122
|
+
return bytes(_header(NodeType.LEAF, node.depth, len(node.entries), node.count) + out)
|
|
123
|
+
if isinstance(node, BranchNode):
|
|
124
|
+
_check_depth(node.depth)
|
|
125
|
+
if not node.children:
|
|
126
|
+
raise NodeFormatError("a branch holds at least one child")
|
|
127
|
+
bitmap = bytearray(_BITMAP_SIZE)
|
|
128
|
+
last = -1
|
|
129
|
+
for index, child in node.children:
|
|
130
|
+
if not last < index < FANOUT:
|
|
131
|
+
raise NodeFormatError("branch children must be strictly sorted bytes")
|
|
132
|
+
last = index
|
|
133
|
+
bitmap[index // BITS_PER_BYTE] |= _HIGH_BIT >> (index % BITS_PER_BYTE)
|
|
134
|
+
_write_hash(out, child)
|
|
135
|
+
if node.count < len(node.children):
|
|
136
|
+
raise NodeFormatError("branch subtree count is smaller than its child count")
|
|
137
|
+
header = _header(NodeType.BRANCH, node.depth, len(node.children), node.count)
|
|
138
|
+
return bytes(header + bitmap + out)
|
|
139
|
+
previous_id = b""
|
|
140
|
+
for index, (partition, ref) in enumerate(node.partitions):
|
|
141
|
+
encoded = _utf8(partition)
|
|
142
|
+
if not encoded or (index and encoded <= previous_id):
|
|
143
|
+
raise NodeFormatError("partition ids must be non-empty and strictly sorted")
|
|
144
|
+
if ref.count < 1:
|
|
145
|
+
raise NodeFormatError("empty partitions are omitted from the root")
|
|
146
|
+
previous_id = encoded
|
|
147
|
+
_write_bytes(out, encoded)
|
|
148
|
+
_write_hash(out, ref.root)
|
|
149
|
+
_write_varint(out, ref.count)
|
|
150
|
+
return bytes(_header(NodeType.ROOT, 0, len(node.partitions), node.count) + out)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def decode_node(data: bytes) -> Node:
|
|
154
|
+
"""Decode canonical node bytes; raise :class:`NodeFormatError` on any deviation."""
|
|
155
|
+
reader = _Reader(data)
|
|
156
|
+
version = reader.byte()
|
|
157
|
+
if version != NODE_FORMAT_VERSION:
|
|
158
|
+
raise NodeFormatError(f"unsupported node format version {version}")
|
|
159
|
+
try:
|
|
160
|
+
node_type = NodeType(reader.byte())
|
|
161
|
+
except ValueError as exc:
|
|
162
|
+
raise NodeFormatError("unknown node type") from exc
|
|
163
|
+
depth = reader.byte()
|
|
164
|
+
entry_count = reader.varint()
|
|
165
|
+
subtree_count = reader.varint()
|
|
166
|
+
node: Node
|
|
167
|
+
if node_type is NodeType.LEAF:
|
|
168
|
+
entries = []
|
|
169
|
+
for _ in range(entry_count):
|
|
170
|
+
key = _from_utf8(reader.take(reader.varint()))
|
|
171
|
+
entries.append((key, reader.take(HASH_SIZE)))
|
|
172
|
+
node = LeafNode(depth, tuple(entries))
|
|
173
|
+
if subtree_count != entry_count:
|
|
174
|
+
raise NodeFormatError("leaf subtree count must equal its entry count")
|
|
175
|
+
elif node_type is NodeType.BRANCH:
|
|
176
|
+
bitmap = reader.take(_BITMAP_SIZE)
|
|
177
|
+
indexes = [
|
|
178
|
+
i
|
|
179
|
+
for i in range(FANOUT)
|
|
180
|
+
if bitmap[i // BITS_PER_BYTE] & (_HIGH_BIT >> (i % BITS_PER_BYTE))
|
|
181
|
+
]
|
|
182
|
+
if len(indexes) != entry_count:
|
|
183
|
+
raise NodeFormatError("branch bitmap does not match its entry count")
|
|
184
|
+
node = BranchNode(depth, tuple((i, reader.take(HASH_SIZE)) for i in indexes), subtree_count)
|
|
185
|
+
else:
|
|
186
|
+
partitions = []
|
|
187
|
+
for _ in range(entry_count):
|
|
188
|
+
partition = _from_utf8(reader.take(reader.varint()))
|
|
189
|
+
root = reader.take(HASH_SIZE)
|
|
190
|
+
partitions.append((partition, PartitionRef(root, reader.varint())))
|
|
191
|
+
node = RootNode(tuple(partitions))
|
|
192
|
+
if depth != 0 or node.count != subtree_count:
|
|
193
|
+
raise NodeFormatError("root header is inconsistent with its partitions")
|
|
194
|
+
reader.finish()
|
|
195
|
+
if encode_node(node) != data:
|
|
196
|
+
raise NodeFormatError("node bytes are not in canonical form")
|
|
197
|
+
return node
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _header(node_type: NodeType, depth: int, entries: int, subtree: int) -> bytearray:
|
|
201
|
+
out = bytearray((NODE_FORMAT_VERSION, node_type, depth))
|
|
202
|
+
_write_varint(out, entries)
|
|
203
|
+
_write_varint(out, subtree)
|
|
204
|
+
return out
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _check_depth(depth: int) -> None:
|
|
208
|
+
if not 0 <= depth < HASH_SIZE:
|
|
209
|
+
raise NodeFormatError(f"depth {depth} out of range")
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _utf8(text: str) -> bytes:
|
|
213
|
+
try:
|
|
214
|
+
return text.encode("utf-8")
|
|
215
|
+
except UnicodeEncodeError as exc:
|
|
216
|
+
raise NodeFormatError("keys must be valid Unicode text") from exc
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _from_utf8(data: bytes) -> str:
|
|
220
|
+
try:
|
|
221
|
+
return data.decode("utf-8")
|
|
222
|
+
except UnicodeDecodeError as exc:
|
|
223
|
+
raise NodeFormatError("keys must be valid UTF-8") from exc
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _write_varint(out: bytearray, value: int) -> None:
|
|
227
|
+
if value < 0:
|
|
228
|
+
raise NodeFormatError("varints are unsigned")
|
|
229
|
+
while True:
|
|
230
|
+
low = value & _VARINT_PAYLOAD_MASK
|
|
231
|
+
value >>= _VARINT_PAYLOAD_BITS
|
|
232
|
+
if value:
|
|
233
|
+
out.append(low | _VARINT_CONTINUATION)
|
|
234
|
+
else:
|
|
235
|
+
out.append(low)
|
|
236
|
+
return
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _write_bytes(out: bytearray, data: bytes) -> None:
|
|
240
|
+
_write_varint(out, len(data))
|
|
241
|
+
out += data
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _write_hash(out: bytearray, value: bytes) -> None:
|
|
245
|
+
if len(value) != HASH_SIZE:
|
|
246
|
+
raise NodeFormatError(f"hashes are {HASH_SIZE} bytes")
|
|
247
|
+
out += value
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
class _Reader:
|
|
251
|
+
__slots__ = ("_data", "_pos")
|
|
252
|
+
|
|
253
|
+
def __init__(self, data: bytes) -> None:
|
|
254
|
+
self._data = data
|
|
255
|
+
self._pos = 0
|
|
256
|
+
|
|
257
|
+
def byte(self) -> int:
|
|
258
|
+
return self.take(1)[0]
|
|
259
|
+
|
|
260
|
+
def take(self, size: int) -> bytes:
|
|
261
|
+
end = self._pos + size
|
|
262
|
+
if end > len(self._data):
|
|
263
|
+
raise NodeFormatError("truncated node")
|
|
264
|
+
chunk = self._data[self._pos : end]
|
|
265
|
+
self._pos = end
|
|
266
|
+
return bytes(chunk)
|
|
267
|
+
|
|
268
|
+
def varint(self) -> int:
|
|
269
|
+
value = 0
|
|
270
|
+
shift = 0
|
|
271
|
+
while True:
|
|
272
|
+
byte = self.byte()
|
|
273
|
+
value |= (byte & _VARINT_PAYLOAD_MASK) << shift
|
|
274
|
+
if not byte & _VARINT_CONTINUATION:
|
|
275
|
+
return value
|
|
276
|
+
shift += _VARINT_PAYLOAD_BITS
|
|
277
|
+
|
|
278
|
+
def finish(self) -> None:
|
|
279
|
+
if self._pos != len(self._data):
|
|
280
|
+
raise NodeFormatError("trailing bytes after node")
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Tree parameters fixed per Domain (design 6.2, AC-11)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
__all__ = ["BITS_PER_BYTE", "DEFAULT_LEAF_SIZE", "FANOUT", "NODE_FORMAT_VERSION", "TreeParams"]
|
|
8
|
+
|
|
9
|
+
BITS_PER_BYTE = 8
|
|
10
|
+
|
|
11
|
+
FANOUT = 1 << BITS_PER_BYTE
|
|
12
|
+
"""Children per branch node: one per value of the key-hash byte at the node depth."""
|
|
13
|
+
|
|
14
|
+
DEFAULT_LEAF_SIZE = 512
|
|
15
|
+
"""Default leaf threshold ``L``: a node is a leaf iff its subtree holds at most ``L`` entries."""
|
|
16
|
+
|
|
17
|
+
NODE_FORMAT_VERSION = 1
|
|
18
|
+
"""Version byte written in every node header."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True, slots=True)
|
|
22
|
+
class TreeParams:
|
|
23
|
+
"""Parameters that are part of the hashing contract of a Domain's trees.
|
|
24
|
+
|
|
25
|
+
They are fixed when the Domain is created: changing them changes every
|
|
26
|
+
root hash of the same content.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
leaf_size: int = DEFAULT_LEAF_SIZE
|
|
30
|
+
|
|
31
|
+
def __post_init__(self) -> None:
|
|
32
|
+
if self.leaf_size < 1:
|
|
33
|
+
raise ValueError("leaf_size must be at least 1")
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Revision tree root: the partition map (design 6.2)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from types import MappingProxyType
|
|
8
|
+
|
|
9
|
+
from lorebase_core.canonical import node_hash
|
|
10
|
+
from lorebase_core.tree.node import NodeFormatError, PartitionRef, RootNode, encode_node
|
|
11
|
+
from lorebase_core.tree.store import NodeStore, read_node
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"LINK_KEY_SEPARATOR",
|
|
15
|
+
"PARTITION_SCHEMA",
|
|
16
|
+
"Tree",
|
|
17
|
+
"kind_partition",
|
|
18
|
+
"link_key",
|
|
19
|
+
"relation_partition",
|
|
20
|
+
"write_root",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
PARTITION_SCHEMA = "schema"
|
|
24
|
+
LINK_KEY_SEPARATOR = "\x1f"
|
|
25
|
+
_KIND_PREFIX = "k:"
|
|
26
|
+
_RELATION_PREFIX = "r:"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def kind_partition(kind_id: str) -> str:
|
|
30
|
+
"""Partition id holding the Records of a Kind, keyed by Record key."""
|
|
31
|
+
return _KIND_PREFIX + kind_id
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def relation_partition(relation_id: str) -> str:
|
|
35
|
+
"""Partition id holding the Links of a Relation, keyed by :func:`link_key`."""
|
|
36
|
+
return _RELATION_PREFIX + relation_id
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def link_key(from_key: str, to_key: str) -> str:
|
|
40
|
+
"""Element key of a Link inside its Relation partition."""
|
|
41
|
+
return from_key + LINK_KEY_SEPARATOR + to_key
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True, eq=False)
|
|
45
|
+
class Tree:
|
|
46
|
+
"""A Revision tree: its root hash and the partition map it points to."""
|
|
47
|
+
|
|
48
|
+
root: bytes
|
|
49
|
+
partitions: Mapping[str, PartitionRef]
|
|
50
|
+
|
|
51
|
+
@classmethod
|
|
52
|
+
def load(cls, store: NodeStore, root: bytes) -> Tree:
|
|
53
|
+
node = read_node(store, root)
|
|
54
|
+
if not isinstance(node, RootNode):
|
|
55
|
+
raise NodeFormatError("hash does not reference a tree root")
|
|
56
|
+
return cls(root, MappingProxyType(dict(node.partitions)))
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def count(self) -> int:
|
|
60
|
+
"""Total number of entries across partitions."""
|
|
61
|
+
return sum(ref.count for ref in self.partitions.values())
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def write_root(store: NodeStore, partitions: Mapping[str, PartitionRef]) -> bytes:
|
|
65
|
+
"""Encode and store a root node for ``partitions``; return its hash."""
|
|
66
|
+
ordered = sorted(partitions.items(), key=lambda item: item[0].encode("utf-8"))
|
|
67
|
+
body = encode_node(RootNode(tuple(ordered)))
|
|
68
|
+
root = node_hash(body)
|
|
69
|
+
store.put(root, body)
|
|
70
|
+
return root
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Node storage protocol and in-memory implementations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from typing import Protocol, runtime_checkable
|
|
7
|
+
|
|
8
|
+
from lorebase_core.canonical import node_hash
|
|
9
|
+
from lorebase_core.tree.node import Node, decode_node
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"InMemoryNodeStore",
|
|
13
|
+
"MemoryNodeStore",
|
|
14
|
+
"MissingNodeError",
|
|
15
|
+
"NodeStore",
|
|
16
|
+
"StagingNodeStore",
|
|
17
|
+
"read_node",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class MissingNodeError(KeyError):
|
|
22
|
+
"""Raised when a node referenced by a tree is not in the store."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@runtime_checkable
|
|
26
|
+
class NodeStore(Protocol):
|
|
27
|
+
"""Content-addressed storage of encoded tree nodes, keyed by node hash."""
|
|
28
|
+
|
|
29
|
+
def get(self, node_hash: bytes) -> bytes | None: ...
|
|
30
|
+
|
|
31
|
+
def put(self, node_hash: bytes, body: bytes) -> None: ...
|
|
32
|
+
|
|
33
|
+
def has(self, node_hash: bytes) -> bool: ...
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class InMemoryNodeStore:
|
|
37
|
+
"""Dictionary-backed :class:`NodeStore` that verifies hashes on write."""
|
|
38
|
+
|
|
39
|
+
def __init__(self) -> None:
|
|
40
|
+
self._nodes: dict[bytes, bytes] = {}
|
|
41
|
+
|
|
42
|
+
def get(self, node_hash: bytes) -> bytes | None:
|
|
43
|
+
return self._nodes.get(node_hash)
|
|
44
|
+
|
|
45
|
+
def put(self, node_hash: bytes, body: bytes) -> None:
|
|
46
|
+
_verify(node_hash, body)
|
|
47
|
+
self._nodes[node_hash] = body
|
|
48
|
+
|
|
49
|
+
def has(self, node_hash: bytes) -> bool:
|
|
50
|
+
return node_hash in self._nodes
|
|
51
|
+
|
|
52
|
+
def __len__(self) -> int:
|
|
53
|
+
return len(self._nodes)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
MemoryNodeStore = InMemoryNodeStore
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class StagingNodeStore:
|
|
60
|
+
"""Reads through to ``base`` and keeps new writes aside in :attr:`written`.
|
|
61
|
+
|
|
62
|
+
Lets a caller collect the nodes produced by a batch update and persist them
|
|
63
|
+
in bulk, or drop them.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
def __init__(self, base: NodeStore) -> None:
|
|
67
|
+
self._base = base
|
|
68
|
+
self._written: dict[bytes, bytes] = {}
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def written(self) -> Mapping[bytes, bytes]:
|
|
72
|
+
"""Nodes written through this store that the base store does not hold."""
|
|
73
|
+
return self._written
|
|
74
|
+
|
|
75
|
+
def get(self, node_hash: bytes) -> bytes | None:
|
|
76
|
+
body = self._written.get(node_hash)
|
|
77
|
+
return body if body is not None else self._base.get(node_hash)
|
|
78
|
+
|
|
79
|
+
def put(self, node_hash: bytes, body: bytes) -> None:
|
|
80
|
+
_verify(node_hash, body)
|
|
81
|
+
if node_hash not in self._written and not self._base.has(node_hash):
|
|
82
|
+
self._written[node_hash] = body
|
|
83
|
+
|
|
84
|
+
def has(self, node_hash: bytes) -> bool:
|
|
85
|
+
return node_hash in self._written or self._base.has(node_hash)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def read_node(store: NodeStore, node_hash: bytes) -> Node:
|
|
89
|
+
"""Fetch and decode a node, raising :class:`MissingNodeError` when absent."""
|
|
90
|
+
body = store.get(node_hash)
|
|
91
|
+
if body is None:
|
|
92
|
+
raise MissingNodeError(node_hash.hex())
|
|
93
|
+
return decode_node(body)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _verify(expected: bytes, body: bytes) -> None:
|
|
97
|
+
if node_hash(body) != expected:
|
|
98
|
+
raise ValueError("node hash does not match node body")
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Type system, cast rules and canonical value encoding (design 5.4 and 6.1)."""
|
|
2
|
+
|
|
3
|
+
from lorebase_core.types.casts import CAST_MATRIX, ELEMENT_RULE, CastRule, cast, cast_rule
|
|
4
|
+
from lorebase_core.types.constraints import (
|
|
5
|
+
ConstraintName,
|
|
6
|
+
Constraints,
|
|
7
|
+
ConstraintViolation,
|
|
8
|
+
check_constraints,
|
|
9
|
+
compile_pattern,
|
|
10
|
+
constraint_problems,
|
|
11
|
+
)
|
|
12
|
+
from lorebase_core.types.encoding import (
|
|
13
|
+
decode_value,
|
|
14
|
+
encode_value,
|
|
15
|
+
format_date,
|
|
16
|
+
format_duration,
|
|
17
|
+
format_timestamp,
|
|
18
|
+
is_canonical,
|
|
19
|
+
normalize_key,
|
|
20
|
+
normalize_text,
|
|
21
|
+
to_text,
|
|
22
|
+
)
|
|
23
|
+
from lorebase_core.types.errors import CastError, EncodingError, EncodingReason, TypeSystemError
|
|
24
|
+
from lorebase_core.types.matching import fold_text, matching_form
|
|
25
|
+
from lorebase_core.types.types import (
|
|
26
|
+
INT64_MAX,
|
|
27
|
+
INT64_MIN,
|
|
28
|
+
ORDERED_TYPES,
|
|
29
|
+
TEXTUAL_TYPES,
|
|
30
|
+
AttributeType,
|
|
31
|
+
CanonicalValue,
|
|
32
|
+
GeoPoint,
|
|
33
|
+
TypeSpec,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
__all__ = [
|
|
37
|
+
"CAST_MATRIX",
|
|
38
|
+
"ELEMENT_RULE",
|
|
39
|
+
"INT64_MAX",
|
|
40
|
+
"INT64_MIN",
|
|
41
|
+
"ORDERED_TYPES",
|
|
42
|
+
"TEXTUAL_TYPES",
|
|
43
|
+
"AttributeType",
|
|
44
|
+
"CanonicalValue",
|
|
45
|
+
"CastError",
|
|
46
|
+
"CastRule",
|
|
47
|
+
"ConstraintName",
|
|
48
|
+
"ConstraintViolation",
|
|
49
|
+
"Constraints",
|
|
50
|
+
"EncodingError",
|
|
51
|
+
"EncodingReason",
|
|
52
|
+
"GeoPoint",
|
|
53
|
+
"TypeSpec",
|
|
54
|
+
"TypeSystemError",
|
|
55
|
+
"cast",
|
|
56
|
+
"cast_rule",
|
|
57
|
+
"check_constraints",
|
|
58
|
+
"compile_pattern",
|
|
59
|
+
"constraint_problems",
|
|
60
|
+
"decode_value",
|
|
61
|
+
"encode_value",
|
|
62
|
+
"fold_text",
|
|
63
|
+
"format_date",
|
|
64
|
+
"format_duration",
|
|
65
|
+
"format_timestamp",
|
|
66
|
+
"is_canonical",
|
|
67
|
+
"matching_form",
|
|
68
|
+
"normalize_key",
|
|
69
|
+
"normalize_text",
|
|
70
|
+
"to_text",
|
|
71
|
+
]
|