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,898 @@
1
+ """The Lorebase function library: signatures for the checker, implementations for evaluation.
2
+
3
+ Global functions
4
+ ``now()``, ``today()``; ``record(ref)``, ``links(ref, "relation")`` (Operations only);
5
+ ``size(x)``, ``count(list)``; conversions ``int``, ``double``, ``decimal``, ``string``,
6
+ ``date``, ``timestamp``, ``duration`` (``"1h30m"`` or ISO 8601 ``"P1DT2H"``); durations
7
+ ``days(n)``, ``hours(n)``, ``minutes(n)``, ``seconds(n)``; ``days_between(a, b)``;
8
+ math ``abs``, ``round(x, places)`` (half up), ``floor``, ``ceil``, ``min``, ``max``
9
+ (two values or a list), ``sum(list)``; ``matches(text, regex)``.
10
+ Methods
11
+ strings: ``size``, ``contains``, ``startsWith``, ``endsWith``, ``matches`` (RE2,
12
+ partial match), ``lower``, ``upper``, ``trim``, ``replace``, ``split``, ``substring``,
13
+ ``indexOf``; lists: ``size``, ``join``; dates and timestamps: ``getFullYear``,
14
+ ``getMonth`` (0-based), ``getDate`` (1-based), ``getDayOfMonth`` (0-based),
15
+ ``getDayOfWeek`` (0 is Sunday), ``getDayOfYear`` (0-based); timestamps and durations:
16
+ ``getHours``, ``getMinutes``, ``getSeconds`` (timestamps in UTC; durations in total).
17
+
18
+ Regular expressions use RE2 (linear time). Literal patterns are validated when compiling.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import math
24
+ import re
25
+ from collections.abc import Callable, Mapping, Sequence
26
+ from dataclasses import dataclass
27
+ from datetime import UTC, date, datetime, timedelta
28
+ from decimal import ROUND_CEILING, ROUND_FLOOR, ROUND_HALF_UP, Context, Decimal
29
+ from typing import Protocol
30
+
31
+ import re2 # type: ignore[import-untyped]
32
+
33
+ from lorebase_core.expr import ops
34
+ from lorebase_core.expr.ast import Call, Literal
35
+ from lorebase_core.expr.environment import Environment
36
+ from lorebase_core.expr.errors import ExprError, evaluation_error, type_error
37
+ from lorebase_core.expr.limits import ExprLimits
38
+ from lorebase_core.expr.types import (
39
+ BOOL,
40
+ DATE,
41
+ DECIMAL,
42
+ DOUBLE,
43
+ DURATION,
44
+ DYN,
45
+ INT,
46
+ NUMERIC,
47
+ ORDERED,
48
+ STRING,
49
+ TIMESTAMP,
50
+ ExprType,
51
+ TypeKind,
52
+ join,
53
+ list_of,
54
+ record_of,
55
+ )
56
+ from lorebase_core.expr.values import RecordValue, Ref, type_name
57
+ from lorebase_core.types import AttributeType, EncodingError, TypeSpec, decode_value, encode_value
58
+ from lorebase_core.types.encoding import (
59
+ float_text,
60
+ format_date,
61
+ format_duration,
62
+ format_timestamp,
63
+ to_decimal,
64
+ )
65
+
66
+ _TIMESTAMP_SPEC = TypeSpec.of(AttributeType.TIMESTAMP)
67
+ _DATE_SPEC = TypeSpec.of(AttributeType.DATE)
68
+ _DURATION_SPEC = TypeSpec.of(AttributeType.DURATION)
69
+ _INT_TEXT = re.compile(r"[+-]?[0-9]+")
70
+ _CEL_DURATION = re.compile(r"(-)?((?:[0-9]+(?:\.[0-9]+)?(?:h|ms|m|s|us|ns))+)")
71
+ _CEL_DURATION_PART = re.compile(r"([0-9]+(?:\.[0-9]+)?)(h|ms|m|s|us|ns)")
72
+ _MICROSECONDS = {
73
+ "h": Decimal(3_600_000_000),
74
+ "m": Decimal(60_000_000),
75
+ "s": Decimal(1_000_000),
76
+ "ms": Decimal(1000),
77
+ "us": Decimal(1),
78
+ "ns": Decimal("0.001"),
79
+ }
80
+
81
+
82
+ class Runtime(Protocol):
83
+ """What implementations need from the evaluator."""
84
+
85
+ @property
86
+ def now(self) -> datetime: ...
87
+
88
+ @property
89
+ def limits(self) -> ExprLimits: ...
90
+
91
+ @property
92
+ def decimals(self) -> Context: ...
93
+
94
+ def load_record(self, ref: Ref) -> RecordValue: ...
95
+
96
+ def load_links(self, ref: Ref, name: str) -> list[RecordValue]: ...
97
+
98
+ def text(self, value: str) -> str:
99
+ """``value`` if within ``max_string_length``; raises otherwise."""
100
+ ...
101
+
102
+ def items(self, value: list[object]) -> list[object]:
103
+ """``value`` if within ``max_list_size``; raises otherwise."""
104
+ ...
105
+
106
+ def regex(self, pattern: str) -> Callable[[str], bool]: ...
107
+
108
+
109
+ @dataclass(frozen=True)
110
+ class CallSite:
111
+ """A call being type checked: argument types (the target first for methods)."""
112
+
113
+ node: Call
114
+ args: tuple[ExprType, ...]
115
+ environment: Environment
116
+
117
+ def literal(self, index: int) -> object:
118
+ """Value of argument ``index`` (target excluded) when it is a literal, else ``None``."""
119
+ if index < len(self.node.args) and isinstance(self.node.args[index], Literal):
120
+ literal = self.node.args[index]
121
+ assert isinstance(literal, Literal)
122
+ return literal.value
123
+ return None
124
+
125
+ def describe(self) -> str:
126
+ return ", ".join(self.environment.describe(t) for t in self.args)
127
+
128
+
129
+ Check = Callable[[CallSite], ExprType]
130
+ Run = Callable[[Runtime, Sequence[object]], object]
131
+
132
+
133
+ @dataclass(frozen=True)
134
+ class Function:
135
+ name: str
136
+ member: bool
137
+ check: Check
138
+ run: Run
139
+ records: bool = False
140
+
141
+
142
+ def compile_regex(pattern: str) -> Callable[[str], bool]:
143
+ """An RE2 search function; raises ``ValueError`` for an invalid pattern."""
144
+ options = re2.Options()
145
+ options.log_errors = False
146
+ try:
147
+ compiled = re2.compile(pattern, options)
148
+ except re2.error as error:
149
+ detail = error.args[0] if error.args else error
150
+ if isinstance(detail, bytes):
151
+ detail = detail.decode("utf-8", "replace")
152
+ raise ValueError(f"invalid regular expression: {detail}") from None
153
+
154
+ def search(text: str) -> bool:
155
+ return compiled.search(text) is not None
156
+
157
+ return search
158
+
159
+
160
+ # Signatures -----------------------------------------------------------------------------------
161
+
162
+ Result = ExprType | Callable[[tuple[ExprType, ...]], ExprType]
163
+
164
+
165
+ def signatures(name: str, *overloads: tuple[tuple[TypeKind | None, ...], Result]) -> Check:
166
+ """A checker accepting the listed parameter kinds (``None`` accepts any type)."""
167
+
168
+ def check(site: CallSite) -> ExprType:
169
+ results: list[ExprType] = []
170
+ arities = {len(params) for params, _ in overloads}
171
+ for params, result in overloads:
172
+ if len(params) != len(site.args):
173
+ continue
174
+ if all(
175
+ p is None or a.kind is TypeKind.DYN or a.kind is p
176
+ for p, a in zip(params, site.args, strict=True)
177
+ ):
178
+ results.append(result(site.args) if callable(result) else result)
179
+ if not results:
180
+ if len(site.args) not in arities:
181
+ expected = " or ".join(str(n) for n in sorted(arities))
182
+ raise type_error(
183
+ f"{name}() takes {expected} argument(s), got {len(site.args)}", site.node.pos
184
+ )
185
+ raise type_error(f"no overload of {name}() accepts ({site.describe()})", site.node.pos)
186
+ if all(r == results[0] for r in results):
187
+ return results[0]
188
+ return DYN
189
+
190
+ return check
191
+
192
+
193
+ def _first(args: tuple[ExprType, ...]) -> ExprType:
194
+ return args[0].plain()
195
+
196
+
197
+ K = TypeKind
198
+
199
+
200
+ # Implementations ------------------------------------------------------------------------------
201
+
202
+
203
+ def _size(rt: Runtime, args: Sequence[object]) -> object:
204
+ (value,) = args
205
+ if isinstance(value, str | bytes | list | dict):
206
+ return len(value)
207
+ raise evaluation_error(f"size() is not defined for {type_name(value)}")
208
+
209
+
210
+ def _to_int(rt: Runtime, args: Sequence[object]) -> object:
211
+ (value,) = args
212
+ if isinstance(value, bool):
213
+ raise evaluation_error("int() does not convert bools")
214
+ if isinstance(value, int):
215
+ return value
216
+ if isinstance(value, float):
217
+ if not math.isfinite(value):
218
+ raise evaluation_error("int() of a non-finite double")
219
+ return ops.check_int(math.trunc(value))
220
+ if isinstance(value, Decimal):
221
+ return ops.check_int(int(value))
222
+ if isinstance(value, str):
223
+ if not _INT_TEXT.fullmatch(value.strip()):
224
+ raise evaluation_error(f"int() cannot parse {value!r}")
225
+ return ops.check_int(int(value))
226
+ if isinstance(value, datetime):
227
+ return math.floor(value.timestamp())
228
+ raise evaluation_error(f"int() is not defined for {type_name(value)}")
229
+
230
+
231
+ def _to_double(rt: Runtime, args: Sequence[object]) -> object:
232
+ (value,) = args
233
+ if isinstance(value, bool) or not isinstance(value, int | float | Decimal | str):
234
+ raise evaluation_error(f"double() is not defined for {type_name(value)}")
235
+ try:
236
+ result = float(to_decimal(value)) if isinstance(value, str) else float(value)
237
+ except (EncodingError, OverflowError) as error:
238
+ raise evaluation_error(f"double() failed: {error}") from None
239
+ if not math.isfinite(result):
240
+ raise evaluation_error("double() result is not finite")
241
+ return result
242
+
243
+
244
+ def _to_decimal(rt: Runtime, args: Sequence[object]) -> object:
245
+ (value,) = args
246
+ try:
247
+ return to_decimal(value)
248
+ except EncodingError as error:
249
+ raise evaluation_error(f"decimal() failed: {error.message}") from None
250
+
251
+
252
+ def _to_string(rt: Runtime, args: Sequence[object]) -> object:
253
+ (value,) = args
254
+ if isinstance(value, str):
255
+ return value
256
+ if isinstance(value, bool):
257
+ return "true" if value else "false"
258
+ if isinstance(value, int):
259
+ return str(value)
260
+ if isinstance(value, float):
261
+ return float_text(value)
262
+ if isinstance(value, Decimal):
263
+ return format(value, "f")
264
+ if isinstance(value, datetime):
265
+ return format_timestamp(value)
266
+ if isinstance(value, date):
267
+ return format_date(value)
268
+ if isinstance(value, timedelta):
269
+ return format_duration(value)
270
+ if isinstance(value, bytes):
271
+ try:
272
+ return value.decode("utf-8")
273
+ except UnicodeDecodeError:
274
+ raise evaluation_error("string() of bytes that are not UTF-8") from None
275
+ if isinstance(value, Ref | RecordValue):
276
+ return value.key
277
+ raise evaluation_error(f"string() is not defined for {type_name(value)}")
278
+
279
+
280
+ def _decode(spec: TypeSpec, value: str, what: str) -> object:
281
+ try:
282
+ return decode_value(spec, encode_value(spec, value))
283
+ except EncodingError as error:
284
+ raise evaluation_error(f"{what}() cannot parse {value!r}: {error.message}") from None
285
+
286
+
287
+ def _to_date(rt: Runtime, args: Sequence[object]) -> object:
288
+ (value,) = args
289
+ if isinstance(value, datetime):
290
+ return value.astimezone(UTC).date()
291
+ if isinstance(value, date):
292
+ return value
293
+ if isinstance(value, str):
294
+ return _decode(_DATE_SPEC, value, "date")
295
+ raise evaluation_error(f"date() is not defined for {type_name(value)}")
296
+
297
+
298
+ def _to_timestamp(rt: Runtime, args: Sequence[object]) -> object:
299
+ (value,) = args
300
+ if isinstance(value, datetime):
301
+ return value
302
+ if isinstance(value, date):
303
+ return datetime(value.year, value.month, value.day, tzinfo=UTC)
304
+ if isinstance(value, str):
305
+ return _decode(_TIMESTAMP_SPEC, value, "timestamp")
306
+ raise evaluation_error(f"timestamp() is not defined for {type_name(value)}")
307
+
308
+
309
+ def parse_duration(text: str) -> timedelta:
310
+ """CEL duration text (``"1h30m"``, ``"-1.5s"``) or ISO 8601 (``"P1DT2H"``)."""
311
+ stripped = text.strip()
312
+ if stripped.lstrip("-").startswith("P"):
313
+ value = _decode(_DURATION_SPEC, stripped, "duration")
314
+ assert isinstance(value, timedelta)
315
+ return value
316
+ match = _CEL_DURATION.fullmatch(stripped)
317
+ if match is None:
318
+ raise evaluation_error(f"duration() cannot parse {text!r}")
319
+ micros = sum(
320
+ (
321
+ Decimal(amount) * _MICROSECONDS[unit]
322
+ for amount, unit in _CEL_DURATION_PART.findall(text)
323
+ ),
324
+ Decimal(0),
325
+ )
326
+ if micros != micros.to_integral_value():
327
+ raise evaluation_error("durations are precise to the microsecond")
328
+ total = int(micros)
329
+ try:
330
+ return timedelta(microseconds=-total if match.group(1) else total)
331
+ except OverflowError:
332
+ raise evaluation_error("duration is out of range") from None
333
+
334
+
335
+ def _to_duration(rt: Runtime, args: Sequence[object]) -> object:
336
+ (value,) = args
337
+ if isinstance(value, timedelta):
338
+ return value
339
+ if isinstance(value, str):
340
+ return parse_duration(value)
341
+ raise evaluation_error(f"duration() is not defined for {type_name(value)}")
342
+
343
+
344
+ def _unit(unit: str) -> Run:
345
+ def run(rt: Runtime, args: Sequence[object]) -> object:
346
+ (value,) = args
347
+ if isinstance(value, bool) or not isinstance(value, int):
348
+ raise evaluation_error(f"{unit}() takes an int")
349
+ try:
350
+ return timedelta(**{unit: value})
351
+ except OverflowError:
352
+ raise evaluation_error(f"{unit}({value}) is out of range") from None
353
+
354
+ return run
355
+
356
+
357
+ def _days_between(rt: Runtime, args: Sequence[object]) -> object:
358
+ start, end = args
359
+ if isinstance(start, datetime) and isinstance(end, datetime):
360
+ return (end - start).days
361
+ if (
362
+ isinstance(start, date)
363
+ and isinstance(end, date)
364
+ and not isinstance(start, datetime)
365
+ and not isinstance(end, datetime)
366
+ ):
367
+ return (end - start).days
368
+ raise evaluation_error(
369
+ f"days_between() takes two dates or two timestamps, got {type_name(start)} "
370
+ f"and {type_name(end)}"
371
+ )
372
+
373
+
374
+ def _abs(rt: Runtime, args: Sequence[object]) -> object:
375
+ (value,) = args
376
+ if isinstance(value, bool) or not ops.is_number(value):
377
+ raise evaluation_error(f"abs() is not defined for {type_name(value)}")
378
+ if isinstance(value, int):
379
+ return ops.check_int(abs(value))
380
+ if isinstance(value, Decimal):
381
+ return rt.decimals.abs(value)
382
+ assert isinstance(value, float)
383
+ return abs(value)
384
+
385
+
386
+ def _rounding(rounding: str, name: str) -> Run:
387
+ def run(rt: Runtime, args: Sequence[object]) -> object:
388
+ value, places = (args[0], args[1]) if len(args) == 2 else (args[0], 0)
389
+ if isinstance(places, bool) or not isinstance(places, int) or places < 0:
390
+ raise evaluation_error(f"{name}() places must be a non-negative int")
391
+ if isinstance(value, bool) or not ops.is_number(value):
392
+ raise evaluation_error(f"{name}() is not defined for {type_name(value)}")
393
+ if isinstance(value, int):
394
+ return value
395
+ exact = value if isinstance(value, Decimal) else to_decimal(value)
396
+ try:
397
+ rounded = exact.quantize(
398
+ Decimal(1).scaleb(-places), rounding=rounding, context=rt.decimals
399
+ )
400
+ except ArithmeticError:
401
+ raise evaluation_error(f"{name}() needs more digits than decimal precision") from None
402
+ return rounded if isinstance(value, Decimal) else float(rounded)
403
+
404
+ return run
405
+
406
+
407
+ def _extreme(name: str, pick: Callable[[int], bool]) -> Run:
408
+ def run(rt: Runtime, args: Sequence[object]) -> object:
409
+ values: Sequence[object] = args
410
+ if len(args) == 1:
411
+ (only,) = args
412
+ if not isinstance(only, list):
413
+ raise evaluation_error(f"{name}() of one argument takes a list")
414
+ values = only
415
+ if not values:
416
+ raise evaluation_error(f"{name}() of an empty list")
417
+ best = values[0]
418
+ for value in values[1:]:
419
+ if pick(ops.compare(value, best)):
420
+ best = value
421
+ return best
422
+
423
+ return run
424
+
425
+
426
+ def _sum(rt: Runtime, args: Sequence[object]) -> object:
427
+ (values,) = args
428
+ if not isinstance(values, list):
429
+ raise evaluation_error(f"sum() takes a list, got {type_name(values)}")
430
+ total: object = 0
431
+ for value in values:
432
+ total = ops.arithmetic("+", total, value, rt.decimals)
433
+ return total
434
+
435
+
436
+ def _text(value: object, what: str) -> str:
437
+ if not isinstance(value, str):
438
+ raise evaluation_error(f"{what} needs a string, got {type_name(value)}")
439
+ return value
440
+
441
+
442
+ def _string_method(name: str, body: Callable[[Runtime, str, list[str]], object]) -> Run:
443
+ def run(rt: Runtime, args: Sequence[object]) -> object:
444
+ target = _text(args[0], f"{name}()")
445
+ rest = [_text(arg, f"{name}() argument") for arg in args[1:]]
446
+ return body(rt, target, rest)
447
+
448
+ return run
449
+
450
+
451
+ def _matches(rt: Runtime, args: Sequence[object]) -> object:
452
+ text = _text(args[0], "matches()")
453
+ pattern = _text(args[1], "matches() pattern")
454
+ return rt.regex(pattern)(text)
455
+
456
+
457
+ def _substring(rt: Runtime, args: Sequence[object]) -> object:
458
+ text = _text(args[0], "substring()")
459
+ bounds = list(args[1:])
460
+ if any(isinstance(b, bool) or not isinstance(b, int) for b in bounds):
461
+ raise evaluation_error("substring() takes int positions")
462
+ start = bounds[0]
463
+ end = bounds[1] if len(bounds) > 1 else len(text)
464
+ assert isinstance(start, int)
465
+ assert isinstance(end, int)
466
+ if not 0 <= start <= end <= len(text):
467
+ raise evaluation_error(f"substring({start}, {end}) is out of range for size {len(text)}")
468
+ return text[start:end]
469
+
470
+
471
+ def _join(rt: Runtime, args: Sequence[object]) -> object:
472
+ items = args[0]
473
+ separator = _text(args[1], "join() separator") if len(args) > 1 else ""
474
+ if not isinstance(items, list):
475
+ raise evaluation_error(f"join() needs a list, got {type_name(items)}")
476
+ return rt.text(separator.join(_text(item, "join() element") for item in items))
477
+
478
+
479
+ def _split(rt: Runtime, target: str, rest: list[str]) -> object:
480
+ if not rest[0]:
481
+ raise evaluation_error("split() needs a non-empty separator")
482
+ return rt.items(list(target.split(rest[0])))
483
+
484
+
485
+ def _date_part(name: str, part: Callable[[date], int]) -> Run:
486
+ def run(rt: Runtime, args: Sequence[object]) -> object:
487
+ (value,) = args
488
+ if isinstance(value, datetime):
489
+ return part(value.astimezone(UTC))
490
+ if isinstance(value, date):
491
+ return part(value)
492
+ raise evaluation_error(f"{name}() is not defined for {type_name(value)}")
493
+
494
+ return run
495
+
496
+
497
+ def _time_part(name: str, unit_seconds: int, modulo: int) -> Run:
498
+ def run(rt: Runtime, args: Sequence[object]) -> object:
499
+ (value,) = args
500
+ if isinstance(value, datetime):
501
+ utc = value.astimezone(UTC)
502
+ seconds = utc.hour * 3600 + utc.minute * 60 + utc.second
503
+ return (seconds // unit_seconds) % modulo
504
+ if isinstance(value, timedelta):
505
+ micros = value // timedelta(microseconds=1)
506
+ whole = abs(micros) // (unit_seconds * 1_000_000)
507
+ return -whole if micros < 0 else whole
508
+ raise evaluation_error(f"{name}() is not defined for {type_name(value)}")
509
+
510
+ return run
511
+
512
+
513
+ def _record(rt: Runtime, args: Sequence[object]) -> object:
514
+ return rt.load_record(ops.ref_key(args[0], "record() argument"))
515
+
516
+
517
+ def _links(rt: Runtime, args: Sequence[object]) -> object:
518
+ name = _text(args[1], "links() relation")
519
+ return rt.load_links(ops.ref_key(args[0], "links() argument"), name)
520
+
521
+
522
+ # Special checks -------------------------------------------------------------------------------
523
+
524
+
525
+ def _check_record(site: CallSite) -> ExprType:
526
+ if len(site.args) != 1:
527
+ raise type_error("record() takes one argument", site.node.pos)
528
+ (arg,) = site.args
529
+ if arg.kind is K.DYN:
530
+ return DYN
531
+ if arg.kind is not K.REF or arg.name is None:
532
+ raise type_error(
533
+ "record() takes a Record reference (a ref parameter, an attribute of type ref "
534
+ f"or $step[n]), got {site.describe()}",
535
+ site.node.pos,
536
+ )
537
+ return record_of(arg.name)
538
+
539
+
540
+ def _check_links(site: CallSite) -> ExprType:
541
+ if len(site.args) != 2:
542
+ raise type_error("links() takes a Record reference and a Relation name", site.node.pos)
543
+ ref, _ = site.args
544
+ name = site.literal(1)
545
+ if not isinstance(name, str):
546
+ raise type_error("links() needs a Relation name as a string literal", site.node.pos)
547
+ found = site.environment.schema.link_by_api_name(name)
548
+ if found is None:
549
+ raise type_error(f"unknown Relation {name!r}", site.node.pos)
550
+ relation, direction = found
551
+ if ref.kind is K.REF and ref.name is not None and relation.endpoint(direction) != ref.name:
552
+ raise type_error(
553
+ f"Relation {name!r} does not start at Kind {site.environment.kind_name(ref.name)}",
554
+ site.node.pos,
555
+ )
556
+ if ref.kind not in (K.REF, K.DYN):
557
+ raise type_error(f"links() takes a Record reference, got {site.describe()}", site.node.pos)
558
+ return list_of(record_of(relation.far_endpoint(direction)))
559
+
560
+
561
+ def _check_matches(site: CallSite) -> ExprType:
562
+ check = signatures("matches", ((K.STRING, K.STRING), BOOL))
563
+ result = check(site)
564
+ pattern = site.literal(len(site.node.args) - 1)
565
+ if isinstance(pattern, str):
566
+ try:
567
+ compile_regex(pattern)
568
+ except ValueError as error:
569
+ raise type_error(str(error), site.node.pos) from None
570
+ return result
571
+
572
+
573
+ def _check_extreme(name: str) -> Check:
574
+ def check(site: CallSite) -> ExprType:
575
+ if len(site.args) == 1:
576
+ (items,) = site.args
577
+ if items.kind is K.DYN:
578
+ return DYN
579
+ if items.kind is not K.LIST:
580
+ raise type_error(f"{name}() of one argument takes a list", site.node.pos)
581
+ element = items.element
582
+ if element.kind not in NUMERIC | ORDERED | {K.DYN}:
583
+ raise type_error(f"{name}() needs orderable elements", site.node.pos)
584
+ return element.plain()
585
+ if len(site.args) != 2:
586
+ raise type_error(f"{name}() takes a list or two values", site.node.pos)
587
+ left, right = site.args
588
+ if K.DYN in (left.kind, right.kind):
589
+ return DYN
590
+ if left.is_numeric and right.is_numeric:
591
+ return join(left, right)
592
+ if left.kind is right.kind and left.kind in ORDERED:
593
+ return left.plain()
594
+ raise type_error(f"{name}() cannot compare {site.describe()}", site.node.pos)
595
+
596
+ return check
597
+
598
+
599
+ def _check_sum(site: CallSite) -> ExprType:
600
+ if len(site.args) != 1:
601
+ raise type_error("sum() takes one list", site.node.pos)
602
+ (items,) = site.args
603
+ if items.kind is K.DYN:
604
+ return DYN
605
+ if items.kind is not K.LIST or items.element.kind not in NUMERIC | {K.DYN}:
606
+ raise type_error(f"sum() takes a list of numbers, got {site.describe()}", site.node.pos)
607
+ return items.element
608
+
609
+
610
+ def _check_join(site: CallSite) -> ExprType:
611
+ if len(site.args) not in (1, 2):
612
+ raise type_error("join() takes an optional separator", site.node.pos)
613
+ items = site.args[0]
614
+ separator_ok = len(site.args) == 1 or site.args[1].kind in (K.STRING, K.DYN)
615
+ if items.kind is K.LIST and items.element.kind in (K.STRING, K.DYN) and separator_ok:
616
+ return STRING
617
+ if items.kind is K.DYN:
618
+ return STRING
619
+ raise type_error(f"join() needs a list of strings, got {site.describe()}", site.node.pos)
620
+
621
+
622
+ def _check_count(site: CallSite) -> ExprType:
623
+ if len(site.args) != 1:
624
+ raise type_error("count() takes one list", site.node.pos)
625
+ if site.args[0].kind not in (K.LIST, K.MAP, K.DYN):
626
+ raise type_error(f"count() takes a list, got {site.describe()}", site.node.pos)
627
+ return INT
628
+
629
+
630
+ _SIZED = ((K.STRING,), INT), ((K.BYTES,), INT), ((K.LIST,), INT), ((K.MAP,), INT)
631
+ _NUMBER_IN_OUT = ((K.INT,), INT), ((K.DOUBLE,), DOUBLE), ((K.DECIMAL,), DECIMAL)
632
+ _DATE_LIKE = ((K.DATE,), INT), ((K.TIMESTAMP,), INT)
633
+ _TIME_LIKE = ((K.TIMESTAMP,), INT), ((K.DURATION,), INT)
634
+
635
+
636
+ def _library() -> dict[tuple[str, bool], Function]:
637
+ entries = [
638
+ Function("now", False, signatures("now", ((), TIMESTAMP)), lambda rt, _: rt.now),
639
+ Function(
640
+ "today",
641
+ False,
642
+ signatures("today", ((), DATE)),
643
+ lambda rt, _: rt.now.astimezone(UTC).date(),
644
+ ),
645
+ Function("record", False, _check_record, _record, records=True),
646
+ Function("links", False, _check_links, _links, records=True),
647
+ Function("size", False, signatures("size", *_SIZED), _size),
648
+ Function("size", True, signatures("size", *_SIZED), _size),
649
+ Function("count", False, _check_count, _size),
650
+ Function(
651
+ "int",
652
+ False,
653
+ signatures(
654
+ "int",
655
+ ((K.INT,), INT),
656
+ ((K.DOUBLE,), INT),
657
+ ((K.DECIMAL,), INT),
658
+ ((K.STRING,), INT),
659
+ ((K.TIMESTAMP,), INT),
660
+ ),
661
+ _to_int,
662
+ ),
663
+ Function(
664
+ "double",
665
+ False,
666
+ signatures(
667
+ "double",
668
+ ((K.INT,), DOUBLE),
669
+ ((K.DOUBLE,), DOUBLE),
670
+ ((K.DECIMAL,), DOUBLE),
671
+ ((K.STRING,), DOUBLE),
672
+ ),
673
+ _to_double,
674
+ ),
675
+ Function(
676
+ "decimal",
677
+ False,
678
+ signatures(
679
+ "decimal",
680
+ ((K.INT,), DECIMAL),
681
+ ((K.DOUBLE,), DECIMAL),
682
+ ((K.DECIMAL,), DECIMAL),
683
+ ((K.STRING,), DECIMAL),
684
+ ),
685
+ _to_decimal,
686
+ ),
687
+ Function(
688
+ "string",
689
+ False,
690
+ signatures(
691
+ "string",
692
+ *[
693
+ ((kind,), STRING)
694
+ for kind in (
695
+ K.STRING,
696
+ K.BOOL,
697
+ K.INT,
698
+ K.DOUBLE,
699
+ K.DECIMAL,
700
+ K.TIMESTAMP,
701
+ K.DATE,
702
+ K.DURATION,
703
+ K.BYTES,
704
+ K.REF,
705
+ K.RECORD,
706
+ )
707
+ ],
708
+ ),
709
+ _to_string,
710
+ ),
711
+ Function(
712
+ "date",
713
+ False,
714
+ signatures("date", ((K.STRING,), DATE), ((K.TIMESTAMP,), DATE), ((K.DATE,), DATE)),
715
+ _to_date,
716
+ ),
717
+ Function(
718
+ "timestamp",
719
+ False,
720
+ signatures(
721
+ "timestamp",
722
+ ((K.STRING,), TIMESTAMP),
723
+ ((K.DATE,), TIMESTAMP),
724
+ ((K.TIMESTAMP,), TIMESTAMP),
725
+ ),
726
+ _to_timestamp,
727
+ ),
728
+ Function(
729
+ "duration",
730
+ False,
731
+ signatures("duration", ((K.STRING,), DURATION), ((K.DURATION,), DURATION)),
732
+ _to_duration,
733
+ ),
734
+ *[
735
+ Function(unit, False, signatures(unit, ((K.INT,), DURATION)), _unit(unit))
736
+ for unit in ("days", "hours", "minutes", "seconds")
737
+ ],
738
+ Function(
739
+ "days_between",
740
+ False,
741
+ signatures("days_between", ((K.DATE, K.DATE), INT), ((K.TIMESTAMP, K.TIMESTAMP), INT)),
742
+ _days_between,
743
+ ),
744
+ Function("abs", False, signatures("abs", *_NUMBER_IN_OUT), _abs),
745
+ Function(
746
+ "round",
747
+ False,
748
+ signatures(
749
+ "round",
750
+ *_NUMBER_IN_OUT,
751
+ ((K.INT, K.INT), INT),
752
+ ((K.DOUBLE, K.INT), DOUBLE),
753
+ ((K.DECIMAL, K.INT), DECIMAL),
754
+ ),
755
+ _rounding(ROUND_HALF_UP, "round"),
756
+ ),
757
+ Function(
758
+ "floor", False, signatures("floor", *_NUMBER_IN_OUT), _rounding(ROUND_FLOOR, "floor")
759
+ ),
760
+ Function(
761
+ "ceil", False, signatures("ceil", *_NUMBER_IN_OUT), _rounding(ROUND_CEILING, "ceil")
762
+ ),
763
+ Function("min", False, _check_extreme("min"), _extreme("min", lambda c: c < 0)),
764
+ Function("max", False, _check_extreme("max"), _extreme("max", lambda c: c > 0)),
765
+ Function("sum", False, _check_sum, _sum),
766
+ Function("matches", False, _check_matches, _matches),
767
+ Function("matches", True, _check_matches, _matches),
768
+ Function(
769
+ "contains",
770
+ True,
771
+ signatures("contains", ((K.STRING, K.STRING), BOOL)),
772
+ _string_method("contains", lambda rt, s, a: a[0] in s),
773
+ ),
774
+ Function(
775
+ "startsWith",
776
+ True,
777
+ signatures("startsWith", ((K.STRING, K.STRING), BOOL)),
778
+ _string_method("startsWith", lambda rt, s, a: s.startswith(a[0])),
779
+ ),
780
+ Function(
781
+ "endsWith",
782
+ True,
783
+ signatures("endsWith", ((K.STRING, K.STRING), BOOL)),
784
+ _string_method("endsWith", lambda rt, s, a: s.endswith(a[0])),
785
+ ),
786
+ Function(
787
+ "lower",
788
+ True,
789
+ signatures("lower", ((K.STRING,), STRING)),
790
+ _string_method("lower", lambda rt, s, a: s.lower()),
791
+ ),
792
+ Function(
793
+ "upper",
794
+ True,
795
+ signatures("upper", ((K.STRING,), STRING)),
796
+ _string_method("upper", lambda rt, s, a: s.upper()),
797
+ ),
798
+ Function(
799
+ "trim",
800
+ True,
801
+ signatures("trim", ((K.STRING,), STRING)),
802
+ _string_method("trim", lambda rt, s, a: s.strip()),
803
+ ),
804
+ Function(
805
+ "replace",
806
+ True,
807
+ signatures("replace", ((K.STRING, K.STRING, K.STRING), STRING)),
808
+ _string_method("replace", lambda rt, s, a: rt.text(s.replace(a[0], a[1]))),
809
+ ),
810
+ Function(
811
+ "split",
812
+ True,
813
+ signatures("split", ((K.STRING, K.STRING), list_of(STRING))),
814
+ _string_method("split", _split),
815
+ ),
816
+ Function(
817
+ "substring",
818
+ True,
819
+ signatures(
820
+ "substring", ((K.STRING, K.INT), STRING), ((K.STRING, K.INT, K.INT), STRING)
821
+ ),
822
+ _substring,
823
+ ),
824
+ Function(
825
+ "indexOf",
826
+ True,
827
+ signatures("indexOf", ((K.STRING, K.STRING), INT)),
828
+ _string_method("indexOf", lambda rt, s, a: s.find(a[0])),
829
+ ),
830
+ Function("join", True, _check_join, _join),
831
+ Function(
832
+ "getFullYear",
833
+ True,
834
+ signatures("getFullYear", *_DATE_LIKE),
835
+ _date_part("getFullYear", lambda d: d.year),
836
+ ),
837
+ Function(
838
+ "getMonth",
839
+ True,
840
+ signatures("getMonth", *_DATE_LIKE),
841
+ _date_part("getMonth", lambda d: d.month - 1),
842
+ ),
843
+ Function(
844
+ "getDate",
845
+ True,
846
+ signatures("getDate", *_DATE_LIKE),
847
+ _date_part("getDate", lambda d: d.day),
848
+ ),
849
+ Function(
850
+ "getDayOfMonth",
851
+ True,
852
+ signatures("getDayOfMonth", *_DATE_LIKE),
853
+ _date_part("getDayOfMonth", lambda d: d.day - 1),
854
+ ),
855
+ Function(
856
+ "getDayOfWeek",
857
+ True,
858
+ signatures("getDayOfWeek", *_DATE_LIKE),
859
+ _date_part("getDayOfWeek", lambda d: (d.weekday() + 1) % 7),
860
+ ),
861
+ Function(
862
+ "getDayOfYear",
863
+ True,
864
+ signatures("getDayOfYear", *_DATE_LIKE),
865
+ _date_part("getDayOfYear", lambda d: d.timetuple().tm_yday - 1),
866
+ ),
867
+ Function(
868
+ "getHours", True, signatures("getHours", *_TIME_LIKE), _time_part("getHours", 3600, 24)
869
+ ),
870
+ Function(
871
+ "getMinutes",
872
+ True,
873
+ signatures("getMinutes", *_TIME_LIKE),
874
+ _time_part("getMinutes", 60, 60),
875
+ ),
876
+ Function(
877
+ "getSeconds",
878
+ True,
879
+ signatures("getSeconds", *_TIME_LIKE),
880
+ _time_part("getSeconds", 1, 60),
881
+ ),
882
+ ]
883
+ return {(f.name, f.member): f for f in entries}
884
+
885
+
886
+ #: Functions by ``(name, member)``.
887
+ LIBRARY: Mapping[tuple[str, bool], Function] = _library()
888
+
889
+
890
+ def lookup(name: str, member: bool) -> Function | None:
891
+ return LIBRARY.get((name, member))
892
+
893
+
894
+ def raise_with(error: ExprError, node: Call) -> ExprError:
895
+ """``error`` positioned at ``node`` unless it carries a position already."""
896
+ if error.position is None:
897
+ error.position = node.pos
898
+ return error