Math Engine, интерпретатор выражений для Python без eval()
Созданный с нуля безопасный движок вычисления математических выражений: токенизатор, парсер рекурсивного спуска, AST, решатель линейных уравнений и типобезопасная система вывода, полностью без Python-функции eval(). Опубликован на PyPI, 399 тестов, 90% покрытие, зелёная сборка на пяти версиях Python.
Задача
Очевидный способ вычислить в 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 строками тестов, плюс полная техническая документация и каталогизированная система ошибок.
Другие проекты
Multi-Process Browser Automation Framework
Python-фреймворк на 17k LOC для параллельных отказоустойчивых browser-воркфлоу, race-safe координация воркеров, cross-process crash-bridge и полный operator-UX через Streamlit.
Book Lister AI
Настольное приложение: сканирует подержанные книги меньше чем за 30 секунд, распознаёт через Gemini Vision, устанавливает цену в реальном времени и публикует на eBay, +400 % к производительности.
Реверс-инжиниринг и миграция legacy-БД
1,47 млн деталей извлечены из защищённой паролем БД производителя объёмом 1,2 ГБ и перенесены в новую систему клиента, ноль нарушений, полностью аудируемо.