Pull to refresh

Как правильно написать калькулятор на питоне с помощью eval()

Level of difficultyEasy
Reading time6 min
Views6.6K

В комментариях к статьям по синтаксическому анализу я иногда вижу такие:

на питоне калькулятор пишется проще простого — print(eval(input()))

Ну, вобщем‑то — да, но если, например, вы прикрутите такой калькулятор к своему сайту, то любой желающий вместо 2+2*2 может написать exec("import os; os.removedirs('/')"), предварительно изучив все ваши секретные файлы подобным же образом. Такая перспектива не может радовать, но и отказываться от eval() тоже не стоит.

— А что делать‑то? — спросите вы. Ответ простой: валидировать входящие данные, как вы это всегда делаете. Только не те, которые передаются функции eval() — для этого вам действительно пришлось бы написать свой синтаксический анализатор, а внутренние данные, которыми оперирует реализация eval().

— А! Я знаю — скажете вы — используем compile(), валидируем код и тогда уже вызываем eval(). Что-ж, возможно и так. Но я не знаю простого способа валидировать байт‑код. Нет. Нам надо валидировать результат питоновского синтаксического анализатора — абстрактное синтаксическое дерево или AST. Это гораздо проще, хоть и звучит пугающе.

Напишем свой eval() так:

def my_eval(expression):
    tree = ast.parse(expression, mode='eval')
    code = compile(tree, filename='', mode='eval')
    return eval(code)

Нам, естественнно, понадобится модуль ast:

>>> import ast

Проверяем - работает ли?

>>> print(my_eval(input()))
2+2*2
6

Итак, у нас есть tree. Вам, конечно же, любопытно, что там внутри, и действительно, надо же знать, что мы собираемся валидировать. Но просто так посмотреть не получится — это объект, и print() или pprint()выдадут всего лишь <ast.Expression object at 0x7f6de7eb3c70> Я пользовался такой функцией:

def dump(node):
    def _format(node, indent):
        if isinstance(node, ast.AST):
            print('%sAST %s' % (' ' * indent, node.__class__.__name__))
            for a, b in ast.iter_fields(node):
                print('%s%s' % (' ' * indent, a))
                _format(b, indent + 4)
        elif isinstance(node, list):
            print('%sLIST %s' % (' ' * indent, node.__class__.__name__))
            for x in node:
                _format(x, indent)
        else:
            print('%s%s' % (' ' * indent, repr(node)))
    _format(node, 0)

Можете вставить dump(tree) в my_eval(), поиграться и посмотреть, какое оно — это дерево. Только не надо копипастить мой пример с removedirs().

Кому играться лениво — вот пара примеров:

>>> print(my_eval(input()))
2+2*2
AST Expression
body
    AST BinOp
    left
        AST Constant
        value
            2
        kind
            None
    op
        AST Add
    right
        AST BinOp
        left
            AST Constant
            value
                2
            kind
                None
        op
            AST Mult
        right
            AST Constant
            value
                2
            kind
                None
6
>>> print(my_eval(input()))
print(globals())
AST Expression
body
    AST Call
    func
        AST Name
        id
            'print'
        ctx
            AST Load
    args
        LIST list
        AST Call
        func
            AST Name
            id
                'globals'
            ctx
                AST Load
        args
            LIST list
        keywords
            LIST list
    keywords
        LIST list
{'__name__': '__main__', '__doc__': None, '__package__': None, '__loader__': <class '_frozen_importlib.BuiltinImporter'>, '__spec__': None, '__annotations__': {}, '__builtins__': <module 'builtins' (built-in)>, 'my_eval': <function my_eval at 0x7f6de9225480>, 'ast': <module 'ast' from '/usr/lib/python3.10/ast.py'>, 'dump': <function dump at 0x7f6de7efb490>}
None

Видите, чем отличаются безопасные выражения от небезопасных? Безопасные содержат только Constant и BinOp, а небезопасные — всякие Call и Name. Полный список операций и других узлов дерева можно посмотреть в документации к модулю ast. Это поможет определиться, что запретить, а что разрешить. Для простого калькулятора, я считаю, достаточно разрешить основные операции BinOp и UnaryOp. Плюс Constant.

Пора писать валидатор:

_allowed_nodes = (
    # базовые узлы:
    ast.BinOp, ast.UnaryOp, ast.Constant,

    # основные BinOps:
    ast.Add, ast.Sub, ast.Mult, ast.Div, ast.FloorDiv, ast.Mod, ast.Pow,

    # основные UnaryOps:
    ast.UAdd, ast.USub
)

def validate_ast(tree):

    # валидируем корень дерева
    if not isinstance(tree, ast.Expression):
        raise Exception('Неправильное выражение')

    # валидируем узлы
    def validate_children(node):
        for child in ast.iter_child_nodes(node):
            if not isinstance(child, _allowed_nodes):
                raise Exception('Неправильное выражение')
            validate_children(child)

    validate_children(tree)

Вот так всё просто. Смело вызывайте эту функцию из my_eval() перед compile():

def my_eval(expression):
    tree = ast.parse(expression, mode='eval')
    validate_ast(tree)
    code = compile(tree, filename='', mode='eval')
    return eval(code)

и будет вам счастье. Но, имейте ввиду, документация к функции compile() сообщает:

Warning

It is possible to crash the Python interpreter with a sufficiently large/complex string when compiling to an AST object due to stack depth limitations in Python’s AST compiler.

Так что ограничивайте длину строки, передаваемой my_eval(). И тогда счастье будет настоящим.

— Постойте! — скажете вы — Мне не нужен простой бухгалтерский калькулятор! Где хоть какой-нибудь минимальный набор математических функций? Не отправлять же пользователя в пешее путешествие искать косинус фи?

М-да, пожалуй, соглашусь с вами. Давайте разрешим кое-что из модуля math. Но это будут вызовы функций, а значит — небезопасные области тьмы. Нам придётся добавить ast.Call, ast.Name и ast.Load в _allowed_nodes, и это открывает путь к exec(). Чтобы избежать неприятностей, надо ограничить контекст выполнения, в котором важно наличие пустого __builtins__:

def my_eval(expression):
    tree = ast.parse(expression, mode='eval')
    validate_ast(tree)
    code = compile(tree, filename='', mode='eval')
    context = {'__builtins__': {}}
    return eval(code, context)

Плюс, надо добавить поддержку узлов-списков в валидатор:

def validate_ast(tree):

    # валидируем корень дерева
    if not isinstance(tree, ast.Expression):
        raise Exception('Неправильное выражение')

    # валидируем узлы
    def validate_children(node):
        for child in ast.iter_child_nodes(node):
            if isinstance(child, list):
                for grandchild in child:
                    validate_children(grandchild)
            else:
                if not isinstance(child, _allowed_nodes):
                    raise Exception('Неправильное выражение')
                validate_children(child)

    validate_children(tree)

Теперь, если мы введём

exec('print(42)')

то получим

NameError: name 'exec' is not defined

Ура! Ничего лучшего я, конечно, придумать не могу, но надеюсь что те, кто умнее меня, напишут в комментариях как это взломать и, соответственно, защититься.

Итак, функции у нас разрешены, надо добавить нужные в контекст выполнения. Я ограничусь синусом и косинусом, остальное вы и сами сможете. По своему вкусу.

import math

def my_eval(expression):
    tree = ast.parse(expression, mode='eval')
    validate_ast(tree)
    code = compile(tree, filename='', mode='eval')
    context = {
        '__builtins__': {},
        'sin': math.sin,
        'cos': math.cos
    }
    return eval(code, context)

Может показаться, что достаточно создать контекст всего один раз, вне функции, как мы это сделали с _allowed_nodes в валидаторе, но! — _allowed_nodes у нас всё-таки immutable, а для контекста есть риск модификации, случайной или преднамеренной. Известные аналогичные грабли — использование dict и list в качестве значений по умолчанию для аргументов. Поэтому лучше создавать контекст каждый раз, внутри функции.

Ну, вот собственно, и всё. Пользуйтесь и наслаждайтесь. Для тех, кому лень собирать копипастой всё в кучу, ловите полный исходник:

import ast
import math


def my_eval(expression):
    tree = ast.parse(expression, mode='eval')
    #dump(tree)
    validate_ast(tree)
    code = compile(tree, filename='', mode='eval')
    context = {
        '__builtins__': {},
        'sin': math.sin,
        'cos': math.cos
    }
    return eval(code, context)


_allowed_nodes = (
    # базовые узлы:
    ast.BinOp, ast.UnaryOp, ast.Constant, ast.Call, ast.Name, ast.Load,

    # основные BinOps:
    ast.Add, ast.Sub, ast.Mult, ast.Div, ast.FloorDiv, ast.Mod, ast.Pow,

    # основные UnaryOps:
    ast.UAdd, ast.USub
)

def validate_ast(tree):

    # валидируем корень дерева
    if not isinstance(tree, ast.Expression):
        raise Exception('Неправильное выражение')

    # валидируем узлы
    def validate_children(node):
        for child in ast.iter_child_nodes(node):
            if isinstance(child, list):
                for grandchild in child:
                    validate_children(grandchild)
            else:
                if not isinstance(child, _allowed_nodes):
                    raise Exception('Неправильное выражение')
                validate_children(child)

    validate_children(tree)


def dump(node):
    def _format(node, indent):
        if isinstance(node, ast.AST):
            print('%sAST %s' % (' ' * indent, node.__class__.__name__))
            for a, b in ast.iter_fields(node):
                print('%s%s' % (' ' * indent, a))
                _format(b, indent + 4)
        elif isinstance(node, list):
            print('%sLIST %s' % (' ' * indent, node.__class__.__name__))
            for x in node:
                _format(x, indent)
        else:
            print('%s%s' % (' ' * indent, repr(node)))
    _format(node, 0)


print(my_eval(input()))

Tags:
Hubs:
Total votes 9: ↑9 and ↓0+9
Comments27

Articles