Перейти к содержимому
← Все записи

Open Source

Math Engine: интерпретатор выражений для Python без eval()

Проект

Созданный с нуля движок вычисления математических выражений, полностью без eval() из Python: токенизатор, парсер рекурсивного спуска, точная Decimal-арифметика и посимвольная диагностика ошибок. Теперь опубликован на PyPI, 399 тестов и 90 % покрытие.

PyPI · v0.6.6 Tests · 399 Coverage · 90 % eval()-Aufrufe · 0

Вычислить число, которое приходит в виде текста, например 3 + 4 * 2 из поля формы, файла конфигурации или API, звучит как одна строка на Python: eval("3 + 4 * 2"). Именно эта строка и есть проблема. С помощью Math Engine я с нуля построил полноценный движок вычислений, который считает математические выражения безопасно, точно и наглядно, полностью без Python-функции eval(). С этой недели он опубликован как версия 0.6.6 на PyPI.

Почему не просто eval()

eval() выполняет произвольный код на Python. Замаскированный под число ввод вроде __import__('os').system(...) будет выполнен без единого вопроса. Для любого приложения, которое принимает выражения из файла, формы или API, eval() тем самым не калькулятор, а открытый путь к выполнению кода.

К этому добавляются два более тихих дефекта. Во-первых, корректность: Python-тип float считает в двоичной системе, 0.1 + 0.2 даёт 0.30000000000000004. Для финансовой формулы или учебного контекста это не почти правильно, а неверно. Во-вторых, диагностика: сломанное выражение возвращает Python-трейсбек на внутреннем номере строки, а не то место во входной строке, где действительно сидит проблема.

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

Безопасно, потому что иначе и не может

Движок нигде не вызывает eval(), exec() или compile(). Это не фильтр, добавленный задним числом, а сама архитектура. Каждый ввод проходит через замкнутый конвейер:

Input → Tokenizer → Parser → Evaluator → Formatter → Output

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

Как парсер понимает приоритет

Умножение перед сложением, скобки в первую очередь: приоритет операторов в Math Engine не вколочен через таблицу, а закодирован как структура. Парсер рекурсивного спуска вкладывает друг в друга десять уровней приоритета, от присваивания через побитовые операторы и сложение до возведения в степень. Левоассоциативен оператор или правоассоциативен, следует из этой структуры само собой: a - b - c читается как (a - b) - c, а 2 ** 3 ** 2, наоборот, связывается вправо, ровно как в Python.

Осознанное решение при этом: ^, это побитовый XOR, а не возведение в степень. Возводят в степень через **, ровно как в C и Python, чтобы движок вёл себя так, как ожидает программист.

Точно вместо почти правильно

Каждое число от первой до последней ступени, это decimal.Decimal, никогда не float. Поэтому 0.1 + 0.2 здесь в точности равно 0.3. Точность вычислений определяется заново для каждого вычисления, от 100 до 10 000 знаков в зависимости от ввода, с жёстким верхним пределом в 20 000 знаков. Так длинный результат никогда не обрезается молча, а короткий не занимает память без необходимости.

Ошибки, которые указывают на символ

Параллельно токенам токенизатор ведёт для каждого спан: начальный столбец, конечный столбец, оригинальный текст. Эта позиция проходит через всё синтаксическое дерево и в конце висит на каждой ошибке. Результат, это диагностика, которая не сообщает «синтаксическая ошибка где-то», а указывает на виновный символ.

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

12 + * 3
     ^
здесь ожидалось значение, а не оператор

Под этим лежит каталогизированная система из 78 четырёхзначных кодов ошибок в девяти семействах. Цифры структурированы: код 3008 означает «Calculator, ядро парсера, более одной точки в числе». Эти коды осознанно никогда не перенумеровываются, они являются контрактом перед интерфейсом и перед внешними парсерами логов.

Больше, чем калькулятор

На том же синтаксическом дереве сидят ещё две возможности. Если выражение содержит = и переменную, движок решает линейное уравнение символически, вместо того чтобы гадать, включая чисто именованные особые случаи для «нет решения» и «бесконечно много решений». А для низкоуровневой арифметики есть режим Programmer's Calculator с фиксированной разрядностью слова от 8 до 64 бит, дополнительным кодом и побитовыми операторами, так что 127 + 1 в 8-битном режиме корректно переполняется в -128.

Проверено, версионировано, поставлено

Безопасный движок, которому нельзя доверять, бесполезен. За примерно 4 200 строками продакшн-кода поэтому стоит набор из 399 pytest-тестов при 90% покрытии. Собственный хелпер проверяет не только то, что выражение завершается ошибкой, но и то, что оно завершается ошибкой с точным кодом ошибки на точной позиции символа. GitHub Actions прогоняет полный набор при каждом push против пяти версий Python, от 3.8 до 3.12.

Поставляется всё это как чистый Python-wheel всего с двумя зависимостями и тремя консольными командами, включая интерактивный REPL с историей и автодополнением по Tab. Шесть релизов примерно за пять месяцев, сквозь и по правилам Semantic Versioning.

Попробовать самому

Math Engine открыт под лицензией MIT и устанавливается в одну строку:

pip install math-engine

Код лежит на GitHub, пакет на PyPI. Та же инженерная дисциплина, которая здесь несёт опубликованную библиотеку, заложена и в клиентских проектах: безопасная обработка выражений без eval() для DSL, редакторов формул и rule-движков.