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,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)