Skip to content

Commit 7de41b7

Browse files
authored
Merge pull request #144 from lmfit/with_lambda
add support for lambda expressions
2 parents f9d6613 + 2009d73 commit 7de41b7

File tree

8 files changed

+130
-70
lines changed

8 files changed

+130
-70
lines changed

README.rst

Lines changed: 51 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -50,42 +50,62 @@ About Asteval
5050
--------------
5151

5252
Asteval is a safe(ish) evaluator of Python expressions and statements,
53-
using Python's ast module. It provides a simple and robust restricted
54-
Python interpreter that can safely handle user input. The emphasis
55-
here is on mathematical expressions so that many functions from
56-
``NumPy`` are imported and used if available.
57-
58-
Asteval supports many Python language constructs by default. These
59-
include conditionals (if-elif-else blocks and if expressions), flow
60-
control (for loops, while loops, and try-except-finally blocks), list
61-
comprehension, slicing, subscripting, f-strings, and more. All data
62-
are Python objects and built-in data structures (dictionaries, tuples,
63-
lists, strings, and ``Numpy`` nd-arrays) are fully supported by
64-
default. It supports these language features by converting input into
65-
Python's own abstract syntax tree (AST) representation and walking
66-
through that tree. This approach effectively guarantees that parsing
67-
of input will be identical to that of Python.
53+
using Python's ast module. It emphasizes mathematical expressions so
54+
that many functions from ``NumPy`` are imported and used if available,
55+
but also provides a pretty complete subset of the Python language.
56+
Asteval provides a simple and robust restricted Python interpreter
57+
that can safely handle user input, and can be used as an embedded
58+
macro language within a large application.
59+
60+
Asteval supports many Python language constructs by default, including
61+
conditionals (if-elif-else blocks and if expressions), flow control
62+
(for loops, while loops, with blocks, and try-except-finally blocks),
63+
list comprehension, slicing, subscripting, and f-strings. All data
64+
are Python objects and the standard built-in data structures
65+
(dictionaries, tuples, lists, sets, strings, functions, and ``Numpy``
66+
nd-arrays) are well supported, but with limited to look "under the
67+
hood" and get private and unsafe methods.
6868

6969
Many of the standard built-in Python functions are available, as are
70-
all mathematical functions from the ``math`` module. If the ``NumPy``
71-
is installed, many of its functions will also be available. Users can
72-
define and run their own functions within the confines of the
73-
limitations of Asteval.
70+
the functions from the ``math`` module. Some of the built-in
71+
operators and functions, such as `getattr`, and `setattr` are not
72+
allowed, and some including `open` and `**` are replaced with versions
73+
intended to make them safer for user input. If the ``NumPy`` is
74+
installed, many of its functions will also be available. Programmers
75+
can add custom functions and data into each Asteval session. Users
76+
can define and run their own functions within the confines of the
77+
limitations of the Asteval language.
78+
79+
Asteval converts user input into Python's own abstract syntax tree
80+
(AST) representation and determines the result by walking through that
81+
tree. This approach guarantees the parsing of input will be identical
82+
to that of Python, eliminating many lexing and parsing challenges and
83+
generating a result that is straightforward to interpret. This makes
84+
"correctness" easy to test and verify with high confidence, so that
85+
the emphasis can be placed on balancing features with safety.
7486

7587
There are several absences and differences with Python, and Asteval is
7688
by no means an attempt to reproduce Python with its own ``ast``
77-
module. Some of the most important differences and absences are:
78-
79-
1. accessing many internal methods and classes of Python objects is
80-
forbidden. This strengthens Asteval against malicious user code.
81-
2. creating classes is not supported.
82-
3. function decorators, `yield`, `async`, `lambda`, `exec`, and
83-
`eval` are not supported.
84-
4. importing modules is not supported by default (it can be enabled).
85-
5. files will be opened in read-only mode by default.
86-
87-
Even with these restrictions, Asteval provides a pretty full-features
88-
``mini-Python`` language that might be useful to expose to user input.
89+
module. While, it does support a large subset of Python, the
90+
following features found in Python are not supported in Asteval:
91+
92+
1. many internal methods and classes of Python objects,
93+
especially ``__dunder__`` methods cannot be accessed.
94+
2. creating classes is not supported
95+
3. `eval`, `exec`, `yield`, `async`, `match/case`, function
96+
decorators, generators, and type annotations are not supported.
97+
4. `f-strings` are supported, but `t-strings` are not supported.
98+
5. importing modules is not supported by default, though it can be
99+
enabled.
100+
101+
Most of these omissions and limitations are intentional, and aimed to
102+
strengthen Asteval against dangerous user code. Some of these
103+
omissions may simply be viewed as not particularly compelling for an
104+
embedded interpreter exposed to user input.
105+
106+
Even with these
107+
restrictions,
108+
89109

90110

91111
Matt Newville <[email protected]>

asteval/asteval.py

Lines changed: 39 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -54,9 +54,9 @@
5454
'constant', 'continue', 'delete', 'dict', 'dictcomp',
5555
'excepthandler', 'expr', 'extslice', 'for',
5656
'functiondef', 'if', 'ifexp', 'import', 'importfrom',
57-
'index', 'interrupt', 'list', 'listcomp', 'module',
58-
'name', 'pass', 'raise', 'repr', 'return', 'set',
59-
'setcomp', 'slice', 'subscript', 'try', 'tuple',
57+
'index', 'interrupt', 'lambda', 'list', 'listcomp',
58+
'module', 'name', 'pass', 'raise', 'repr', 'return',
59+
'set', 'setcomp', 'slice', 'subscript', 'try', 'tuple',
6060
'unaryop', 'while', 'with', 'formattedvalue',
6161
'joinedstr']
6262

@@ -65,8 +65,9 @@
6565
DEFAULT_CONFIG = {'import': False, 'importfrom': False}
6666

6767
for _tnode in ('assert', 'augassign', 'delete', 'if', 'ifexp', 'for',
68-
'formattedvalue', 'functiondef', 'print', 'raise', 'listcomp',
69-
'dictcomp', 'setcomp', 'try', 'while', 'with'):
68+
'formattedvalue', 'functiondef', 'print', 'raise',
69+
'lambda', 'listcomp', 'dictcomp', 'setcomp', 'try',
70+
'while', 'with'):
7071
MINIMAL_CONFIG[_tnode] = False
7172
DEFAULT_CONFIG[_tnode] = True
7273

@@ -103,7 +104,7 @@ class Interpreter:
103104
-----
104105
1. setting `minimal=True` is equivalent to setting a config with the following
105106
nodes disabled: ('import', 'importfrom', 'if', 'for', 'while', 'try', 'with',
106-
'functiondef', 'ifexp', 'listcomp', 'dictcomp', 'setcomp', 'augassign',
107+
'functiondef', 'ifexp', 'lambda', 'listcomp', 'dictcomp', 'setcomp', 'augassign',
107108
'assert', 'delete', 'raise', 'print')
108109
2. by default 'import' and 'importfrom' are disabled, though they can be enabled.
109110
"""
@@ -951,18 +952,24 @@ def on_arg(self, node): # ('test', 'msg')
951952
"""Arg for function definitions."""
952953
return node.arg
953954

954-
def on_functiondef(self, node):
955+
def on_functiondef(self, node, is_lambda=False):
955956
"""Define procedures."""
956957
# ('name', 'args', 'body', 'decorator_list')
957-
if node.decorator_list:
958-
raise Warning("decorated procedures not supported!")
959-
kwargs = []
960-
961-
if (not valid_symbol_name(node.name) or
962-
node.name in self.readonly_symbols):
963-
errmsg = f"invalid function name (reserved word?) {node.name}"
964-
self.raise_exception(node, exc=NameError, msg=errmsg)
958+
if is_lambda:
959+
name = 'lambda'
960+
body = [node.body]
961+
else:
962+
name = node.name
963+
body = node.body
964+
965+
if node.decorator_list:
966+
raise Warning("decorated procedures not supported!")
967+
if (not valid_symbol_name(name) or
968+
name in self.readonly_symbols):
969+
errmsg = f"invalid function name (reserved word?) {name}"
970+
self.raise_exception(node, exc=NameError, msg=errmsg)
965971

972+
kwargs = []
966973
offset = len(node.args.args) - len(node.args.defaults)
967974
for idef, defnode in enumerate(node.args.defaults):
968975
defval = self.run(defnode)
@@ -971,7 +978,7 @@ def on_functiondef(self, node):
971978

972979
args = [tnode.arg for tnode in node.args.args[:offset]]
973980
doc = None
974-
nb0 = node.body[0]
981+
nb0 = body[0]
975982
if isinstance(nb0, ast.Expr) and isinstance(nb0.value, ast.Constant):
976983
doc = nb0.value
977984
varkws = node.args.kwarg
@@ -980,11 +987,19 @@ def on_functiondef(self, node):
980987
vararg = vararg.arg
981988
if isinstance(varkws, ast.arg):
982989
varkws = varkws.arg
983-
self.symtable[node.name] = Procedure(node.name, self, doc=doc,
984-
lineno=self.lineno,
985-
body=node.body,
986-
text=ast.unparse(node),
987-
args=args, kwargs=kwargs,
988-
vararg=vararg, varkws=varkws)
989-
if node.name in self.no_deepcopy:
990-
self.no_deepcopy.remove(node.name)
990+
991+
proc = Procedure(name, self, doc=doc, lineno=self.lineno,
992+
body=body, text=ast.unparse(node),
993+
args=args, kwargs=kwargs, vararg=vararg,
994+
varkws=varkws, is_lambda=is_lambda)
995+
996+
if is_lambda:
997+
return proc
998+
else:
999+
self.symtable[name] = proc
1000+
if name in self.no_deepcopy:
1001+
self.no_deepcopy.remove(name)
1002+
1003+
def on_lambda(self, node):
1004+
"""Lambda."""
1005+
return self.on_functiondef(node, is_lambda=True)

asteval/astutils.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -571,7 +571,7 @@ class Procedure:
571571

572572
def __init__(self, name, interp, doc=None, lineno=None,
573573
body=None, text=None, args=None, kwargs=None,
574-
vararg=None, varkws=None):
574+
vararg=None, varkws=None, is_lambda=False):
575575
"""TODO: docstring in public method."""
576576
self.__ininit__ = True
577577
self.name = name
@@ -586,6 +586,10 @@ def __init__(self, name, interp, doc=None, lineno=None,
586586
self.__varkws__ = varkws
587587
self.lineno = lineno
588588
self.__text__ = text
589+
self.__is_lambda__ = is_lambda
590+
if is_lambda:
591+
self.name = self.__name__ = 'lambda'
592+
589593
if text is None:
590594
self.__text__ = f'{self.__signature__()}\n' + ast.unparse(self.__body__)
591595
self.__ininit__ = False
@@ -732,9 +736,12 @@ def __call__(self, *args, **kwargs):
732736
# evaluate script of function
733737
self.__asteval__.code_text.append(self.__text__)
734738
for node in self.__body__:
735-
self.__asteval__.run(node, lineno=node.lineno)
739+
out = self.__asteval__.run(node, lineno=node.lineno)
736740
if len(self.__asteval__.error) > 0:
737741
break
742+
if self.__is_lambda__:
743+
retval = out
744+
break
738745
if self.__asteval__.retval is not None:
739746
retval = self.__asteval__.retval
740747
self.__asteval__.retval = None

doc/api.rst

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,8 @@ but will full support for Python data types and array slicing.
8080
+----------------+----------------------+-------------------+-------------------+
8181
| raise | raise statements | True | False |
8282
+----------------+----------------------+-------------------+-------------------+
83+
| lambda | lambda expressions | True | False |
84+
+----------------+----------------------+-------------------+-------------------+
8385
| listcomp | list comprehension | True | False |
8486
+----------------+----------------------+-------------------+-------------------+
8587
| dictcomp | dict comprehension | True | False |
@@ -105,6 +107,7 @@ The ``default`` configuration adds many language constructs, including
105107
* with blocks
106108
* augmented assignments: ``x += 1``
107109
* if-expressions: ``x = a if TEST else b``
110+
* lambda expressions: ``dist = lambda x,y: sqrt(x**2 + y**2)``
108111
* list comprehension: ``out = [sqrt(i) for i in values]``
109112
* set and dict comprehension, too.
110113
* print formatting with ``%``, ``str.format()``, or f-strings.
@@ -133,11 +136,13 @@ Passing, ``minimal=True`` will turn off all the nodes listed in Table
133136
>>>
134137
>>> aeval_min = Interpreter(minimal=True)
135138
>>> aeval_min.config
136-
{'import': False, 'importfrom': False, 'assert': False, 'augassign': False,
137-
'delete': False, 'if': False, 'ifexp': False, 'for': False,
138-
'formattedvalue': False, 'functiondef': False, 'print': False,
139-
'raise': False, 'listcomp': False, 'dictcomp': False, 'setcomp': False,
140-
'try': False, 'while': False, 'with': False}
139+
{'import': False, 'importfrom': False, 'assert': False,
140+
'augassign': False, 'delete': False, 'if': False, 'ifexp': False,
141+
'for': False, 'formattedvalue': False, 'functiondef': False,
142+
'print': False, 'raise': False, 'lambda': False,
143+
'listcomp': False, 'dictcomp': False, 'setcomp': False,
144+
'try': False, 'while': False, 'with': False,
145+
'nested_symtable': False}
141146

142147
As shown above, importing Python modules with ``import module`` or ``from
143148
module import method`` can be enabled, but is disabled by default. To enable
@@ -146,12 +151,13 @@ these, use ``with_import=True`` and ``with_importfrom=True``, as ::
146151
>>> from asteval import Interpreter
147152
>>> aeval_max = Interpreter(with_import=True, with_importfrom=True)
148153

149-
or by setting the config dictionary as described above:
154+
or by setting the config dictionary passed to ``Interpreter`` as
155+
described above.
150156

151157
Interpreter methods and attributes
152158
====================================
153159

154-
An Interpreter instance has many methods, but most of them are
160+
The Asteval Interpreter instance has many methods, but most of them are
155161
implementation details for how to handle particular AST nodes, and should
156162
not be considered as part of the usable API. The methods described below,
157163
and the examples elsewhere in this documentation should be used as the
@@ -312,8 +318,6 @@ Utility Functions
312318

313319
.. autofunction:: valid_symbol_name
314320

315-
316-
317321
.. autofunction:: make_symbol_table
318322

319323

doc/basics.rst

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,8 @@ User-defined functions can be written and executed, as in python with a
155155

156156
>>> from asteval import Interpreter
157157
>>> aeval = Interpreter()
158-
>>> code = """def func(a, b, norm=1.0):
158+
>>> code = """
159+
... def func(a, b, norm=1.0):
159160
... return (a + b)/norm
160161
... """
161162
>>> aeval(code)

doc/index.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ These differences and absences include:
4141
access can also be used.
4242
2. creating classes is not allowed.
4343
3. importing modules is not allowed, unless specifically enabled.
44-
4. decorators, generators, type hints, and ``lambda`` are not supported.
44+
4. decorators, generators, and type hints are not supported.
4545
5. ``yield``, ``await``, and async programming are not supported.
4646
6. Many builtin functions (:py:func:`eval`, :py:func:`getattr`,
4747
:py:func:`hasattr`, :py:func:`setattr`, and :py:func:`delattr`) are not allowed.

doc/motivation.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ makes user input difficult to trust. Since asteval does not support the
2020
code cannot access the :py:mod:`os` and :py:mod:`sys` modules or any functions
2121
or classes outside those provided in the symbol table.
2222

23-
Many of the other missing features (modules, classes, lambda, yield,
23+
Many of the other missing features (modules, classes, yield,
2424
generators) are similarly motivated by a desire for a safer version of
2525
:py:func:`eval`. The idea for asteval is to make a simple procedural,
2626
mathematically-oriented language that can be embedded into larger applications.

tests/test_asteval.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1149,6 +1149,19 @@ def c(x=10):
11491149
isvalue(interp, 'o1', 3.5)
11501150
isvalue(interp, 'o2', 1.5)
11511151

1152+
@pytest.mark.parametrize("nested", [False, True])
1153+
def test_lambda(nested):
1154+
"""test using lambda definitions"""
1155+
interp = make_interpreter(nested_symtable=nested)
1156+
1157+
interp(textwrap.dedent("""
1158+
my_func = lambda x: 2 + 3*x
1159+
out = my_func(3)
1160+
"""))
1161+
assert len(interp.error) == 0
1162+
isvalue(interp, 'out', 11.0)
1163+
1164+
11521165
@pytest.mark.parametrize("nested", [False, True])
11531166
def test_astdump(nested):
11541167
"""test ast parsing and dumping"""
@@ -1231,7 +1244,7 @@ def test_kaboom(nested):
12311244
interp("""(lambda fc=(lambda n: [c for c in ().__class__.__bases__[0].__subclasses__() if c.__name__ == n][0]):
12321245
fc("function")(fc("code")(0,0,0,0,"KABOOM",(),(),(),"","",0,""),{})()
12331246
)()""")
1234-
check_error(interp, 'NotImplementedError') # Safe, lambda is not supported
1247+
check_error(interp, 'AttributeError') # Safe, unassigned lambda is not supported
12351248

12361249
interp("""[print(c) for c in ().__class__.__bases__[0].__subclasses__()]""") # Try a portion of the kaboom...
12371250

0 commit comments

Comments
 (0)