Best way to evaluate boolean expressions from strings?

Arnaud Delobelle arnodel at googlemail.com
Tue Apr 28 16:09:36 EDT 2009


Gustavo Narea <me at gustavonarea.net> writes:

> Hello, everybody.
>
> I need to evaluate boolean expressions like "foo == 1" or "foo ==1 and
> (bar > 2 or bar == 0)" which are defined as strings (in a database or
> a plain text file, for example). How would you achieve this?
>
> These expressions will contain placeholders for Python objects (like
> "foo" and "bar" in the examples above). Also, the Python objects that
> will get injected in the expression will support at least one of the
> following operations: "==", "!=", ">", "<", ">=", "<=", "&", "|",
> "in".
>
> I don't need the ability to import modules, define classes, define
> functions, etc. I just need to evaluate boolean expressions defined as
> strings (using the Python syntax is fine, or even desirable).
>
> Here's a complete example:
>
> I have the "user_ip" and "valid_ips" placeholders defined in Python as
> follows:
> """
> user_ip = '111.111.111.111'
>
> class IPCollection(object):
>     def __init__(self, *valid_ips):
>         self.valid_ips = valid_ips
>     def __contains__(self, value):
>         return value in self.valid_ips
>
> valid_ips = IPCollection('222.222.222.222', '111.111.111.111')
> """
>
> So the following boolean expressions given as strings should be
> evaluated as:
>  * "user_ip == '127.0.0.1'"  --->  False
>  * "user_ip == '127.0.0.1' or user_ip in valid_ips"  --->  True
>  * "user_ip not in valid_ips" ---> False
>
> That's it. How would you deal with this? I would love to re-use
> existing stuff as much as possible, that works in Python 2.4-2.6 and
> also that has a simple syntax (these expressions may not be written by
> technical people; hence I'm not sure about using TALES).
>
> Thanks in advance!

Here is a proof of concept using the ast module (Python >= 2.6):

----------------------------------------
import ast

class UnsafeError(Exception): pass

class SafetyChecker(ast.NodeVisitor):
    def __init__(self, allowed_nodes, allowed_names):
        self.allowed_nodes = allowed_nodes
        self.allowed_names = allowed_names
    def visit(self, node):
        if isinstance(node, ast.Name):
            if node.id in self.allowed_names:
                return
            raise UnsafeError('unsafe name: %s' % node.id)
        if type(node) not in self.allowed_nodes:
            if not any(tp in self.allowed_nodes for tp in type(node).__bases__):
                raise UnsafeError('unsafe node: %s' % type(node).__name__)
        ast.NodeVisitor.visit(self, node)

node_whitelist = [
    'Expression', 'Load', 
    'operator', 'unaryop', 'UnaryOp', 'BoolOp', 'BinOp',
    'boolop', 'cmpop', # Operators
    'Num', 'Str', 'List', 'Tuple', # Literals
    ]

node_whitelist = [getattr(ast, name) for name in node_whitelist]

checker = SafetyChecker(node_whitelist, ['a', 'b', 'foo', 'bar'])

def safe_eval(expr, checker=checker):
    t = ast.parse(expr, 'test.py', 'eval')
    checker.visit(t)
    return eval(expr)
----------------------------------------
Example:

>>> safe_eval('2*a - bar')
-4
>>> safe_eval('[1, 2] + [3, 4]')
[1, 2, 3, 4]
>>> safe_eval('f(foo)')
Traceback (most recent call last):
[...]
__main__.UnsafeError: unsafe node: Call
>>> safe_eval('x + 1')
Traceback (most recent call last):
[...]
__main__.UnsafeError: unsafe name: x
>>> safe_eval('lambda: x')
Traceback (most recent call last):
[...]
__main__.UnsafeError: unsafe node: Lambda
>>> safe_eval('foo or not foo')
'hello'


You'd have to tweak the node_whitelist using the info at

    http://docs.python.org/library/ast.html#abstract-grammar

-- 
Arnaud



More information about the Python-list mailing list