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,414 @@
1
+ """Bounded, deterministic evaluation of a checked AST.
2
+
3
+ The budget comes from ``ExprLimits``: steps (one per node visited), wall-clock time (checked
4
+ every few steps, a safety net only), sizes of built strings and lists, and Record lookups.
5
+ The clock is the ``now`` the caller passes: ``now()`` and ``today()`` never read the system
6
+ time, so the same inputs give the same result. Nothing here performs I/O; Records come from
7
+ the caller's ``RecordResolver``.
8
+
9
+ ``&&`` and ``||`` follow CEL: an error on one side is absorbed when the other side decides
10
+ the result. Comprehensions stop at the first deciding element and propagate errors.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import time
16
+ from collections.abc import Callable, Mapping
17
+ from datetime import datetime
18
+ from decimal import ROUND_HALF_EVEN, Context
19
+
20
+ from pydantic import JsonValue
21
+
22
+ from lorebase_core.expr import functions, ops
23
+ from lorebase_core.expr.ast import (
24
+ Binary,
25
+ Call,
26
+ Comprehension,
27
+ Conditional,
28
+ Ident,
29
+ Index,
30
+ ListLit,
31
+ Literal,
32
+ MapLit,
33
+ Node,
34
+ Select,
35
+ Unary,
36
+ )
37
+ from lorebase_core.expr.environment import Environment
38
+ from lorebase_core.expr.errors import (
39
+ ExprError,
40
+ ExprErrorCode,
41
+ evaluation_error,
42
+ limit_error,
43
+ )
44
+ from lorebase_core.expr.limits import ExprLimits
45
+ from lorebase_core.expr.resolver import RecordResolver
46
+ from lorebase_core.expr.values import (
47
+ LinksValue,
48
+ ObjectValue,
49
+ RecordValue,
50
+ Ref,
51
+ type_name,
52
+ )
53
+ from lorebase_core.model.record import format_ref
54
+ from lorebase_core.types import GeoPoint
55
+
56
+ #: Steps between two wall-clock checks.
57
+ _CLOCK_EVERY = 64
58
+
59
+ Overlay = Mapping[tuple[str, str], Mapping[str, JsonValue] | None]
60
+
61
+
62
+ class Evaluator:
63
+ """Evaluates one expression; implements ``functions.Runtime``."""
64
+
65
+ def __init__(
66
+ self,
67
+ environment: Environment,
68
+ variables: Mapping[str, object],
69
+ *,
70
+ now: datetime,
71
+ limits: ExprLimits,
72
+ resolver: RecordResolver | None = None,
73
+ overlay: Overlay | None = None,
74
+ ) -> None:
75
+ if now.tzinfo is None or now.utcoffset() is None:
76
+ raise ValueError("the evaluation clock must be timezone-aware")
77
+ self.env = environment
78
+ self.variables = variables
79
+ self._now = now
80
+ self._limits = limits
81
+ self.resolver = resolver
82
+ self.overlay = overlay or {}
83
+ self._decimals = Context(prec=limits.decimal_precision, rounding=ROUND_HALF_EVEN)
84
+ self.scopes: list[dict[str, object]] = []
85
+ self.steps = 0
86
+ self.lookups = 0
87
+ self.deadline = time.monotonic() + limits.max_duration_ms / 1000
88
+ self._patterns: dict[str, Callable[[str], bool]] = {}
89
+
90
+ # Runtime protocol ---------------------------------------------------------------------------
91
+
92
+ @property
93
+ def now(self) -> datetime:
94
+ return self._now
95
+
96
+ @property
97
+ def limits(self) -> ExprLimits:
98
+ return self._limits
99
+
100
+ @property
101
+ def decimals(self) -> Context:
102
+ return self._decimals
103
+
104
+ def text(self, value: str) -> str:
105
+ if len(value) > self._limits.max_string_length:
106
+ raise limit_error(f"string longer than {self._limits.max_string_length} characters")
107
+ return value
108
+
109
+ def items(self, value: list[object]) -> list[object]:
110
+ if len(value) > self._limits.max_list_size:
111
+ raise limit_error(f"list longer than {self._limits.max_list_size} elements")
112
+ return value
113
+
114
+ def regex(self, pattern: str) -> Callable[[str], bool]:
115
+ compiled = self._patterns.get(pattern)
116
+ if compiled is None:
117
+ try:
118
+ compiled = functions.compile_regex(pattern)
119
+ except ValueError as error:
120
+ raise evaluation_error(str(error)) from None
121
+ self._patterns[pattern] = compiled
122
+ return compiled
123
+
124
+ def _count_lookup(self) -> RecordResolver:
125
+ self.lookups += 1
126
+ if self.lookups > self._limits.max_record_lookups:
127
+ raise limit_error(f"more than {self._limits.max_record_lookups} Record lookups")
128
+ if self.resolver is None:
129
+ raise evaluation_error("Records cannot be read in this evaluation")
130
+ return self.resolver
131
+
132
+ def load_record(self, ref: Ref) -> RecordValue:
133
+ kind = self.env.schema.kinds.get(ref.kind)
134
+ if kind is None:
135
+ raise evaluation_error(f"unknown Kind {ref.kind}")
136
+ if (ref.kind, ref.key) in self.overlay:
137
+ self._count_lookup()
138
+ values = self.overlay[(ref.kind, ref.key)]
139
+ else:
140
+ values = self._count_lookup().record(ref.kind, ref.key)
141
+ if values is None:
142
+ raise ExprError(
143
+ ExprErrorCode.RECORD_NOT_FOUND,
144
+ f"Record {format_ref(kind.api_name, ref.key)} does not exist",
145
+ )
146
+ return RecordValue(kind, ref.key, values)
147
+
148
+ def load_links(self, ref: Ref, name: str) -> list[RecordValue]:
149
+ found = self.env.schema.link_by_api_name(name)
150
+ if found is None:
151
+ raise evaluation_error(f"unknown Relation {name!r}")
152
+ relation, direction = found
153
+ if relation.endpoint(direction) != ref.kind:
154
+ raise evaluation_error(f"Relation {name!r} does not start at this Record's Kind")
155
+ far = self.env.schema.kinds.get(relation.far_endpoint(direction))
156
+ if far is None:
157
+ raise evaluation_error(f"Relation {name!r} ends at an unknown Kind")
158
+ linked = self._count_lookup().linked(relation.id, direction, ref.key)
159
+ self.items(list(linked))
160
+ return [RecordValue(far, item.key, item.values) for item in linked]
161
+
162
+ # Evaluation -----------------------------------------------------------------------------------
163
+
164
+ def evaluate(self, node: Node) -> object:
165
+ try:
166
+ return self.eval(node)
167
+ except RecursionError:
168
+ raise limit_error("expression nests too deeply to evaluate") from None
169
+
170
+ def eval(self, node: Node) -> object:
171
+ self.steps += 1
172
+ if self.steps > self._limits.max_steps:
173
+ raise limit_error(f"evaluation took more than {self._limits.max_steps} steps", node.pos)
174
+ if self.steps % _CLOCK_EVERY == 0 and time.monotonic() > self.deadline:
175
+ raise limit_error(
176
+ f"evaluation took longer than {self._limits.max_duration_ms} ms", node.pos
177
+ )
178
+ try:
179
+ return self._eval(node)
180
+ except ExprError as error:
181
+ if error.position is None:
182
+ error.position = node.pos
183
+ raise
184
+
185
+ def _eval(self, node: Node) -> object:
186
+ if isinstance(node, Literal):
187
+ return node.value
188
+ if isinstance(node, Ident):
189
+ return self._ident(node)
190
+ if isinstance(node, Select):
191
+ operand = self.eval(node.operand)
192
+ if node.test:
193
+ return self._has(operand, node.field)
194
+ return self._field(operand, node.field)
195
+ if isinstance(node, Index):
196
+ return self._index(self.eval(node.operand), self.eval(node.index))
197
+ if isinstance(node, Call):
198
+ return self._call(node)
199
+ if isinstance(node, Comprehension):
200
+ return self._comprehension(node)
201
+ if isinstance(node, ListLit):
202
+ return self.items([self.eval(item) for item in node.items])
203
+ if isinstance(node, MapLit):
204
+ return self._map(node)
205
+ if isinstance(node, Unary):
206
+ operand = self.eval(node.operand)
207
+ if node.op == "!":
208
+ return not ops.require_bool(operand, "! operand")
209
+ return ops.negate(operand, self._decimals)
210
+ if isinstance(node, Binary):
211
+ return self._binary(node)
212
+ if isinstance(node, Conditional):
213
+ condition = ops.require_bool(self.eval(node.condition), "condition")
214
+ return self.eval(node.then if condition else node.otherwise)
215
+ raise evaluation_error(f"unsupported expression {type(node).__name__}")
216
+
217
+ def _ident(self, node: Ident) -> object:
218
+ for scope in reversed(self.scopes):
219
+ if node.name in scope:
220
+ return scope[node.name]
221
+ if node.name in self.variables:
222
+ return self.variables[node.name]
223
+ raise evaluation_error(f"no value for {node.name!r}")
224
+
225
+ def _field(self, operand: object, name: str) -> object:
226
+ if isinstance(operand, RecordValue):
227
+ if name == "key":
228
+ return operand.key
229
+ if name == "ref":
230
+ return operand.ref
231
+ return operand.get(name)
232
+ if isinstance(operand, LinksValue):
233
+ return self._links_field(operand, name)
234
+ if isinstance(operand, ObjectValue):
235
+ if name not in operand.fields:
236
+ raise evaluation_error(f"{operand.name} has no field {name!r}")
237
+ return operand.fields[name]
238
+ if isinstance(operand, GeoPoint) and name in ("lat", "lon"):
239
+ return getattr(operand, name)
240
+ if isinstance(operand, dict):
241
+ if name not in operand:
242
+ raise evaluation_error(f"no key {name!r} in map")
243
+ return operand[name]
244
+ if isinstance(operand, Ref) and name == "key":
245
+ return operand.key
246
+ if operand is None:
247
+ raise evaluation_error(
248
+ f"cannot read {name!r} of an absent value (null); test presence with has()"
249
+ )
250
+ raise evaluation_error(f"{type_name(operand)} has no field {name!r}")
251
+
252
+ def _has(self, operand: object, name: str) -> bool:
253
+ if isinstance(operand, RecordValue):
254
+ return name in ("key", "ref") or operand.has(name)
255
+ if isinstance(operand, ObjectValue):
256
+ return operand.fields.get(name) is not None
257
+ if isinstance(operand, dict):
258
+ return name in operand
259
+ if isinstance(operand, LinksValue | GeoPoint):
260
+ return True
261
+ if operand is None:
262
+ raise evaluation_error(f"has() on an absent value (null) selecting {name!r}")
263
+ raise evaluation_error(f"has() is not defined on {type_name(operand)}")
264
+
265
+ def _links_field(self, links: LinksValue, name: str) -> list[RecordValue]:
266
+ field = self.env.link_fields.get(name)
267
+ if field is None:
268
+ raise evaluation_error(f"no Relation {name!r} at Kind {links.kind.api_name}")
269
+ far = self.env.schema.kinds.get(field.relation.far_endpoint(field.direction))
270
+ if far is None:
271
+ raise evaluation_error(f"Relation {name!r} ends at an unknown Kind")
272
+ linked = self._count_lookup().linked(field.relation.id, field.direction, links.key)
273
+ self.items(list(linked))
274
+ return [RecordValue(far, item.key, item.values) for item in linked]
275
+
276
+ def _index(self, operand: object, index: object) -> object:
277
+ if isinstance(operand, list):
278
+ if isinstance(index, bool) or not isinstance(index, int):
279
+ raise evaluation_error(f"list index must be an int, got {type_name(index)}")
280
+ if not 0 <= index < len(operand):
281
+ raise evaluation_error(f"index {index} out of range for a list of {len(operand)}")
282
+ return operand[index]
283
+ if isinstance(operand, dict):
284
+ for key, value in operand.items():
285
+ if ops.equals(key, index):
286
+ return value
287
+ raise evaluation_error(f"no key {index!r} in map")
288
+ if operand is None:
289
+ raise evaluation_error("cannot index an absent value (null)")
290
+ raise evaluation_error(f"cannot index {type_name(operand)}")
291
+
292
+ def _call(self, node: Call) -> object:
293
+ function = functions.lookup(node.function, node.target is not None)
294
+ if function is None:
295
+ raise evaluation_error(f"unknown function {node.function!r}")
296
+ args = [self.eval(arg) for arg in node.args]
297
+ if node.target is not None:
298
+ args.insert(0, self.eval(node.target))
299
+ try:
300
+ result = function.run(self, args)
301
+ except (ArithmeticError, ValueError) as error:
302
+ raise evaluation_error(f"{node.function}() failed: {error}") from None
303
+ if isinstance(result, str):
304
+ self.text(result)
305
+ return result
306
+
307
+ def _comprehension(self, node: Comprehension) -> object:
308
+ target = self.eval(node.target)
309
+ if isinstance(target, list):
310
+ elements: list[object] = target
311
+ elif isinstance(target, dict):
312
+ elements = list(target)
313
+ else:
314
+ raise evaluation_error(f"{node.macro}() needs a list or a map, got {type_name(target)}")
315
+ scope: dict[str, object] = {}
316
+ self.scopes.append(scope)
317
+ try:
318
+ return self._iterate(node, elements, scope)
319
+ finally:
320
+ self.scopes.pop()
321
+
322
+ def _iterate(
323
+ self, node: Comprehension, elements: list[object], scope: dict[str, object]
324
+ ) -> object:
325
+ macro = node.macro
326
+ results: list[object] = []
327
+ matches = 0
328
+ for element in elements:
329
+ scope[node.variable] = element
330
+ value = self.eval(node.body)
331
+ if macro == "map":
332
+ results.append(value)
333
+ self.items(results)
334
+ continue
335
+ accepted = ops.require_bool(value, f"{macro}() condition")
336
+ if macro == "all" and not accepted:
337
+ return False
338
+ if macro == "exists" and accepted:
339
+ return True
340
+ if accepted:
341
+ matches += 1
342
+ if macro == "filter":
343
+ results.append(element)
344
+ if macro == "all":
345
+ return True
346
+ if macro == "exists":
347
+ return False
348
+ if macro == "exists_one":
349
+ return matches == 1
350
+ if macro == "count":
351
+ return matches
352
+ return results
353
+
354
+ def _map(self, node: MapLit) -> dict[object, object]:
355
+ result: dict[object, object] = {}
356
+ for key_node, value_node in node.entries:
357
+ key = self.eval(key_node)
358
+ if not isinstance(key, str | int):
359
+ raise evaluation_error(
360
+ f"map keys must be strings, ints or bools, got {type_name(key)}"
361
+ )
362
+ if any(ops.equals(key, existing) for existing in result):
363
+ raise evaluation_error(f"duplicate map key {key!r}")
364
+ result[key] = self.eval(value_node)
365
+ self.items(list(result))
366
+ return result
367
+
368
+ def _binary(self, node: Binary) -> object:
369
+ op = node.op
370
+ if op in ("&&", "||"):
371
+ return self._logical(node)
372
+ left = self.eval(node.left)
373
+ right = self.eval(node.right)
374
+ if op == "==":
375
+ return ops.equals(left, right)
376
+ if op == "!=":
377
+ return not ops.equals(left, right)
378
+ if op == "<":
379
+ return ops.compare(left, right) < 0
380
+ if op == "<=":
381
+ return ops.compare(left, right) <= 0
382
+ if op == ">":
383
+ return ops.compare(left, right) > 0
384
+ if op == ">=":
385
+ return ops.compare(left, right) >= 0
386
+ if op == "in":
387
+ return ops.contains(right, left)
388
+ result = ops.arithmetic(op, left, right, self._decimals)
389
+ if isinstance(result, str):
390
+ self.text(result)
391
+ elif isinstance(result, list):
392
+ self.items(result)
393
+ elif isinstance(result, bytes) and len(result) > self._limits.max_string_length:
394
+ raise limit_error(f"bytes longer than {self._limits.max_string_length}")
395
+ return result
396
+
397
+ def _logical(self, node: Binary) -> bool:
398
+ deciding = node.op == "||" # the value that decides the result on its own
399
+ failure: ExprError | None = None
400
+ try:
401
+ left = ops.require_bool(self.eval(node.left), f"{node.op} operand")
402
+ except ExprError as error:
403
+ if error.code is ExprErrorCode.LIMIT_EXCEEDED:
404
+ raise
405
+ failure = error
406
+ else:
407
+ if left is deciding:
408
+ return deciding
409
+ right = ops.require_bool(self.eval(node.right), f"{node.op} operand")
410
+ if right is deciding:
411
+ return deciding
412
+ if failure is not None:
413
+ raise failure
414
+ return not deciding