Skip to content

Commit b722325

Browse files
committed
Experiment with expression rewriting
Signed-off-by: Eric Kerfoot <17726042+ericspod@users.noreply.github.com>
1 parent 456d069 commit b722325

3 files changed

Lines changed: 42 additions & 1 deletion

File tree

monai/bundle/scripts.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,7 @@ def _get_fake_spatial_shape(shape: Sequence[str | int], p: int = 1, n: int = 1,
162162
for c in _get_var_names(i):
163163
if c not in ["p", "n"]:
164164
raise ValueError(f"only support variables 'p' and 'n' so far, but got: {c}.")
165-
ret.append(safe_eval(i, {"p": p, "n": n}))
165+
ret.append(safe_eval(i, {"p": p, "n": n}, rewrite_np=True))
166166
else:
167167
raise ValueError(f"spatial shape items must be int or string, but got: {type(i)} {i}.")
168168
return tuple(ret)

monai/utils/safeeval.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515
from collections.abc import Mapping, Sequence
1616
from typing import Any
1717

18+
import numpy as np
19+
1820
__all__ = ["SAFE_TYPES", "safe_eval"]
1921

2022
# default set of safe AST node types
@@ -37,11 +39,28 @@
3739
)
3840

3941

42+
class _RewriteConstNp(ast.NodeTransformer):
43+
"""Replaces int and float constants in the tree with those wrapped in Numpy types."""
44+
def __init__(self, int_type_str: str, float_type_str: str):
45+
self.int_type_str = int_type_str
46+
self.float_type_str = float_type_str
47+
48+
def visit_Constant(self, node):
49+
if isinstance(node.value, (int, float)):
50+
type_str = self.int_type_str if isinstance(node.value, int) else self.float_type_str
51+
return ast.parse(f"{type_str}({node.value})")
52+
53+
return node
54+
55+
4056
def safe_eval(
4157
expr: str,
4258
globals_vars: Mapping[str, Any] | None = None,
4359
locals_vars: Mapping[str, object] | None = None,
4460
allowed_types: Sequence[type] = SAFE_TYPES,
61+
rewrite_np: bool = False,
62+
int_type_str: str = "np.int32",
63+
float_type_str: str = "np.float32",
4564
) -> Any:
4665
"""
4766
Evaluate the Python expression `expr` using `eval`, but only if it is a safe expression in that its parsed AST
@@ -50,11 +69,20 @@ def safe_eval(
5069
expressions with constants and names can be evaluated, so excludes attribute access, indexing, and calls. Code
5170
injection is infeasible through such expressions, so this is a safe and secure way of evaluating simple expressions.
5271
72+
If `rewrite_np` is True, int and float constants in the given expression will be wrapped with Numpy types as given
73+
by `int_type_str` and `float_type_str`. These are expected to be constructor names prefixed with `np.` as Numpy
74+
will be present in the expression global variables under that name. The values can be changed to other types if
75+
needed, such as "int64". One advantage of doing this is to avoid denial-of-service attacks by attempting to evaluate
76+
an expressoini which is incredibly slow under native Python but fast (though potentially erroneous) under Numpy.
77+
5378
Args:
5479
expr: expression to evaluate, this will be stripped before parsing to avoid indentation complaints
5580
globals_vars: global variable mapping, this will be treated as read-only for this function, unlike `eval`
5681
locals_vars: local variable mapping
5782
allowed_types: sequence of allowed AST types which can be found in `expr` when parsed
83+
rewrite_np: if True, wrap int or float literals in Numpy types
84+
int_type_str: int Numpy wrapping type string
85+
float_type_str: float Numpy wrapping type string
5886
5987
Raises:
6088
ValueError: raised when any node in the AST parsed from `expr` has a type not in `allowed_types`
@@ -70,4 +98,8 @@ def safe_eval(
7098
if disallowed:
7199
raise ValueError(f"Unsafe expression `{expr}` not evaluated, contains disallowed components: {disallowed}")
72100

101+
if rewrite_np:
102+
parsed = _RewriteConstNp(int_type_str, float_type_str).visit(parsed)
103+
locals_vars = {"np": np, **(locals_vars or {})}
104+
73105
return eval(expr, dict(globals_vars) if globals_vars else None, locals_vars)

tests/utils/test_safe_eval.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,12 +38,21 @@ def test_good_exprs(self, expr, globals_vars, locals_vars, expected):
3838
result = safe_eval(expr, globals_vars, locals_vars)
3939
self.assertEqual(result, expected)
4040

41+
@parameterized.expand(GOOD_EXPRS)
42+
def test_good_exprs_np(self, expr, globals_vars, locals_vars, expected):
43+
"""Test valid expressions with globals/locals evaluate to correct values with Numpy wrapping."""
44+
result = safe_eval(expr, globals_vars, locals_vars, rewrite_np=True)
45+
self.assertEqual(result, expected)
46+
4147
@parameterized.expand(BAD_EXPRS)
4248
def test_bad_exprs(self, expr):
4349
"""Test bad expressions correctly raise ValueError."""
4450
with self.assertRaises(ValueError):
4551
safe_eval(expr)
4652

53+
with self.assertRaises(ValueError):
54+
safe_eval(expr, rewrite_np=True)
55+
4756
def test_allowed_types(self):
4857
"""Test restricting the allowed list of types."""
4958
allowed = [ast.Expression, ast.Constant, ast.BinOp, ast.Add]

0 commit comments

Comments
 (0)