14 мая 2020

Динамическое выполнение выражений в Python: eval()

Пишу, перевожу и иллюстрирую IT-статьи. На proglib написал 140 материалов. Увлекаюсь Python, вебом и Data Science. Открыт к диалогу – ссылки на соцсети и мессенджеры: https://matyushkin.github.io/links/ Если понравился стиль изложения, упорядоченный список публикаций — https://github.com/matyushkin/lessons
О встроенной функции eval(), вопросах ее безопасного и эффективного использования в Python. В конце статьи пишем программу, обобщающую полученные знания.
Динамическое выполнение выражений в Python: eval()

Функция eval() полезна, когда необходимо выполнить динамически обновляемое выражение Python из какого-либо ввода (например, функции input()), представленного в виде строки или объекта байт-кода. Это невероятно полезный инструмент, но то, что она может выполнять программный код, имеет важные последствия для безопасности, которые следует учесть перед ее применением.

Статья является сокращенным переводом публикации Леоданиса Посо Рамоса Python eval(): Evaluate Expressions Dynamically. Из этого руководства вы узнаете:

  • Как работает eval().
  • Как использовать eval() для динамического выполнения кода.
  • Как минимизировать риски для безопасности, связанные с использованием eval().
Примечание
Если вы умеете пользоваться блокнотами Jupyter, текст также адаптирован в виде ipynb-файла, доступного в GitHub-репозитории. Файл можно запустить в интерактивно, ничего не устанавливая, в Colab.

Разбираемся в том, как работает eval()

Вы можете использовать встроеннyю функцию eval() для динамического исполнения выражений из ввода на основе строки или скомпилированного кода. Если вы передаете в eval() строку, то функция анализирует ее, компилирует в байт-код и выполняет как выражение Python.

Сигнатура eval() определена следующим образом:

        eval(expression[, globals[, locals]])
    

Первый аргумент expression содержит выражение, которое необходимо выполнить. Функция также принимает два необязательных аргумента globals и locals, о которых мы поговорим в соответствующих разделах. Начнём по порядку – с аргумента expression.

Примечание
Для динамического выполнения кода можно также использовать функцию exec(). Основное различие между eval() и exec() состоит в том, что eval() может выполнять лишь выражения, тогда как функции exec() можно «скормить» любой фрагмент кода Python.

Первый аргумент: expression

Когда мы вызываем eval(), содержание expression воспринимается интерпретатором как выражение Python. Посмотрите на следующие примеры, принимающие строковый ввод:

        >>> eval("2 ** 8")
256
>>> eval("1024 + 1024")
2048
>>> eval("sum([8, 16, 32])")
56
>>> x = 100
>>> eval("x * 2")
200
    

При вызове eval() со строковым выражением в качестве аргумента, функция возвращает значение, полученное в результате оценки входной строки. По умолчанию eval() имеет доступ к глобальным именам, таким как x в приведенном выше примере.

Чтобы оценить строковое выражение, eval() выполняет следующую последовательность действий:

  1. Парсинг выражения.
  2. Компилирование в байт-код.
  3. Выполнение кода выражения Python.
  4. Возвращение результата.

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

        >>> x = 100
>>> eval("if x: print(x)")
SyntaxError: invalid syntax

    

Таким образом, в eval() нельзя передать конструкции c if, import, def или class, с циклами for и while. Однако ключевое слово for может использоваться в eval() в случае выражений для генераторов списков.

В eval() запрещены и операции присваивания:

        >>> eval("pi = 3.1416")
SyntaxError: invalid syntax
    

SyntaxError также вызывается в случаях, когда eval() не удается распарсить выражение из-за ошибки в записи:

        >>> eval("5 + 7 *")
SyntaxError: unexpected EOF while parsing
    

В eval() можно передавать объекты кода (code objects). Чтобы скомпилировать код, который вы собираетесь передать eval(), можно использовать compile(). Это встроенная функция, которая может компилировать строку в объект кода или AST-объект.

Детали того, как использовать compile(), выходят за рамки этого руководства, но здесь мы кратко рассмотрим первые три обязательных аргумента:

  1. source содержит исходный код, который необходимо скомпилировать. Этот аргумент принимает обычные строки, байтовые строки и объекты AST.
  2. filename определяет файл, из которого прочитан код. Если используется строчный ввод, значение аргумента должно быть равно строке <string>.
  3. mode указывает, какой тип объекта кода мы хотим получить. Если нужно обработать код с помощью eval(), в качестве значения аргумента указывается "eval"
Примечание
Для лучшего ознакомления воспользуйтесь официальной документацией функции compile().

Таким образом, мы можем использовать compile() для предоставления объектов кода вeval() вместо обычных строк:

        >>> code = compile("5 + 4", "<string>", "eval")
>>> eval(code)
9
>>> import math
>>> code = compile("4 / 3 * math.pi * math.pow(25, 3)", "<string>", "eval")
>>> eval(code)
65449.84694978735
    

Использование объектов кода полезно при многократном вызове. Если мы предварительно скомпилируем входное выражение, то последующие вызовы eval() будут выполняться быстрее, так как не будут повторяться шаги синтаксического анализа и компиляции.

🐍 Библиотека питониста
Больше полезных материалов вы найдете на нашем телеграм-канале «Библиотека питониста»
🐍🎓 Библиотека собеса по Python
Подтянуть свои знания по Python вы можете на нашем телеграм-канале «Библиотека собеса по Python»
🐍🧩 Библиотека задач по Python
Интересные задачи по Python для практики можно найти на нашем телеграм-канале «Библиотека задач по Python»

Второй аргумент: globals

Аргумент globals опционален. Он содержит словарь, обеспечивающий доступ eval() к глобальному пространству имен. С помощью глобальных переменных можно указать eval(), какие глобальные имена использовать при выполнении выражения.

Глобальные имена – это все те имена, которые доступны в текущей глобальной области или пространстве имен. Вы можете получить к ним доступ из любого места в вашем коде.

Все имена, переданные глобальным переменным в словаре, будут доступны eval() во время выполнения.

        >>> x = 100  # Глобальная переменная
>>> eval("x + 100", {"x": x})
200
>>> y = 200  # Другая глобальная переменная
>>> eval("x + y", {"x": x})
NameError: name 'y' is not defined
    

Любые глобальные имена, определенные вне пользовательского словаря globals, не будут доступны изнутри eval(), будет вызвано исключение NameError.

        >>> eval("x + y", {"x": x, "y": y})
300
    

Вы также можете указать имена, которых нет в текущей глобальной области видимости. Чтобы это работало, нужно указать конкретное значение для каждого имени. Тогда eval() будет интерпретировать эти имена, как если бы это были глобальные переменные:

        >>> eval("x + y + z", {"x": x, "y": y, "z": 300})
600
>>> z  # самой переменной нет в глобальной области видимости
NameError: name 'z' is not defined
    

Если вы предоставите eval() пользовательский словарь, который не содержит значения для ключа "__builtins__", то ссылка на словарь встроенных функций всё равно будет автоматически добавлена к ключу "__builtins__", прежде чем выражение будет проанализировано. Это гарантирует, что eval() имеет полный доступ ко всем встроенным именам Python при оценке выражения.

        >>> eval("sum([2, 2, 2])", {})
6
>>> eval("min([1, 2, 3])", {})
1
>>> eval("pow(10, 2)", {})
100
    

Несмотря на переданный пустой словарь ({}), eval() имеет доступ к встроенным функциям.

При вызове eval() без передачи пользовательского словаря в глобальные переменные аргумент по умолчанию будет использовать словарь, возвращаемый globals() в среде, где вызывается eval():

        >>> x = 100  # Глобальная переменная
>>> y = 200  # Другая глобальная переменная
>>> eval("x + y")
300
    

Таким образом, передача словаря в аргументе globals служит как способ намеренно ограничить область видимость имен для функции eval().

Третий аргумент: locals

Аргумент locals также является необязательным аргументом. В этом случае словарь содержит переменные, которые eval() использует в качестве локальных имен при оценке выражения.

Локальными называются те имена (переменные, функции, классы и т.д.), которые мы определяем внутри данной функции. Локальные имена видны только изнутри включающей функции.

        >>> eval("x + 100", {}, {"x": 100})
200
>>> eval("x + y", {}, {"x": 100})
NameError: name 'y' is not defined
    

Обратите внимание, что для передачи словаря locals сначала необходимо предоставить словарь для globals. Передача по ключу в случае eval() не работает:

        >>> eval("x + 100", locals={"x": 100})
TypeError: eval() takes no keyword arguments
    

Главное практическое различие между globals и locals заключается в том, что Python автоматически вставит ключ "__builtins__" в globals, если этот ключ еще не существует. Cловарь locals остается неизменным во время выполнения eval().

Выполнение выражений с eval()

Функция eval() используется, когда нужно динамически изменять выражения, а применение других техник и инструментов Python требует избыточных усилий. В этом разделе мы обсудим, как использовать eval() для булевых, математических и прочих выражений Python.

Булевы выражения

Булевы выражения – это выражения Python, которые возвращают логическое значение. Обычно они используются для проверки, является ли какое-либо условие истинным или ложным:

        >>> x = 100
>>> y = 100
>>> eval("x != y")
False
>>> eval("x < 200 and y > 100")
False
>>> eval("x is y")
True
>>> eval("x in {50, 100, 150, 200}")
True
    

Зачем же может потребоваться использовать eval() вместо непосредственного применения логического выражения? Предположим, нам нужно реализовать условный оператор, но вы хотите на лету менять условие:

        def func(a, b, condition):
    if eval(condition):
        return a + b
    return a - b
    
        >>> func(2, 4, "a > b")
-2
>>> func(2, 4, "a < b")
6
>>> func(2, 2, "a is b")
4
    

Внутри func() для оценки предоставленного условия используется функция eval(), возвращающая a+b или a-b в соответствии с результатом оценки.

Теперь представьте, как бы вы реализовали то же поведение без eval() для обработки любого логического выражения.

Математические выражения

Один из распространенных вариантов использования eval() в Python – оценка математических выражений из строкового ввода. Например, если вы хотите создать калькулятор на Python, вы можете использовать eval(), чтобы оценить вводимые пользователем данные и вернуть результат вычислений:

        >>> eval("5 + 7")
12
>>> eval("(5 + 7) / 2")
6.0
>>> import math
>>> eval("math.sqrt(math.pow(10, 2) + math.pow(15, 2))")
18.027756377319946
    

Выражения общего вида

Вы можете использовать eval() и с более сложными выражениями Python, включающими вызовы функций, создание объектов, доступ к атрибутам и т. д.

Например, можно вызвать встроенную функцию или функцию, импортированную с помощью стандартного или стороннего модуля. В следующих примерах eval() используется для запуска различных системных команд.

        >>> import subprocess
>>> # Запуск команды echo
>>> eval("subprocess.getoutput('echo Hello, World')")
'Hello, World'
>>> # Запуск Firefox (если он установлен)
>>> eval("subprocess.getoutput('firefox')")
    

Таким образом, можно передавать команды через какой-либо строковый интерфейс (например, форму в браузере) и выполнять код Python.

Однако по той же причине eval() может подвергнуть нас серьезным угрозам безопасности, например, позволит злоумышленнику запускать системные команды или выполнять кода на нашем компьютере. В следующем разделе мы обсудим способы устранения угроз безопасности, связанных с eval().

Минимизация проблем безопасности, связанных с eval()

В связи с проблемами безопасности обычно рекомендуется по возможности не использовать eval(). Но если вы решили, что функция необходима, простое практическое правило состоит в том, чтобы никогда не использовать ее для любого ввода, которому нельзя доверять. Сложность в том, чтобы выяснить, каким видам ввода доверять можно .

В качестве примера того, как безответственное использование eval() может сделать ваш код небезопасным, предположим, что вы хотите создать онлайн-сервис для оценки произвольных выражений Python. Ваш пользователь вводит выражения и нажимает кнопку «Выполнить». Приложение получает пользовательский ввод и передает его для выполнения в eval() .

Если вы используете Linux и приложения имеет необходимые разрешения, то злонамеренный пользователь может ввести опасную строку, подобную следующей:

        "__import__('subprocess').getoutput('rm –rf *')"
    

Выполнение выражения удалит все файлы в текущей директории.

Примечание
__import__() – это встроенная функция, которая принимает имя модуля в виде строки и возвращает ссылку на объект модуля. __import__() – это функция, которая полностью отличается от оператора import. Как мы упоминали выше, вы не можете вызвать оператор импорта с помощью eval().

Ограничение globals и locals

Вы можете ограничить среду выполнения eval(), задавая собственные словари аргументам globals и locals. Например, пустые словари для обоих аргументов, чтобы eval() не мог получить доступ к именам в текущей области или пространстве имен вызывающей стороны:

        >>> x = 100
>>> eval("x * 5", {}, {})
NameError: name 'x' is not defined
    

К сожалению, это ограничение не устраняет другие проблемы безопасности, связанные с использованием eval(), поскольку остается доступ ко всем встроенным именам и функциям Python.

Ограничение __builtins__

Как мы видели ранее, перед синтаксическим анализом выражения eval() автоматически вставляет ссылку на словарь __builtins__ в globals. Злоумышленник может использовать это поведение, используя встроенную функцию __import__(), чтобы получить доступ к стандартной библиотеке или любому стороннему модулю, установленному в системе:

        >>> eval("__import__('math').sqrt(25)", {}, {})
5.0
>>> eval("__import__('subprocess').getoutput('echo Hello, World')", {}, {})
'Hello, World'
    

Чтобы минимизировать риски, можно переопределить __builtins__ в globals:

        >>> eval("__import__('math').sqrt(25)", {"__builtins__": {}}, {})
NameError: name '__import__' is not defined
    

Ограничение имён во входных данных

Однако даже после таких ухищрений Python останется уязвим. Например, можно получить доступ к объекту класса, используя литерал типа, например "", [], {} или (), а также некоторые специальные атрибуты:

        >>> "".__class__.__base__
object
>>> [].__class__.__base__
object
    

Получив доступ к объекту, можно использовать специальный метод .__subclasses__(), чтобы получить доступ ко всем классам, которые наследованы объектом. Вот как это работает:

        for sub_class in ().__class__.__base__.__subclasses__():
    print(sub_class.__name__)
    
        type
weakref
weakcallableproxy
weakproxy
int
bytearray
bytes
list
...
    

Этот код напечатает большой список классов. Некоторые из этих классов довольно мощные и могут быть чрезвычайно опасны в чужих руках. Это открывает еще одну важную дыру в безопасности, которую вы не сможете закрыть, просто ограничивая окружение eval():

        input_string = """[
    c for c in ().__class__.__base__.__subclasses__()
    if c.__name__ == "range"
    ][0](10)"""
    
        >>> list(eval(input_string, {"__builtins__": {}}, {}))
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
    

Генератор списка в приведенном коде фильтрует классы объекта, чтобы вернуть список, содержащий класс range. Далее range вызывается для создания соответствующего объекта. Это хитрый способ обойти исключение TypeError, вызываемое в результате ограничения "__builtins__".

        >>> list(eval(range(10), {"__builtins__": {}}, {}))
TypeError: eval() arg 1 must be a string, bytes or code object
    

Возможное решение этой уязвимости состоит в том, чтобы ограничить использование имен во входных данных набором безопасных имен либо исключить всякое использование любых имен.

Чтобы реализовать эту технику, необходимо выполнить следующие шаги:

  1. Создать словарь, содержащий имена, которые могут использоваться в eval().
  2. Скомпилировать входную строку в байт-код, используя compile() в режиме eval.
  3. Проверить .co_names в объекте байт-кода, чтобы убедиться, что он содержит только разрешенные имена.
  4. Вызвать исключение NameError, если пользователь пытается использовать недопустимое имя.

Взглянем на следующую функцию, в которой реализованы все эти шаги:

        def eval_expression(input_string):
    allowed_names = {"sum": sum}
    code = compile(input_string, "<string>", "eval")
    for name in code.co_names:
        if name not in allowed_names:
            raise NameError(f"Использование {name} не разрешено.")
    return eval(code, {"__builtins__": {}}, allowed_names)
    

Эта функция ограничивает имена, которые можно использовать в eval(), именами в словаре allowed_names. Для этого функция использует .co_names – атрибут объекта кода, содержащий кортеж имен в объекте кода.

Следующие примеры показывают, как написанная нами функция eval_expression() работает на практике:

        >>> eval_expression("3 + 4 * 5 + 25 / 2")
35.5
>>> eval_expression("sum([1, 2, 3])")
6
>>> eval_expression("pow(10, 2)")
NameError: Использование pow не разрешено.
    

Если нужно полностью запретить применение имен, достаточно переписать eval_expression() следующим образом:

        def eval_expression(input_string):
    code = compile(input_string, "<string>", "eval")
    if code.co_names:
        raise NameError(f"Использование имён запрещено.")
    return eval(code, {"__builtins__": {}}, {})
    
        >>> eval_expression("3 + 4 * 5 + 25 / 2")
35.5
>>> eval_expression("sum([1, 2, 3])")
NameError: Использование имён запрещено.
    

Ограничение входных данных до литералов

Типичный пример использования eval() в Python – это выполнение выражений, содержащих стандартные литералы Python. Задача настолько распространенная, что стандартная библиотека предоставляет соответствующую функцию literal_eval(). Функция не поддерживает операторы, но работает со списками, кортежами, числами, строками и т. д.:

        >>> from ast import literal_eval
>>> literal_eval("15.02")
15.02
>>> literal_eval("[1, 15]")
[1, 15]
>>> literal_eval("{'one': 1, 'two': 2}")
{'one': 1, 'two': 2}
>>> literal_eval("sum([1, 15]) + 5 + 8 * 2")
ValueError: malformed node or string: ...
    

Использование eval() совместно с input()

В Python 3.x встроенная функция input() читает пользовательский ввод из командной строки, преобразует его в строку, удаляет завершающий символ новой строки и возвращает результат вызывающей стороне. Поскольку результатом input() является строка, ее можно передать в eval() и выполнить как выражение Python:

        >>> eval(input("Введите математическое выражение: "))
Введите математическое выражение: 15*2
30
    

Это распространенный вариант использования eval(). Он также эмулирует поведеие input() в версиях Python 2.x, где функции можно было передать строковое выражение для выполнения (впоследствии от этого отказались из соображений безопасности).

Построим обработчик математических выражений

Итак, мы узнали, как работает eval() в Python и как использовать функцию на практике. Мы также выяснили, что eval() имеет важные последствия для безопасности и что обычно считается хорошей практикой избегать использования eval() в коде. Однако в некоторых ситуациях eval() может сэкономить много времени и усилий.

В этом разделе вы напишем приложение для оценки математических выражений на лету. Без использования eval(), нам потребовалось бы выполнить следующие шаги:

  1. Распарсить входное выражение.
  2. Преобразовать компоненты выражения в объекты Python (числа, операторы, функции).
  3. Объединить всё в исполняемое выражение.
  4. Проверить валидность выражения для Python.
  5. Выполнить итоговое выражение и вернуть результат вычислений.

Это потребовало бы большой работы, учитывая разнообразие возможных выражений, которые Python может обрабатывать. К счастью, теперь мы знаем о функции eval().

Всё приложение будет храниться в скрипте mathrepl.py. Постепенно мы его заполним необходимым содержимым. Начнем со следующего кода:

mathrepl.py
        import math

__version__ = "1.0"

ALLOWED_NAMES = {
    k: v for k, v in math.__dict__.items() if not k.startswith("__")
}

PS1 = "mr>>"

WELCOME = f"""
MathREPL {__version__} - обработчик математических выражений на Python!
Введите математическое выражение после приглашения "{PS1}".
Для дополнительной информации используйте команду help.
Чтобы выйти, наберите quit или exit.
"""

USAGE = f"""
Соберите математическое выражение из чисел и операторов.
Можно использовать любые из следующих функций и констант:

{', '.join(ALLOWED_NAMES.keys())}
"""
    

Модуль math мы используем для того, чтобы определить все доступные имена. Три строковые константы применяются для вывода строк в интерфейсе программы. Напишем ключевую функцию нашей программы:

mathrepl.py
        def evaluate(expression):
    """Вычисляет математическое выражение."""
    # Компиляция выражения в байт-код
    code = compile(expression, "<string>", "eval")

    # Валидация доступных имен
    for name in code.co_names:
        if name not in ALLOWED_NAMES:
            raise NameError(f"The use of '{name}' is not allowed")

    return eval(code, {"__builtins__": {}}, ALLOWED_NAMES)
    

Осталось лишь написать код для взаимодействия с пользователем. В функции main() мы определяем основной цикл программы для чтения введенных данных и расчета математических выражений, введенных пользователем:

mathrepl.py
        def main():
    """Читает и рассчитывает введенное выражение"""
    print(WELCOME)
    while True:
        # Читаем пользовательский ввод
        try:
            expression = input(f"{PS1} ")
        except (KeyboardInterrupt, EOFError):
            raise SystemExit()

        # Поддержка специальных команд
        if expression.lower() == "help":
            print(USAGE)
            continue
        if expression.lower() in {"quit", "exit"}:
            raise SystemExit()

        # Вычисление выражения и обработка ошибок
        try:
            result = evaluate(expression)
        except SyntaxError:
            # Некорректное выражение
            print("Вы ввели некорректное выражение.")
            continue
        except (NameError, ValueError) as err:
            # Если пользователь попытался использовать неразрешенное имя
            # или неверное значение в переданной функции
            print(err)
            continue

        # Выводим результат, если не было ошибок
        print(f"Результат: {result}")
    

Проверим результат нашей работы:

Shell
        python3 mathrepl.py

MathREPL 1.0 - обработчик математических выражений на Python!
Введите математическое выражение после приглашения "mr>>".
Для дополнительной информации используйте команду help.
Чтобы выйти, наберите quit или exit.

mr>> 25 * 2
Результат: 50
mr>> sqrt(25)
Результат: 5.0
mr>> pi
Результат: 3.141592653589793
mr>> 5 * (25 + 4
Вы ввели некорректное выражение.
mr>> sum([1, 2, 3, 4, 5])
The use of 'sum' is not allowed
mr>> sqrt(-15)
math domain error
mr>> factorial(-15)
factorial() not defined for negative values
mr>> exit
    

Вот и всё – наш обработчик математических выражений готов! В случае ошибок при вводе или математически некорректных выражений мы получаем необходимое пояснение. Для самой обработки введенных данных потребовалось лишь несколько строк и функция eval().

Заключение

Итак, вы можете использовать eval() для выполнения выражений Python из строкового или кодового ввода. Эта встроенная функция полезна, когда вы пытаетесь динамически обновлять выражения Python и хотите избежать проблем с созданием собственного обработчика выражений. Однако пользоваться ей стоит с осторожностью.

***

Другие наши недавние статьи с подробным разбором различных аспектов стандартной библиотеки Python:

Рекомендуем также обратить внимание на учебный курс по Python от «Библиотеки программиста».

Источники

МЕРОПРИЯТИЯ

Комментарии

ВАКАНСИИ

Добавить вакансию
Разработчик С#
от 200000 RUB до 400000 RUB
Go-разработчик
по итогам собеседования

ЛУЧШИЕ СТАТЬИ ПО ТЕМЕ