schemify-cli 0.1.0__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.
- schemify/__init__.py +21 -0
- schemify/cli/__init__.py +1 -0
- schemify/cli/dependencies/__init__.py +4 -0
- schemify/cli/dependencies/console.py +9 -0
- schemify/cli/dependencies/schema.py +128 -0
- schemify/cli/main.py +138 -0
- schemify/computers.py +682 -0
- schemify/fillers.py +272 -0
- schemify/formatters.py +66 -0
- schemify/generators.py +323 -0
- schemify/models.py +6 -0
- schemify/py.typed +0 -0
- schemify/registry.py +69 -0
- schemify/validators.py +300 -0
- schemify_cli-0.1.0.dist-info/METADATA +93 -0
- schemify_cli-0.1.0.dist-info/RECORD +18 -0
- schemify_cli-0.1.0.dist-info/WHEEL +4 -0
- schemify_cli-0.1.0.dist-info/entry_points.txt +3 -0
schemify/computers.py
ADDED
|
@@ -0,0 +1,682 @@
|
|
|
1
|
+
"""Computers: functions filling a property from other values instead of asking for it.
|
|
2
|
+
|
|
3
|
+
A property with the `x-compute` keyword is not asked, it is computed once the data is entered:
|
|
4
|
+
|
|
5
|
+
```json
|
|
6
|
+
"full_name": {"type": "string", "x-compute": {"fn": "join", "from": ["first", "last"]}}
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
- `fn`: a registered computer name.
|
|
10
|
+
- `from` (optional): the source paths, a list passed as positional arguments,
|
|
11
|
+
or an `{alias: path}` object passed as keyword arguments (used by `template` and `expr`).
|
|
12
|
+
- `options` (optional): extra keyword arguments, like `{"sep": ", "}`.
|
|
13
|
+
- `default` (optional): the value when a source is missing, else the property is left out.
|
|
14
|
+
|
|
15
|
+
Paths are relative to the object owning the property (`name`, `address.city`, `items[0]`),
|
|
16
|
+
or to the data root (`$.company`). `items[*].price` gives the list of every item price.
|
|
17
|
+
A path ending with `?` gives `None` when missing instead of skipping the computation,
|
|
18
|
+
for optional parts like `middle_name?` in a `join`.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import ast
|
|
22
|
+
import calendar
|
|
23
|
+
import math
|
|
24
|
+
import operator
|
|
25
|
+
import re
|
|
26
|
+
import statistics
|
|
27
|
+
import unicodedata
|
|
28
|
+
import uuid as uuid_
|
|
29
|
+
from collections.abc import Callable, Iterable
|
|
30
|
+
from datetime import date, datetime
|
|
31
|
+
from typing import Any
|
|
32
|
+
|
|
33
|
+
from num2words import num2words
|
|
34
|
+
|
|
35
|
+
from .models import Schema
|
|
36
|
+
from .registry import Registry
|
|
37
|
+
|
|
38
|
+
type ComputeFn = Callable[..., Any]
|
|
39
|
+
"""Called with the source values then the options: `fn(*values, **options)`."""
|
|
40
|
+
|
|
41
|
+
KEYWORD = "x-compute"
|
|
42
|
+
|
|
43
|
+
computers = Registry[ComputeFn]("Computer")
|
|
44
|
+
"""The default registry, used by `compute`."""
|
|
45
|
+
|
|
46
|
+
groups: dict[str, list[str]] = {}
|
|
47
|
+
"""The built-in computer names by group, in order, for the prompts.
|
|
48
|
+
|
|
49
|
+
Computers registered elsewhere are not in a group.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _register(group: str, name: str) -> Callable[[ComputeFn], ComputeFn]:
|
|
54
|
+
"""Register a built-in computer under `name`, listed in `group`."""
|
|
55
|
+
groups.setdefault(group, []).append(name)
|
|
56
|
+
|
|
57
|
+
return computers.register(name)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class ComputeError(ValueError):
|
|
61
|
+
"""Raised when a computed property can not be computed (bad path, bad value, ...)."""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class _MissingError(LookupError):
|
|
65
|
+
"""A source path has no value in the data, like a skipped optional property."""
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def compute(schema: Schema, data: Any, root: Any = None) -> Any: # noqa: C901
|
|
69
|
+
"""Fill every `x-compute` property of `data` in place, and return it.
|
|
70
|
+
|
|
71
|
+
Children are computed first, so a parent can aggregate them (`sum` of `items[*].total`),
|
|
72
|
+
then the object's own computed properties, in order, so one can use an earlier one.
|
|
73
|
+
|
|
74
|
+
Raises:
|
|
75
|
+
NotSupportedError: A computer name is not registered.
|
|
76
|
+
ComputeError: A source path is not a property, or the computer rejects its values.
|
|
77
|
+
|
|
78
|
+
"""
|
|
79
|
+
root = data if root is None else root
|
|
80
|
+
|
|
81
|
+
if isinstance(data, list):
|
|
82
|
+
for item in data:
|
|
83
|
+
compute(schema.get("items", {}), item, root)
|
|
84
|
+
|
|
85
|
+
return data
|
|
86
|
+
|
|
87
|
+
if not isinstance(data, dict):
|
|
88
|
+
return data
|
|
89
|
+
|
|
90
|
+
properties: Schema = schema.get("properties", {})
|
|
91
|
+
|
|
92
|
+
for name, property_schema in properties.items():
|
|
93
|
+
if name in data and KEYWORD not in property_schema:
|
|
94
|
+
compute(property_schema, data[name], root)
|
|
95
|
+
|
|
96
|
+
for name, property_schema in properties.items():
|
|
97
|
+
if KEYWORD not in property_schema:
|
|
98
|
+
continue
|
|
99
|
+
|
|
100
|
+
value = _apply(property_schema[KEYWORD], name, data, root, properties)
|
|
101
|
+
|
|
102
|
+
if value is not _SKIP:
|
|
103
|
+
data[name] = value
|
|
104
|
+
|
|
105
|
+
# computed properties were added last, put every property back in the schema order
|
|
106
|
+
for name in properties:
|
|
107
|
+
if name in data:
|
|
108
|
+
data[name] = data.pop(name)
|
|
109
|
+
|
|
110
|
+
return data
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
_SKIP: Any = object()
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _apply(spec: Schema, name: str, data: dict[str, Any], root: Any, properties: Schema) -> Any:
|
|
117
|
+
"""Run the computer of `spec` for the property `name` of `data`."""
|
|
118
|
+
fn = computers[spec["fn"]]
|
|
119
|
+
sources = spec.get("from", [])
|
|
120
|
+
options = spec.get("options", {})
|
|
121
|
+
|
|
122
|
+
for path in sources.values() if isinstance(sources, dict) else sources:
|
|
123
|
+
first = re.split(r"[.\[?]", path, maxsplit=1)[0]
|
|
124
|
+
|
|
125
|
+
if not path.startswith("$") and first not in properties:
|
|
126
|
+
message = f"Can not compute '{name}': '{path}' is not a property"
|
|
127
|
+
raise ComputeError(message)
|
|
128
|
+
|
|
129
|
+
try:
|
|
130
|
+
if isinstance(sources, dict):
|
|
131
|
+
args: list[Any] = []
|
|
132
|
+
kwargs = {alias: resolve(path, data, root) for alias, path in sources.items()}
|
|
133
|
+
else:
|
|
134
|
+
args = [resolve(path, data, root) for path in sources]
|
|
135
|
+
kwargs = {}
|
|
136
|
+
except _MissingError:
|
|
137
|
+
return spec.get("default", _SKIP)
|
|
138
|
+
|
|
139
|
+
try:
|
|
140
|
+
return fn(*args, **kwargs, **options)
|
|
141
|
+
except (ValueError, TypeError, ArithmeticError, LookupError, NotImplementedError) as e:
|
|
142
|
+
message = f"Can not compute '{name}' with '{spec['fn']}': {e}"
|
|
143
|
+
raise ComputeError(message) from e
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
_TOKEN = re.compile(r"([^.\[\]]+)|\[(\d+|\*)\]")
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def resolve(path: str, data: Any, root: Any = None) -> Any:
|
|
150
|
+
"""Return the value at `path` in `data`, or from `root` when the path starts with `$.`.
|
|
151
|
+
|
|
152
|
+
Raises:
|
|
153
|
+
_MissingError: Nothing is at `path`, and it does not end with `?`.
|
|
154
|
+
|
|
155
|
+
"""
|
|
156
|
+
optional = path.endswith("?")
|
|
157
|
+
path = path.removesuffix("?")
|
|
158
|
+
current, path = (
|
|
159
|
+
(root, path.removeprefix("$").lstrip(".")) if path.startswith("$") else (data, path)
|
|
160
|
+
)
|
|
161
|
+
values = [current]
|
|
162
|
+
spread = False
|
|
163
|
+
|
|
164
|
+
for name, index in _TOKEN.findall(path):
|
|
165
|
+
if index == "*":
|
|
166
|
+
values = [item for value in values if isinstance(value, list) for item in value]
|
|
167
|
+
spread = True
|
|
168
|
+
continue
|
|
169
|
+
|
|
170
|
+
key: str | int = name or int(index)
|
|
171
|
+
found = [_get(value, key) for value in values]
|
|
172
|
+
# a spread path skips the items missing the value, like an optional item price
|
|
173
|
+
values = [value for value in found if value is not _SKIP] if spread else found
|
|
174
|
+
|
|
175
|
+
if spread:
|
|
176
|
+
return values
|
|
177
|
+
|
|
178
|
+
if values[0] is _SKIP:
|
|
179
|
+
if optional:
|
|
180
|
+
return None
|
|
181
|
+
|
|
182
|
+
raise _MissingError(path)
|
|
183
|
+
|
|
184
|
+
return values[0]
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _get(container: Any, key: str | int) -> Any:
|
|
188
|
+
try:
|
|
189
|
+
return container[key]
|
|
190
|
+
except (KeyError, IndexError, TypeError):
|
|
191
|
+
return _SKIP
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
# -----------------------
|
|
195
|
+
# utils
|
|
196
|
+
# -----------------------
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _flat(values: Iterable[Any]) -> list[Any]:
|
|
200
|
+
"""Spread lists one level, so `sum(a, items[*].price)` sums every value."""
|
|
201
|
+
return [item for value in values for item in (value if isinstance(value, list) else [value])]
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _present(values: Iterable[Any]) -> list[Any]:
|
|
205
|
+
"""Drop `None` and empty strings, like a skipped optional source."""
|
|
206
|
+
return [value for value in _flat(values) if value is not None and value != ""]
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _date(value: str) -> date:
|
|
210
|
+
"""Parse an ISO date or datetime string."""
|
|
211
|
+
return datetime.fromisoformat(value).date()
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
# -----------------------
|
|
215
|
+
# text
|
|
216
|
+
# -----------------------
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
@_register("Text", "join")
|
|
220
|
+
def join(*values: Any, sep: str = " ") -> str:
|
|
221
|
+
"""Join the non-empty values: `join("Rama", None, "Ali") == "Rama Ali"`."""
|
|
222
|
+
return sep.join(str(value) for value in _present(values))
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
@_register("Text", "concat")
|
|
226
|
+
def concat(*values: Any) -> str:
|
|
227
|
+
"""Join the non-empty values with nothing between them."""
|
|
228
|
+
return join(*values, sep="")
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
@_register("Text", "template")
|
|
232
|
+
def template(template: str, **values: Any) -> str:
|
|
233
|
+
"""Format `template` with the named sources: `"{first} {last}"`."""
|
|
234
|
+
return template.format_map(values)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
@_register("Text", "upper")
|
|
238
|
+
def upper(value: Any) -> str:
|
|
239
|
+
"""Upper case."""
|
|
240
|
+
return str(value).upper()
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
@_register("Text", "lower")
|
|
244
|
+
def lower(value: Any) -> str:
|
|
245
|
+
"""Lower case."""
|
|
246
|
+
return str(value).lower()
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
@_register("Text", "title")
|
|
250
|
+
def title(value: Any) -> str:
|
|
251
|
+
"""Capitalize each word."""
|
|
252
|
+
return str(value).title()
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
@_register("Text", "capitalize")
|
|
256
|
+
def capitalize(value: Any) -> str:
|
|
257
|
+
"""Capitalize the first letter."""
|
|
258
|
+
return str(value).capitalize()
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
@_register("Text", "trim")
|
|
262
|
+
def trim(value: Any) -> str:
|
|
263
|
+
"""Strip the surrounding whitespace."""
|
|
264
|
+
return str(value).strip()
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
@_register("Text", "replace")
|
|
268
|
+
def replace(value: Any, old: str, new: str) -> str:
|
|
269
|
+
"""Replace every `old` with `new`."""
|
|
270
|
+
return str(value).replace(old, new)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
@_register("Text", "slice")
|
|
274
|
+
def slice_(value: Any, start: int = 0, end: int | None = None) -> Any:
|
|
275
|
+
"""Part of a string or a list: `slice("2026-11-04", end=4) == "2026"`."""
|
|
276
|
+
return value[start:end]
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
@_register("Text", "initials")
|
|
280
|
+
def initials(*values: Any, sep: str = "") -> str:
|
|
281
|
+
"""Return the first letter of each word: `initials("Rama Ali") == "RA"`."""
|
|
282
|
+
words = [word for value in _present(values) for word in str(value).split()]
|
|
283
|
+
|
|
284
|
+
return sep.join(word[0].upper() for word in words)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
@_register("Text", "slug")
|
|
288
|
+
def slug(value: Any) -> str:
|
|
289
|
+
"""Make a URL slug, keeping non latin letters: `slug("Hello World!") == "hello-world"`."""
|
|
290
|
+
text = unicodedata.normalize("NFKC", str(value)).lower()
|
|
291
|
+
|
|
292
|
+
return re.sub(r"[\W_]+", "-", text).strip("-")
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
@_register("Text", "length")
|
|
296
|
+
def length(value: Any) -> int:
|
|
297
|
+
"""Return the length of a string or a list."""
|
|
298
|
+
return len(value)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
@_register("Text", "pad")
|
|
302
|
+
def pad(value: Any, width: int, char: str = "0") -> str:
|
|
303
|
+
"""Pad on the left: `pad(42, 5) == "00042"`."""
|
|
304
|
+
return str(value).rjust(width, char)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
@_register("Text", "mask")
|
|
308
|
+
def mask(value: Any, keep: int = 4, char: str = "*") -> str:
|
|
309
|
+
"""Hide all but the last `keep` characters: `mask("12345678") == "****5678"`."""
|
|
310
|
+
text = str(value)
|
|
311
|
+
hidden = max(len(text) - keep, 0)
|
|
312
|
+
|
|
313
|
+
return char * hidden + text[hidden:]
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
# -----------------------
|
|
317
|
+
# numbers
|
|
318
|
+
# -----------------------
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
@_register("Numbers", "sum")
|
|
322
|
+
def total(*values: Any) -> Any:
|
|
323
|
+
"""Add the values, lists are spread: `sum(items[*].price)`."""
|
|
324
|
+
return sum(_flat(values))
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
@_register("Numbers", "subtract")
|
|
328
|
+
def subtract(value: Any, *values: Any) -> Any:
|
|
329
|
+
"""Subtract the other values from the first one."""
|
|
330
|
+
return value - sum(_flat(values))
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
@_register("Numbers", "multiply")
|
|
334
|
+
def multiply(*values: Any) -> Any:
|
|
335
|
+
"""Multiply the values, lists are spread."""
|
|
336
|
+
return math.prod(_flat(values))
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
@_register("Numbers", "divide")
|
|
340
|
+
def divide(value: Any, divisor: Any) -> float:
|
|
341
|
+
"""Divide, a zero divisor is an error."""
|
|
342
|
+
return value / divisor
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
@_register("Numbers", "average")
|
|
346
|
+
def average(*values: Any) -> float:
|
|
347
|
+
"""Return the mean of the values, lists are spread."""
|
|
348
|
+
return statistics.fmean(_flat(values))
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
@_register("Numbers", "min")
|
|
352
|
+
def minimum(*values: Any) -> Any:
|
|
353
|
+
"""Return the smallest value, lists are spread."""
|
|
354
|
+
return min(_flat(values))
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
@_register("Numbers", "max")
|
|
358
|
+
def maximum(*values: Any) -> Any:
|
|
359
|
+
"""Return the largest value, lists are spread."""
|
|
360
|
+
return max(_flat(values))
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
@_register("Numbers", "abs")
|
|
364
|
+
def absolute(value: Any) -> Any:
|
|
365
|
+
"""Return the absolute value."""
|
|
366
|
+
return abs(value)
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
@_register("Numbers", "round")
|
|
370
|
+
def round_(value: Any, digits: int = 2) -> Any:
|
|
371
|
+
"""Round to `digits` decimals."""
|
|
372
|
+
return round(value, digits)
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
@_register("Numbers", "percent")
|
|
376
|
+
def percent(part: Any, whole: Any) -> float:
|
|
377
|
+
"""How many percent `part` is of `whole`: `percent(1, 4) == 25`."""
|
|
378
|
+
return part / whole * 100
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
@_register("Numbers", "percent_of")
|
|
382
|
+
def percent_of(value: Any, rate: Any) -> float:
|
|
383
|
+
"""`rate` percent of `value`, like a tax: `percent_of(200, 15) == 30`."""
|
|
384
|
+
return value * rate / 100
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
_BINARY: dict[type, Callable[[Any, Any], Any]] = {
|
|
388
|
+
ast.Add: operator.add,
|
|
389
|
+
ast.Sub: operator.sub,
|
|
390
|
+
ast.Mult: operator.mul,
|
|
391
|
+
ast.Div: operator.truediv,
|
|
392
|
+
ast.FloorDiv: operator.floordiv,
|
|
393
|
+
ast.Mod: operator.mod,
|
|
394
|
+
ast.Pow: operator.pow,
|
|
395
|
+
ast.Eq: operator.eq,
|
|
396
|
+
ast.NotEq: operator.ne,
|
|
397
|
+
ast.Lt: operator.lt,
|
|
398
|
+
ast.LtE: operator.le,
|
|
399
|
+
ast.Gt: operator.gt,
|
|
400
|
+
ast.GtE: operator.ge,
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
_UNARY: dict[type, Callable[[Any], Any]] = {
|
|
404
|
+
ast.USub: operator.neg,
|
|
405
|
+
ast.UAdd: operator.pos,
|
|
406
|
+
ast.Not: operator.not_,
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
_FUNCTIONS: dict[str, Callable[..., Any]] = {
|
|
410
|
+
"round": round,
|
|
411
|
+
"min": min,
|
|
412
|
+
"max": max,
|
|
413
|
+
"abs": abs,
|
|
414
|
+
"sum": sum,
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
_MAX_EXPONENT = 1000
|
|
418
|
+
"""Stops entered values from hanging the expression, like `x ** y` with a huge `y`."""
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
@_register("Numbers", "expr")
|
|
422
|
+
def expr(expr: str, **values: Any) -> Any:
|
|
423
|
+
"""Evaluate a safe expression of the named sources: `"round(qty * price * 1.15, 2)"`.
|
|
424
|
+
|
|
425
|
+
Allows numbers, strings, arithmetic, comparisons, `and` / `or` / `not`, `a if c else b`
|
|
426
|
+
and the functions round, min, max, abs and sum. Anything else is an error, nothing is `eval`ed.
|
|
427
|
+
"""
|
|
428
|
+
try:
|
|
429
|
+
tree = ast.parse(expr, mode="eval")
|
|
430
|
+
except SyntaxError as e:
|
|
431
|
+
message = f"Invalid expression '{expr}': {e.msg}"
|
|
432
|
+
raise ValueError(message) from None
|
|
433
|
+
|
|
434
|
+
return _evaluate(tree.body, values)
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def _evaluate(node: ast.AST, names: dict[str, Any]) -> Any: # noqa: C901, PLR0911
|
|
438
|
+
match node:
|
|
439
|
+
case ast.Constant(value=value) if isinstance(value, int | float | str):
|
|
440
|
+
return value
|
|
441
|
+
|
|
442
|
+
case ast.Name(id=name) if name in names:
|
|
443
|
+
return names[name]
|
|
444
|
+
|
|
445
|
+
case ast.BinOp(left=left, op=op, right=right) if type(op) in _BINARY:
|
|
446
|
+
right_value = _evaluate(right, names)
|
|
447
|
+
|
|
448
|
+
if isinstance(op, ast.Pow) and abs(right_value) > _MAX_EXPONENT:
|
|
449
|
+
message = f"Exponent {right_value} is too large"
|
|
450
|
+
raise ValueError(message)
|
|
451
|
+
|
|
452
|
+
return _BINARY[type(op)](_evaluate(left, names), right_value)
|
|
453
|
+
|
|
454
|
+
case ast.UnaryOp(op=op, operand=operand) if type(op) in _UNARY:
|
|
455
|
+
return _UNARY[type(op)](_evaluate(operand, names))
|
|
456
|
+
|
|
457
|
+
case ast.Compare(left=left, ops=ops, comparators=comparators) if all(
|
|
458
|
+
type(op) in _BINARY for op in ops
|
|
459
|
+
):
|
|
460
|
+
values = [_evaluate(left, names), *(_evaluate(c, names) for c in comparators)]
|
|
461
|
+
|
|
462
|
+
return all(
|
|
463
|
+
_BINARY[type(op)](a, b) for op, a, b in zip(ops, values, values[1:], strict=False)
|
|
464
|
+
)
|
|
465
|
+
|
|
466
|
+
case ast.BoolOp(op=ast.And(), values=values):
|
|
467
|
+
return all(_evaluate(value, names) for value in values)
|
|
468
|
+
|
|
469
|
+
case ast.BoolOp(op=ast.Or(), values=values):
|
|
470
|
+
return any(_evaluate(value, names) for value in values)
|
|
471
|
+
|
|
472
|
+
case ast.IfExp(test=test, body=body, orelse=orelse):
|
|
473
|
+
return _evaluate(body if _evaluate(test, names) else orelse, names)
|
|
474
|
+
|
|
475
|
+
case ast.Call(func=ast.Name(id=fn), args=args, keywords=[]) if fn in _FUNCTIONS:
|
|
476
|
+
return _FUNCTIONS[fn](*(_evaluate(arg, names) for arg in args))
|
|
477
|
+
|
|
478
|
+
case ast.List(elts=items) | ast.Tuple(elts=items):
|
|
479
|
+
return [_evaluate(item, names) for item in items]
|
|
480
|
+
|
|
481
|
+
message = f"'{ast.unparse(node)}' is not allowed in an expression"
|
|
482
|
+
raise ValueError(message)
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
# -----------------------
|
|
486
|
+
# number to words
|
|
487
|
+
# -----------------------
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
@_register("Number to words", "words")
|
|
491
|
+
def words(value: Any, lang: str = "en", to: str = "cardinal", currency: str | None = None) -> str:
|
|
492
|
+
"""Spell a number: `words(12) == "twelve"`.
|
|
493
|
+
|
|
494
|
+
`to` is cardinal, ordinal, ordinal_num, year or currency (with an ISO `currency` like `USD`),
|
|
495
|
+
`lang` is like `en`, `ar` or `fr`.
|
|
496
|
+
"""
|
|
497
|
+
if currency and to != "currency":
|
|
498
|
+
message = f"The 'currency' option needs \"to\": \"currency\", not '{to}'"
|
|
499
|
+
raise ValueError(message)
|
|
500
|
+
|
|
501
|
+
# num2words' Arabic falls back to riyal for any other code instead of raising
|
|
502
|
+
if to == "currency" and lang == "ar" and (currency or "SR") not in {"SR", "EGP", "KWD", "TND"}:
|
|
503
|
+
message = f"Arabic has no '{currency}' currency, only SR, EGP, KWD and TND"
|
|
504
|
+
raise ValueError(message)
|
|
505
|
+
|
|
506
|
+
extra = {"currency": currency} if currency else {}
|
|
507
|
+
|
|
508
|
+
return num2words(value, lang=lang, to=to, **extra)
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
# -----------------------
|
|
512
|
+
# dates
|
|
513
|
+
# -----------------------
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
@_register("Dates", "today")
|
|
517
|
+
def today() -> str:
|
|
518
|
+
"""Today's ISO date: `2026-11-04`."""
|
|
519
|
+
return date.today().isoformat() # noqa: DTZ011
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
@_register("Dates", "now")
|
|
523
|
+
def now() -> str:
|
|
524
|
+
"""Return the current local RFC 3339 datetime: `2026-11-04T10:30:00+03:00`."""
|
|
525
|
+
return datetime.now().astimezone().isoformat(timespec="seconds")
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
@_register("Dates", "add_days")
|
|
529
|
+
def add_days(value: str, days: int) -> str:
|
|
530
|
+
"""Add (or subtract) days to an ISO date."""
|
|
531
|
+
return date.fromordinal(_date(value).toordinal() + days).isoformat()
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
@_register("Dates", "add_months")
|
|
535
|
+
def add_months(value: str, months: int) -> str:
|
|
536
|
+
"""Add months, clamping to the month end: `add_months("2026-01-31", 1) == "2026-02-28"`."""
|
|
537
|
+
day = _date(value)
|
|
538
|
+
years, month = divmod(day.month - 1 + months, 12)
|
|
539
|
+
year = day.year + years
|
|
540
|
+
last_day = calendar.monthrange(year, month + 1)[1]
|
|
541
|
+
|
|
542
|
+
return day.replace(year=year, month=month + 1, day=min(day.day, last_day)).isoformat()
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
@_register("Dates", "add_years")
|
|
546
|
+
def add_years(value: str, years: int) -> str:
|
|
547
|
+
"""Add years, Feb 29 becomes Feb 28 on a non leap year."""
|
|
548
|
+
return add_months(value, years * 12)
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
@_register("Dates", "days_between")
|
|
552
|
+
def days_between(start: str, end: str) -> int:
|
|
553
|
+
"""Days from `start` to `end`, negative when `end` is before."""
|
|
554
|
+
return (_date(end) - _date(start)).days
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
@_register("Dates", "age")
|
|
558
|
+
def age(birth_date: str, on: str | None = None) -> int:
|
|
559
|
+
"""Whole years from `birth_date` to `on` (today by default)."""
|
|
560
|
+
born = _date(birth_date)
|
|
561
|
+
day = _date(on) if on else date.today() # noqa: DTZ011
|
|
562
|
+
|
|
563
|
+
return day.year - born.year - ((day.month, day.day) < (born.month, born.day))
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
@_register("Dates", "year")
|
|
567
|
+
def year(value: str) -> int:
|
|
568
|
+
"""Return the year of an ISO date."""
|
|
569
|
+
return _date(value).year
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
@_register("Dates", "month")
|
|
573
|
+
def month(value: str) -> int:
|
|
574
|
+
"""Return the month (1 to 12) of an ISO date."""
|
|
575
|
+
return _date(value).month
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
@_register("Dates", "day")
|
|
579
|
+
def day(value: str) -> int:
|
|
580
|
+
"""Return the day of the month of an ISO date."""
|
|
581
|
+
return _date(value).day
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
@_register("Dates", "weekday")
|
|
585
|
+
def weekday(value: str, fmt: str = "%A") -> str:
|
|
586
|
+
"""Return the weekday name of an ISO date: `Wednesday`."""
|
|
587
|
+
return _date(value).strftime(fmt)
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
@_register("Dates", "format_date")
|
|
591
|
+
def format_date(value: str, fmt: str = "%d/%m/%Y") -> str:
|
|
592
|
+
"""Format an ISO date or datetime with `strftime` codes."""
|
|
593
|
+
return datetime.fromisoformat(value).strftime(fmt)
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
@_register("Dates", "start_of_month")
|
|
597
|
+
def start_of_month(value: str) -> str:
|
|
598
|
+
"""Return the first day of the month of an ISO date."""
|
|
599
|
+
return _date(value).replace(day=1).isoformat()
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
@_register("Dates", "end_of_month")
|
|
603
|
+
def end_of_month(value: str) -> str:
|
|
604
|
+
"""Return the last day of the month of an ISO date."""
|
|
605
|
+
day = _date(value)
|
|
606
|
+
|
|
607
|
+
return day.replace(day=calendar.monthrange(day.year, day.month)[1]).isoformat()
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
# -----------------------
|
|
611
|
+
# logic, lookup and misc
|
|
612
|
+
# -----------------------
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
@_register("Logic, lookup and misc", "coalesce")
|
|
616
|
+
def coalesce(*values: Any) -> Any:
|
|
617
|
+
"""Return the first non-empty value, use `?` paths so missing ones are `None`."""
|
|
618
|
+
present = _present(values)
|
|
619
|
+
|
|
620
|
+
if not present:
|
|
621
|
+
message = "All values are empty"
|
|
622
|
+
raise ValueError(message)
|
|
623
|
+
|
|
624
|
+
return present[0]
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
@_register("Logic, lookup and misc", "if_else")
|
|
628
|
+
def if_else(condition: Any, then: Any, otherwise: Any) -> Any:
|
|
629
|
+
"""`then` when `condition` is truthy, else `otherwise`."""
|
|
630
|
+
return then if condition else otherwise
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
@_register("Logic, lookup and misc", "equals")
|
|
634
|
+
def equals(value: Any, other: Any) -> bool:
|
|
635
|
+
"""Whether both values are equal."""
|
|
636
|
+
return value == other
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
_NO_FALLBACK: Any = object()
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
@_register("Logic, lookup and misc", "lookup")
|
|
643
|
+
def lookup(key: Any, map: dict[str, Any], fallback: Any = _NO_FALLBACK) -> Any: # noqa: A002
|
|
644
|
+
"""Map a value through `map`: `{"SY": "Syria"}`, keys are compared as strings."""
|
|
645
|
+
if str(key) in map:
|
|
646
|
+
return map[str(key)]
|
|
647
|
+
|
|
648
|
+
if fallback is _NO_FALLBACK:
|
|
649
|
+
message = f"'{key}' is not in the lookup map"
|
|
650
|
+
raise ValueError(message)
|
|
651
|
+
|
|
652
|
+
return fallback
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
@_register("Logic, lookup and misc", "constant")
|
|
656
|
+
def constant(value: Any) -> Any:
|
|
657
|
+
"""Return `value` from the options, always the same."""
|
|
658
|
+
return value
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
@_register("Logic, lookup and misc", "count")
|
|
662
|
+
def count(values: list[Any]) -> int:
|
|
663
|
+
"""Return the number of items."""
|
|
664
|
+
return len(values)
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
@_register("Logic, lookup and misc", "count_if")
|
|
668
|
+
def count_if(values: list[Any], value: Any) -> int:
|
|
669
|
+
"""How many items equal `value`."""
|
|
670
|
+
return values.count(value)
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
@_register("Logic, lookup and misc", "unique")
|
|
674
|
+
def unique(values: list[Any]) -> list[Any]:
|
|
675
|
+
"""Return the items without duplicates, in order."""
|
|
676
|
+
return list(dict.fromkeys(values))
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
@_register("Logic, lookup and misc", "uuid")
|
|
680
|
+
def uuid() -> str:
|
|
681
|
+
"""Return a random UUID."""
|
|
682
|
+
return str(uuid_.uuid4())
|