lorebase-core 0.2.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- lorebase_core/__init__.py +1 -0
- lorebase_core/canonical/__init__.py +35 -0
- lorebase_core/canonical/hashing.py +128 -0
- lorebase_core/canonical/jcs.py +162 -0
- lorebase_core/expr/__init__.py +118 -0
- lorebase_core/expr/ast.py +443 -0
- lorebase_core/expr/checker.py +428 -0
- lorebase_core/expr/compiler.py +87 -0
- lorebase_core/expr/context.py +82 -0
- lorebase_core/expr/derivations.py +306 -0
- lorebase_core/expr/environment.py +176 -0
- lorebase_core/expr/errors.py +98 -0
- lorebase_core/expr/evaluator.py +414 -0
- lorebase_core/expr/functions.py +898 -0
- lorebase_core/expr/limits.py +52 -0
- lorebase_core/expr/operations.py +479 -0
- lorebase_core/expr/ops.py +243 -0
- lorebase_core/expr/resolver.py +96 -0
- lorebase_core/expr/rows.py +148 -0
- lorebase_core/expr/sensitivity.py +337 -0
- lorebase_core/expr/types.py +227 -0
- lorebase_core/expr/validation.py +51 -0
- lorebase_core/expr/values.py +169 -0
- lorebase_core/expr/visibility.py +363 -0
- lorebase_core/infer/__init__.py +36 -0
- lorebase_core/infer/columns.py +621 -0
- lorebase_core/infer/engine.py +546 -0
- lorebase_core/infer/keys.py +164 -0
- lorebase_core/infer/names.py +145 -0
- lorebase_core/infer/options.py +290 -0
- lorebase_core/infer/profile.py +54 -0
- lorebase_core/infer/proposal.py +122 -0
- lorebase_core/infer/references.py +314 -0
- lorebase_core/infer/suggestions.py +731 -0
- lorebase_core/infer/tables.py +81 -0
- lorebase_core/merge/__init__.py +89 -0
- lorebase_core/merge/conflicts.py +262 -0
- lorebase_core/merge/consistency.py +440 -0
- lorebase_core/merge/data_merge.py +522 -0
- lorebase_core/merge/engine.py +476 -0
- lorebase_core/merge/index.py +100 -0
- lorebase_core/merge/objects.py +204 -0
- lorebase_core/merge/policies.py +322 -0
- lorebase_core/merge/resolve.py +321 -0
- lorebase_core/merge/schema_merge.py +429 -0
- lorebase_core/merge/state.py +443 -0
- lorebase_core/model/__init__.py +245 -0
- lorebase_core/model/attribute.py +72 -0
- lorebase_core/model/base.py +61 -0
- lorebase_core/model/derivation.py +95 -0
- lorebase_core/model/domain.py +71 -0
- lorebase_core/model/embedding.py +193 -0
- lorebase_core/model/ids.py +78 -0
- lorebase_core/model/kind.py +85 -0
- lorebase_core/model/lineage.py +114 -0
- lorebase_core/model/operation.py +161 -0
- lorebase_core/model/paths.py +141 -0
- lorebase_core/model/record.py +127 -0
- lorebase_core/model/relation.py +83 -0
- lorebase_core/model/schema.py +219 -0
- lorebase_core/model/source.py +204 -0
- lorebase_core/model/trait.py +37 -0
- lorebase_core/model/vault.py +43 -0
- lorebase_core/model/yaml_schema.py +1058 -0
- lorebase_core/query/__init__.py +97 -0
- lorebase_core/query/dsl.py +227 -0
- lorebase_core/query/resolve.py +794 -0
- lorebase_core/revision/__init__.py +59 -0
- lorebase_core/revision/ancestry.py +272 -0
- lorebase_core/revision/changeset.py +62 -0
- lorebase_core/revision/revision.py +121 -0
- lorebase_core/tree/__init__.py +76 -0
- lorebase_core/tree/diff.py +119 -0
- lorebase_core/tree/hamt.py +333 -0
- lorebase_core/tree/node.py +280 -0
- lorebase_core/tree/params.py +33 -0
- lorebase_core/tree/root.py +70 -0
- lorebase_core/tree/store.py +98 -0
- lorebase_core/types/__init__.py +71 -0
- lorebase_core/types/casts.py +271 -0
- lorebase_core/types/constraints.py +299 -0
- lorebase_core/types/encoding.py +538 -0
- lorebase_core/types/errors.py +41 -0
- lorebase_core/types/matching.py +40 -0
- lorebase_core/types/types.py +187 -0
- lorebase_core/validate/__init__.py +49 -0
- lorebase_core/validate/changes.py +70 -0
- lorebase_core/validate/changeset.py +551 -0
- lorebase_core/validate/data_rules.py +291 -0
- lorebase_core/validate/issues.py +123 -0
- lorebase_core/validate/schema_rules.py +1304 -0
- lorebase_core-0.2.3.dist-info/METADATA +9 -0
- lorebase_core-0.2.3.dist-info/RECORD +94 -0
- lorebase_core-0.2.3.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,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))
|