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 +19 -0
- mandat/expr.py +600 -0
- mandat/guard.py +466 -0
- mandat/jwt.py +178 -0
- mandat/mcp.py +127 -0
- mandat/policy.py +326 -0
- mandat/redact.py +92 -0
- mandat/remote.py +295 -0
- mandat/revocation.py +44 -0
- mandat-0.1.0.dist-info/METADATA +94 -0
- mandat-0.1.0.dist-info/RECORD +13 -0
- mandat-0.1.0.dist-info/WHEEL +4 -0
- mandat-0.1.0.dist-info/licenses/LICENSE +202 -0
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)
|