lorebase-keeper 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_keeper/__init__.py +1 -0
- lorebase_keeper/__main__.py +456 -0
- lorebase_keeper/agents/__init__.py +1 -0
- lorebase_keeper/agents/budget.py +247 -0
- lorebase_keeper/agents/context.py +529 -0
- lorebase_keeper/agents/explain.py +582 -0
- lorebase_keeper/agents/feed.py +259 -0
- lorebase_keeper/agents/markdown.py +219 -0
- lorebase_keeper/agents/pack.py +403 -0
- lorebase_keeper/agents/settings.py +111 -0
- lorebase_keeper/app.py +373 -0
- lorebase_keeper/auth/__init__.py +4 -0
- lorebase_keeper/auth/api_keys.py +127 -0
- lorebase_keeper/auth/audit.py +356 -0
- lorebase_keeper/auth/cache.py +53 -0
- lorebase_keeper/auth/config.py +618 -0
- lorebase_keeper/auth/deps.py +183 -0
- lorebase_keeper/auth/keyring.py +14 -0
- lorebase_keeper/auth/oidc.py +242 -0
- lorebase_keeper/auth/permissions.py +142 -0
- lorebase_keeper/auth/ports.py +212 -0
- lorebase_keeper/auth/principals.py +377 -0
- lorebase_keeper/auth/provider.py +250 -0
- lorebase_keeper/auth/rbac.py +247 -0
- lorebase_keeper/auth/read_audit.py +357 -0
- lorebase_keeper/auth/runtime.py +113 -0
- lorebase_keeper/auth/sessions.py +452 -0
- lorebase_keeper/bootstrap.py +151 -0
- lorebase_keeper/client_address.py +129 -0
- lorebase_keeper/context.py +226 -0
- lorebase_keeper/db.py +93 -0
- lorebase_keeper/deps.py +47 -0
- lorebase_keeper/errors.py +257 -0
- lorebase_keeper/health.py +61 -0
- lorebase_keeper/kindhealth/__init__.py +6 -0
- lorebase_keeper/kindhealth/config.py +51 -0
- lorebase_keeper/kindhealth/markdown.py +163 -0
- lorebase_keeper/kindhealth/models.py +179 -0
- lorebase_keeper/kindhealth/report.py +154 -0
- lorebase_keeper/kindhealth/scan.py +512 -0
- lorebase_keeper/middleware.py +285 -0
- lorebase_keeper/pagination.py +87 -0
- lorebase_keeper/ratelimit/__init__.py +10 -0
- lorebase_keeper/ratelimit/bucket.py +118 -0
- lorebase_keeper/ratelimit/config.py +283 -0
- lorebase_keeper/ratelimit/enforce.py +393 -0
- lorebase_keeper/ratelimit/limiter.py +149 -0
- lorebase_keeper/ratelimit/quotas.py +204 -0
- lorebase_keeper/ratelimit/shared.py +242 -0
- lorebase_keeper/readiness.py +257 -0
- lorebase_keeper/reading/__init__.py +1 -0
- lorebase_keeper/reading/caching.py +251 -0
- lorebase_keeper/reading/compiler.py +678 -0
- lorebase_keeper/reading/cursors.py +56 -0
- lorebase_keeper/reading/diff.py +408 -0
- lorebase_keeper/reading/graph.py +268 -0
- lorebase_keeper/reading/history.py +152 -0
- lorebase_keeper/reading/points.py +410 -0
- lorebase_keeper/reading/problems.py +74 -0
- lorebase_keeper/reading/record_history.py +722 -0
- lorebase_keeper/reading/records.py +780 -0
- lorebase_keeper/reading/requests.py +107 -0
- lorebase_keeper/reading/runtime.py +145 -0
- lorebase_keeper/reading/search.py +708 -0
- lorebase_keeper/reading/sensitivity.py +882 -0
- lorebase_keeper/reading/settings.py +339 -0
- lorebase_keeper/reading/snapshots.py +148 -0
- lorebase_keeper/reading/tree.py +98 -0
- lorebase_keeper/reading/views.py +264 -0
- lorebase_keeper/reading/visibility.py +93 -0
- lorebase_keeper/routes/__init__.py +1 -0
- lorebase_keeper/routes/admin.py +597 -0
- lorebase_keeper/routes/agents.py +160 -0
- lorebase_keeper/routes/branches.py +438 -0
- lorebase_keeper/routes/derivations.py +215 -0
- lorebase_keeper/routes/documents.py +497 -0
- lorebase_keeper/routes/domain_api.py +59 -0
- lorebase_keeper/routes/domains.py +220 -0
- lorebase_keeper/routes/events.py +187 -0
- lorebase_keeper/routes/kind_health.py +166 -0
- lorebase_keeper/routes/operations.py +392 -0
- lorebase_keeper/routes/proposal_items.py +563 -0
- lorebase_keeper/routes/proposals.py +1107 -0
- lorebase_keeper/routes/read.py +493 -0
- lorebase_keeper/routes/revisions.py +523 -0
- lorebase_keeper/routes/schema.py +682 -0
- lorebase_keeper/routes/schema_inference.py +437 -0
- lorebase_keeper/routes/schema_suggestions.py +328 -0
- lorebase_keeper/routes/sessions.py +286 -0
- lorebase_keeper/routes/source_preview.py +232 -0
- lorebase_keeper/routes/sources.py +200 -0
- lorebase_keeper/routes/trace.py +506 -0
- lorebase_keeper/routes/vault.py +332 -0
- lorebase_keeper/routes/webhooks.py +298 -0
- lorebase_keeper/routes/whoami.py +88 -0
- lorebase_keeper/services/__init__.py +1 -0
- lorebase_keeper/services/agent_writes.py +1035 -0
- lorebase_keeper/services/branches.py +337 -0
- lorebase_keeper/services/config.py +431 -0
- lorebase_keeper/services/derivation_preview.py +279 -0
- lorebase_keeper/services/document_urls.py +114 -0
- lorebase_keeper/services/domains.py +118 -0
- lorebase_keeper/services/errors.py +196 -0
- lorebase_keeper/services/operation_catalog.py +806 -0
- lorebase_keeper/services/operation_events.py +199 -0
- lorebase_keeper/services/operations.py +1229 -0
- lorebase_keeper/services/pii_import.py +222 -0
- lorebase_keeper/services/proposal_reads.py +158 -0
- lorebase_keeper/services/proposals.py +850 -0
- lorebase_keeper/services/reads.py +286 -0
- lorebase_keeper/services/schema.py +601 -0
- lorebase_keeper/services/schema_inference.py +390 -0
- lorebase_keeper/services/schema_suggestions.py +418 -0
- lorebase_keeper/services/source_preview.py +127 -0
- lorebase_keeper/settings.py +313 -0
- lorebase_keeper/tracing/__init__.py +1 -0
- lorebase_keeper/tracing/capture.py +350 -0
- lorebase_keeper/tracing/config.py +157 -0
- lorebase_keeper/tracing/explain.py +97 -0
- lorebase_keeper/tracing/middleware.py +359 -0
- lorebase_keeper/tracing/notes.py +80 -0
- lorebase_keeper/tracing/recorder.py +180 -0
- lorebase_keeper-0.2.3.dist-info/METADATA +21 -0
- lorebase_keeper-0.2.3.dist-info/RECORD +126 -0
- lorebase_keeper-0.2.3.dist-info/WHEEL +4 -0
- lorebase_keeper-0.2.3.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Opaque cursors of read endpoints, bound to the request they were issued for.
|
|
2
|
+
|
|
3
|
+
A cursor carries a keyset position and a fingerprint of the request shape (query, filters,
|
|
4
|
+
ordering), so a cursor replayed against a different request is rejected instead of silently
|
|
5
|
+
returning a wrong page.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import hashlib
|
|
11
|
+
import json
|
|
12
|
+
from collections.abc import Sequence
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from lorebase_keeper.errors import VALIDATION_FAILED, ProblemError
|
|
16
|
+
from lorebase_keeper.pagination import decode_cursor, encode_cursor
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def fingerprint(*parts: Any) -> str:
|
|
20
|
+
"""Short stable digest of the request parts a cursor depends on."""
|
|
21
|
+
raw = json.dumps(parts, sort_keys=True, separators=(",", ":"), default=str)
|
|
22
|
+
return hashlib.sha256(raw.encode()).hexdigest()[:16]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def issue(shape: str, position: Sequence[Any]) -> str:
|
|
26
|
+
return encode_cursor({"q": shape, "p": list(position)})
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def position_of(cursor: str | None, shape: str, pointer: str) -> list[Any] | None:
|
|
30
|
+
"""Position of a cursor issued for `shape`; 422 naming `pointer` otherwise."""
|
|
31
|
+
if not cursor:
|
|
32
|
+
return None
|
|
33
|
+
try:
|
|
34
|
+
decoded = decode_cursor(cursor)
|
|
35
|
+
except ValueError:
|
|
36
|
+
decoded = {}
|
|
37
|
+
position = decoded.get("p")
|
|
38
|
+
if decoded.get("q") != shape or not isinstance(position, list):
|
|
39
|
+
raise invalid_cursor(pointer)
|
|
40
|
+
return position
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def invalid_cursor(pointer: str) -> ProblemError:
|
|
44
|
+
return ProblemError(
|
|
45
|
+
VALIDATION_FAILED,
|
|
46
|
+
"One or more request members are invalid.",
|
|
47
|
+
extensions={
|
|
48
|
+
"errors": [
|
|
49
|
+
{
|
|
50
|
+
"pointer": pointer,
|
|
51
|
+
"message": "is not a cursor issued for this request",
|
|
52
|
+
"code": "invalid_cursor",
|
|
53
|
+
}
|
|
54
|
+
]
|
|
55
|
+
},
|
|
56
|
+
)
|
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
"""Diff of two Revisions and expansion of element changes (design 6.3, 6.7).
|
|
2
|
+
|
|
3
|
+
`compute_diff` loads, level by level and in batched queries, only the tree nodes that differ
|
|
4
|
+
between the two trees (equal subtrees are never read), then runs the core `tree_diff` over
|
|
5
|
+
them in memory. The result is the ordered list of `(partition, key, old, new)` hashes, capped
|
|
6
|
+
by `read.diff.max_changes`; partition filters are applied before any node is read.
|
|
7
|
+
|
|
8
|
+
`expand` turns hash-level changes into element changes: Records and Links attribute by
|
|
9
|
+
attribute, schema elements property by property, with api names taken from the newer schema
|
|
10
|
+
(the older one for removed elements).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from collections.abc import Iterable, Mapping, Sequence
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from enum import StrEnum
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from pydantic import BaseModel, Field, JsonValue
|
|
21
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
22
|
+
|
|
23
|
+
from lorebase_core.canonical import node_hash
|
|
24
|
+
from lorebase_core.model import Attribute, Schema
|
|
25
|
+
from lorebase_core.tree import (
|
|
26
|
+
BranchNode,
|
|
27
|
+
DiffEntry,
|
|
28
|
+
MissingNodeError,
|
|
29
|
+
PartitionRef,
|
|
30
|
+
RootNode,
|
|
31
|
+
Tree,
|
|
32
|
+
decode_node,
|
|
33
|
+
encode_node,
|
|
34
|
+
tree_diff,
|
|
35
|
+
)
|
|
36
|
+
from lorebase_keeper.errors import VALIDATION_FAILED, ProblemError
|
|
37
|
+
from lorebase_keeper.reading.runtime import ReadRuntime
|
|
38
|
+
from lorebase_keeper.reading.sensitivity import Masking, relation_kinds
|
|
39
|
+
from lorebase_store.materialize.rows import PartitionType, parse_partition, split_link_key
|
|
40
|
+
from lorebase_store.nodestore import NodeRepository
|
|
41
|
+
from lorebase_store.objectstore import PgObjectStore
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ChangeType(StrEnum):
|
|
45
|
+
ADDED = "added"
|
|
46
|
+
REMOVED = "removed"
|
|
47
|
+
MODIFIED = "modified"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class ValueChange(BaseModel):
|
|
51
|
+
"""One changed Attribute (Records, Links) or property (schema elements)."""
|
|
52
|
+
|
|
53
|
+
name: str
|
|
54
|
+
old: JsonValue = None
|
|
55
|
+
new: JsonValue = None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class ChangeOut(BaseModel):
|
|
59
|
+
"""One changed element."""
|
|
60
|
+
|
|
61
|
+
element: PartitionType
|
|
62
|
+
change: ChangeType
|
|
63
|
+
partition: str
|
|
64
|
+
kind: str | None = None
|
|
65
|
+
relation: str | None = None
|
|
66
|
+
key: str | None = Field(default=None, description="Record key or schema element id.")
|
|
67
|
+
from_key: str | None = None
|
|
68
|
+
to_key: str | None = None
|
|
69
|
+
element_type: str | None = Field(default=None, description="Schema object type.")
|
|
70
|
+
api_name: str | None = None
|
|
71
|
+
old_hash: str | None = None
|
|
72
|
+
new_hash: str | None = None
|
|
73
|
+
changes: list[ValueChange] | None = None
|
|
74
|
+
provenance: dict[str, str] | None = Field(
|
|
75
|
+
default=None, description="Provenance entry per changed Attribute (changesets)."
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class PartitionSummary(BaseModel):
|
|
80
|
+
partition: str
|
|
81
|
+
element: PartitionType
|
|
82
|
+
name: str
|
|
83
|
+
added: int = 0
|
|
84
|
+
removed: int = 0
|
|
85
|
+
modified: int = 0
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass(frozen=True, slots=True)
|
|
89
|
+
class HashChange:
|
|
90
|
+
partition: str
|
|
91
|
+
key: str
|
|
92
|
+
old_hash: bytes | None
|
|
93
|
+
new_hash: bytes | None
|
|
94
|
+
provenance: Mapping[str, str] | None = None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class _Nodes:
|
|
98
|
+
"""Read-only in-memory `NodeStore` over prefetched node bodies."""
|
|
99
|
+
|
|
100
|
+
def __init__(self) -> None:
|
|
101
|
+
self.bodies: dict[bytes, bytes] = {}
|
|
102
|
+
|
|
103
|
+
def get(self, node_hash: bytes) -> bytes | None:
|
|
104
|
+
return self.bodies.get(node_hash)
|
|
105
|
+
|
|
106
|
+
def put(self, node_hash: bytes, body: bytes) -> None:
|
|
107
|
+
self.bodies[node_hash] = body
|
|
108
|
+
|
|
109
|
+
def has(self, node_hash: bytes) -> bool:
|
|
110
|
+
return node_hash in self.bodies
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def too_large(message: str) -> ProblemError:
|
|
114
|
+
return ProblemError(
|
|
115
|
+
VALIDATION_FAILED,
|
|
116
|
+
message,
|
|
117
|
+
extensions={
|
|
118
|
+
"errors": [{"pointer": "/query/kinds", "message": message, "code": "diff_too_large"}]
|
|
119
|
+
},
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
async def compute_diff(
|
|
124
|
+
session: AsyncSession,
|
|
125
|
+
runtime: ReadRuntime,
|
|
126
|
+
domain_id: str,
|
|
127
|
+
root_a: bytes,
|
|
128
|
+
root_b: bytes,
|
|
129
|
+
*,
|
|
130
|
+
partitions: set[str] | None = None,
|
|
131
|
+
) -> list[DiffEntry]:
|
|
132
|
+
"""Changes from tree `root_a` to tree `root_b`, in partition then key-hash order."""
|
|
133
|
+
settings = runtime.store_settings
|
|
134
|
+
cap = runtime.read.diff.max_changes
|
|
135
|
+
nodes = NodeRepository(
|
|
136
|
+
session,
|
|
137
|
+
domain_id,
|
|
138
|
+
writes=settings.writes,
|
|
139
|
+
cache=runtime.store.node_cache,
|
|
140
|
+
batch=settings.nodes.prefetch_batch,
|
|
141
|
+
)
|
|
142
|
+
tree_a = await nodes.load_tree(root_a)
|
|
143
|
+
tree_b = await nodes.load_tree(root_b)
|
|
144
|
+
selected = sorted(
|
|
145
|
+
p
|
|
146
|
+
for p in tree_a.partitions.keys() | tree_b.partitions.keys()
|
|
147
|
+
if partitions is None or p in partitions
|
|
148
|
+
)
|
|
149
|
+
lower_bound = sum(
|
|
150
|
+
abs(_count(tree_a, p) - _count(tree_b, p))
|
|
151
|
+
for p in selected
|
|
152
|
+
if _root(tree_a, p) != _root(tree_b, p)
|
|
153
|
+
)
|
|
154
|
+
if lower_bound > cap:
|
|
155
|
+
raise too_large(
|
|
156
|
+
f"The diff holds at least {lower_bound} changes, more than the limit of {cap}; "
|
|
157
|
+
"narrow it with kinds or relations."
|
|
158
|
+
)
|
|
159
|
+
store = _Nodes()
|
|
160
|
+
frontier = [
|
|
161
|
+
(_root(tree_a, p), _root(tree_b, p))
|
|
162
|
+
for p in selected
|
|
163
|
+
if _root(tree_a, p) != _root(tree_b, p)
|
|
164
|
+
]
|
|
165
|
+
while frontier:
|
|
166
|
+
wanted = {h for pair in frontier for h in pair if h is not None and h not in store.bodies}
|
|
167
|
+
store.bodies.update(await nodes.get_many(wanted))
|
|
168
|
+
following: list[tuple[bytes | None, bytes | None]] = []
|
|
169
|
+
for a, b in frontier:
|
|
170
|
+
if a == b:
|
|
171
|
+
continue
|
|
172
|
+
children_a = _children(store, a)
|
|
173
|
+
children_b = _children(store, b)
|
|
174
|
+
if children_a is None and children_b is None:
|
|
175
|
+
continue
|
|
176
|
+
for index in sorted((children_a or {}).keys() | (children_b or {}).keys()):
|
|
177
|
+
child_a = None if children_a is None else children_a.get(index)
|
|
178
|
+
child_b = None if children_b is None else children_b.get(index)
|
|
179
|
+
if child_a != child_b:
|
|
180
|
+
following.append((child_a, child_b))
|
|
181
|
+
frontier = following
|
|
182
|
+
root_a = _write_root(store, tree_a, selected)
|
|
183
|
+
root_b = _write_root(store, tree_b, selected)
|
|
184
|
+
entries: list[DiffEntry] = []
|
|
185
|
+
try:
|
|
186
|
+
for entry in tree_diff(store, root_a, root_b):
|
|
187
|
+
entries.append(entry)
|
|
188
|
+
if len(entries) > cap:
|
|
189
|
+
raise too_large(
|
|
190
|
+
f"The diff holds more than {cap} changes; narrow it with kinds or relations."
|
|
191
|
+
)
|
|
192
|
+
except MissingNodeError as error: # pragma: no cover - prefetch covers every read
|
|
193
|
+
raise RuntimeError(f"tree node {error} was not prefetched") from error
|
|
194
|
+
return entries
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _root(tree: Tree, partition: str) -> bytes | None:
|
|
198
|
+
ref = tree.partitions.get(partition)
|
|
199
|
+
return None if ref is None else ref.root
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _count(tree: Tree, partition: str) -> int:
|
|
203
|
+
ref = tree.partitions.get(partition)
|
|
204
|
+
return 0 if ref is None else ref.count
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _children(store: _Nodes, digest: bytes | None) -> dict[int, bytes] | None:
|
|
208
|
+
"""Children of a branch node; None for leaves and absent sides."""
|
|
209
|
+
if digest is None:
|
|
210
|
+
return None
|
|
211
|
+
body = store.bodies.get(digest)
|
|
212
|
+
if body is None:
|
|
213
|
+
raise MissingNodeError(digest.hex())
|
|
214
|
+
node = decode_node(body)
|
|
215
|
+
return dict(node.children) if isinstance(node, BranchNode) else None
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _write_root(store: _Nodes, tree: Tree, partitions: Sequence[str]) -> bytes:
|
|
219
|
+
"""A root node holding only the selected partitions of `tree`."""
|
|
220
|
+
kept: list[tuple[str, PartitionRef]] = [
|
|
221
|
+
(p, tree.partitions[p]) for p in partitions if p in tree.partitions
|
|
222
|
+
]
|
|
223
|
+
kept.sort(key=lambda item: item[0].encode("utf-8"))
|
|
224
|
+
body = encode_node(RootNode(tuple(kept)))
|
|
225
|
+
digest = node_hash(body)
|
|
226
|
+
store.bodies[digest] = body
|
|
227
|
+
return digest
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
# Expansion --------------------------------------------------------------------------------
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def partition_name(partition: str, schemas: Sequence[Schema]) -> tuple[PartitionType, str]:
|
|
234
|
+
"""Type of a partition and the api name of its Kind or Relation (id when unknown)."""
|
|
235
|
+
parsed = parse_partition(partition)
|
|
236
|
+
for schema in schemas:
|
|
237
|
+
if parsed.type is PartitionType.KIND and parsed.element_id in schema.kinds:
|
|
238
|
+
return parsed.type, schema.kinds[parsed.element_id].api_name
|
|
239
|
+
if parsed.type is PartitionType.RELATION and parsed.element_id in schema.relations:
|
|
240
|
+
return parsed.type, schema.relations[parsed.element_id].api_name
|
|
241
|
+
return parsed.type, "schema" if parsed.type is PartitionType.SCHEMA else parsed.element_id
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def summarize(
|
|
245
|
+
changes: Iterable[HashChange | DiffEntry], schemas: Sequence[Schema]
|
|
246
|
+
) -> list[PartitionSummary]:
|
|
247
|
+
"""Added, removed and modified elements per partition."""
|
|
248
|
+
summary: dict[str, PartitionSummary] = {}
|
|
249
|
+
for change in changes:
|
|
250
|
+
old, new = _hashes(change)
|
|
251
|
+
item = summary.get(change.partition)
|
|
252
|
+
if item is None:
|
|
253
|
+
element, name = partition_name(change.partition, schemas)
|
|
254
|
+
item = summary[change.partition] = PartitionSummary(
|
|
255
|
+
partition=change.partition, element=element, name=name
|
|
256
|
+
)
|
|
257
|
+
if old is None:
|
|
258
|
+
item.added += 1
|
|
259
|
+
elif new is None:
|
|
260
|
+
item.removed += 1
|
|
261
|
+
else:
|
|
262
|
+
item.modified += 1
|
|
263
|
+
return list(summary.values())
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _hashes(change: HashChange | DiffEntry) -> tuple[bytes | None, bytes | None]:
|
|
267
|
+
if isinstance(change, HashChange):
|
|
268
|
+
return change.old_hash, change.new_hash
|
|
269
|
+
return change.hash_a, change.hash_b
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def as_hash_change(entry: DiffEntry) -> HashChange:
|
|
273
|
+
return HashChange(entry.partition, entry.key, entry.hash_a, entry.hash_b)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
async def expand(
|
|
277
|
+
session: AsyncSession,
|
|
278
|
+
runtime: ReadRuntime,
|
|
279
|
+
domain_id: str,
|
|
280
|
+
changes: Sequence[HashChange],
|
|
281
|
+
*,
|
|
282
|
+
old_schema: Schema,
|
|
283
|
+
new_schema: Schema,
|
|
284
|
+
masking: Masking,
|
|
285
|
+
) -> list[ChangeOut]:
|
|
286
|
+
"""Element-level changes, attribute by attribute (see module doc), with the values the
|
|
287
|
+
caller is not cleared for masked."""
|
|
288
|
+
objects = PgObjectStore(
|
|
289
|
+
session,
|
|
290
|
+
domain_id,
|
|
291
|
+
writes=runtime.store_settings.writes,
|
|
292
|
+
batch=runtime.store_settings.materialize.read_batch_rows,
|
|
293
|
+
)
|
|
294
|
+
hashes = {h for c in changes for h in (c.old_hash, c.new_hash) if h is not None}
|
|
295
|
+
bodies = {digest: stored.body for digest, stored in (await objects.get_many(hashes)).items()}
|
|
296
|
+
schemas = (new_schema, old_schema)
|
|
297
|
+
return [_expand(change, bodies, schemas, masking) for change in changes]
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _expand(
|
|
301
|
+
change: HashChange,
|
|
302
|
+
bodies: Mapping[bytes, Mapping[str, Any]],
|
|
303
|
+
schemas: Sequence[Schema],
|
|
304
|
+
masking: Masking,
|
|
305
|
+
) -> ChangeOut:
|
|
306
|
+
old = None if change.old_hash is None else bodies.get(change.old_hash, {})
|
|
307
|
+
new = None if change.new_hash is None else bodies.get(change.new_hash, {})
|
|
308
|
+
kind = (
|
|
309
|
+
ChangeType.ADDED
|
|
310
|
+
if old is None
|
|
311
|
+
else ChangeType.REMOVED
|
|
312
|
+
if new is None
|
|
313
|
+
else ChangeType.MODIFIED
|
|
314
|
+
)
|
|
315
|
+
element, name = partition_name(change.partition, schemas)
|
|
316
|
+
out = ChangeOut(
|
|
317
|
+
element=element,
|
|
318
|
+
change=kind,
|
|
319
|
+
partition=change.partition,
|
|
320
|
+
old_hash=None if change.old_hash is None else change.old_hash.hex(),
|
|
321
|
+
new_hash=None if change.new_hash is None else change.new_hash.hex(),
|
|
322
|
+
)
|
|
323
|
+
parsed = parse_partition(change.partition)
|
|
324
|
+
if element is PartitionType.SCHEMA:
|
|
325
|
+
body = new if new is not None else old or {}
|
|
326
|
+
out.key = change.key
|
|
327
|
+
out.api_name = body.get("api_name") or body.get("id")
|
|
328
|
+
out.element_type = _schema_type(change.key, schemas)
|
|
329
|
+
out.changes = _value_changes(old or {}, new or {}, lambda key: key)
|
|
330
|
+
out.provenance = None if change.provenance is None else dict(change.provenance)
|
|
331
|
+
return out
|
|
332
|
+
names = _attribute_names(parsed.type, parsed.element_id, schemas)
|
|
333
|
+
if element is PartitionType.KIND:
|
|
334
|
+
out.kind = name
|
|
335
|
+
out.key = change.key
|
|
336
|
+
else:
|
|
337
|
+
out.relation = name
|
|
338
|
+
out.from_key, out.to_key = split_link_key(change.key)
|
|
339
|
+
attributes, kinds = _attributes(parsed.type, parsed.element_id, schemas)
|
|
340
|
+
out.changes = [
|
|
341
|
+
ValueChange(
|
|
342
|
+
name=value.name,
|
|
343
|
+
old=masking.value(attributes.get(value.name), kinds, value.old),
|
|
344
|
+
new=masking.value(attributes.get(value.name), kinds, value.new),
|
|
345
|
+
)
|
|
346
|
+
for value in _value_changes(
|
|
347
|
+
(old or {}).get("values", {}), (new or {}).get("values", {}), lambda a: a
|
|
348
|
+
)
|
|
349
|
+
]
|
|
350
|
+
for value in out.changes:
|
|
351
|
+
value.name = names.get(value.name, value.name)
|
|
352
|
+
if change.provenance is not None:
|
|
353
|
+
out.provenance = {names.get(a, a): p for a, p in sorted(change.provenance.items())}
|
|
354
|
+
return out
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _attribute_names(
|
|
358
|
+
partition_type: PartitionType, element_id: str, schemas: Sequence[Schema]
|
|
359
|
+
) -> dict[str, str]:
|
|
360
|
+
names: dict[str, str] = {}
|
|
361
|
+
for schema in reversed(schemas): # older first, newer names win
|
|
362
|
+
owner = (
|
|
363
|
+
schema.kinds.get(element_id)
|
|
364
|
+
if partition_type is PartitionType.KIND
|
|
365
|
+
else schema.relations.get(element_id)
|
|
366
|
+
)
|
|
367
|
+
if owner is not None:
|
|
368
|
+
names.update({a.id: a.api_name for a in owner.attributes.values()})
|
|
369
|
+
return names
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _attributes(
|
|
373
|
+
partition_type: PartitionType, element_id: str, schemas: Sequence[Schema]
|
|
374
|
+
) -> tuple[dict[str, Attribute], tuple[str, ...]]:
|
|
375
|
+
"""Attributes of a Kind or Relation by id (newer schemas win) and the api names of the
|
|
376
|
+
Kinds holding their values, for masking."""
|
|
377
|
+
found: dict[str, Attribute] = {}
|
|
378
|
+
kinds: tuple[str, ...] = ()
|
|
379
|
+
for schema in reversed(schemas): # older first, newer definitions win
|
|
380
|
+
if partition_type is PartitionType.KIND:
|
|
381
|
+
kind = schema.kinds.get(element_id)
|
|
382
|
+
kinds = (element_id,)
|
|
383
|
+
if kind is not None:
|
|
384
|
+
found.update(kind.attributes)
|
|
385
|
+
else:
|
|
386
|
+
relation = schema.relations.get(element_id)
|
|
387
|
+
if relation is not None:
|
|
388
|
+
found.update(relation.attributes)
|
|
389
|
+
kinds = relation_kinds(schema, relation)
|
|
390
|
+
return found, kinds
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def _schema_type(element_id: str, schemas: Sequence[Schema]) -> str:
|
|
394
|
+
for schema in schemas:
|
|
395
|
+
if element_id == schema.domain.id:
|
|
396
|
+
return "domain"
|
|
397
|
+
for collection in ("kinds", "relations", "traits", "operations", "derivations", "sources"):
|
|
398
|
+
if element_id in getattr(schema, collection):
|
|
399
|
+
return collection.removesuffix("s")
|
|
400
|
+
return "element"
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def _value_changes(old: Mapping[str, Any], new: Mapping[str, Any], name: Any) -> list[ValueChange]:
|
|
404
|
+
return [
|
|
405
|
+
ValueChange(name=name(key), old=old.get(key), new=new.get(key))
|
|
406
|
+
for key in sorted(old.keys() | new.keys())
|
|
407
|
+
if old.get(key) != new.get(key)
|
|
408
|
+
]
|