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.
Files changed (94) hide show
  1. lorebase_core/__init__.py +1 -0
  2. lorebase_core/canonical/__init__.py +35 -0
  3. lorebase_core/canonical/hashing.py +128 -0
  4. lorebase_core/canonical/jcs.py +162 -0
  5. lorebase_core/expr/__init__.py +118 -0
  6. lorebase_core/expr/ast.py +443 -0
  7. lorebase_core/expr/checker.py +428 -0
  8. lorebase_core/expr/compiler.py +87 -0
  9. lorebase_core/expr/context.py +82 -0
  10. lorebase_core/expr/derivations.py +306 -0
  11. lorebase_core/expr/environment.py +176 -0
  12. lorebase_core/expr/errors.py +98 -0
  13. lorebase_core/expr/evaluator.py +414 -0
  14. lorebase_core/expr/functions.py +898 -0
  15. lorebase_core/expr/limits.py +52 -0
  16. lorebase_core/expr/operations.py +479 -0
  17. lorebase_core/expr/ops.py +243 -0
  18. lorebase_core/expr/resolver.py +96 -0
  19. lorebase_core/expr/rows.py +148 -0
  20. lorebase_core/expr/sensitivity.py +337 -0
  21. lorebase_core/expr/types.py +227 -0
  22. lorebase_core/expr/validation.py +51 -0
  23. lorebase_core/expr/values.py +169 -0
  24. lorebase_core/expr/visibility.py +363 -0
  25. lorebase_core/infer/__init__.py +36 -0
  26. lorebase_core/infer/columns.py +621 -0
  27. lorebase_core/infer/engine.py +546 -0
  28. lorebase_core/infer/keys.py +164 -0
  29. lorebase_core/infer/names.py +145 -0
  30. lorebase_core/infer/options.py +290 -0
  31. lorebase_core/infer/profile.py +54 -0
  32. lorebase_core/infer/proposal.py +122 -0
  33. lorebase_core/infer/references.py +314 -0
  34. lorebase_core/infer/suggestions.py +731 -0
  35. lorebase_core/infer/tables.py +81 -0
  36. lorebase_core/merge/__init__.py +89 -0
  37. lorebase_core/merge/conflicts.py +262 -0
  38. lorebase_core/merge/consistency.py +440 -0
  39. lorebase_core/merge/data_merge.py +522 -0
  40. lorebase_core/merge/engine.py +476 -0
  41. lorebase_core/merge/index.py +100 -0
  42. lorebase_core/merge/objects.py +204 -0
  43. lorebase_core/merge/policies.py +322 -0
  44. lorebase_core/merge/resolve.py +321 -0
  45. lorebase_core/merge/schema_merge.py +429 -0
  46. lorebase_core/merge/state.py +443 -0
  47. lorebase_core/model/__init__.py +245 -0
  48. lorebase_core/model/attribute.py +72 -0
  49. lorebase_core/model/base.py +61 -0
  50. lorebase_core/model/derivation.py +95 -0
  51. lorebase_core/model/domain.py +71 -0
  52. lorebase_core/model/embedding.py +193 -0
  53. lorebase_core/model/ids.py +78 -0
  54. lorebase_core/model/kind.py +85 -0
  55. lorebase_core/model/lineage.py +114 -0
  56. lorebase_core/model/operation.py +161 -0
  57. lorebase_core/model/paths.py +141 -0
  58. lorebase_core/model/record.py +127 -0
  59. lorebase_core/model/relation.py +83 -0
  60. lorebase_core/model/schema.py +219 -0
  61. lorebase_core/model/source.py +204 -0
  62. lorebase_core/model/trait.py +37 -0
  63. lorebase_core/model/vault.py +43 -0
  64. lorebase_core/model/yaml_schema.py +1058 -0
  65. lorebase_core/query/__init__.py +97 -0
  66. lorebase_core/query/dsl.py +227 -0
  67. lorebase_core/query/resolve.py +794 -0
  68. lorebase_core/revision/__init__.py +59 -0
  69. lorebase_core/revision/ancestry.py +272 -0
  70. lorebase_core/revision/changeset.py +62 -0
  71. lorebase_core/revision/revision.py +121 -0
  72. lorebase_core/tree/__init__.py +76 -0
  73. lorebase_core/tree/diff.py +119 -0
  74. lorebase_core/tree/hamt.py +333 -0
  75. lorebase_core/tree/node.py +280 -0
  76. lorebase_core/tree/params.py +33 -0
  77. lorebase_core/tree/root.py +70 -0
  78. lorebase_core/tree/store.py +98 -0
  79. lorebase_core/types/__init__.py +71 -0
  80. lorebase_core/types/casts.py +271 -0
  81. lorebase_core/types/constraints.py +299 -0
  82. lorebase_core/types/encoding.py +538 -0
  83. lorebase_core/types/errors.py +41 -0
  84. lorebase_core/types/matching.py +40 -0
  85. lorebase_core/types/types.py +187 -0
  86. lorebase_core/validate/__init__.py +49 -0
  87. lorebase_core/validate/changes.py +70 -0
  88. lorebase_core/validate/changeset.py +551 -0
  89. lorebase_core/validate/data_rules.py +291 -0
  90. lorebase_core/validate/issues.py +123 -0
  91. lorebase_core/validate/schema_rules.py +1304 -0
  92. lorebase_core-0.2.3.dist-info/METADATA +9 -0
  93. lorebase_core-0.2.3.dist-info/RECORD +94 -0
  94. lorebase_core-0.2.3.dist-info/WHEEL +4 -0
@@ -0,0 +1,1058 @@
1
+ """Schema as code: load and dump a Domain schema as YAML.
2
+
3
+ The YAML form is the human-friendly face of a ``Schema``. Elements are keyed by api name and
4
+ refer to each other by api name; the stable ``id`` of each element is an optional field.
5
+
6
+ Document layout (every section is optional except ``domain``)::
7
+
8
+ format: lorebase.schema/v1
9
+ domain: {id: supply, display_name: Supply chain}
10
+ kinds:
11
+ Supplier: # key = api name
12
+ id: knd_01JB... # stable id; minted when absent
13
+ primary_key: [supplier_code] # Attribute api names
14
+ title_attribute: legal_name
15
+ attributes:
16
+ supplier_code:
17
+ id: att_01JB...
18
+ type: string # see "Types" below
19
+ required: true
20
+ traits:
21
+ - trait: Located
22
+ attribute_map: {country: country} # trait Attribute -> Kind Attribute
23
+ relations:
24
+ supplied_by: # key = forward api name
25
+ inverse_api_name: supplies
26
+ from_kind: Part
27
+ to_kind: Supplier
28
+ cardinality: many_to_many
29
+ attributes: {...} # link Attributes, same shape as Kind Attributes
30
+ traits: {Located: {attributes: {country: {type: string}}}}
31
+ operations: {...} # parameters, preconditions, steps, review_policy, ...
32
+ derivations: {...} # definition: {variant: expression | aggregate | path, ...}
33
+ sources: {...} # key = dotted api name such as erp.vendors
34
+
35
+ Fields not mentioned above keep the names of the model (``display_name``, ``description``,
36
+ ``status``, ``constraints``, ``sensitivity``, ``indexed``, ``searchable`` and so on) and are
37
+ omitted from the dump when they hold their default. Two keys differ from the model because they
38
+ hold api names instead of ids: ``derivation`` (``derivation_id``) and ``trait``
39
+ (``trait_id``). Expressions are CEL source strings.
40
+
41
+ Types are written ``string``, ``decimal(12,2)``, ``ref(Supplier)``, ``array<string>`` or, for
42
+ enums and arrays of complex types, as a one-key mapping: ``{enum: [low, high]}``,
43
+ ``{array: {enum: [a, b]}}``.
44
+
45
+ Ids on load: an explicit ``id`` wins (so a rename keeps the element); without one, the element
46
+ with the same api name in the optional ``base`` schema is reused; otherwise a new id is minted.
47
+ Dumps always write ids.
48
+ """
49
+
50
+ from __future__ import annotations
51
+
52
+ import re
53
+ from collections.abc import Callable, Iterable, Mapping
54
+ from contextlib import contextmanager
55
+ from dataclasses import dataclass
56
+ from typing import TYPE_CHECKING, Any, NoReturn, TypeVar, cast
57
+
58
+ import yaml
59
+ from pydantic import BaseModel, ValidationError
60
+
61
+ from lorebase_core.model.attribute import Attribute
62
+ from lorebase_core.model.base import Model, SchemaElement
63
+ from lorebase_core.model.derivation import (
64
+ AggregateDerivation,
65
+ Derivation,
66
+ ExpressionDerivation,
67
+ PathDerivation,
68
+ PathStep,
69
+ )
70
+ from lorebase_core.model.domain import Domain
71
+ from lorebase_core.model.ids import IdPrefix, id_prefix, new_id
72
+ from lorebase_core.model.kind import Kind, TraitImplementation
73
+ from lorebase_core.model.operation import (
74
+ CreateLinkStep,
75
+ CreateRecordStep,
76
+ DeleteLinkStep,
77
+ DeleteRecordStep,
78
+ Operation,
79
+ OperationParameter,
80
+ OperationStep,
81
+ UpdateRecordStep,
82
+ )
83
+ from lorebase_core.model.relation import LinkDirection, Relation
84
+ from lorebase_core.model.schema import Schema
85
+ from lorebase_core.model.source import RelationMapping, Source
86
+ from lorebase_core.model.trait import Trait, TraitAttribute
87
+ from lorebase_core.types import AttributeType, TypeSpec
88
+
89
+ if TYPE_CHECKING:
90
+ from lorebase_core.validate.issues import Issue
91
+ from lorebase_core.validate.schema_rules import SchemaRules
92
+
93
+ #: Value of the optional ``format`` key; written on every dump.
94
+ SCHEMA_FORMAT = "lorebase.schema/v1"
95
+
96
+ #: Schema sections holding api-name keyed elements, in document order, with their id prefix.
97
+ _SECTIONS: tuple[tuple[str, IdPrefix], ...] = (
98
+ ("kinds", IdPrefix.KIND),
99
+ ("relations", IdPrefix.RELATION),
100
+ ("traits", IdPrefix.TRAIT),
101
+ ("operations", IdPrefix.OPERATION),
102
+ ("derivations", IdPrefix.DERIVATION),
103
+ ("sources", IdPrefix.SOURCE),
104
+ )
105
+ _ROOT_KEYS = frozenset({"format", "domain", *(name for name, _ in _SECTIONS)})
106
+
107
+ #: Model field -> YAML key, where the YAML value is an api name instead of an id.
108
+ _YAML_KEYS = {"derivation_id": "derivation", "trait_id": "trait"}
109
+ _MODEL_KEYS = {yaml_key: key for key, yaml_key in _YAML_KEYS.items()}
110
+
111
+ _PathPart = str | int
112
+ _Path = tuple[_PathPart, ...]
113
+ IdFactory = Callable[[IdPrefix], str]
114
+
115
+
116
+ @dataclass(frozen=True)
117
+ class SchemaYamlProblem:
118
+ """A problem found while reading a schema document; ``path`` locates it in the YAML."""
119
+
120
+ path: tuple[str | int, ...]
121
+ message: str
122
+
123
+ def __str__(self) -> str:
124
+ return f"{render_path(self.path) or '<document>'}: {self.message}"
125
+
126
+
127
+ class SchemaYamlError(ValueError):
128
+ """The document is not a loadable schema. ``problems`` lists everything found."""
129
+
130
+ def __init__(self, problems: Iterable[SchemaYamlProblem]) -> None:
131
+ self.problems = tuple(problems)
132
+ super().__init__("\n".join(str(problem) for problem in self.problems))
133
+
134
+
135
+ def render_path(path: Iterable[str | int]) -> str:
136
+ """``("kinds", "Supplier", "attributes", "tier")`` -> ``kinds/Supplier/attributes/tier``."""
137
+ return "/".join(str(part) for part in path)
138
+
139
+
140
+ class _LoadError(Exception):
141
+ def __init__(self, problems: list[SchemaYamlProblem]) -> None:
142
+ super().__init__(problems)
143
+ self.problems = problems
144
+
145
+
146
+ def _fail(path: _Path, message: str) -> NoReturn:
147
+ raise _LoadError([SchemaYamlProblem(path, message)])
148
+
149
+
150
+ # YAML parsing -------------------------------------------------------------------------------
151
+
152
+
153
+ class _Loader(yaml.SafeLoader):
154
+ """Safe loader without implicit timestamps (dates stay text) and with duplicate-key errors."""
155
+
156
+ def construct_mapping(self, node: yaml.MappingNode, deep: bool = False) -> dict[Any, Any]:
157
+ seen: set[Any] = set()
158
+ for key_node, _ in node.value:
159
+ key = self.construct_object(key_node, deep=True)
160
+ if isinstance(key, Mapping | list | set):
161
+ continue
162
+ if key in seen:
163
+ raise yaml.constructor.ConstructorError(
164
+ None, None, f"duplicate key {key!r}", key_node.start_mark
165
+ )
166
+ seen.add(key)
167
+ return super().construct_mapping(node, deep=deep)
168
+
169
+
170
+ _Loader.yaml_implicit_resolvers = {
171
+ first: [(tag, pattern) for tag, pattern in resolvers if tag != "tag:yaml.org,2002:timestamp"]
172
+ for first, resolvers in yaml.SafeLoader.yaml_implicit_resolvers.items()
173
+ }
174
+
175
+
176
+ class _Dumper(yaml.SafeDumper):
177
+ """Block style, short scalar lists in flow style, no anchors, literal multi-line text."""
178
+
179
+ def ignore_aliases(self, data: Any) -> bool:
180
+ return True
181
+
182
+ def increase_indent(self, flow: bool = False, indentless: bool = False) -> None:
183
+ super().increase_indent(flow, False)
184
+
185
+ def represent_list(self, data: Any) -> yaml.SequenceNode:
186
+ node = super().represent_list(data)
187
+ simple = all(isinstance(item, str | int | float | bool) or item is None for item in data)
188
+ node.flow_style = simple
189
+ return node
190
+
191
+ def represent_dict(self, data: Any) -> yaml.MappingNode:
192
+ node = super().represent_dict(data)
193
+ node.flow_style = False
194
+ return node
195
+
196
+
197
+ def _represent_str(dumper: yaml.SafeDumper, data: str) -> yaml.ScalarNode:
198
+ style = "|" if "\n" in data else None
199
+ return dumper.represent_scalar("tag:yaml.org,2002:str", data, style=style)
200
+
201
+
202
+ _Dumper.add_representer(str, _represent_str)
203
+ _Dumper.add_representer(list, _Dumper.represent_list)
204
+ _Dumper.add_representer(dict, _Dumper.represent_dict)
205
+
206
+
207
+ def parse_yaml(text: str) -> Any:
208
+ """Parse YAML text with the schema loader; syntax errors become ``SchemaYamlError``."""
209
+ try:
210
+ return yaml.load(text, Loader=_Loader)
211
+ except yaml.YAMLError as exc:
212
+ mark = getattr(exc, "problem_mark", None)
213
+ where = f" (line {mark.line + 1}, column {mark.column + 1})" if mark is not None else ""
214
+ problem = getattr(exc, "problem", None) or str(exc)
215
+ raise SchemaYamlError([SchemaYamlProblem((), f"invalid YAML{where}: {problem}")]) from exc
216
+
217
+
218
+ # Types ----------------------------------------------------------------------------------------
219
+
220
+ _PARAMETERIZED = frozenset(
221
+ {AttributeType.DECIMAL, AttributeType.ENUM, AttributeType.ARRAY, AttributeType.REF}
222
+ )
223
+ _DECIMAL = re.compile(r"decimal\(\s*(\d+)\s*,\s*(\d+)\s*\)")
224
+ _REF = re.compile(r"ref\(\s*([^()\s]+)\s*\)")
225
+ _ARRAY = re.compile(r"array<(.+)>")
226
+
227
+
228
+ def _type_node(spec: TypeSpec, names: Mapping[str, str]) -> str | dict[str, Any]:
229
+ if spec.name is AttributeType.ENUM:
230
+ return {"enum": list(spec.enum_values or ())}
231
+ if spec.name is AttributeType.DECIMAL:
232
+ return f"decimal({spec.precision},{spec.scale})"
233
+ if spec.name is AttributeType.REF:
234
+ kind_ref = spec.kind_ref or ""
235
+ return f"ref({names.get(kind_ref, kind_ref)})"
236
+ if spec.name is AttributeType.ARRAY and spec.element is not None:
237
+ element = _type_node(spec.element, names)
238
+ return f"array<{element}>" if isinstance(element, str) else {"array": element}
239
+ return str(spec.name)
240
+
241
+
242
+ # Loader ---------------------------------------------------------------------------------------
243
+
244
+ _M = TypeVar("_M", bound=BaseModel)
245
+
246
+ _STEP_MODELS: dict[str, type[Model]] = {
247
+ "create_record": CreateRecordStep,
248
+ "update_record": UpdateRecordStep,
249
+ "delete_record": DeleteRecordStep,
250
+ "create_link": CreateLinkStep,
251
+ "delete_link": DeleteLinkStep,
252
+ }
253
+
254
+
255
+ def _kind_of(value: object) -> str:
256
+ return "null" if value is None else type(value).__name__
257
+
258
+
259
+ def _mapping(value: object, path: _Path) -> dict[str, Any]:
260
+ """A copy of a mapping with string keys; ``None`` reads as an empty mapping."""
261
+ if value is None:
262
+ return {}
263
+ if not isinstance(value, dict):
264
+ _fail(path, f"expected a mapping, got {_kind_of(value)}")
265
+ for key in value:
266
+ if not isinstance(key, str):
267
+ _fail((*path, str(key)), f"keys must be text, got {_kind_of(key)}; quote the key")
268
+ return dict(value)
269
+
270
+
271
+ def _sequence(value: object, path: _Path) -> list[Any]:
272
+ if value is None:
273
+ return []
274
+ if not isinstance(value, list):
275
+ _fail(path, f"expected a list, got {_kind_of(value)}")
276
+ return list(value)
277
+
278
+
279
+ def _names(value: object, path: _Path) -> list[str]:
280
+ items = _sequence(value, path)
281
+ for index, item in enumerate(items):
282
+ if not isinstance(item, str):
283
+ _fail((*path, index), f"expected a name, got {_kind_of(item)}")
284
+ return items
285
+
286
+
287
+ class _Reader:
288
+ """One load run: assigns ids to every element first, then builds the models."""
289
+
290
+ def __init__(self, base: Schema | None, id_factory: IdFactory) -> None:
291
+ self.base = base
292
+ self.id_factory = id_factory
293
+ self.problems: list[SchemaYamlProblem] = []
294
+ self.used: set[str] = set()
295
+ self.ids: dict[str, dict[str, str]] = {name: {} for name, _ in _SECTIONS}
296
+ self.attribute_ids: dict[tuple[str, str], dict[str, str]] = {}
297
+ self.ends: dict[str, tuple[object, object]] = {}
298
+ self.names: dict[str, str] = {}
299
+
300
+ # Error collection ------------------------------------------------------------------------
301
+
302
+ @contextmanager
303
+ def collect(self) -> Any:
304
+ try:
305
+ yield
306
+ except _LoadError as failure:
307
+ self.problems.extend(failure.problems)
308
+
309
+ def _yaml_key(self, part: object) -> _PathPart:
310
+ if isinstance(part, int):
311
+ return part
312
+ text = str(part)
313
+ return _YAML_KEYS.get(text, self.names.get(text, text))
314
+
315
+ def build(self, model: type[_M], path: _Path, data: dict[str, Any], **fields: Any) -> _M:
316
+ """Validate ``data`` plus the already resolved ``fields`` into a model."""
317
+ for key in fields:
318
+ if key in data:
319
+ _fail((*path, key), f"unknown key {key!r}")
320
+ try:
321
+ return model.model_validate({**data, **fields})
322
+ except ValidationError as exc:
323
+ raise _LoadError(
324
+ [
325
+ SchemaYamlProblem(
326
+ (*path, *(self._yaml_key(part) for part in error["loc"])), error["msg"]
327
+ )
328
+ for error in exc.errors()
329
+ ]
330
+ ) from exc
331
+
332
+ # Pass 1: ids -----------------------------------------------------------------------------
333
+
334
+ def _entries(self, value: object, path: _Path) -> dict[str, Any]:
335
+ with self.collect():
336
+ return _mapping(value, path)
337
+ return {}
338
+
339
+ def _claim(self, value: object, prefix: IdPrefix, path: _Path) -> str:
340
+ if not isinstance(value, str) or id_prefix(value) is not prefix:
341
+ self.problems.append(
342
+ SchemaYamlProblem(path, f"expected an id starting with '{prefix.value}_'")
343
+ )
344
+ elif value in self.used:
345
+ self.problems.append(SchemaYamlProblem(path, f"id {value} is used twice"))
346
+ else:
347
+ self.used.add(value)
348
+ return value
349
+ return self._mint(prefix)
350
+
351
+ def _mint(self, prefix: IdPrefix) -> str:
352
+ fresh = self.id_factory(prefix)
353
+ self.used.add(fresh)
354
+ return fresh
355
+
356
+ def _assign(
357
+ self,
358
+ entries: dict[str, Any],
359
+ prefix: IdPrefix,
360
+ known: Mapping[str, str],
361
+ path: _Path,
362
+ ) -> dict[str, str]:
363
+ """Ids by api name: explicit first, then those of ``known`` (the base), then new ones."""
364
+ assigned: dict[str, str] = {}
365
+ for name, entry in entries.items():
366
+ if isinstance(entry, dict) and entry.get("id") is not None:
367
+ assigned[name] = self._claim(entry["id"], prefix, (*path, name, "id"))
368
+ for name in entries:
369
+ if name in assigned:
370
+ continue
371
+ existing = known.get(name)
372
+ if existing is not None and existing not in self.used:
373
+ self.used.add(existing)
374
+ assigned[name] = existing
375
+ else:
376
+ assigned[name] = self._mint(prefix)
377
+ return {name: assigned[name] for name in entries}
378
+
379
+ def assign_ids(self, root: dict[str, Any]) -> dict[str, dict[str, Any]]:
380
+ entries = {
381
+ section: self._entries(root.get(section), (section,)) for section, _ in _SECTIONS
382
+ }
383
+ for section, prefix in _SECTIONS:
384
+ base_items = getattr(self.base, section, {}) if self.base is not None else {}
385
+ known = {item.api_name: item.id for item in base_items.values()}
386
+ self.ids[section] = self._assign(entries[section], prefix, known, (section,))
387
+ for name, element_id in self.ids[section].items():
388
+ self.names[element_id] = name
389
+ for section in ("kinds", "relations", "traits"):
390
+ base_items = getattr(self.base, section, {}) if self.base is not None else {}
391
+ for name, entry in entries[section].items():
392
+ owner = base_items.get(self.ids[section][name])
393
+ known = (
394
+ {a.api_name: a.id for a in owner.attributes.values()}
395
+ if owner is not None
396
+ else {}
397
+ )
398
+ body = entry if isinstance(entry, dict) else {}
399
+ attributes = self._entries(body.get("attributes"), (section, name, "attributes"))
400
+ ids = self._assign(
401
+ attributes, IdPrefix.ATTRIBUTE, known, (section, name, "attributes")
402
+ )
403
+ self.attribute_ids[(section, name)] = ids
404
+ self.names.update({attribute_id: n for n, attribute_id in ids.items()})
405
+ for name, entry in entries["relations"].items():
406
+ if isinstance(entry, dict):
407
+ self.ends[name] = (entry.get("from_kind"), entry.get("to_kind"))
408
+ for name, entry in entries["derivations"].items():
409
+ ends = self._path_ends(entry)
410
+ if ends is not None and name not in self.ends:
411
+ self.ends[name] = ends
412
+ return entries
413
+
414
+ def _path_ends(self, entry: object) -> tuple[object, object] | None:
415
+ """First near and last far Kind name of a `path` Derivation entry (an aggregate may
416
+ read a path like a Relation); `None` when it is not one or its steps are unknown."""
417
+ definition = entry.get("definition") if isinstance(entry, dict) else None
418
+ if not isinstance(definition, dict) or definition.get("variant") != "path":
419
+ return None
420
+ steps = definition.get("steps")
421
+ kinds: list[object] = []
422
+ for step in steps if isinstance(steps, list) else []:
423
+ if not isinstance(step, dict) or str(step.get("relation")) not in self.ends:
424
+ return None
425
+ ends = self.ends[str(step["relation"])]
426
+ inverse = step.get("direction") == LinkDirection.INVERSE
427
+ kinds.append(ends[1] if inverse else ends[0])
428
+ kinds.append(ends[0] if inverse else ends[1])
429
+ return (kinds[0], kinds[-1]) if kinds else None
430
+
431
+ # Reference resolution --------------------------------------------------------------------
432
+
433
+ def lookup(self, section: str, name: object, path: _Path) -> str:
434
+ table = self.ids[section]
435
+ if not isinstance(name, str) or name not in table:
436
+ label = {"kinds": "Kind", "relations": "Relation", "traits": "Trait"}.get(
437
+ section, section[:-1].capitalize()
438
+ )
439
+ _fail(path, f"unknown {label} {name!r}")
440
+ return table[name]
441
+
442
+ def attribute(self, scope: tuple[str, str], name: object, path: _Path) -> str:
443
+ table = self.attribute_ids.get(scope)
444
+ if table is None:
445
+ _fail(path, f"unknown {scope[0][:-1]} {scope[1]!r}")
446
+ if not isinstance(name, str) or name not in table:
447
+ _fail(path, f"unknown Attribute {name!r} of {scope[0][:-1]} {scope[1]!r}")
448
+ return table[name]
449
+
450
+ def rekey(self, value: object, scope: tuple[str, str], path: _Path) -> dict[str, Any]:
451
+ """A mapping keyed by Attribute api name -> the same mapping keyed by Attribute id."""
452
+ return {
453
+ self.attribute(scope, name, (*path, name)): item
454
+ for name, item in _mapping(value, path).items()
455
+ }
456
+
457
+ def type_spec(self, node: object, path: _Path) -> TypeSpec:
458
+ try:
459
+ return self._type_spec(node, path)
460
+ except ValidationError as exc:
461
+ _fail(path, "; ".join(error["msg"] for error in exc.errors()))
462
+
463
+ def _type_spec(self, node: object, path: _Path) -> TypeSpec:
464
+ if isinstance(node, str):
465
+ text = node.strip()
466
+ if match := _DECIMAL.fullmatch(text):
467
+ return TypeSpec.decimal(int(match.group(1)), int(match.group(2)))
468
+ if match := _REF.fullmatch(text):
469
+ return TypeSpec.ref(self.lookup("kinds", match.group(1), path))
470
+ if match := _ARRAY.fullmatch(text):
471
+ return TypeSpec.array(self._type_spec(match.group(1), path))
472
+ try:
473
+ base = AttributeType(text)
474
+ except ValueError:
475
+ _fail(path, f"unknown type {text!r}")
476
+ if base in _PARAMETERIZED:
477
+ _fail(path, f"type {text!r} needs parameters, see the schema format notes")
478
+ return TypeSpec.of(base)
479
+ if isinstance(node, dict) and len(node) == 1:
480
+ ((key, value),) = node.items()
481
+ if key == "enum":
482
+ values = _sequence(value, path)
483
+ if not all(isinstance(v, str) for v in values):
484
+ _fail(path, "enum values must be text; quote numbers and booleans")
485
+ return TypeSpec.enum(*values)
486
+ if key == "array":
487
+ return TypeSpec.array(self._type_spec(value, path))
488
+ _fail(path, "a type is a name such as 'string', 'decimal(12,2)' or a {enum: [...]} mapping")
489
+
490
+ def required_type(self, data: dict[str, Any], path: _Path) -> TypeSpec:
491
+ if "type" not in data:
492
+ _fail((*path, "type"), "Field required")
493
+ return self.type_spec(data.pop("type"), (*path, "type"))
494
+
495
+ # Pass 2: models --------------------------------------------------------------------------
496
+
497
+ def attributes(
498
+ self, value: object, scope: tuple[str, str], path: _Path, *, trait: bool = False
499
+ ) -> dict[str, Any]:
500
+ result: dict[str, Any] = {}
501
+ for name, entry in _mapping(value, path).items():
502
+ with self.collect():
503
+ item_path = (*path, name)
504
+ data = _mapping(entry, item_path)
505
+ data.pop("id", None)
506
+ element_id = self.attribute_ids[scope][name]
507
+ fields: dict[str, Any] = {
508
+ "id": element_id,
509
+ "api_name": name,
510
+ "type": self.required_type(data, item_path),
511
+ }
512
+ if trait:
513
+ result[element_id] = self.build(TraitAttribute, item_path, data, **fields)
514
+ continue
515
+ if "derivation" in data:
516
+ fields["derivation_id"] = self.lookup(
517
+ "derivations", data.pop("derivation"), (*item_path, "derivation")
518
+ )
519
+ if "authority" in data:
520
+ fields["authority"] = self.lookup(
521
+ "sources", data.pop("authority"), (*item_path, "authority")
522
+ )
523
+ result[element_id] = self.build(Attribute, item_path, data, **fields)
524
+ return result
525
+
526
+ def kind(self, name: str, entry: object) -> Kind:
527
+ path: _Path = ("kinds", name)
528
+ scope = ("kinds", name)
529
+ data = _mapping(entry, path)
530
+ data.pop("id", None)
531
+ fields: dict[str, Any] = {
532
+ "id": self.ids["kinds"][name],
533
+ "api_name": name,
534
+ "attributes": self.attributes(
535
+ data.pop("attributes", None), scope, (*path, "attributes")
536
+ ),
537
+ }
538
+ if "primary_key" in data:
539
+ key_path = (*path, "primary_key")
540
+ fields["primary_key"] = tuple(
541
+ self.attribute(scope, item, (*key_path, index))
542
+ for index, item in enumerate(_names(data.pop("primary_key"), key_path))
543
+ )
544
+ if data.get("title_attribute") is not None:
545
+ fields["title_attribute"] = self.attribute(
546
+ scope, data.pop("title_attribute"), (*path, "title_attribute")
547
+ )
548
+ else:
549
+ data.pop("title_attribute", None)
550
+ if "traits" in data:
551
+ traits_path = (*path, "traits")
552
+ implementations = []
553
+ for index, raw in enumerate(_sequence(data.pop("traits"), traits_path)):
554
+ with self.collect():
555
+ implementations.append(self.trait_implementation(raw, scope, index, path))
556
+ fields["traits"] = tuple(implementations)
557
+ return self.build(Kind, path, data, **fields)
558
+
559
+ def trait_implementation(
560
+ self, raw: object, scope: tuple[str, str], index: int, kind_path: _Path
561
+ ) -> TraitImplementation:
562
+ path = (*kind_path, "traits", index)
563
+ data = _mapping(raw, path)
564
+ trait_name = data.pop("trait", None)
565
+ trait_id = self.lookup("traits", trait_name, (*path, "trait"))
566
+ mapping_path = (*path, "attribute_map")
567
+ attribute_map = {
568
+ self.attribute(("traits", str(trait_name)), key, (*mapping_path, key)): self.attribute(
569
+ scope, target, (*mapping_path, key)
570
+ )
571
+ for key, target in _mapping(data.pop("attribute_map", None), mapping_path).items()
572
+ }
573
+ return self.build(
574
+ TraitImplementation, path, data, trait_id=trait_id, attribute_map=attribute_map
575
+ )
576
+
577
+ def relation(self, name: str, entry: object) -> Relation:
578
+ path: _Path = ("relations", name)
579
+ scope = ("relations", name)
580
+ data = _mapping(entry, path)
581
+ data.pop("id", None)
582
+ fields: dict[str, Any] = {
583
+ "id": self.ids["relations"][name],
584
+ "api_name": name,
585
+ "attributes": self.attributes(
586
+ data.pop("attributes", None), scope, (*path, "attributes")
587
+ ),
588
+ }
589
+ for end in ("from_kind", "to_kind"):
590
+ if end in data:
591
+ fields[end] = self.lookup("kinds", data.pop(end), (*path, end))
592
+ if data.get("foreign_key_attribute") is not None:
593
+ from_kind = self.ends.get(name, (None, None))[0]
594
+ fields["foreign_key_attribute"] = self.attribute(
595
+ ("kinds", str(from_kind)),
596
+ data.pop("foreign_key_attribute"),
597
+ (*path, "foreign_key_attribute"),
598
+ )
599
+ else:
600
+ data.pop("foreign_key_attribute", None)
601
+ return self.build(Relation, path, data, **fields)
602
+
603
+ def trait(self, name: str, entry: object) -> Trait:
604
+ path: _Path = ("traits", name)
605
+ data = _mapping(entry, path)
606
+ data.pop("id", None)
607
+ attributes = self.attributes(
608
+ data.pop("attributes", None), ("traits", name), (*path, "attributes"), trait=True
609
+ )
610
+ return self.build(
611
+ Trait, path, data, id=self.ids["traits"][name], api_name=name, attributes=attributes
612
+ )
613
+
614
+ def operation(self, name: str, entry: object) -> Operation:
615
+ path: _Path = ("operations", name)
616
+ data = _mapping(entry, path)
617
+ data.pop("id", None)
618
+ fields: dict[str, Any] = {"id": self.ids["operations"][name], "api_name": name}
619
+ if "parameters" in data:
620
+ parameters_path = (*path, "parameters")
621
+ parameters = []
622
+ for index, raw in enumerate(_sequence(data.pop("parameters"), parameters_path)):
623
+ with self.collect():
624
+ item_path = (*parameters_path, index)
625
+ item = _mapping(raw, item_path)
626
+ type_spec = self.required_type(item, item_path)
627
+ parameters.append(
628
+ self.build(OperationParameter, item_path, item, type=type_spec)
629
+ )
630
+ fields["parameters"] = tuple(parameters)
631
+ if "steps" in data:
632
+ steps_path = (*path, "steps")
633
+ steps = []
634
+ for index, raw in enumerate(_sequence(data.pop("steps"), steps_path)):
635
+ with self.collect():
636
+ steps.append(self.step(raw, (*steps_path, index)))
637
+ fields["steps"] = tuple(steps)
638
+ return self.build(Operation, path, data, **fields)
639
+
640
+ def step(self, raw: object, path: _Path) -> OperationStep:
641
+ data = _mapping(raw, path)
642
+ step_name = data.pop("step", None)
643
+ model = _STEP_MODELS.get(step_name) if isinstance(step_name, str) else None
644
+ if model is None:
645
+ _fail((*path, "step"), f"unknown step {step_name!r}; use {', '.join(_STEP_MODELS)}")
646
+ fields: dict[str, Any] = {"step": step_name}
647
+ scope: tuple[str, str] | None = None
648
+ if "kind" in model.model_fields:
649
+ kind_name = data.pop("kind", None)
650
+ fields["kind"] = self.lookup("kinds", kind_name, (*path, "kind"))
651
+ scope = ("kinds", str(kind_name))
652
+ else:
653
+ relation_name = data.pop("relation", None)
654
+ fields["relation"] = self.lookup("relations", relation_name, (*path, "relation"))
655
+ scope = ("relations", str(relation_name))
656
+ if "values" in data:
657
+ fields["values"] = self.rekey(data.pop("values"), scope, (*path, "values"))
658
+ return cast(OperationStep, self.build(model, path, data, **fields))
659
+
660
+ def derivation(self, name: str, entry: object) -> Derivation:
661
+ path: _Path = ("derivations", name)
662
+ data = _mapping(entry, path)
663
+ data.pop("id", None)
664
+ definition_path = (*path, "definition")
665
+ definition = self.definition(
666
+ _mapping(data.pop("definition", None), definition_path), definition_path
667
+ )
668
+ return self.build(
669
+ Derivation,
670
+ path,
671
+ data,
672
+ id=self.ids["derivations"][name],
673
+ api_name=name,
674
+ definition=definition,
675
+ )
676
+
677
+ def definition(self, data: dict[str, Any], path: _Path) -> Model:
678
+ variant = data.pop("variant", None)
679
+ fields: dict[str, Any] = {"variant": variant}
680
+ if variant in ("expression", "aggregate"):
681
+ kind_name = data.pop("kind", None)
682
+ fields["kind"] = self.lookup("kinds", kind_name, (*path, "kind"))
683
+ fields["attribute"] = self.attribute(
684
+ ("kinds", str(kind_name)), data.pop("attribute", None), (*path, "attribute")
685
+ )
686
+ if variant == "expression":
687
+ return self.build(ExpressionDerivation, path, data, **fields)
688
+ if variant == "aggregate":
689
+ relation_name = data.pop("relation", None)
690
+ fields["relation"] = (
691
+ self.ids["derivations"][relation_name]
692
+ if isinstance(relation_name, str)
693
+ and relation_name not in self.ids["relations"]
694
+ and relation_name in self.ids["derivations"]
695
+ else self.lookup("relations", relation_name, (*path, "relation"))
696
+ ) # a path Derivation is read like a Relation
697
+ if data.get("source_attribute") is not None:
698
+ far_end = 0 if data.get("direction") == LinkDirection.INVERSE else 1
699
+ far = self.ends.get(str(relation_name), (None, None))[far_end]
700
+ fields["source_attribute"] = self.attribute(
701
+ ("kinds", str(far)), data.pop("source_attribute"), (*path, "source_attribute")
702
+ )
703
+ else:
704
+ data.pop("source_attribute", None)
705
+ return self.build(AggregateDerivation, path, data, **fields)
706
+ if variant == "path":
707
+ steps_path = (*path, "steps")
708
+ steps = []
709
+ for index, raw in enumerate(_sequence(data.pop("steps", None), steps_path)):
710
+ with self.collect():
711
+ item_path = (*steps_path, index)
712
+ item = _mapping(raw, item_path)
713
+ relation = self.lookup(
714
+ "relations", item.pop("relation", None), (*item_path, "relation")
715
+ )
716
+ steps.append(self.build(PathStep, item_path, item, relation=relation))
717
+ return self.build(PathDerivation, path, data, steps=tuple(steps), **fields)
718
+ _fail((*path, "variant"), "variant must be 'expression', 'aggregate' or 'path'")
719
+
720
+ def source(self, name: str, entry: object) -> Source:
721
+ path: _Path = ("sources", name)
722
+ data = _mapping(entry, path)
723
+ data.pop("id", None)
724
+ fields: dict[str, Any] = {"id": self.ids["sources"][name], "api_name": name}
725
+ if "target_kind" in data:
726
+ kind_name = data.pop("target_kind")
727
+ fields["target_kind"] = self.lookup("kinds", kind_name, (*path, "target_kind"))
728
+ if "attribute_mapping" in data:
729
+ fields["attribute_mapping"] = self.rekey(
730
+ data.pop("attribute_mapping"),
731
+ ("kinds", str(kind_name)),
732
+ (*path, "attribute_mapping"),
733
+ )
734
+ if "relation_mapping" in data:
735
+ mapping_path = (*path, "relation_mapping")
736
+ mappings = []
737
+ for index, raw in enumerate(_sequence(data.pop("relation_mapping"), mapping_path)):
738
+ with self.collect():
739
+ item_path = (*mapping_path, index)
740
+ item = _mapping(raw, item_path)
741
+ relation = self.lookup(
742
+ "relations", item.pop("relation", None), (*item_path, "relation")
743
+ )
744
+ mappings.append(self.build(RelationMapping, item_path, item, relation=relation))
745
+ fields["relation_mapping"] = tuple(mappings)
746
+ return self.build(Source, path, data, **fields)
747
+
748
+ def read(self, document: object) -> Schema:
749
+ with self.collect():
750
+ root = _mapping(document, ())
751
+ unknown = sorted(set(root) - _ROOT_KEYS)
752
+ for key in unknown:
753
+ self.problems.append(SchemaYamlProblem((key,), "unknown section"))
754
+ declared = root.get("format")
755
+ if declared is not None and declared != SCHEMA_FORMAT:
756
+ self.problems.append(
757
+ SchemaYamlProblem(("format",), f"unsupported format, expected {SCHEMA_FORMAT}")
758
+ )
759
+ entries = self.assign_ids(root)
760
+ domain: Domain | None = None
761
+ with self.collect():
762
+ domain = self.build(Domain, ("domain",), _mapping(root.get("domain"), ("domain",)))
763
+ builders: dict[str, Callable[[str, object], SchemaElement]] = {
764
+ "kinds": self.kind,
765
+ "relations": self.relation,
766
+ "traits": self.trait,
767
+ "operations": self.operation,
768
+ "derivations": self.derivation,
769
+ "sources": self.source,
770
+ }
771
+ built: dict[str, dict[str, Any]] = {name: {} for name, _ in _SECTIONS}
772
+ for section, _ in _SECTIONS:
773
+ for name, entry in entries[section].items():
774
+ with self.collect():
775
+ element = builders[section](name, entry)
776
+ built[section][element.id] = element
777
+ if not self.problems and domain is not None:
778
+ return Schema(domain=domain, **built)
779
+ raise SchemaYamlError(self.problems)
780
+
781
+
782
+ def schema_from_document(
783
+ document: object, *, base: Schema | None = None, id_factory: IdFactory = new_id
784
+ ) -> Schema:
785
+ """Build a ``Schema`` from a parsed document; raises ``SchemaYamlError``."""
786
+ return _Reader(base, id_factory).read(document)
787
+
788
+
789
+ def load_schema(text: str, *, base: Schema | None = None, id_factory: IdFactory = new_id) -> Schema:
790
+ """Load a Schema from YAML text.
791
+
792
+ ``base`` is the schema the YAML evolves: elements without an ``id`` that share an api name
793
+ with a base element keep the base id. ``id_factory`` mints ids for new elements.
794
+ """
795
+ return schema_from_document(parse_yaml(text), base=base, id_factory=id_factory)
796
+
797
+
798
+ # Dumper ---------------------------------------------------------------------------------------
799
+
800
+
801
+ def id_names(schema: Schema) -> dict[str, str]:
802
+ """Every element and Attribute id of ``schema`` -> its api name (Domain excluded)."""
803
+ names: dict[str, str] = {}
804
+ for element in schema.elements():
805
+ names[element.id] = element.api_name
806
+ if isinstance(element, Kind | Relation | Trait):
807
+ for attribute in element.attributes.values():
808
+ names[attribute.id] = attribute.api_name
809
+ return names
810
+
811
+
812
+ def _plain(model: BaseModel, exclude: set[str]) -> dict[str, Any]:
813
+ return model.model_dump(mode="json", by_alias=True, exclude_defaults=True, exclude=exclude)
814
+
815
+
816
+ def _compose(
817
+ element_id: str, plain: dict[str, Any], extras: dict[str, Any], order: tuple[str, ...] = ()
818
+ ) -> dict[str, Any]:
819
+ """``id``, naming fields, then ``order``ed keys, then the remaining fields."""
820
+ pool = {**plain, **{key: value for key, value in extras.items() if value not in (None, {}, [])}}
821
+ out: dict[str, Any] = {"id": element_id}
822
+ for key in ("display_name", "description", *order):
823
+ if key in pool:
824
+ out[key] = pool.pop(key)
825
+ out.update(pool)
826
+ return out
827
+
828
+
829
+ class _Writer:
830
+ def __init__(self, schema: Schema) -> None:
831
+ self.schema = schema
832
+ self.names = id_names(schema)
833
+
834
+ def name(self, element_id: str | None) -> str | None:
835
+ return None if element_id is None else self.names.get(element_id, element_id)
836
+
837
+ def attributes(self, attributes: Mapping[str, Attribute | TraitAttribute]) -> dict[str, Any]:
838
+ result: dict[str, Any] = {}
839
+ for attribute in attributes.values():
840
+ extras: dict[str, Any] = {"type": _type_node(attribute.type, self.names)}
841
+ exclude = {"id", "api_name", "type"}
842
+ if isinstance(attribute, Attribute):
843
+ exclude |= {"derivation_id", "authority"}
844
+ extras["derivation"] = self.name(attribute.derivation_id)
845
+ extras["authority"] = self.name(attribute.authority)
846
+ result[attribute.api_name] = _compose(
847
+ attribute.id, _plain(attribute, exclude), extras, ("type",)
848
+ )
849
+ return result
850
+
851
+ def kind(self, kind: Kind) -> dict[str, Any]:
852
+ extras = {
853
+ "primary_key": [self.name(a) for a in kind.primary_key],
854
+ "title_attribute": self.name(kind.title_attribute),
855
+ "attributes": self.attributes(kind.attributes),
856
+ "traits": [
857
+ {
858
+ "trait": self.name(impl.trait_id),
859
+ "attribute_map": {
860
+ self.names.get(k, k): self.names.get(v, v)
861
+ for k, v in impl.attribute_map.items()
862
+ },
863
+ }
864
+ for impl in kind.traits
865
+ ],
866
+ }
867
+ plain = _plain(
868
+ kind, {"id", "api_name", "primary_key", "title_attribute", "attributes", "traits"}
869
+ )
870
+ return _compose(
871
+ kind.id, plain, extras, ("primary_key", "title_attribute", "attributes", "traits")
872
+ )
873
+
874
+ def relation(self, relation: Relation) -> dict[str, Any]:
875
+ exclude = {"id", "api_name", "from_kind", "to_kind", "attributes", "foreign_key_attribute"}
876
+ extras = {
877
+ "from_kind": self.name(relation.from_kind),
878
+ "to_kind": self.name(relation.to_kind),
879
+ "foreign_key_attribute": self.name(relation.foreign_key_attribute),
880
+ "attributes": self.attributes(relation.attributes),
881
+ }
882
+ order = ("inverse_api_name", "from_kind", "to_kind", "cardinality", "required_from")
883
+ return _compose(relation.id, _plain(relation, exclude), extras, order)
884
+
885
+ def trait(self, trait: Trait) -> dict[str, Any]:
886
+ extras = {"attributes": self.attributes(trait.attributes)}
887
+ return _compose(trait.id, _plain(trait, {"id", "api_name", "attributes"}), extras)
888
+
889
+ def step(self, step: OperationStep) -> dict[str, Any]:
890
+ out: dict[str, Any] = {"step": step.step}
891
+ if isinstance(step, CreateRecordStep | UpdateRecordStep | DeleteRecordStep):
892
+ out["kind"] = self.name(step.kind)
893
+ else:
894
+ out["relation"] = self.name(step.relation)
895
+ if isinstance(step, UpdateRecordStep | DeleteRecordStep):
896
+ out["target"] = step.target
897
+ if isinstance(step, CreateLinkStep | DeleteLinkStep):
898
+ out["from"] = step.from_
899
+ out["to"] = step.to
900
+ if not isinstance(step, DeleteRecordStep | DeleteLinkStep) and step.values:
901
+ out["values"] = {self.names.get(k, k): v for k, v in step.values.items()}
902
+ return out
903
+
904
+ def operation(self, operation: Operation) -> dict[str, Any]:
905
+ parameters = [
906
+ _compose_parameter(parameter, _type_node(parameter.type, self.names))
907
+ for parameter in operation.parameters
908
+ ]
909
+ extras = {"parameters": parameters, "steps": [self.step(s) for s in operation.steps]}
910
+ plain = _plain(operation, {"id", "api_name", "parameters", "steps"})
911
+ order = ("parameters", "preconditions", "steps", "review_policy", "permissions")
912
+ return _compose(operation.id, plain, extras, order)
913
+
914
+ def derivation(self, derivation: Derivation) -> dict[str, Any]:
915
+ definition = derivation.definition
916
+ out: dict[str, Any] = {"variant": definition.variant}
917
+ if isinstance(definition, ExpressionDerivation | AggregateDerivation):
918
+ out["kind"] = self.name(definition.kind)
919
+ out["attribute"] = self.name(definition.attribute)
920
+ if isinstance(definition, ExpressionDerivation):
921
+ out["expression"] = definition.expression
922
+ elif isinstance(definition, AggregateDerivation):
923
+ out["relation"] = self.name(definition.relation)
924
+ if definition.direction is not LinkDirection.FORWARD:
925
+ out["direction"] = definition.direction.value
926
+ out["function"] = definition.function.value
927
+ if definition.source_attribute is not None:
928
+ out["source_attribute"] = self.name(definition.source_attribute)
929
+ if definition.filter is not None:
930
+ out["filter"] = definition.filter
931
+ else:
932
+ out["steps"] = [
933
+ {"relation": self.name(step.relation)}
934
+ | (
935
+ {}
936
+ if step.direction is LinkDirection.FORWARD
937
+ else {"direction": step.direction.value}
938
+ )
939
+ for step in definition.steps
940
+ ]
941
+ if definition.inverse_api_name is not None:
942
+ out["inverse_api_name"] = definition.inverse_api_name
943
+ if definition.materialized:
944
+ out["materialized"] = True
945
+ plain = _plain(derivation, {"id", "api_name", "definition"})
946
+ return _compose(derivation.id, plain, {"definition": out}, ("definition",))
947
+
948
+ def source(self, source: Source) -> dict[str, Any]:
949
+ extras = {
950
+ "target_kind": self.name(source.target_kind),
951
+ "attribute_mapping": {
952
+ self.names.get(k, k): _plain(v, set()) for k, v in source.attribute_mapping.items()
953
+ },
954
+ "relation_mapping": [
955
+ {"relation": self.name(m.relation)} | _plain(m, {"relation"})
956
+ for m in source.relation_mapping
957
+ ],
958
+ }
959
+ exclude = {"id", "api_name", "target_kind", "attribute_mapping", "relation_mapping"}
960
+ order = ("type", "connection", "target_kind", "key_mapping", "attribute_mapping")
961
+ return _compose(source.id, _plain(source, exclude), extras, order)
962
+
963
+ def document(self) -> dict[str, Any]:
964
+ schema = self.schema
965
+ document: dict[str, Any] = {
966
+ "format": SCHEMA_FORMAT,
967
+ "domain": _plain(schema.domain, set()),
968
+ }
969
+ sections: dict[str, Iterable[SchemaElement]] = {
970
+ "kinds": schema.kinds.values(),
971
+ "relations": schema.relations.values(),
972
+ "traits": schema.traits.values(),
973
+ "operations": schema.operations.values(),
974
+ "derivations": schema.derivations.values(),
975
+ "sources": schema.sources.values(),
976
+ }
977
+ writers: dict[str, Callable[[Any], dict[str, Any]]] = {
978
+ "kinds": self.kind,
979
+ "relations": self.relation,
980
+ "traits": self.trait,
981
+ "operations": self.operation,
982
+ "derivations": self.derivation,
983
+ "sources": self.source,
984
+ }
985
+ for section, elements in sections.items():
986
+ items = {element.api_name: writers[section](element) for element in elements}
987
+ if items:
988
+ document[section] = items
989
+ return document
990
+
991
+
992
+ def _compose_parameter(
993
+ parameter: OperationParameter, type_node: str | dict[str, Any]
994
+ ) -> dict[str, Any]:
995
+ plain = _plain(parameter, {"name", "type"})
996
+ return {"name": parameter.name, "type": type_node, **plain}
997
+
998
+
999
+ def schema_to_document(schema: Schema) -> dict[str, Any]:
1000
+ """The document (plain dicts and lists) that ``dump_schema`` renders as YAML."""
1001
+ return _Writer(schema).document()
1002
+
1003
+
1004
+ def dump_schema(schema: Schema) -> str:
1005
+ """Render a Schema as YAML text. Ids are always written; defaults are omitted."""
1006
+ return yaml.dump(
1007
+ schema_to_document(schema),
1008
+ Dumper=_Dumper,
1009
+ sort_keys=False,
1010
+ allow_unicode=True,
1011
+ width=100,
1012
+ )
1013
+
1014
+
1015
+ # Validation with YAML paths ----------------------------------------------------------------
1016
+
1017
+
1018
+ def yaml_path(schema: Schema, path: Iterable[str | int]) -> tuple[str | int, ...]:
1019
+ """Translate an issue path (ids) into the path of the same spot in the YAML document."""
1020
+ return _translate(id_names(schema), path)
1021
+
1022
+
1023
+ def _translate(names: Mapping[str, str], path: Iterable[str | int]) -> tuple[str | int, ...]:
1024
+ return tuple(
1025
+ part if isinstance(part, int) else _YAML_KEYS.get(part, names.get(part, part))
1026
+ for part in path
1027
+ )
1028
+
1029
+
1030
+ def locate_issues(schema: Schema, issues: Iterable[Issue]) -> list[Issue]:
1031
+ """Copies of ``issues`` whose ``path`` and element references use YAML names."""
1032
+ names = id_names(schema)
1033
+ located = []
1034
+ for issue in issues:
1035
+ update: dict[str, Any] = {"path": _translate(names, issue.path)}
1036
+ for field in ("kind", "relation", "attribute"):
1037
+ value = getattr(issue, field)
1038
+ if value is not None:
1039
+ update[field] = names.get(value, value)
1040
+ located.append(issue.model_copy(update=update))
1041
+ return located
1042
+
1043
+
1044
+ def validate_schema_yaml(
1045
+ text: str,
1046
+ *,
1047
+ base: Schema | None = None,
1048
+ rules: SchemaRules | None = None,
1049
+ id_factory: IdFactory = new_id,
1050
+ ) -> list[Issue]:
1051
+ """Load ``text`` and run static validation; issue paths point into the YAML.
1052
+
1053
+ Raises ``SchemaYamlError`` when the document cannot be loaded at all.
1054
+ """
1055
+ from lorebase_core.validate.schema_rules import validate_schema
1056
+
1057
+ schema = load_schema(text, base=base, id_factory=id_factory)
1058
+ return locate_issues(schema, validate_schema(schema, rules))