Zum Inhalt springen
← Zurück zur Übersicht
Recursive-Descent-Parser AST pytest PyPI Open Source

Math Engine, eval()-freier Ausdrucks-Interpreter für Python

Eine von Grund auf gebaute, sichere Auswertungs-Engine für mathematische Ausdrücke: Tokenizer, Recursive-Descent-Parser, AST, linearer Gleichungslöser und typsicheres Ausgabesystem, ganz ohne Pythons eval(). Live auf PyPI, 399 Tests, 90 % Coverage, grün über fünf Python-Versionen.

PyPI version Python Versions codecov PyPI Downloads

Die Herausforderung

Der naheliegende Weg, in Python einen Ausdruck wie 3 + 4 * 2 auszuwerten, ist eine einzige Zeile: eval("3 + 4 * 2"). Genau diese Zeile ist das Problem. eval() führt beliebigen Python-Code aus, eine als Zahleneingabe getarnte Zeichenkette wie __import__('os').system('rm -rf …') wird klaglos ausgeführt. Für jede Anwendung, die Ausdrücke aus einer Datei, einem Formularfeld, einer API oder einem Konfigurations-String entgegennimmt, ist eval() damit ein direkter Code-Execution-Vektor, kein Taschenrechner.

Der zweite, leisere Defekt ist Korrektheit. eval() und Pythons float rechnen binär: 0.1 + 0.2 ergibt 0.30000000000000004, 1/3 ist abgeschnitten, große Ganzzahlen kippen in wissenschaftliche Notation. Für einen Taschenrechner, eine Finanz-Formel oder einen Lehrkontext ist das nicht „fast richtig", sondern falsch.

Der dritte Defekt ist Diagnostik. Wer eval() einen kaputten Ausdruck gibt, bekommt einen Python-Traceback an einer internen Zeilennummer, nicht die Stelle im Eingabe-String, an der das Problem sitzt. Für ein Tool, das Endnutzer-Eingaben verarbeitet, ist das unbrauchbar.

Die Aufgabe also: eine vollständige Auswertungs-Engine von Grund auf, die (1) niemals fremden Code ausführt, (2) exakt statt binär-näherungsweise rechnet, (3) jeden Fehler zeichengenau lokalisiert, und (4) das alles in Bibliotheks-Qualität, getestet, dokumentiert, versioniert und auf PyPI installierbar. Nicht ein Wochenend-Parser, sondern eine Engine mit der Disziplin eines kleinen Compilers.

Die Umsetzung

eval()-frei per Konstruktion

Die gesamte Bibliothek ruft an keiner Stelle Pythons eval(), exec() oder compile() auf, das ist kein nachträglicher Filter, sondern die Architektur selbst. Eingabe-Strings durchlaufen eine geschlossene Pipeline (Input → Tokenizer → Parser → Evaluator/Solver → Formatter → Output-Converter), deren Alphabet ein endliches Set aus Zahlen, Operatoren, Klammern und einer Whitelist von Funktionsnamen ist. Ein angreifergesteuerter String kann im schlimmsten Fall einen typisierten MathError auslösen, niemals Code-Ausführung. Selbst die einzige Stelle, die eine vom Nutzer gelieferte Datenstruktur parst, nutzt das sichere ast.literal_eval, das ausschließlich Literale akzeptiert.

Recursive-Descent-Parser mit 10-Ebenen-Präzedenzkette

Operator-Präzedenz wird nicht per Regex oder Shunting-Yard-Tabelle hineingehackt, sondern strukturell als zehn ineinander geschachtelte Parser-Closures kodiert, jede mit genau einer Präzedenzebene: von parse_gleichung (=) über bitweise Operatoren, Schiebeoperationen, Summe und Term bis zu parse_power (**) und parse_factor. Links- vs. Rechtsassoziativität fällt aus der Struktur: wer in einer Schleife konsumiert, ist linksassoziativ (a - b - c = (a - b) - c); parse_power rekursiert nach rechts und macht ** korrekt rechtsassoziativ. Eine bewusste Entscheidung: ^ ist Bitwise XOR, nicht Potenz, exakt wie in C und Python.

Decimal-Präzision mit dynamischem Scaling

Jede Zahl ist vom Tokenizer bis zur Ausgabe ein decimal.Decimal, nie ein float, deshalb ist 0.1 + 0.2 exakt 0.3. Die Präzision des Decimal-Kontexts wird pro Berechnung neu bestimmt (zwischen 100 und 10.000 Stellen, je nach Eingabe), dazu eine harte Eingabe-Obergrenze von 20.000 Stellen. Der Sinn: ein langes Ergebnis wird nie still abgeschnitten, ein kurzes verbraucht nie unnötig Speicher. Genau die Klasse Korrektheit, die float-basierte Rechner hier leise verlieren.

Zeichen-genaue Fehlerpositionierung

Parallel zur Token-Liste führt der Tokenizer eine Span-Liste: zu jedem Token ein (start_col, end_col, original_text)-Tripel. Jeder AST-Knoten und jeder MathError trägt position_start / position_end. Der Ertrag: ein Fehler sagt nicht „Syntaxfehler irgendwo", sondern zeigt auf das exakte Zeichen. Diese Buchführung ist der Grund, warum die Engine über eine API hinweg debugbar ist. Über ein einziges Setting (readable_error) schaltet dieselbe Positionsinfo zwischen zwei Verträgen um: typisierte Exceptions für die Bibliothek, eine visuelle Diagnostik mit ^-Zeiger unter der fehlerhaften Spalte für die Konsole.

Typisiertes, katalogisiertes Fehlersystem

Eine Basisklasse MathError plus genau sieben Domänen-Subklassen, darunter ein Katalog von 78 eindeutigen, vierstelligen Fehlercodes in neun Familien. Die Ziffern sind strukturiert: erste Ziffer = Familie, zweite = Komponente, Rest = laufende Nummer. Code 3008 heißt damit „Calculator-Familie, Kern-Parser, mehr als ein '.' in einer Zahl". Diese Codes werden bewusst nie umnummeriert, sie sind Vertrag gegenüber UI und externen Log-Parsern. Die öffentliche calculate()-Funktion kapselt die ganze Pipeline in einem geschichteten except-Block, sodass kein roher ZeroDivisionError oder ValueError je den Aufrufer erreicht, alles landet typisiert in der MathError-Hierarchie.

Mehr als ein Taschenrechner

Auf demselben AST sitzen zwei weitere Fähigkeiten. Enthält ein Ausdruck ein = und eine Variable, löst die Engine die lineare Gleichung symbolisch: jeder Knoten liefert ein (Faktor, Konstante)-Paar, der Solver bringt beide Seiten in die Form A·x + B = C·x + D und berechnet x. Nicht-Linearität wird strukturell abgefangen (Variable·Variable, Variable im Nenner, Variable im Exponenten), entartete Fälle sauber benannt („No Solution", „Inf. Solutions"). Dazu ein Programmer's-Calculator-Modus mit fester Wortbreite (8/16/32/64 Bit), Zweierkomplement und bitweisen Operatoren, sodass 127 + 1 im 8-Bit-signed-Modus korrekt zu -128 überläuft. Ein prefix-gesteuertes Output-System (dec:, int:, hex: …) bestimmt den Python-Rückgabetyp und verweigert verlustbehaftete Konvertierungen, statt still abzuschneiden.

Engineering Highlights & Test-Disziplin

Zuverlässigkeit war hier kein Feature, sondern die Daseinsberechtigung, eine sichere Engine, der man nicht vertrauen kann, ist nutzlos.

  • 399 pytest-Tests, 90 % Coverage. Die Suite wurde von 234 auf 399 Tests erweitert, die Coverage von 69 % auf 90 % gehoben. Ein eigener Helper assert_error_location(expr, code, start, end) prüft nicht nur, dass ein Ausdruck fehlschlägt, sondern dass er mit dem exakten Fehlercode an der exakten Zeichenposition fehlschlägt, die Positionsdaten sind selbst Teil des Test-Vertrags.
  • CI-Matrix über fünf Python-Versionen. GitHub Actions fährt die volle Suite bei jedem Push und Pull Request gegen Python 3.8, 3.9, 3.10, 3.11 und 3.12; der Coverage-Report geht an Codecov. Toter und Work-in-Progress-Code wird ehrlich aus der Coverage genommen, statt die Zahl zu schönen.
  • Saubere Schichtung, gebrochene Zyklen. Klar getrennte Module (calculator / utility / cli / plugins); zirkuläre Importe werden über bewusst verzögerte Importe aufgelöst. Jede Klasse und Funktion trägt einen Docstring, eine eigenständige DOCUMENTATION.md hält Architektur, vollständige API, Parser-Interna und den kompletten Fehlercode-Katalog fest.
  • Bibliotheks-Qualität bei Auslieferung. Reines-Python-Wheel, drei Konsolen-Einstiegspunkte, exakt zwei Laufzeit-Abhängigkeiten (rich, prompt_toolkit). Das interaktive REPL bietet persistente History und Tab-Completion. Sechs Minor-Releases (0.1.0 → 0.6.7) in rund fünf Monaten, durchgehend nach Semantic Versioning.

Das Ergebnis

  • Live auf PyPI als math-engine, installierbar per pip install math-engine, MIT-lizenziert, reines-Python-Wheel für Python 3.8+, mit drei Konsolen-Kommandos out of the box.
  • eval()-frei per Konstruktion. Geschlossenes Eingabe-Alphabet, der schlimmste Fall einer feindlichen Eingabe ist ein typisierter Fehler, niemals Code-Ausführung.
  • 399 Tests, 90 % Coverage, grün über fünf Python-Versionen (3.8–3.12) bei jedem Push, mit Test-Fällen, die exakte Fehlercodes an exakten Zeichenpositionen festschreiben.
  • Exakte Decimal-Arithmetik mit adaptiver Präzision (100 … 10.000 Stellen) und 20.000-Stellen-Eingabegrenze, keine stille Float-Drift, keine stille Trunkierung.
  • Zeichengenaue Diagnostik: 78 Fehlercodes in neun Familien, achtklassige typisierte Exception-Hierarchie, position_start / position_end an jedem Fehler.
  • Rund 4.200 LOC Produktionscode in sauber geschichteten Modulen, abgesichert durch ~2.400 LOC Tests, dazu eine vollständige technische Dokumentation und ein katalogisiertes Fehlersystem.