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,306 @@
1
+ """Derivations (design 5.8): compile ``expression`` Derivations and aggregate filters, evaluate
2
+ them over one Record, and tell which derived Attributes and Relations they read.
3
+
4
+ Recomputing derived values when Records change is the materializer's job; this module only
5
+ computes one value from one Record (whose values may include other derived values).
6
+ ``derivation_inputs`` lists the derived Attributes a Derivation may read, for cycle detection
7
+ and evaluation order; ``links_read`` lists the ``links.<relation>`` fields an expression reads.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from collections.abc import Iterator
13
+ from dataclasses import dataclass, fields
14
+
15
+ from pydantic import JsonValue
16
+
17
+ from lorebase_core.expr.ast import Ident, Node, Select, parse
18
+ from lorebase_core.expr.compiler import CompiledExpression, compile, run
19
+ from lorebase_core.expr.context import EvaluationContext, activation
20
+ from lorebase_core.expr.environment import LINKS, LinkField, link_fields, record_environment
21
+ from lorebase_core.expr.errors import ExprError, ExprErrorCode, PathPart
22
+ from lorebase_core.expr.limits import ExprLimits
23
+ from lorebase_core.expr.ops import require_bool
24
+ from lorebase_core.expr.types import BOOL, from_type_spec
25
+ from lorebase_core.expr.values import encode_result
26
+ from lorebase_core.model.derivation import AggregateDerivation, Derivation, ExpressionDerivation
27
+ from lorebase_core.model.record import Record
28
+ from lorebase_core.model.schema import Schema
29
+ from lorebase_core.types import TypeSpec
30
+
31
+
32
+ @dataclass(frozen=True, eq=False)
33
+ class CompiledDerivation:
34
+ """An ``expression`` Derivation (``expression`` and ``target`` set) or an aggregate with a
35
+ ``filter`` over the Records it aggregates."""
36
+
37
+ derivation: Derivation
38
+ expression: CompiledExpression | None = None
39
+ target: TypeSpec | None = None
40
+ filter: CompiledExpression | None = None
41
+
42
+
43
+ def _compile(
44
+ schema: Schema, derivation: Derivation, limits: ExprLimits, errors: list[ExprError] | None
45
+ ) -> CompiledDerivation:
46
+ definition = derivation.definition
47
+
48
+ def fail(error: ExprError, path: tuple[PathPart, ...]) -> None:
49
+ error.located(path=path)
50
+ if errors is None:
51
+ raise error
52
+ errors.append(error)
53
+
54
+ if isinstance(definition, ExpressionDerivation):
55
+ kind = schema.kinds.get(definition.kind)
56
+ attribute = kind.attributes.get(definition.attribute) if kind is not None else None
57
+ if kind is None or attribute is None:
58
+ if errors is None:
59
+ raise ExprError(ExprErrorCode.TYPE, "the Derivation's target does not exist")
60
+ return CompiledDerivation(derivation)
61
+ if LINKS in {a.api_name for a in kind.attributes.values()}:
62
+ fail(
63
+ ExprError(
64
+ ExprErrorCode.TYPE, f"attribute name {LINKS!r} is reserved in expressions"
65
+ ),
66
+ ("definition", "kind"),
67
+ )
68
+ try:
69
+ program = compile(
70
+ definition.expression,
71
+ record_environment(schema, kind.id),
72
+ expected=from_type_spec(attribute.type),
73
+ nullable=True,
74
+ limits=limits,
75
+ )
76
+ except ExprError as error:
77
+ fail(error, ("definition", "expression"))
78
+ return CompiledDerivation(derivation)
79
+ return CompiledDerivation(derivation, expression=program, target=attribute.type)
80
+ if isinstance(definition, AggregateDerivation) and definition.filter is not None:
81
+ relation = schema.link_relation(definition.relation)
82
+ if relation is None:
83
+ if errors is None:
84
+ raise ExprError(ExprErrorCode.TYPE, "the aggregated Relation does not exist")
85
+ return CompiledDerivation(derivation)
86
+ far = relation.far_endpoint(definition.direction)
87
+ try:
88
+ program = compile(
89
+ definition.filter,
90
+ record_environment(schema, far),
91
+ expected=BOOL,
92
+ limits=limits,
93
+ )
94
+ except ExprError as error:
95
+ fail(error, ("definition", "filter"))
96
+ return CompiledDerivation(derivation)
97
+ return CompiledDerivation(derivation, filter=program)
98
+ return CompiledDerivation(derivation)
99
+
100
+
101
+ def compile_derivation(
102
+ schema: Schema, derivation: Derivation, limits: ExprLimits | None = None
103
+ ) -> CompiledDerivation:
104
+ """Compile the expressions of ``derivation``; raises the first ``ExprError``."""
105
+ return _compile(schema, derivation, limits or ExprLimits(), None)
106
+
107
+
108
+ def derivation_expression_errors(
109
+ schema: Schema, derivation: Derivation, limits: ExprLimits | None = None
110
+ ) -> list[ExprError]:
111
+ """Every expression problem of ``derivation`` (paths inside the element)."""
112
+ errors: list[ExprError] = []
113
+ _compile(schema, derivation, limits or ExprLimits(), errors)
114
+ return errors
115
+
116
+
117
+ def evaluate_derivation(
118
+ compiled: CompiledDerivation, record: Record, context: EvaluationContext
119
+ ) -> JsonValue:
120
+ """The canonical value an ``expression`` Derivation computes for ``record``.
121
+
122
+ ``None`` means no value. Raises ``ExprError`` when evaluation fails or the result does
123
+ not fit the target Attribute (``expression.value_invalid``).
124
+ """
125
+ if compiled.expression is None or compiled.target is None:
126
+ raise ExprError(ExprErrorCode.EVALUATION, "not an expression Derivation")
127
+ program = compiled.expression
128
+ variables = activation(program.environment, record=record)
129
+ value = run(
130
+ program,
131
+ variables,
132
+ now=context.now,
133
+ resolver=context.resolver,
134
+ limits=context.limits,
135
+ )
136
+ return encode_result(compiled.target, value)
137
+
138
+
139
+ def matches_filter(
140
+ compiled: CompiledDerivation, record: Record, context: EvaluationContext
141
+ ) -> bool:
142
+ """Whether a Record at the far end of an aggregate passes its ``filter`` (true if none)."""
143
+ if compiled.filter is None:
144
+ return True
145
+ variables = activation(compiled.filter.environment, record=record)
146
+ value = run(
147
+ compiled.filter,
148
+ variables,
149
+ now=context.now,
150
+ resolver=context.resolver,
151
+ limits=context.limits,
152
+ )
153
+ return require_bool(value, "filter")
154
+
155
+
156
+ # Dependencies ------------------------------------------------------------------------------------
157
+
158
+
159
+ def _nodes(node: Node) -> Iterator[Node]:
160
+ """``node`` and every node below it."""
161
+ yield node
162
+ for item in fields(node):
163
+ value = getattr(node, item.name)
164
+ children = value if isinstance(value, tuple) else (value,)
165
+ for child in children:
166
+ for part in child if isinstance(child, tuple) else (child,):
167
+ if isinstance(part, Node):
168
+ yield from _nodes(part)
169
+
170
+
171
+ @dataclass(frozen=True)
172
+ class _Names:
173
+ """Identifiers, selected fields and ``links.<name>`` fields of an expression."""
174
+
175
+ idents: frozenset[str]
176
+ fields: frozenset[str]
177
+ links: frozenset[str]
178
+
179
+
180
+ def _names(root: Node) -> _Names:
181
+ idents: set[str] = set()
182
+ selected: set[str] = set()
183
+ links: set[str] = set()
184
+ for node in _nodes(root):
185
+ if isinstance(node, Ident):
186
+ idents.add(node.name)
187
+ elif isinstance(node, Select):
188
+ selected.add(node.field)
189
+ if isinstance(node.operand, Ident) and node.operand.name == LINKS:
190
+ links.add(node.field)
191
+ return _Names(frozenset(idents), frozenset(selected), frozenset(links))
192
+
193
+
194
+ def links_read(program: CompiledExpression) -> list[LinkField]:
195
+ """The ``links.<relation>`` fields a Record expression reads, in name order."""
196
+ names = _names(program.root).links
197
+ available = program.environment.link_fields
198
+ return [available[name] for name in sorted(names) if name in available]
199
+
200
+
201
+ def _record_inputs(schema: Schema, kind_id: str, source: str, limits: ExprLimits) -> set[str]:
202
+ """Derived Attributes an expression over a Record of ``kind_id`` may read: its own derived
203
+ Attributes named in it, and derived Attributes of Records one hop away (through a
204
+ ``links.<relation>`` field) whose name appears in it. Conservative by design."""
205
+ try:
206
+ names = _names(parse(source, limits))
207
+ except ExprError:
208
+ return set() # reported by expression validation
209
+ words = names.idents | names.fields
210
+ inputs: set[str] = set()
211
+ kind = schema.kinds.get(kind_id)
212
+ if kind is not None:
213
+ inputs.update(
214
+ a.id for a in kind.attributes.values() if a.is_derived and a.api_name in names.idents
215
+ )
216
+ available = link_fields(schema, kind_id)
217
+ for name in names.links:
218
+ field = available.get(name)
219
+ far = (
220
+ None
221
+ if field is None
222
+ else schema.kinds.get(field.relation.far_endpoint(field.direction))
223
+ )
224
+ if far is not None:
225
+ inputs.update(
226
+ a.id for a in far.attributes.values() if a.is_derived and a.api_name in words
227
+ )
228
+ return inputs
229
+
230
+
231
+ def derivation_inputs(
232
+ schema: Schema, derivation: Derivation, limits: ExprLimits | None = None
233
+ ) -> frozenset[str]:
234
+ """Ids of the derived Attributes ``derivation`` may read (a superset, never fewer).
235
+
236
+ An ``expression`` Derivation reads its Record and the Records one hop away; an aggregate
237
+ reads the ``source_attribute`` and the ``filter`` of the Records it aggregates. Path
238
+ Derivations read nothing derived.
239
+ """
240
+ limits = limits or ExprLimits()
241
+ definition = derivation.definition
242
+ if isinstance(definition, ExpressionDerivation):
243
+ return frozenset(_record_inputs(schema, definition.kind, definition.expression, limits))
244
+ if not isinstance(definition, AggregateDerivation):
245
+ return frozenset()
246
+ relation = schema.link_relation(definition.relation)
247
+ if relation is None:
248
+ return frozenset()
249
+ far_id = relation.far_endpoint(definition.direction)
250
+ far = schema.kinds.get(far_id)
251
+ inputs: set[str] = set()
252
+ if far is not None and definition.source_attribute is not None:
253
+ source = far.attributes.get(definition.source_attribute)
254
+ if source is not None and source.is_derived:
255
+ inputs.add(source.id)
256
+ if definition.filter is not None:
257
+ inputs.update(_record_inputs(schema, far_id, definition.filter, limits))
258
+ return frozenset(inputs)
259
+
260
+
261
+ def _expression_reads(
262
+ schema: Schema, kind_id: str, source: str, limits: ExprLimits
263
+ ) -> set[tuple[str, str]]:
264
+ """`(Kind id, Attribute id)` of every Attribute an expression over a Record of `kind_id`
265
+ may read: its own Attributes named in it and those of Records one hop away (through a
266
+ ``links.<relation>`` field) whose name appears in it. A superset, never fewer."""
267
+ try:
268
+ names = _names(parse(source, limits))
269
+ except ExprError:
270
+ return set() # reported by expression validation
271
+ words = names.idents | names.fields
272
+ reads: set[tuple[str, str]] = set()
273
+ kind = schema.kinds.get(kind_id)
274
+ if kind is not None:
275
+ reads.update((kind_id, a.id) for a in kind.attributes.values() if a.api_name in words)
276
+ available = link_fields(schema, kind_id)
277
+ for name in names.links:
278
+ field = available.get(name)
279
+ far_id = None if field is None else field.relation.far_endpoint(field.direction)
280
+ far = None if far_id is None else schema.kinds.get(far_id)
281
+ if far is not None:
282
+ reads.update((far.id, a.id) for a in far.attributes.values() if a.api_name in words)
283
+ return reads
284
+
285
+
286
+ def derivation_reads(
287
+ schema: Schema, derivation: Derivation, limits: ExprLimits | None = None
288
+ ) -> frozenset[tuple[str, str]]:
289
+ """`(Kind id, Attribute id)` of every Attribute, stored or derived, a Derivation may read
290
+ (a superset): what its value can tell, for sensitivity (`schema.sensitivity_inherited`)."""
291
+ limits = limits or ExprLimits()
292
+ definition = derivation.definition
293
+ if isinstance(definition, ExpressionDerivation):
294
+ return frozenset(_expression_reads(schema, definition.kind, definition.expression, limits))
295
+ if not isinstance(definition, AggregateDerivation):
296
+ return frozenset()
297
+ relation = schema.link_relation(definition.relation)
298
+ if relation is None:
299
+ return frozenset()
300
+ far_id = relation.far_endpoint(definition.direction)
301
+ reads: set[tuple[str, str]] = set()
302
+ if definition.source_attribute is not None:
303
+ reads.add((far_id, definition.source_attribute))
304
+ if definition.filter is not None:
305
+ reads.update(_expression_reads(schema, far_id, definition.filter, limits))
306
+ return frozenset(reads)
@@ -0,0 +1,176 @@
1
+ """Typed environments: the names an expression may use, derived from the Schema.
2
+
3
+ - Operation expressions (preconditions, review policy, step values): ``params.<name>`` and
4
+ each parameter by its bare name, ``principal.id`` and ``principal.subjects``, the
5
+ functions ``record(ref)`` and ``links(ref, "relation")``; in steps also ``$step[n]``, the
6
+ Record of an earlier step.
7
+ - Record expressions (Derivations, aggregate filters): the Record's Attributes by api name,
8
+ ``key``, and ``links.<relation>``: the Records one hop away, by forward or inverse
9
+ Relation name (or path Derivation name).
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from collections.abc import Mapping
15
+ from dataclasses import dataclass, field
16
+
17
+ from lorebase_core.expr.types import (
18
+ DOUBLE,
19
+ DYN,
20
+ GEO_POINT,
21
+ STRING,
22
+ ExprType,
23
+ TypeKind,
24
+ from_type_spec,
25
+ list_of,
26
+ object_of,
27
+ record_of,
28
+ ref_of,
29
+ )
30
+ from lorebase_core.model.operation import (
31
+ CreateRecordStep,
32
+ DeleteRecordStep,
33
+ Operation,
34
+ UpdateRecordStep,
35
+ )
36
+ from lorebase_core.model.relation import LinkDirection, Relation
37
+ from lorebase_core.model.schema import Schema
38
+ from lorebase_core.types import TypeSpec
39
+
40
+ #: Names an Operation environment defines itself; parameters cannot use them.
41
+ PARAMS = "params"
42
+ PRINCIPAL = "principal"
43
+ #: Names a Record environment defines itself; Attributes cannot use them (``key`` is
44
+ #: reserved for Attributes by schema validation already).
45
+ KEY = "key"
46
+ LINKS = "links"
47
+ #: Fields of a ``record(Kind)`` value besides its Attributes.
48
+ RECORD_KEY = "key"
49
+ RECORD_REF = "ref"
50
+
51
+ PRINCIPAL_FIELDS: Mapping[str, ExprType] = {"id": STRING, "subjects": list_of(STRING)}
52
+ GEO_POINT_FIELDS: Mapping[str, ExprType] = {"lat": DOUBLE, "lon": DOUBLE}
53
+
54
+
55
+ @dataclass(frozen=True, slots=True)
56
+ class LinkField:
57
+ """A ``links.<name>`` field: the Relation and the direction it is traversed in."""
58
+
59
+ relation: Relation
60
+ direction: LinkDirection
61
+
62
+
63
+ @dataclass(frozen=True, eq=False)
64
+ class Environment:
65
+ """Declared names and types. ``subject`` is the Kind id whose Attributes are variables;
66
+ ``parameters`` are the Operation parameter types (to decode parameter values)."""
67
+
68
+ schema: Schema
69
+ variables: Mapping[str, ExprType]
70
+ objects: Mapping[str, Mapping[str, ExprType]] = field(default_factory=dict)
71
+ subject: str | None = None
72
+ records: bool = False
73
+ step_types: tuple[ExprType | None, ...] | None = None
74
+ link_fields: Mapping[str, LinkField] = field(default_factory=dict)
75
+ parameters: Mapping[str, TypeSpec] = field(default_factory=dict)
76
+ _record_fields: dict[str, dict[str, ExprType]] = field(
77
+ default_factory=dict, repr=False, compare=False
78
+ )
79
+
80
+ def kind_name(self, kind_id: str) -> str:
81
+ kind = self.schema.kinds.get(kind_id)
82
+ return kind.api_name if kind is not None else kind_id
83
+
84
+ def describe(self, expr_type: ExprType) -> str:
85
+ return expr_type.describe(self.kind_name)
86
+
87
+ def record_fields(self, kind_id: str) -> dict[str, ExprType]:
88
+ """Fields of ``record(kind)`` values: Attributes by api name, ``key`` and ``ref``."""
89
+ cached = self._record_fields.get(kind_id)
90
+ if cached is not None:
91
+ return cached
92
+ fields: dict[str, ExprType] = {}
93
+ kind = self.schema.kinds.get(kind_id)
94
+ if kind is not None:
95
+ fields = {a.api_name: from_type_spec(a.type) for a in kind.attributes.values()}
96
+ fields[RECORD_KEY] = STRING
97
+ fields[RECORD_REF] = ref_of(kind_id)
98
+ self._record_fields[kind_id] = fields
99
+ return fields
100
+
101
+ def fields_of(self, expr_type: ExprType) -> Mapping[str, ExprType] | None:
102
+ """Fields of a record or object type; ``None`` for other types."""
103
+ if expr_type.kind is TypeKind.RECORD and expr_type.name is not None:
104
+ return self.record_fields(expr_type.name)
105
+ if expr_type.kind is TypeKind.OBJECT and expr_type.name is not None:
106
+ if expr_type.name == GEO_POINT:
107
+ return GEO_POINT_FIELDS
108
+ return self.objects.get(expr_type.name)
109
+ return None
110
+
111
+
112
+ def operation_environment(
113
+ schema: Schema, operation: Operation, *, step_index: int | None = None
114
+ ) -> Environment:
115
+ """Environment of an Operation's expressions; with ``step_index``, of that step's."""
116
+ parameters = {p.name: from_type_spec(p.type) for p in operation.parameters}
117
+ variables: dict[str, ExprType] = dict(parameters)
118
+ variables[PARAMS] = object_of(PARAMS)
119
+ variables[PRINCIPAL] = object_of(PRINCIPAL)
120
+ step_types: tuple[ExprType | None, ...] | None = None
121
+ if step_index is not None:
122
+ step_types = tuple(_step_type(schema, step) for step in operation.steps[:step_index])
123
+ return Environment(
124
+ schema=schema,
125
+ variables=variables,
126
+ objects={PARAMS: parameters, PRINCIPAL: PRINCIPAL_FIELDS},
127
+ records=True,
128
+ step_types=step_types,
129
+ parameters={p.name: p.type for p in operation.parameters},
130
+ )
131
+
132
+
133
+ def _step_type(schema: Schema, step: object) -> ExprType | None:
134
+ """Type of ``$step[n]``: the Record reference, ``dyn`` for an unknown Kind (reported by
135
+ schema validation), ``None`` for Link steps."""
136
+ if isinstance(step, CreateRecordStep | UpdateRecordStep | DeleteRecordStep):
137
+ return ref_of(step.kind) if step.kind in schema.kinds else DYN
138
+ return None
139
+
140
+
141
+ def record_environment(schema: Schema, kind_id: str) -> Environment:
142
+ """Environment of expressions over one Record of ``kind_id`` (Derivations, filters)."""
143
+ kind = schema.kinds.get(kind_id)
144
+ variables: dict[str, ExprType] = {}
145
+ if kind is not None:
146
+ variables = {a.api_name: from_type_spec(a.type) for a in kind.attributes.values()}
147
+ links = link_fields(schema, kind_id)
148
+ variables[KEY] = STRING
149
+ variables[LINKS] = object_of(LINKS)
150
+ link_types = {
151
+ name: list_of(record_of(f.relation.far_endpoint(f.direction))) for name, f in links.items()
152
+ }
153
+ return Environment(
154
+ schema=schema,
155
+ variables=variables,
156
+ objects={LINKS: link_types},
157
+ subject=kind_id,
158
+ link_fields=links,
159
+ )
160
+
161
+
162
+ def link_fields(schema: Schema, kind_id: str) -> dict[str, LinkField]:
163
+ """Relation names usable from a Record of ``kind_id``: forward names of Relations that
164
+ start at the Kind, inverse names of Relations that end there; path Derivations count as
165
+ Relations (their virtual Relation, design 5.8)."""
166
+ fields: dict[str, LinkField] = {}
167
+ relations = [
168
+ *schema.relations.values(),
169
+ *(path.relation for path in schema.path_relations().values()),
170
+ ]
171
+ for relation in relations:
172
+ if relation.to_kind == kind_id and relation.inverse_api_name:
173
+ fields[relation.inverse_api_name] = LinkField(relation, LinkDirection.INVERSE)
174
+ if relation.from_kind == kind_id:
175
+ fields[relation.api_name] = LinkField(relation, LinkDirection.FORWARD)
176
+ return fields
@@ -0,0 +1,98 @@
1
+ """Errors of the expression engine: one exception type with a stable code and a position."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from enum import StrEnum
7
+
8
+ PathPart = str | int
9
+
10
+
11
+ class ExprErrorCode(StrEnum):
12
+ """Stable codes; ``syntax`` and ``type`` are static, the others come from evaluation."""
13
+
14
+ SYNTAX = "expression.syntax"
15
+ TYPE = "expression.type"
16
+ EVALUATION = "expression.evaluation"
17
+ LIMIT_EXCEEDED = "expression.limit_exceeded"
18
+ VALUE_INVALID = "expression.value_invalid"
19
+ RECORD_NOT_FOUND = "record.not_found"
20
+ RECORD_EXISTS = "record.exists"
21
+
22
+
23
+ @dataclass(frozen=True, slots=True)
24
+ class Position:
25
+ """1-based line and column of a node in the expression source."""
26
+
27
+ line: int
28
+ column: int
29
+
30
+ def __str__(self) -> str:
31
+ return f"{self.line}:{self.column}"
32
+
33
+
34
+ class ExprError(Exception):
35
+ """A problem with an expression: compiling it, or evaluating it.
36
+
37
+ ``path`` locates the expression inside its schema element (for example
38
+ ``("preconditions", 0, "expression")``); it is empty until a caller sets it.
39
+ """
40
+
41
+ def __init__(
42
+ self,
43
+ code: ExprErrorCode,
44
+ message: str,
45
+ *,
46
+ position: Position | None = None,
47
+ source: str | None = None,
48
+ path: tuple[PathPart, ...] = (),
49
+ ) -> None:
50
+ super().__init__(message)
51
+ self.code = code
52
+ self.message = message
53
+ self.position = position
54
+ self.source = source
55
+ self.path = path
56
+
57
+ def located(
58
+ self, *, source: str | None = None, path: tuple[PathPart, ...] | None = None
59
+ ) -> ExprError:
60
+ """This error with the expression source and path filled in where they are missing."""
61
+ if source is not None and self.source is None:
62
+ self.source = source
63
+ if path is not None and not self.path:
64
+ self.path = path
65
+ return self
66
+
67
+ def describe(self) -> str:
68
+ """``line:column: message`` when the position is known, the message otherwise."""
69
+ if self.position is None:
70
+ return self.message
71
+ return f"{self.position}: {self.message}"
72
+
73
+ def snippet(self) -> str:
74
+ """The source line of the error with a caret under the column (empty if unknown)."""
75
+ if self.position is None or self.source is None:
76
+ return ""
77
+ lines = self.source.splitlines() or [""]
78
+ line = lines[min(self.position.line, len(lines)) - 1]
79
+ return f"{line}\n{' ' * (self.position.column - 1)}^"
80
+
81
+ def __str__(self) -> str:
82
+ return self.describe()
83
+
84
+
85
+ def syntax_error(message: str, position: Position | None = None) -> ExprError:
86
+ return ExprError(ExprErrorCode.SYNTAX, message, position=position)
87
+
88
+
89
+ def type_error(message: str, position: Position | None = None) -> ExprError:
90
+ return ExprError(ExprErrorCode.TYPE, message, position=position)
91
+
92
+
93
+ def evaluation_error(message: str, position: Position | None = None) -> ExprError:
94
+ return ExprError(ExprErrorCode.EVALUATION, message, position=position)
95
+
96
+
97
+ def limit_error(message: str, position: Position | None = None) -> ExprError:
98
+ return ExprError(ExprErrorCode.LIMIT_EXCEEDED, message, position=position)