Перейти к содержимому
← Назад к обзору
Recursive-Descent-Parser AST pytest PyPI Open Source

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

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

PyPI version Python Versions codecov PyPI Downloads

Задача

Очевидный способ вычислить в Python выражение вроде 3 + 4 * 2, это одна строка: eval("3 + 4 * 2"). Именно эта строка и есть проблема. eval() выполняет произвольный код на Python, и замаскированная под ввод числа строка вроде __import__('os').system('rm -rf …') будет выполнена без единого вопроса. Для любого приложения, которое принимает выражения из файла, поля формы, API или строки конфигурации, eval() тем самым является прямым вектором выполнения кода, а не калькулятором.

Второй, более тихий дефект, это корректность. eval() и Python-тип float считают в двоичной системе: 0.1 + 0.2 даёт 0.30000000000000004, 1/3 обрезается, большие целые числа уходят в научную нотацию. Для калькулятора, финансовой формулы или учебного контекста это не «почти правильно», а просто неверно.

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

Итак, задача: полноценный движок вычислений с нуля, который (1) никогда не выполняет чужой код, (2) считает точно, а не приближённо в двоичной системе, (3) локализует каждую ошибку с точностью до символа, и (4) всё это в библиотечном качестве, с тестами, документацией, версионированием и установкой через PyPI. Не парсер на выходные, а движок с дисциплиной небольшого компилятора.

Реализация

Без eval() по построению

Вся библиотека нигде не вызывает Python-функции eval(), exec() или compile(), и это не фильтр, добавленный задним числом, а сама архитектура. Входные строки проходят через замкнутый конвейер (Input → Tokenizer → Parser → Evaluator/Solver → Formatter → Output-Converter), алфавит которого, это конечный набор из чисел, операторов, скобок и белого списка имён функций. Управляемая злоумышленником строка в худшем случае может вызвать типизированную ошибку MathError, но никогда выполнение кода. Даже единственное место, которое парсит предоставленную пользователем структуру данных, использует безопасный ast.literal_eval, принимающий исключительно литералы.

Парсер рекурсивного спуска с цепочкой приоритетов из 10 уровней

Приоритет операторов не вколочен через регулярные выражения или таблицу Shunting-Yard, а закодирован структурно, как десять вложенных друг в друга парсер-замыканий, каждое ровно с одним уровнем приоритета: от parse_gleichung (=) через побитовые операторы, операции сдвига, сумму и слагаемое до parse_power (**) и parse_factor. Лево- против правоассоциативности следует из структуры: тот, кто потребляет в цикле, левоассоциативен (a - b - c = (a - b) - c); parse_power рекурсирует вправо и делает ** корректно правоассоциативным. Осознанное решение: ^, это побитовый XOR, а не возведение в степень, ровно как в C и Python.

Decimal-точность с динамическим масштабированием

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

Позиционирование ошибок с точностью до символа

Параллельно списку токенов токенизатор ведёт список спанов: к каждому токену тройку (start_col, end_col, original_text). Каждый узел AST и каждая MathError несёт position_start / position_end. Выигрыш: ошибка не говорит «синтаксическая ошибка где-то», а указывает на точный символ. Этот учёт, это причина, по которой движок отлаживается через API. Через единственную настройку (readable_error) одна и та же позиционная информация переключается между двумя контрактами: типизированные исключения для библиотеки и визуальная диагностика с указателем ^ под ошибочным столбцом для консоли.

Типизированная, каталогизированная система ошибок

Базовый класс MathError плюс ровно семь доменных подклассов, среди них каталог из 78 уникальных четырёхзначных кодов ошибок в девяти семействах. Цифры структурированы: первая цифра = семейство, вторая = компонент, остальное = порядковый номер. Код 3008 тем самым означает «семейство Calculator, ядро парсера, более одной '.' в числе». Эти коды осознанно никогда не перенумеровываются, они являются контрактом перед UI и внешними парсерами логов. Публичная функция calculate() инкапсулирует весь конвейер в многослойном блоке except, так что ни один сырой ZeroDivisionError или ValueError никогда не достигнет вызывающего кода, всё попадает типизированным в иерархию MathError.

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

На том же AST сидят ещё две возможности. Если выражение содержит = и переменную, движок решает линейное уравнение символически: каждый узел отдаёт пару (коэффициент, константа), решатель приводит обе стороны к виду A·x + B = C·x + D и вычисляет x. Нелинейность отлавливается структурно (переменная·переменная, переменная в знаменателе, переменная в показателе степени), вырожденные случаи чисто именуются («No Solution», «Inf. Solutions»). Плюс режим Programmer's Calculator с фиксированной разрядностью слова (8/16/32/64 бит), дополнительным кодом и побитовыми операторами, так что 127 + 1 в 8-битном знаковом режиме корректно переполняется в -128. Управляемая префиксами система вывода (dec:, int:, hex: …) определяет тип возвращаемого Python-значения и отказывается от конверсий с потерями вместо того, чтобы молча обрезать.

Инженерные особенности и дисциплина тестирования

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

  • 399 pytest-тестов, 90% покрытие. Набор был расширен с 234 до 399 тестов, покрытие поднято с 69% до 90%. Собственный хелпер assert_error_location(expr, code, start, end) проверяет не только то, что выражение завершается ошибкой, но и то, что оно завершается ошибкой с точным кодом ошибки на точной позиции символа, позиционные данные сами являются частью тестового контракта.
  • CI-матрица на пяти версиях Python. GitHub Actions прогоняет полный набор при каждом push и pull request против Python 3.8, 3.9, 3.10, 3.11 и 3.12; отчёт о покрытии уходит в Codecov. Мёртвый и незавершённый код честно исключается из покрытия вместо того, чтобы приукрашивать цифру.
  • Чистое расслоение, разорванные циклы. Чётко разделённые модули (calculator / utility / cli / plugins); циклические импорты разрешаются через осознанно отложенные импорты. Каждый класс и функция несёт docstring, отдельная DOCUMENTATION.md фиксирует архитектуру, полный API, внутренности парсера и весь каталог кодов ошибок.
  • Библиотечное качество при поставке. Чистый Python-wheel, три консольные точки входа, ровно две зависимости времени выполнения (rich, prompt_toolkit). Интерактивный REPL предлагает постоянную историю и автодополнение по Tab. Шесть минорных релизов (0.1.0 → 0.6.7) примерно за пять месяцев, сквозь и по правилам Semantic Versioning.

Результат

  • Опубликован на PyPI как math-engine, устанавливается через pip install math-engine, под лицензией MIT, чистый Python-wheel для Python 3.8+, с тремя консольными командами из коробки.
  • Без eval() по построению. Замкнутый входной алфавит, худший случай враждебного ввода, это типизированная ошибка, никогда не выполнение кода.
  • 399 тестов, 90% покрытие, зелёная сборка на пяти версиях Python (3.8–3.12) при каждом push, с тест-кейсами, фиксирующими точные коды ошибок на точных позициях символов.
  • Точная Decimal-арифметика с адаптивной точностью (100 … 10 000 знаков) и пределом ввода в 20 000 знаков, без тихого float-дрейфа, без тихого усечения.
  • Диагностика с точностью до символа: 78 кодов ошибок в девяти семействах, восьмиуровневая типизированная иерархия исключений, position_start / position_end на каждой ошибке.
  • Около 4 200 строк продакшн-кода в чисто расслоённых модулях, защищённых примерно 2 400 строками тестов, плюс полная техническая документация и каталогизированная система ошибок.