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,243 @@
|
|
|
1
|
+
"""Operator semantics on runtime values: equality, ordering, arithmetic.
|
|
2
|
+
|
|
3
|
+
CEL rules with the Lorebase additions: ``decimal`` arithmetic is exact (with ``int``
|
|
4
|
+
operands promoted), numbers of different kinds compare by value, ``date`` takes whole-day
|
|
5
|
+
durations, and integer results must stay in the int64 range. ``int`` and ``double`` do not
|
|
6
|
+
mix in arithmetic (convert with ``double()`` or ``decimal()``). Division by zero, overflow
|
|
7
|
+
and arithmetic on an absent value (null) are evaluation errors.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import math
|
|
13
|
+
from datetime import date, datetime, timedelta
|
|
14
|
+
from decimal import Context, Decimal, DecimalException
|
|
15
|
+
from typing import TypeGuard
|
|
16
|
+
|
|
17
|
+
from lorebase_core.expr.errors import ExprError, evaluation_error
|
|
18
|
+
from lorebase_core.expr.values import ObjectValue, RecordValue, Ref, type_name
|
|
19
|
+
from lorebase_core.types import INT64_MAX, INT64_MIN, GeoPoint
|
|
20
|
+
|
|
21
|
+
_DAY = timedelta(days=1)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
Number = int | float | Decimal
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def is_number(value: object) -> TypeGuard[Number]:
|
|
28
|
+
return isinstance(value, int | float | Decimal) and not isinstance(value, bool)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def check_int(value: int) -> int:
|
|
32
|
+
if not INT64_MIN <= value <= INT64_MAX:
|
|
33
|
+
raise evaluation_error("integer overflow: the result is outside the int64 range")
|
|
34
|
+
return value
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def require_bool(value: object, what: str) -> bool:
|
|
38
|
+
if not isinstance(value, bool):
|
|
39
|
+
raise evaluation_error(f"{what} must be a bool, got {type_name(value)}")
|
|
40
|
+
return value
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _absent(operation: str) -> ExprError:
|
|
44
|
+
return evaluation_error(
|
|
45
|
+
f"{operation} on an absent value (null); test presence with has() first"
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def equals(left: object, right: object) -> bool:
|
|
50
|
+
"""CEL equality: values of different types are not equal, numbers compare by value."""
|
|
51
|
+
if left is None or right is None:
|
|
52
|
+
return left is None and right is None
|
|
53
|
+
if isinstance(left, bool) or isinstance(right, bool):
|
|
54
|
+
return isinstance(left, bool) and isinstance(right, bool) and left == right
|
|
55
|
+
if is_number(left) and is_number(right):
|
|
56
|
+
return bool(left == right)
|
|
57
|
+
if isinstance(left, datetime) or isinstance(right, datetime):
|
|
58
|
+
return isinstance(left, datetime) and isinstance(right, datetime) and left == right
|
|
59
|
+
if isinstance(left, RecordValue) and isinstance(right, RecordValue):
|
|
60
|
+
return left.kind.id == right.kind.id and left.key == right.key
|
|
61
|
+
if isinstance(left, list) and isinstance(right, list):
|
|
62
|
+
return len(left) == len(right) and all(
|
|
63
|
+
equals(a, b) for a, b in zip(left, right, strict=True)
|
|
64
|
+
)
|
|
65
|
+
if isinstance(left, dict) and isinstance(right, dict):
|
|
66
|
+
if len(left) != len(right):
|
|
67
|
+
return False
|
|
68
|
+
for key, value in left.items():
|
|
69
|
+
match = [k for k in right if equals(k, key)]
|
|
70
|
+
if not match or not equals(value, right[match[0]]):
|
|
71
|
+
return False
|
|
72
|
+
return True
|
|
73
|
+
if isinstance(left, ObjectValue) and isinstance(right, ObjectValue):
|
|
74
|
+
return left.name == right.name and equals(dict(left.fields), dict(right.fields))
|
|
75
|
+
if type(left) is not type(right) and not (
|
|
76
|
+
isinstance(left, GeoPoint) and isinstance(right, GeoPoint)
|
|
77
|
+
):
|
|
78
|
+
return False
|
|
79
|
+
return bool(left == right)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def contains(container: object, item: object) -> bool:
|
|
83
|
+
"""``item in container`` for lists (elements) and maps (keys)."""
|
|
84
|
+
if isinstance(container, list):
|
|
85
|
+
return any(equals(item, element) for element in container)
|
|
86
|
+
if isinstance(container, dict):
|
|
87
|
+
return any(equals(item, key) for key in container)
|
|
88
|
+
if container is None:
|
|
89
|
+
raise _absent("'in'")
|
|
90
|
+
raise evaluation_error(f"'in' needs a list or a map, got {type_name(container)}")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def compare(left: object, right: object) -> int:
|
|
94
|
+
"""-1, 0 or 1; numbers of any kind compare by value, other values within their type."""
|
|
95
|
+
if left is None or right is None:
|
|
96
|
+
raise _absent("comparison")
|
|
97
|
+
if is_number(left) and is_number(right):
|
|
98
|
+
return int(left > right) - int(left < right)
|
|
99
|
+
same = type(left) is type(right) or (
|
|
100
|
+
isinstance(left, date)
|
|
101
|
+
and isinstance(right, date)
|
|
102
|
+
and isinstance(left, datetime) == isinstance(right, datetime)
|
|
103
|
+
)
|
|
104
|
+
if same and isinstance(left, str | bytes | bool | date | timedelta):
|
|
105
|
+
return (left > right) - (left < right) # type: ignore[operator]
|
|
106
|
+
raise evaluation_error(f"cannot order {type_name(left)} and {type_name(right)}")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def negate(value: object, decimals: Context) -> object:
|
|
110
|
+
if value is None:
|
|
111
|
+
raise _absent("negation")
|
|
112
|
+
if isinstance(value, bool):
|
|
113
|
+
raise evaluation_error("cannot negate a bool; use !")
|
|
114
|
+
if isinstance(value, int):
|
|
115
|
+
return check_int(-value)
|
|
116
|
+
if isinstance(value, float):
|
|
117
|
+
return -value
|
|
118
|
+
if isinstance(value, Decimal):
|
|
119
|
+
return decimals.minus(value)
|
|
120
|
+
if isinstance(value, timedelta):
|
|
121
|
+
return -value
|
|
122
|
+
raise evaluation_error(f"cannot negate {type_name(value)}")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def arithmetic(op: str, left: object, right: object, decimals: Context) -> object:
|
|
126
|
+
"""``+ - * / %`` on runtime values (see the module docstring)."""
|
|
127
|
+
if left is None or right is None:
|
|
128
|
+
raise _absent(f"operator {op}")
|
|
129
|
+
try:
|
|
130
|
+
result = _arithmetic(op, left, right, decimals)
|
|
131
|
+
except (OverflowError, DecimalException) as error:
|
|
132
|
+
raise evaluation_error(f"operator {op} failed: {_reason(error)}") from None
|
|
133
|
+
if result is NotImplemented:
|
|
134
|
+
raise evaluation_error(
|
|
135
|
+
f"operator {op} is not defined for {type_name(left)} and {type_name(right)}"
|
|
136
|
+
)
|
|
137
|
+
return result
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _reason(error: Exception) -> str:
|
|
141
|
+
if isinstance(error, DecimalException):
|
|
142
|
+
return error.__class__.__name__.lower()
|
|
143
|
+
return str(error) or error.__class__.__name__
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _arithmetic(op: str, left: object, right: object, decimals: Context) -> object:
|
|
147
|
+
if isinstance(left, bool) or isinstance(right, bool):
|
|
148
|
+
return NotImplemented
|
|
149
|
+
if is_number(left) and is_number(right):
|
|
150
|
+
return _numeric(op, left, right, decimals)
|
|
151
|
+
if op == "+":
|
|
152
|
+
if isinstance(left, str) and isinstance(right, str):
|
|
153
|
+
return left + right
|
|
154
|
+
if isinstance(left, bytes) and isinstance(right, bytes):
|
|
155
|
+
return left + right
|
|
156
|
+
if isinstance(left, list) and isinstance(right, list):
|
|
157
|
+
return left + right
|
|
158
|
+
return _temporal(op, left, right)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _numeric(op: str, left: object, right: object, decimals: Context) -> object:
|
|
162
|
+
if isinstance(left, Decimal) or isinstance(right, Decimal):
|
|
163
|
+
if isinstance(left, float) or isinstance(right, float):
|
|
164
|
+
raise evaluation_error("decimal and double do not mix; convert with decimal()")
|
|
165
|
+
assert is_number(left)
|
|
166
|
+
assert is_number(right)
|
|
167
|
+
a = left if isinstance(left, Decimal) else Decimal(left)
|
|
168
|
+
b = right if isinstance(right, Decimal) else Decimal(right)
|
|
169
|
+
if op in ("/", "%") and b.is_zero():
|
|
170
|
+
raise evaluation_error("division by zero")
|
|
171
|
+
operations = {
|
|
172
|
+
"+": decimals.add,
|
|
173
|
+
"-": decimals.subtract,
|
|
174
|
+
"*": decimals.multiply,
|
|
175
|
+
"/": decimals.divide,
|
|
176
|
+
"%": decimals.remainder,
|
|
177
|
+
}
|
|
178
|
+
return operations[op](a, b)
|
|
179
|
+
if isinstance(left, float) or isinstance(right, float):
|
|
180
|
+
if not (isinstance(left, float) and isinstance(right, float)):
|
|
181
|
+
raise evaluation_error("int and double do not mix; convert with double() or int()")
|
|
182
|
+
if op == "%":
|
|
183
|
+
return NotImplemented
|
|
184
|
+
if op == "/" and right == 0.0:
|
|
185
|
+
raise evaluation_error("division by zero")
|
|
186
|
+
value = {"+": left + right, "-": left - right, "*": left * right}.get(op)
|
|
187
|
+
result = left / right if value is None else value
|
|
188
|
+
if not math.isfinite(result):
|
|
189
|
+
raise evaluation_error(f"operator {op} overflowed the double range")
|
|
190
|
+
return result
|
|
191
|
+
assert isinstance(left, int)
|
|
192
|
+
assert isinstance(right, int)
|
|
193
|
+
if op in ("/", "%"):
|
|
194
|
+
if right == 0:
|
|
195
|
+
raise evaluation_error("division by zero")
|
|
196
|
+
quotient = abs(left) // abs(right)
|
|
197
|
+
if (left < 0) != (right < 0):
|
|
198
|
+
quotient = -quotient
|
|
199
|
+
return check_int(quotient if op == "/" else left - right * quotient)
|
|
200
|
+
return check_int({"+": left + right, "-": left - right, "*": left * right}[op])
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _temporal(op: str, left: object, right: object) -> object:
|
|
204
|
+
if op == "+":
|
|
205
|
+
if isinstance(left, timedelta) and isinstance(right, date | timedelta):
|
|
206
|
+
left, right = right, left
|
|
207
|
+
if isinstance(left, datetime) and isinstance(right, timedelta):
|
|
208
|
+
return left + right
|
|
209
|
+
if isinstance(left, date) and isinstance(right, timedelta):
|
|
210
|
+
return left + _whole_days(right)
|
|
211
|
+
if isinstance(left, timedelta) and isinstance(right, timedelta):
|
|
212
|
+
return left + right
|
|
213
|
+
return NotImplemented
|
|
214
|
+
if op == "-":
|
|
215
|
+
if isinstance(left, datetime) and isinstance(right, datetime):
|
|
216
|
+
return left - right
|
|
217
|
+
if isinstance(left, datetime) and isinstance(right, timedelta):
|
|
218
|
+
return left - right
|
|
219
|
+
if isinstance(left, datetime) or isinstance(right, datetime):
|
|
220
|
+
return NotImplemented
|
|
221
|
+
if isinstance(left, date) and isinstance(right, date):
|
|
222
|
+
return left - right
|
|
223
|
+
if isinstance(left, date) and isinstance(right, timedelta):
|
|
224
|
+
return left - _whole_days(right)
|
|
225
|
+
if isinstance(left, timedelta) and isinstance(right, timedelta):
|
|
226
|
+
return left - right
|
|
227
|
+
return NotImplemented
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _whole_days(duration: timedelta) -> timedelta:
|
|
231
|
+
if duration % _DAY:
|
|
232
|
+
raise evaluation_error("only whole days can be added to or subtracted from a date")
|
|
233
|
+
return duration
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def ref_key(value: object, what: str) -> Ref:
|
|
237
|
+
if isinstance(value, Ref):
|
|
238
|
+
return value
|
|
239
|
+
if isinstance(value, RecordValue):
|
|
240
|
+
return value.ref
|
|
241
|
+
if value is None:
|
|
242
|
+
raise evaluation_error(f"{what} is null")
|
|
243
|
+
raise evaluation_error(f"{what} must be a Record reference, got {type_name(value)}")
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""How evaluation reads Records it does not hold: the ``RecordResolver`` protocol.
|
|
2
|
+
|
|
3
|
+
The engine never touches a store. Callers pass a resolver answering from Records they have
|
|
4
|
+
loaded. A resolver that has not loaded what is asked raises ``RecordNotLoaded``; the engine
|
|
5
|
+
lets it through untouched, so the caller can load the Record (or the Links) and evaluate
|
|
6
|
+
again. Evaluation is deterministic, so a retry computes the same result.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections.abc import Mapping, Sequence
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from typing import Protocol
|
|
14
|
+
|
|
15
|
+
from pydantic import JsonValue
|
|
16
|
+
|
|
17
|
+
from lorebase_core.model.relation import LinkDirection
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True, slots=True)
|
|
21
|
+
class RecordRequest:
|
|
22
|
+
"""One Record by Kind id and key."""
|
|
23
|
+
|
|
24
|
+
kind: str
|
|
25
|
+
key: str
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True, slots=True)
|
|
29
|
+
class LinksRequest:
|
|
30
|
+
"""The Records linked to ``key`` through ``relation``, traversed in ``direction``."""
|
|
31
|
+
|
|
32
|
+
relation: str
|
|
33
|
+
direction: LinkDirection
|
|
34
|
+
key: str
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True, slots=True)
|
|
38
|
+
class LinkedRecord:
|
|
39
|
+
"""A Record at the far end of a Link: its key, its values and the Link's values."""
|
|
40
|
+
|
|
41
|
+
key: str
|
|
42
|
+
values: Mapping[str, JsonValue]
|
|
43
|
+
link_values: Mapping[str, JsonValue] = field(default_factory=dict)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class RecordNotLoaded(Exception): # noqa: N818 - a signal to the caller, not an error
|
|
47
|
+
"""The resolver does not hold what evaluation needs; load ``request`` and retry."""
|
|
48
|
+
|
|
49
|
+
def __init__(self, request: RecordRequest | LinksRequest) -> None:
|
|
50
|
+
super().__init__(f"not loaded: {request}")
|
|
51
|
+
self.request = request
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class RecordResolver(Protocol):
|
|
55
|
+
"""Read access to Records for ``record()``, ``links()`` and ``links.<relation>``.
|
|
56
|
+
|
|
57
|
+
Values are canonical and keyed by Attribute id (derived values may be included).
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
def record(self, kind_id: str, key: str) -> Mapping[str, JsonValue] | None:
|
|
61
|
+
"""The values of the Record, ``None`` if it does not exist."""
|
|
62
|
+
...
|
|
63
|
+
|
|
64
|
+
def linked(
|
|
65
|
+
self, relation_id: str, direction: LinkDirection, key: str
|
|
66
|
+
) -> Sequence[LinkedRecord]:
|
|
67
|
+
"""Records linked to ``key``; ``direction`` says which end ``key`` is."""
|
|
68
|
+
...
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass
|
|
72
|
+
class MemoryResolver:
|
|
73
|
+
"""A resolver over loaded data. Unknown entries raise ``RecordNotLoaded``.
|
|
74
|
+
|
|
75
|
+
``records`` maps ``(kind_id, key)`` to values, or to ``None`` for a Record known not to
|
|
76
|
+
exist. ``links`` maps ``(relation_id, direction, key)`` to the linked Records.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
records: dict[tuple[str, str], Mapping[str, JsonValue] | None] = field(default_factory=dict)
|
|
80
|
+
links: dict[tuple[str, LinkDirection, str], Sequence[LinkedRecord]] = field(
|
|
81
|
+
default_factory=dict
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
def record(self, kind_id: str, key: str) -> Mapping[str, JsonValue] | None:
|
|
85
|
+
try:
|
|
86
|
+
return self.records[(kind_id, key)]
|
|
87
|
+
except KeyError:
|
|
88
|
+
raise RecordNotLoaded(RecordRequest(kind_id, key)) from None
|
|
89
|
+
|
|
90
|
+
def linked(
|
|
91
|
+
self, relation_id: str, direction: LinkDirection, key: str
|
|
92
|
+
) -> Sequence[LinkedRecord]:
|
|
93
|
+
try:
|
|
94
|
+
return self.links[(relation_id, direction, key)]
|
|
95
|
+
except KeyError:
|
|
96
|
+
raise RecordNotLoaded(LinksRequest(relation_id, direction, key)) from None
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""Expressions over one row of a Source (design 5.9): the ``expression`` of an Attribute
|
|
2
|
+
mapping.
|
|
3
|
+
|
|
4
|
+
A row is what the Source reads before any mapping: column name to raw value (text for CSV and
|
|
5
|
+
TSV, native values for JSON and databases). Every column whose name is an identifier is a
|
|
6
|
+
variable, typed ``dyn`` because the Source does not declare column types; ``row`` is the map of
|
|
7
|
+
all columns, for names that are not identifiers (``row["Legal name"]``). Nothing else is in
|
|
8
|
+
scope: no Records, no Links, no principal.
|
|
9
|
+
|
|
10
|
+
The result is a runtime value that the mapping's transforms take as the column value, and that
|
|
11
|
+
is coerced to the Attribute type like one. ``row_value`` gives its JSON form.
|
|
12
|
+
|
|
13
|
+
``RowExpressions`` compiles each expression once over the columns of a Source (the header, or
|
|
14
|
+
every key its rows have) and evaluates it on any row: a column the row lacks is null. Scribe
|
|
15
|
+
maps rows and Keeper previews them with it, so both give the same value for the same rows.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import re
|
|
21
|
+
from collections.abc import Iterable, Mapping
|
|
22
|
+
from datetime import date, datetime
|
|
23
|
+
from decimal import Decimal
|
|
24
|
+
|
|
25
|
+
from pydantic import JsonValue
|
|
26
|
+
|
|
27
|
+
from lorebase_core.expr.compiler import CompiledExpression, compile, run
|
|
28
|
+
from lorebase_core.expr.environment import Environment
|
|
29
|
+
from lorebase_core.expr.errors import ExprError
|
|
30
|
+
from lorebase_core.expr.limits import ExprLimits
|
|
31
|
+
from lorebase_core.expr.types import DYN, STRING, map_of
|
|
32
|
+
from lorebase_core.expr.values import to_raw
|
|
33
|
+
from lorebase_core.model.schema import Schema
|
|
34
|
+
|
|
35
|
+
#: The variable holding every column of the row.
|
|
36
|
+
ROW = "row"
|
|
37
|
+
|
|
38
|
+
#: Decimal results whose adjusted exponent is beyond this get the exponent form in
|
|
39
|
+
#: ``row_value`` (``1E+100000``), not a plain form of that many digits.
|
|
40
|
+
PLAIN_DECIMAL_MAX_EXPONENT = 100
|
|
41
|
+
|
|
42
|
+
_IDENTIFIER = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
|
43
|
+
_RESERVED = frozenset(
|
|
44
|
+
{"true", "false", "null", "in", "as", "break", "const", "continue", "else", "for", "function"}
|
|
45
|
+
| {"if", "import", "let", "loop", "package", "namespace", "return", "var", "void", "while"}
|
|
46
|
+
| {ROW}
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def row_variables(columns: Iterable[str]) -> list[str]:
|
|
51
|
+
"""The columns that are variables of a row expression, sorted."""
|
|
52
|
+
return sorted({c for c in columns if _IDENTIFIER.match(c) and c not in _RESERVED})
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def row_environment(schema: Schema, columns: Iterable[str]) -> Environment:
|
|
56
|
+
"""Environment of the expressions over rows with these columns."""
|
|
57
|
+
variables = dict.fromkeys(row_variables(columns), DYN)
|
|
58
|
+
variables[ROW] = map_of(STRING, DYN)
|
|
59
|
+
return Environment(schema=schema, variables=variables)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def compile_row_expression(
|
|
63
|
+
schema: Schema, expression: str, columns: Iterable[str], limits: ExprLimits | None = None
|
|
64
|
+
) -> CompiledExpression:
|
|
65
|
+
"""Parse and type check ``expression`` over rows with ``columns``; raises ``ExprError``."""
|
|
66
|
+
return compile(expression, row_environment(schema, columns), limits=limits)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def evaluate_row_expression(
|
|
70
|
+
compiled: CompiledExpression, row: Mapping[str, object], *, now: datetime
|
|
71
|
+
) -> object:
|
|
72
|
+
"""The runtime value of ``compiled`` for ``row``; raises ``ExprError``. Every column
|
|
73
|
+
``compiled`` was compiled over is a variable: one the row lacks is null."""
|
|
74
|
+
variables: dict[str, object] = {
|
|
75
|
+
name: row.get(name) for name in compiled.environment.variables if name != ROW
|
|
76
|
+
}
|
|
77
|
+
variables[ROW] = dict(row)
|
|
78
|
+
return run(compiled, variables, now=now)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class RowExpressions:
|
|
82
|
+
"""The expressions of one Source over its columns, each compiled once.
|
|
83
|
+
|
|
84
|
+
``columns`` are the columns the Source has (its header, or every key its rows have); a row
|
|
85
|
+
with a column not declared yet adds it, and the expressions are compiled again over the
|
|
86
|
+
larger set. A declared column a row lacks is null, as a variable and in ``row``.
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
def __init__(
|
|
90
|
+
self, schema: Schema, columns: Iterable[str] = (), limits: ExprLimits | None = None
|
|
91
|
+
) -> None:
|
|
92
|
+
self._schema = schema
|
|
93
|
+
self._limits = limits
|
|
94
|
+
self._columns: dict[str, None] = dict.fromkeys(columns)
|
|
95
|
+
self._programs: dict[str, CompiledExpression | ExprError] = {}
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def columns(self) -> tuple[str, ...]:
|
|
99
|
+
"""The declared columns, in the order they were first seen."""
|
|
100
|
+
return tuple(self._columns)
|
|
101
|
+
|
|
102
|
+
def declare(self, columns: Iterable[str]) -> None:
|
|
103
|
+
"""Add columns; the expressions compile again only when one is new."""
|
|
104
|
+
added = [c for c in columns if c not in self._columns]
|
|
105
|
+
if added:
|
|
106
|
+
self._columns.update(dict.fromkeys(added))
|
|
107
|
+
self._programs.clear()
|
|
108
|
+
|
|
109
|
+
def compiled(self, expression: str) -> CompiledExpression:
|
|
110
|
+
"""``expression`` compiled over the declared columns; raises ``ExprError`` (the error
|
|
111
|
+
is kept like a program, so a bad expression is checked once)."""
|
|
112
|
+
program = self._programs.get(expression)
|
|
113
|
+
if program is None:
|
|
114
|
+
try:
|
|
115
|
+
program = compile_row_expression(
|
|
116
|
+
self._schema, expression, self._columns, self._limits
|
|
117
|
+
)
|
|
118
|
+
except ExprError as error:
|
|
119
|
+
program = error
|
|
120
|
+
self._programs[expression] = program
|
|
121
|
+
if isinstance(program, ExprError):
|
|
122
|
+
raise program.with_traceback(None)
|
|
123
|
+
return program
|
|
124
|
+
|
|
125
|
+
def evaluate(self, expression: str, row: Mapping[str, object], *, now: datetime) -> object:
|
|
126
|
+
"""The runtime value of ``expression`` for ``row``; raises ``ExprError``."""
|
|
127
|
+
self.declare(row)
|
|
128
|
+
compiled = self.compiled(expression)
|
|
129
|
+
return evaluate_row_expression(compiled, {**dict.fromkeys(self._columns), **row}, now=now)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def row_value(value: object) -> JsonValue:
|
|
133
|
+
"""JSON form of a runtime value: decimals and dates as text, containers recursively.
|
|
134
|
+
A decimal is in plain form unless its exponent is beyond ``PLAIN_DECIMAL_MAX_EXPONENT``."""
|
|
135
|
+
raw = to_raw(value)
|
|
136
|
+
if raw is None or isinstance(raw, bool | int | float | str):
|
|
137
|
+
return raw
|
|
138
|
+
if isinstance(raw, Decimal):
|
|
139
|
+
if raw.is_finite() and abs(raw.adjusted()) > PLAIN_DECIMAL_MAX_EXPONENT:
|
|
140
|
+
return str(raw)
|
|
141
|
+
return format(raw, "f")
|
|
142
|
+
if isinstance(raw, datetime | date):
|
|
143
|
+
return raw.isoformat()
|
|
144
|
+
if isinstance(raw, list):
|
|
145
|
+
return [row_value(item) for item in raw]
|
|
146
|
+
if isinstance(raw, dict):
|
|
147
|
+
return {str(k): row_value(v) for k, v in raw.items()}
|
|
148
|
+
return str(raw)
|