forked from fgmacedo/python-statemachine
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathtest_spec_parser_values.py
More file actions
168 lines (132 loc) · 6 KB
/
Copy pathtest_spec_parser_values.py
File metadata and controls
168 lines (132 loc) · 6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
"""Tests for the value-expression support added to ``spec_parser`` (the restricted
AST-allowlist evaluator used to avoid ``eval`` on SCXML datamodel expressions)."""
import pytest
from statemachine.spec_parser import parse_expr
def hook_from(values: dict):
"""Build a ``variable_hook`` that resolves names from a static dict."""
def hook(name: str):
def resolver(*args, **kwargs):
try:
return values[name]
except KeyError as exc:
raise NameError(name) from exc
return resolver
return hook
class _Obj:
def __init__(self, **attrs):
self.__dict__.update(attrs)
def method(self): # pragma: no cover - only referenced by a rejected expression
return "called"
class TestValueExpressions:
def test_constant(self):
assert parse_expr("42", hook_from({}))() == 42
def test_name_resolution(self):
assert parse_expr("x", hook_from({"x": 7}))() == 7
def test_arithmetic(self):
hook = hook_from({"x": 4})
assert parse_expr("x + 1", hook)() == 5
assert parse_expr("x - 1", hook)() == 3
assert parse_expr("x * 2", hook)() == 8
assert parse_expr("x / 2", hook)() == 2
assert parse_expr("x // 3", hook)() == 1
assert parse_expr("x % 3", hook)() == 1
assert parse_expr("x ** 2", hook)() == 16
def test_unary_minus_and_plus(self):
hook = hook_from({"x": 4})
assert parse_expr("-x", hook)() == -4
assert parse_expr("+x", hook)() == 4
def test_list_tuple_set(self):
hook = hook_from({"x": 2})
assert parse_expr("[1, x, 3]", hook)() == [1, 2, 3]
assert parse_expr("(1, x)", hook)() == (1, 2)
assert parse_expr("{1, x, 3}", hook)() == {1, 2, 3}
def test_dict(self):
assert parse_expr("{'a': 1, 'b': 2}", hook_from({}))() == {"a": 1, "b": 2}
def test_subscript(self):
hook = hook_from({"arr": [10, 20, 30], "d": {"k": "v"}, "i": 1})
assert parse_expr("arr[0]", hook)() == 10
assert parse_expr("arr[i]", hook)() == 20
assert parse_expr("d['k']", hook)() == "v"
def test_attribute_read_allowed(self):
hook = hook_from({"obj": _Obj(value=10)})
assert parse_expr("obj.value", hook)() == 10
def test_comparison_returns_bool(self):
hook = hook_from({"x": 4})
assert parse_expr("x > 2", hook)() is True
assert parse_expr("x == 5", hook)() is False
def test_boolean_short_circuit_returns_value(self):
# BoolOp keeps Python short-circuit value semantics (not coerced to bool).
hook = hook_from({"x": 0, "y": 9})
assert parse_expr("x or y", hook)() == 9
assert parse_expr("x and y", hook)() == 0
def test_nested_expression(self):
hook = hook_from({"items": [1, 2, 3], "factor": 10})
assert parse_expr("items[2] * factor + 1", hook)() == 31
class TestArithmeticMagnitudeCaps:
"""``**`` and ``*`` stay usable but cannot blow up: the denial-of-service forms raise,
while ordinary and edge-case arithmetic pass through (GHSA-r8gj-366q-cgvj)."""
@pytest.mark.parametrize("expr", ["9**9**9", "2**100000", "[0]*20000000", "'a'*20000000"])
def test_dos_forms_rejected(self, expr):
with pytest.raises(ValueError, match="too large"):
parse_expr(expr, hook_from({}))()
@pytest.mark.parametrize(
("expr", "expected"),
[
("2 ** 8", 256), # int pow under the cap
("2.5 ** 2", 6.25), # non-int base: cap check skipped
("2 ** 2.0", 4.0), # non-int exponent: cap check skipped
("5 ** 0", 1), # exponent <= 0: cap check skipped
("1 ** 5", 1), # base in (0, 1, -1) can't grow: cap check skipped
("[0] * 3", [0, 0, 0]), # small sequence replication
("[0] * 0", []), # non-positive count: cap check skipped
("'ab' * 2", "abab"), # small str replication
("3 * 4", 12), # scalar multiply: neither operand a sequence
],
)
def test_bounded_and_edge_cases_pass(self, expr, expected):
assert parse_expr(expr, hook_from({}))() == expected
class TestRejectedExpressions:
"""Each of these must raise at parse/compile time (becomes InvalidDefinition)."""
@pytest.mark.parametrize(
"expr",
[
"__import__('os')",
"().__class__",
"x.__class__",
"().__class__.__bases__",
"obj.method()",
"lambda: 1",
"[i for i in x]",
"(y := 1)",
"{**d}",
"a[1:2]",
"x ^ y",
"x | y",
],
)
def test_unsupported_structures_raise_value_error(self, expr):
with pytest.raises(ValueError, match="Unsupported|not allowed"):
parse_expr(expr, hook_from({"x": 1, "y": 2, "obj": _Obj(), "a": [1, 2], "d": {}}))
def test_attribute_dunder_message(self):
with pytest.raises(ValueError, match="Attribute access to '__class__' is not allowed"):
parse_expr("x.__class__", hook_from({"x": 1}))
def test_unknown_function_rejected(self):
with pytest.raises(ValueError, match="Unsupported function"):
parse_expr("open('f')", hook_from({}))
def test_empty_expression_raises_syntax_error(self):
with pytest.raises(SyntaxError):
parse_expr(" ", hook_from({}))
def test_statement_rejected_as_syntax_error(self):
with pytest.raises(SyntaxError):
parse_expr("import os", hook_from({}))
class TestRuntimeErrorsArePropagated:
"""Name/value errors surface at call time (so the engine can map them to
error.execution), not at parse time."""
def test_undefined_name_raises_at_runtime(self):
fn = parse_expr("missing + 1", hook_from({})) # compiles fine
with pytest.raises(NameError):
fn()
def test_type_error_raises_at_runtime(self):
fn = parse_expr("x + 1", hook_from({"x": "str"})) # compiles fine
with pytest.raises(TypeError):
fn()