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.
Files changed (126) hide show
  1. lorebase_keeper/__init__.py +1 -0
  2. lorebase_keeper/__main__.py +456 -0
  3. lorebase_keeper/agents/__init__.py +1 -0
  4. lorebase_keeper/agents/budget.py +247 -0
  5. lorebase_keeper/agents/context.py +529 -0
  6. lorebase_keeper/agents/explain.py +582 -0
  7. lorebase_keeper/agents/feed.py +259 -0
  8. lorebase_keeper/agents/markdown.py +219 -0
  9. lorebase_keeper/agents/pack.py +403 -0
  10. lorebase_keeper/agents/settings.py +111 -0
  11. lorebase_keeper/app.py +373 -0
  12. lorebase_keeper/auth/__init__.py +4 -0
  13. lorebase_keeper/auth/api_keys.py +127 -0
  14. lorebase_keeper/auth/audit.py +356 -0
  15. lorebase_keeper/auth/cache.py +53 -0
  16. lorebase_keeper/auth/config.py +618 -0
  17. lorebase_keeper/auth/deps.py +183 -0
  18. lorebase_keeper/auth/keyring.py +14 -0
  19. lorebase_keeper/auth/oidc.py +242 -0
  20. lorebase_keeper/auth/permissions.py +142 -0
  21. lorebase_keeper/auth/ports.py +212 -0
  22. lorebase_keeper/auth/principals.py +377 -0
  23. lorebase_keeper/auth/provider.py +250 -0
  24. lorebase_keeper/auth/rbac.py +247 -0
  25. lorebase_keeper/auth/read_audit.py +357 -0
  26. lorebase_keeper/auth/runtime.py +113 -0
  27. lorebase_keeper/auth/sessions.py +452 -0
  28. lorebase_keeper/bootstrap.py +151 -0
  29. lorebase_keeper/client_address.py +129 -0
  30. lorebase_keeper/context.py +226 -0
  31. lorebase_keeper/db.py +93 -0
  32. lorebase_keeper/deps.py +47 -0
  33. lorebase_keeper/errors.py +257 -0
  34. lorebase_keeper/health.py +61 -0
  35. lorebase_keeper/kindhealth/__init__.py +6 -0
  36. lorebase_keeper/kindhealth/config.py +51 -0
  37. lorebase_keeper/kindhealth/markdown.py +163 -0
  38. lorebase_keeper/kindhealth/models.py +179 -0
  39. lorebase_keeper/kindhealth/report.py +154 -0
  40. lorebase_keeper/kindhealth/scan.py +512 -0
  41. lorebase_keeper/middleware.py +285 -0
  42. lorebase_keeper/pagination.py +87 -0
  43. lorebase_keeper/ratelimit/__init__.py +10 -0
  44. lorebase_keeper/ratelimit/bucket.py +118 -0
  45. lorebase_keeper/ratelimit/config.py +283 -0
  46. lorebase_keeper/ratelimit/enforce.py +393 -0
  47. lorebase_keeper/ratelimit/limiter.py +149 -0
  48. lorebase_keeper/ratelimit/quotas.py +204 -0
  49. lorebase_keeper/ratelimit/shared.py +242 -0
  50. lorebase_keeper/readiness.py +257 -0
  51. lorebase_keeper/reading/__init__.py +1 -0
  52. lorebase_keeper/reading/caching.py +251 -0
  53. lorebase_keeper/reading/compiler.py +678 -0
  54. lorebase_keeper/reading/cursors.py +56 -0
  55. lorebase_keeper/reading/diff.py +408 -0
  56. lorebase_keeper/reading/graph.py +268 -0
  57. lorebase_keeper/reading/history.py +152 -0
  58. lorebase_keeper/reading/points.py +410 -0
  59. lorebase_keeper/reading/problems.py +74 -0
  60. lorebase_keeper/reading/record_history.py +722 -0
  61. lorebase_keeper/reading/records.py +780 -0
  62. lorebase_keeper/reading/requests.py +107 -0
  63. lorebase_keeper/reading/runtime.py +145 -0
  64. lorebase_keeper/reading/search.py +708 -0
  65. lorebase_keeper/reading/sensitivity.py +882 -0
  66. lorebase_keeper/reading/settings.py +339 -0
  67. lorebase_keeper/reading/snapshots.py +148 -0
  68. lorebase_keeper/reading/tree.py +98 -0
  69. lorebase_keeper/reading/views.py +264 -0
  70. lorebase_keeper/reading/visibility.py +93 -0
  71. lorebase_keeper/routes/__init__.py +1 -0
  72. lorebase_keeper/routes/admin.py +597 -0
  73. lorebase_keeper/routes/agents.py +160 -0
  74. lorebase_keeper/routes/branches.py +438 -0
  75. lorebase_keeper/routes/derivations.py +215 -0
  76. lorebase_keeper/routes/documents.py +497 -0
  77. lorebase_keeper/routes/domain_api.py +59 -0
  78. lorebase_keeper/routes/domains.py +220 -0
  79. lorebase_keeper/routes/events.py +187 -0
  80. lorebase_keeper/routes/kind_health.py +166 -0
  81. lorebase_keeper/routes/operations.py +392 -0
  82. lorebase_keeper/routes/proposal_items.py +563 -0
  83. lorebase_keeper/routes/proposals.py +1107 -0
  84. lorebase_keeper/routes/read.py +493 -0
  85. lorebase_keeper/routes/revisions.py +523 -0
  86. lorebase_keeper/routes/schema.py +682 -0
  87. lorebase_keeper/routes/schema_inference.py +437 -0
  88. lorebase_keeper/routes/schema_suggestions.py +328 -0
  89. lorebase_keeper/routes/sessions.py +286 -0
  90. lorebase_keeper/routes/source_preview.py +232 -0
  91. lorebase_keeper/routes/sources.py +200 -0
  92. lorebase_keeper/routes/trace.py +506 -0
  93. lorebase_keeper/routes/vault.py +332 -0
  94. lorebase_keeper/routes/webhooks.py +298 -0
  95. lorebase_keeper/routes/whoami.py +88 -0
  96. lorebase_keeper/services/__init__.py +1 -0
  97. lorebase_keeper/services/agent_writes.py +1035 -0
  98. lorebase_keeper/services/branches.py +337 -0
  99. lorebase_keeper/services/config.py +431 -0
  100. lorebase_keeper/services/derivation_preview.py +279 -0
  101. lorebase_keeper/services/document_urls.py +114 -0
  102. lorebase_keeper/services/domains.py +118 -0
  103. lorebase_keeper/services/errors.py +196 -0
  104. lorebase_keeper/services/operation_catalog.py +806 -0
  105. lorebase_keeper/services/operation_events.py +199 -0
  106. lorebase_keeper/services/operations.py +1229 -0
  107. lorebase_keeper/services/pii_import.py +222 -0
  108. lorebase_keeper/services/proposal_reads.py +158 -0
  109. lorebase_keeper/services/proposals.py +850 -0
  110. lorebase_keeper/services/reads.py +286 -0
  111. lorebase_keeper/services/schema.py +601 -0
  112. lorebase_keeper/services/schema_inference.py +390 -0
  113. lorebase_keeper/services/schema_suggestions.py +418 -0
  114. lorebase_keeper/services/source_preview.py +127 -0
  115. lorebase_keeper/settings.py +313 -0
  116. lorebase_keeper/tracing/__init__.py +1 -0
  117. lorebase_keeper/tracing/capture.py +350 -0
  118. lorebase_keeper/tracing/config.py +157 -0
  119. lorebase_keeper/tracing/explain.py +97 -0
  120. lorebase_keeper/tracing/middleware.py +359 -0
  121. lorebase_keeper/tracing/notes.py +80 -0
  122. lorebase_keeper/tracing/recorder.py +180 -0
  123. lorebase_keeper-0.2.3.dist-info/METADATA +21 -0
  124. lorebase_keeper-0.2.3.dist-info/RECORD +126 -0
  125. lorebase_keeper-0.2.3.dist-info/WHEEL +4 -0
  126. 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
+ ]