mandat 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.
mandat/__init__.py ADDED
@@ -0,0 +1,19 @@
1
+ """Mandat: authorization, delegation and audit for AI agent actions (Python guard).
2
+
3
+ Same policy language, decisions and control plane as the TypeScript SDK. The MCP integration lives in
4
+ `mandat.mcp` (requires `pip install mandat[mcp]`).
5
+ """
6
+
7
+ from .expr import ExprError, compile_expr
8
+ from .guard import AuthError, Guard, GuardDenied, VerifiedAuth, create_guard, tool_hash
9
+ from .policy import CompiledPolicy, Decision, PolicyError, compile_policy
10
+ from .redact import redact
11
+ from .remote import ControlPlane, FilePolicy, HttpLedger, MemoryLedger, RemoteApprovals, RemotePolicy, RemoteRevocations, StaticPolicy
12
+
13
+ __version__ = "0.1.0"
14
+
15
+ __all__ = [
16
+ "AuthError", "CompiledPolicy", "ControlPlane", "Decision", "ExprError", "FilePolicy", "Guard", "GuardDenied", "HttpLedger",
17
+ "MemoryLedger", "PolicyError", "RemoteApprovals", "RemotePolicy", "RemoteRevocations", "StaticPolicy", "VerifiedAuth",
18
+ "compile_expr", "compile_policy", "create_guard", "redact", "tool_hash",
19
+ ]
mandat/expr.py ADDED
@@ -0,0 +1,600 @@
1
+ """Mandat policy expression language (CEL-flavoured), a port of src/policy/expr.ts.
2
+
3
+ args.amount <= 50 && args.currency == 'EUR'
4
+ session.tainted && !args.to.endsWith('@acme.com')
5
+ args.recipients.all(r, r.endsWith('@acme.com'))
6
+
7
+ Parsed once into an AST; evaluation is a tree walk with no access to the Python runtime.
8
+ Semantics follow the TypeScript reference exactly (spec/conformance/policy.json): missing fields
9
+ are null, ordering comparisons with null are false, booleans are never numbers, `%` keeps the
10
+ sign of the dividend.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import math
17
+ import re
18
+ from dataclasses import dataclass, field
19
+ from typing import Any
20
+
21
+ __all__ = ["ExprError", "CompiledExpr", "compile_expr"]
22
+
23
+
24
+ class ExprError(Exception):
25
+ pass
26
+
27
+
28
+ MAX_EXPR_LENGTH = 4096
29
+ MAX_REGEX_LENGTH = 256
30
+ OPS = ["&&", "||", "==", "!=", "<=", ">=", "<", ">", "!", "+", "-", "*", "/", "%", "(", ")", "[", "]", ".", ",", "?", ":"]
31
+ FUNCTIONS = {"size", "has", "int", "double", "string", "lower", "upper", "abs", "min", "max"}
32
+ METHODS = {"startsWith", "endsWith", "contains", "matches", "size", "lower", "upper", "trim", "keys"}
33
+ MACROS = {"all", "exists", "filter"}
34
+
35
+ _NUM = re.compile(r"\d+(\.\d+)?")
36
+ _IDENT = re.compile(r"[A-Za-z_][A-Za-z0-9_]*")
37
+
38
+
39
+ @dataclass
40
+ class Tok:
41
+ t: str # num | str | id | op | eof
42
+ v: str
43
+ pos: int
44
+
45
+
46
+ @dataclass
47
+ class Node:
48
+ k: str
49
+ v: Any = None
50
+ name: str = ""
51
+ op: str = ""
52
+ fn: str = ""
53
+ var: str = ""
54
+ items: list["Node"] = field(default_factory=list)
55
+ args: list["Node"] = field(default_factory=list)
56
+ a: "Node | None" = None # obj / left / condition / operand
57
+ b: "Node | None" = None # index / right / then / body
58
+ c: "Node | None" = None # else
59
+
60
+
61
+ def _tokenize(src: str) -> list[Tok]:
62
+ if len(src) > MAX_EXPR_LENGTH:
63
+ raise ExprError("Expression too long")
64
+ toks: list[Tok] = []
65
+ i = 0
66
+ while i < len(src):
67
+ ch = src[i]
68
+ if ch.isspace():
69
+ i += 1
70
+ continue
71
+ if ch.isdigit() and ch.isascii():
72
+ m = _NUM.match(src, i)
73
+ assert m
74
+ toks.append(Tok("num", m.group(0), i))
75
+ i = m.end()
76
+ continue
77
+ if ch in ("'", '"'):
78
+ j = i + 1
79
+ out = []
80
+ while j < len(src) and src[j] != ch:
81
+ if src[j] == "\\" and j + 1 < len(src):
82
+ n = src[j + 1]
83
+ out.append("\n" if n == "n" else "\t" if n == "t" else n)
84
+ j += 2
85
+ else:
86
+ out.append(src[j])
87
+ j += 1
88
+ if j >= len(src):
89
+ raise ExprError(f"Unterminated string at {i}")
90
+ toks.append(Tok("str", "".join(out), i))
91
+ i = j + 1
92
+ continue
93
+ m = _IDENT.match(src, i)
94
+ if m:
95
+ toks.append(Tok("id", m.group(0), i))
96
+ i = m.end()
97
+ continue
98
+ op = next((o for o in OPS if src.startswith(o, i)), None)
99
+ if op is None:
100
+ raise ExprError(f"Unexpected character '{ch}' at {i}")
101
+ toks.append(Tok("op", op, i))
102
+ i += len(op)
103
+ toks.append(Tok("eof", "", len(src)))
104
+ return toks
105
+
106
+
107
+ class _Parser:
108
+ def __init__(self, toks: list[Tok]) -> None:
109
+ self.toks = toks
110
+ self.i = 0
111
+
112
+ def peek(self) -> Tok:
113
+ return self.toks[self.i]
114
+
115
+ def next(self) -> Tok:
116
+ t = self.toks[self.i]
117
+ self.i += 1
118
+ return t
119
+
120
+ def is_op(self, v: str) -> bool:
121
+ t = self.peek()
122
+ return t.t == "op" and t.v == v
123
+
124
+ def expect(self, v: str) -> None:
125
+ t = self.next()
126
+ if t.t != "op" or t.v != v:
127
+ raise ExprError(f"Expected '{v}' at {t.pos}, got '{t.v or 'end'}'")
128
+
129
+ def parse(self) -> Node:
130
+ n = self.ternary()
131
+ if self.peek().t != "eof":
132
+ raise ExprError(f"Unexpected '{self.peek().v}' at {self.peek().pos}")
133
+ return n
134
+
135
+ def ternary(self) -> Node:
136
+ c = self.or_()
137
+ if self.is_op("?"):
138
+ self.next()
139
+ t = self.ternary()
140
+ self.expect(":")
141
+ f = self.ternary()
142
+ return Node("cond", a=c, b=t, c=f)
143
+ return c
144
+
145
+ def or_(self) -> Node:
146
+ left = self.and_()
147
+ while self.is_op("||"):
148
+ self.next()
149
+ left = Node("bin", op="||", a=left, b=self.and_())
150
+ return left
151
+
152
+ def and_(self) -> Node:
153
+ left = self.cmp()
154
+ while self.is_op("&&"):
155
+ self.next()
156
+ left = Node("bin", op="&&", a=left, b=self.cmp())
157
+ return left
158
+
159
+ def cmp(self) -> Node:
160
+ left = self.add()
161
+ t = self.peek()
162
+ if (t.t == "op" and t.v in ("==", "!=", "<", "<=", ">", ">=")) or (t.t == "id" and t.v == "in"):
163
+ self.next()
164
+ return Node("bin", op=t.v, a=left, b=self.add())
165
+ return left
166
+
167
+ def add(self) -> Node:
168
+ left = self.mul()
169
+ while self.is_op("+") or self.is_op("-"):
170
+ op = self.next().v
171
+ left = Node("bin", op=op, a=left, b=self.mul())
172
+ return left
173
+
174
+ def mul(self) -> Node:
175
+ left = self.unary()
176
+ while self.is_op("*") or self.is_op("/") or self.is_op("%"):
177
+ op = self.next().v
178
+ left = Node("bin", op=op, a=left, b=self.unary())
179
+ return left
180
+
181
+ def unary(self) -> Node:
182
+ if self.is_op("!"):
183
+ self.next()
184
+ return Node("un", op="!", a=self.unary())
185
+ if self.is_op("-"):
186
+ self.next()
187
+ return Node("un", op="-", a=self.unary())
188
+ return self.postfix()
189
+
190
+ def postfix(self) -> Node:
191
+ n = self.primary()
192
+ while True:
193
+ if self.is_op("."):
194
+ self.next()
195
+ name = self.next()
196
+ if name.t != "id":
197
+ raise ExprError(f"Expected property name at {name.pos}")
198
+ if self.is_op("("):
199
+ self.next()
200
+ if name.v in MACROS:
201
+ v = self.next()
202
+ if v.t != "id":
203
+ raise ExprError(f"{name.v}() expects a variable name at {v.pos}")
204
+ self.expect(",")
205
+ body = self.ternary()
206
+ self.expect(")")
207
+ n = Node("macro", a=n, fn=name.v, var=v.v, b=body)
208
+ else:
209
+ n = Node("method", a=n, fn=name.v, args=self.call_args())
210
+ else:
211
+ n = Node("member", a=n, name=name.v)
212
+ elif self.is_op("["):
213
+ self.next()
214
+ idx = self.ternary()
215
+ self.expect("]")
216
+ n = Node("index", a=n, b=idx)
217
+ else:
218
+ return n
219
+
220
+ def call_args(self) -> list[Node]:
221
+ out: list[Node] = []
222
+ if self.is_op(")"):
223
+ self.next()
224
+ return out
225
+ while True:
226
+ out.append(self.ternary())
227
+ if self.is_op(","):
228
+ self.next()
229
+ continue
230
+ self.expect(")")
231
+ return out
232
+
233
+ def primary(self) -> Node:
234
+ t = self.next()
235
+ if t.t == "num":
236
+ return Node("lit", v=float(t.v) if "." in t.v else int(t.v))
237
+ if t.t == "str":
238
+ return Node("lit", v=t.v)
239
+ if t.t == "id":
240
+ if t.v == "true":
241
+ return Node("lit", v=True)
242
+ if t.v == "false":
243
+ return Node("lit", v=False)
244
+ if t.v == "null":
245
+ return Node("lit", v=None)
246
+ if self.is_op("("):
247
+ self.next()
248
+ return Node("call", fn=t.v, args=self.call_args())
249
+ return Node("id", name=t.v)
250
+ if t.t == "op" and t.v == "(":
251
+ n = self.ternary()
252
+ self.expect(")")
253
+ return n
254
+ if t.t == "op" and t.v == "[":
255
+ items: list[Node] = []
256
+ if not self.is_op("]"):
257
+ while True:
258
+ items.append(self.ternary())
259
+ if self.is_op(","):
260
+ self.next()
261
+ continue
262
+ break
263
+ self.expect("]")
264
+ return Node("list", items=items)
265
+ raise ExprError(f"Unexpected '{t.v or 'end of expression'}' at {t.pos}")
266
+
267
+
268
+ def _validate(n: Node) -> None:
269
+ if n.k == "call":
270
+ if n.fn not in FUNCTIONS:
271
+ raise ExprError(f"Unknown function {n.fn}()")
272
+ if n.fn == "has" and (len(n.args) != 1 or n.args[0].k not in ("member", "index")):
273
+ raise ExprError("has() expects a field selection, e.g. has(args.x)")
274
+ for a in n.args:
275
+ _validate(a)
276
+ elif n.k == "method":
277
+ if n.fn not in METHODS:
278
+ raise ExprError(f"Unknown method .{n.fn}()")
279
+ if n.fn == "matches":
280
+ p = n.args[0] if n.args else None
281
+ if p is None or p.k != "lit" or not isinstance(p.v, str):
282
+ raise ExprError(".matches() requires a string literal pattern")
283
+ if len(p.v) > MAX_REGEX_LENGTH:
284
+ raise ExprError("Regex pattern too long")
285
+ try:
286
+ re.compile(p.v)
287
+ except re.error as e:
288
+ raise ExprError(f"Invalid regex: {e}") from e
289
+ _validate(n.a) # type: ignore[arg-type]
290
+ for a in n.args:
291
+ _validate(a)
292
+ elif n.k in ("macro", "index", "bin"):
293
+ _validate(n.a) # type: ignore[arg-type]
294
+ _validate(n.b) # type: ignore[arg-type]
295
+ elif n.k in ("member", "un"):
296
+ _validate(n.a) # type: ignore[arg-type]
297
+ elif n.k == "list":
298
+ for i in n.items:
299
+ _validate(i)
300
+ elif n.k == "cond":
301
+ _validate(n.a) # type: ignore[arg-type]
302
+ _validate(n.b) # type: ignore[arg-type]
303
+ _validate(n.c) # type: ignore[arg-type]
304
+
305
+
306
+ # ---------- values (JavaScript semantics) ----------
307
+
308
+ def _is_num(v: Any) -> bool:
309
+ return isinstance(v, (int, float)) and not isinstance(v, bool)
310
+
311
+
312
+ def _type_name(v: Any) -> str:
313
+ if v is None:
314
+ return "null"
315
+ if isinstance(v, bool):
316
+ return "boolean"
317
+ if _is_num(v):
318
+ return "number"
319
+ if isinstance(v, str):
320
+ return "string"
321
+ if isinstance(v, list):
322
+ return "list"
323
+ return "object"
324
+
325
+
326
+ def _prop(obj: Any, key: str) -> Any:
327
+ if isinstance(obj, dict):
328
+ return obj.get(key) if key in obj else None
329
+ if isinstance(obj, list):
330
+ if key == "length":
331
+ return len(obj)
332
+ if key.isdigit() and int(key) < len(obj):
333
+ return obj[int(key)]
334
+ return None
335
+
336
+
337
+ def _truthy(v: Any) -> bool:
338
+ if not isinstance(v, bool):
339
+ raise ExprError(f"Expected boolean, got {_type_name(v)}")
340
+ return v
341
+
342
+
343
+ def _equals(a: Any, b: Any) -> bool:
344
+ if isinstance(a, bool) or isinstance(b, bool):
345
+ return isinstance(a, bool) and isinstance(b, bool) and a == b
346
+ if _is_num(a) and _is_num(b):
347
+ return a == b
348
+ if isinstance(a, list) and isinstance(b, list):
349
+ return len(a) == len(b) and all(_equals(x, y) for x, y in zip(a, b))
350
+ if isinstance(a, dict) and isinstance(b, dict):
351
+ return len(a) == len(b) and all(k in b and _equals(a[k], b[k]) for k in a)
352
+ if a is None or b is None:
353
+ return a is b
354
+ return type(a) is type(b) and a == b
355
+
356
+
357
+ def _size(v: Any) -> int:
358
+ if isinstance(v, (str, list, dict)):
359
+ return len(v)
360
+ raise ExprError(f"size() of {_type_name(v)}")
361
+
362
+
363
+ def _str(v: Any, fn: str) -> str:
364
+ if not isinstance(v, str):
365
+ raise ExprError(f".{fn}() expects a string, got {_type_name(v)}")
366
+ return v
367
+
368
+
369
+ def _js_number(v: Any) -> float:
370
+ """JavaScript Number(v) for the value kinds policies see."""
371
+ if isinstance(v, bool):
372
+ return 1.0 if v else 0.0
373
+ if v is None:
374
+ return 0.0
375
+ if _is_num(v):
376
+ return float(v)
377
+ if isinstance(v, str):
378
+ s = v.strip()
379
+ if s == "":
380
+ return 0.0
381
+ try:
382
+ return float(s)
383
+ except ValueError:
384
+ return math.nan
385
+ return math.nan
386
+
387
+
388
+ def _num_out(x: float) -> int | float:
389
+ return int(x) if x.is_integer() else x
390
+
391
+
392
+ def _js_string(v: Any) -> str:
393
+ if v is None:
394
+ return "null"
395
+ if isinstance(v, bool):
396
+ return "true" if v else "false"
397
+ if isinstance(v, float):
398
+ if math.isnan(v):
399
+ return "NaN"
400
+ if math.isinf(v):
401
+ return "Infinity" if v > 0 else "-Infinity"
402
+ return str(int(v)) if v.is_integer() else repr(v)
403
+ if isinstance(v, (list, dict)):
404
+ return json.dumps(v, separators=(",", ":"), ensure_ascii=False)
405
+ return str(v)
406
+
407
+
408
+ _regex_cache: dict[str, re.Pattern[str]] = {}
409
+
410
+
411
+ def _evaluate(n: Node, ctx: dict[str, Any], vars: dict[str, Any]) -> Any:
412
+ k = n.k
413
+ if k == "lit":
414
+ return n.v
415
+ if k == "id":
416
+ return vars[n.name] if n.name in vars else _prop(ctx, n.name)
417
+ if k == "list":
418
+ return [_evaluate(i, ctx, vars) for i in n.items]
419
+ if k == "member":
420
+ return _prop(_evaluate(n.a, ctx, vars), n.name) # type: ignore[arg-type]
421
+ if k == "index":
422
+ o = _evaluate(n.a, ctx, vars) # type: ignore[arg-type]
423
+ i = _evaluate(n.b, ctx, vars) # type: ignore[arg-type]
424
+ if isinstance(o, list) and _is_num(i):
425
+ if float(i).is_integer() and 0 <= i < len(o):
426
+ return o[int(i)]
427
+ return None
428
+ if isinstance(i, str):
429
+ return _prop(o, i)
430
+ return None
431
+ if k == "cond":
432
+ return _evaluate(n.b if _truthy(_evaluate(n.a, ctx, vars)) else n.c, ctx, vars) # type: ignore[arg-type]
433
+ if k == "un":
434
+ x = _evaluate(n.a, ctx, vars) # type: ignore[arg-type]
435
+ if n.op == "!":
436
+ return not _truthy(x)
437
+ if not _is_num(x):
438
+ raise ExprError(f"Unary minus on {_type_name(x)}")
439
+ return -x
440
+ if k == "bin":
441
+ return _binary(n, ctx, vars)
442
+ if k == "call":
443
+ return _call(n, ctx, vars)
444
+ if k == "method":
445
+ return _method(n, ctx, vars)
446
+ if k == "macro":
447
+ o = _evaluate(n.a, ctx, vars) # type: ignore[arg-type]
448
+ items = [] if o is None else o
449
+ if not isinstance(items, list):
450
+ raise ExprError(f".{n.fn}() on {_type_name(o)}")
451
+
452
+ def run(item: Any) -> Any:
453
+ return _evaluate(n.b, ctx, {**vars, n.var: item}) # type: ignore[arg-type]
454
+
455
+ if n.fn == "all":
456
+ return all(_truthy(run(x)) for x in items)
457
+ if n.fn == "exists":
458
+ return any(_truthy(run(x)) for x in items)
459
+ return [x for x in items if _truthy(run(x))]
460
+ raise ExprError(f"Unknown node {k}")
461
+
462
+
463
+ def _binary(n: Node, ctx: dict[str, Any], vars: dict[str, Any]) -> Any:
464
+ op = n.op
465
+ if op == "&&":
466
+ return _truthy(_evaluate(n.a, ctx, vars)) and _truthy(_evaluate(n.b, ctx, vars)) # type: ignore[arg-type]
467
+ if op == "||":
468
+ return _truthy(_evaluate(n.a, ctx, vars)) or _truthy(_evaluate(n.b, ctx, vars)) # type: ignore[arg-type]
469
+ left = _evaluate(n.a, ctx, vars) # type: ignore[arg-type]
470
+ right = _evaluate(n.b, ctx, vars) # type: ignore[arg-type]
471
+ if op == "==":
472
+ return _equals(left, right)
473
+ if op == "!=":
474
+ return not _equals(left, right)
475
+ if op == "in":
476
+ if isinstance(right, list):
477
+ return any(_equals(x, left) for x in right)
478
+ if isinstance(right, str) and isinstance(left, str):
479
+ return left in right
480
+ if isinstance(right, dict) and isinstance(left, str):
481
+ return left in right
482
+ return False
483
+ if op in ("<", "<=", ">", ">="):
484
+ if left is None or right is None:
485
+ return False
486
+ same = (_is_num(left) and _is_num(right)) or (isinstance(left, str) and isinstance(right, str))
487
+ if not same:
488
+ raise ExprError(f"Cannot compare {_type_name(left)} {op} {_type_name(right)}")
489
+ return left < right if op == "<" else left <= right if op == "<=" else left > right if op == ">" else left >= right
490
+ if op == "+":
491
+ if _is_num(left) and _is_num(right):
492
+ return left + right
493
+ if isinstance(left, str) and isinstance(right, str):
494
+ return left + right
495
+ if isinstance(left, list) and isinstance(right, list):
496
+ return [*left, *right]
497
+ raise ExprError(f"Cannot add {_type_name(left)} and {_type_name(right)}")
498
+ if op in ("-", "*", "/", "%"):
499
+ if not (_is_num(left) and _is_num(right)):
500
+ raise ExprError(f"Arithmetic on {_type_name(left)}, {_type_name(right)}")
501
+ if op in ("/", "%") and right == 0:
502
+ raise ExprError("Division by zero")
503
+ if op == "-":
504
+ return left - right
505
+ if op == "*":
506
+ return left * right
507
+ if op == "/":
508
+ return _num_out(left / right)
509
+ r = math.fmod(left, right) # JavaScript: the result takes the sign of the dividend
510
+ return int(r) if isinstance(left, int) and isinstance(right, int) else r
511
+ raise ExprError(f"Unknown operator {op}")
512
+
513
+
514
+ def _call(n: Node, ctx: dict[str, Any], vars: dict[str, Any]) -> Any:
515
+ if n.fn == "has":
516
+ sel = n.args[0]
517
+ base = _evaluate(sel.a, ctx, vars) # type: ignore[arg-type]
518
+ key = sel.name if sel.k == "member" else _evaluate(sel.b, ctx, vars) # type: ignore[arg-type]
519
+ if isinstance(base, dict):
520
+ return _js_string(key) in base
521
+ if isinstance(base, list):
522
+ s = _js_string(key)
523
+ return s.isdigit() and int(s) < len(base)
524
+ return False
525
+ a = [_evaluate(x, ctx, vars) for x in n.args]
526
+ fn = n.fn
527
+ if fn == "size":
528
+ return _size(a[0] if a else None)
529
+ if fn in ("int", "double"):
530
+ v = _js_number(a[0] if a else None)
531
+ if not math.isfinite(v):
532
+ raise ExprError(f"{fn}() of non-number")
533
+ return int(v) if fn == "int" else _num_out(v)
534
+ if fn == "string":
535
+ return _js_string(a[0] if a else None)
536
+ if fn == "lower":
537
+ return _str(a[0] if a else None, "lower").lower()
538
+ if fn == "upper":
539
+ return _str(a[0] if a else None, "upper").upper()
540
+ if fn == "abs":
541
+ if not a or not _is_num(a[0]):
542
+ raise ExprError("abs() of non-number")
543
+ return abs(a[0])
544
+ if fn in ("min", "max"):
545
+ nums = a[0] if len(a) == 1 and isinstance(a[0], list) else a
546
+ if not nums or any(not _is_num(x) for x in nums):
547
+ raise ExprError(f"{fn}() expects numbers")
548
+ return min(nums) if fn == "min" else max(nums)
549
+ raise ExprError(f"Unknown function {fn}")
550
+
551
+
552
+ def _method(n: Node, ctx: dict[str, Any], vars: dict[str, Any]) -> Any:
553
+ o = _evaluate(n.a, ctx, vars) # type: ignore[arg-type]
554
+ a = [_evaluate(x, ctx, vars) for x in n.args]
555
+ fn = n.fn
556
+ arg0 = a[0] if a else None
557
+ if fn == "startsWith":
558
+ return _str(o, fn).startswith(_str(arg0, fn))
559
+ if fn == "endsWith":
560
+ return _str(o, fn).endswith(_str(arg0, fn))
561
+ if fn == "contains":
562
+ if isinstance(o, list):
563
+ return any(_equals(x, arg0) for x in o)
564
+ return _str(arg0, fn) in _str(o, fn)
565
+ if fn == "matches":
566
+ pattern = arg0
567
+ rx = _regex_cache.get(pattern)
568
+ if rx is None:
569
+ rx = re.compile(pattern)
570
+ _regex_cache[pattern] = rx
571
+ return rx.search(_str(o, fn)[:10_000]) is not None
572
+ if fn == "size":
573
+ return _size(o)
574
+ if fn == "lower":
575
+ return _str(o, fn).lower()
576
+ if fn == "upper":
577
+ return _str(o, fn).upper()
578
+ if fn == "trim":
579
+ return _str(o, fn).strip()
580
+ if fn == "keys":
581
+ if isinstance(o, dict):
582
+ return list(o.keys())
583
+ raise ExprError(".keys() of non-map")
584
+ raise ExprError(f"Unknown method {fn}")
585
+
586
+
587
+ @dataclass
588
+ class CompiledExpr:
589
+ source: str
590
+ ast: Node
591
+
592
+ def eval(self, ctx: dict[str, Any]) -> Any:
593
+ return _evaluate(self.ast, ctx, {})
594
+
595
+
596
+ def compile_expr(source: str) -> CompiledExpr:
597
+ """Parses and statically validates an expression. Raises ExprError on bad syntax."""
598
+ ast = _Parser(_tokenize(source)).parse()
599
+ _validate(ast)
600
+ return CompiledExpr(source, ast)