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/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())