Skip to content

Math Evaluator — Python source

Evaluate math expressions - arithmetic, functions (sqrt, sin, log), comparisons, and constants (pi, e) - safely, in real time. Input is constrained to math-safe characters, fully client-side.

This is the Python implementation — the same logic the interactive tool runs, in a shareable, citable form.

"""math-evaluator — safe arithmetic expression evaluator (no eval).

Language: Python (3.9+, standard library only)
Source:   CosmoDev polyglot showcase port of the Math Evaluator tool, ported
          from cli/math-evaluator/math-evaluator.go (the Go CLI twin) which
          itself mirrors src/lib/math-evaluator.ts.
License:  display source — part of CosmoDev's polyglot tool pages.

Design goals:
  - Pure + deterministic; never raises (public API returns Optional[str]).
  - Functionally equivalent to the Go twin: same inputs -> same outputs.
  - Self-contained: stdlib only (no pip packages — and notably NO mathjs,
    which the TS lib uses; the snippet ports the Go twin's hand-rolled
    evaluator onto the Python ``math`` module instead).

The input guard (SAFE_CHARS allowlist + FORBIDDEN word blacklist + MAX_LEN
200) is COPIED VERBATIM from src/lib/math-evaluator.ts — every port must
accept and reject exactly the same inputs. Grammar (precedence low -> high):
comparison (< >) -> additive (+ -) -> multiplicative (* / %) -> exponent
(^, right-assoc) -> unary (- prefix) -> postfix factorial (!) -> primary
(number, parens, function call, constants pi/e). Functions: sqrt, sin, cos,
tan, log (natural log / ln), abs, exp. Constants: pi, e (case-insensitive).
A bare function name with no call mirrors mathjs returning a function
object, so evaluate returns None.
"""
from __future__ import annotations

import math
import re
from typing import List, Optional, Tuple, Union

# COPIED VERBATIM from src/lib/math-evaluator.ts — the code-injection guard.
SAFE_CHARS = re.compile(r"^[0-9+\-*/().,\s a-zA-Z%^!<>]+$")
FORBIDDEN = re.compile(
    r"\b(import|require|eval|function|while|for|process|global|"
    r"this|window|document|constructor)\b"
)
MAX_LEN = 200  # mirrors MAX_LEN in src/lib/math-evaluator.ts

# Recognized single-arg functions, mapped onto the math built-ins.
_FUNCTIONS = {
    "sqrt": math.sqrt,
    "sin": math.sin,
    "cos": math.cos,
    "tan": math.tan,
    "log": math.log,  # natural log / ln
    "abs": abs,
    "exp": math.exp,
}

# Named constants (looked up case-insensitively).
_CONSTANTS = {"pi": math.pi, "e": math.e}

# Sentinel marking a bare function reference (mirrors mathjs function objects).
# A unique object is falsy-proof: it is neither a number nor a bool.
_FUNC = object()


class _ParseError(Exception):
    """Raised internally for any parse/eval failure; the public API swallows it."""


def _is_digit(ch: str) -> bool:
    return "0" <= ch <= "9"


def _is_alpha(ch: str) -> bool:
    return ("a" <= ch <= "z") or ("A" <= ch <= "Z")


def _factorial(n: float) -> Optional[float]:
    """n! for a non-negative integer-valued float; None for negative/non-integer.

    171!+ overflows float64 -> +inf, matching JS."""
    if n < 0 or n != math.trunc(n):
        return None
    if n > 170:
        return math.inf
    r = 1.0
    i = 2.0
    while i <= n:
        r *= i
        i += 1.0
    return r


def _tokenize(s: str) -> Optional[List[Tuple[str, object]]]:
    """Convert the (already SAFE_CHARS-validated) expression into a token list
    terminated by ("eof", None). Returns None on a malformed number/unexpected
    char (the latter unreachable given the allowlist)."""
    toks: List[Tuple[str, object]] = []
    i = 0
    n = len(s)
    while i < n:
        c = s[i]
        if c in " \t\n\r\v\f":
            i += 1
            continue
        if _is_digit(c) or c == ".":
            start = i
            while i < n and (_is_digit(s[i]) or s[i] == "."):
                i += 1
            try:
                num = float(s[start:i])
            except ValueError:
                return None
            if num != num:  # NaN from a stray '.' parse — reject
                return None
            toks.append(("num", num))
            continue
        if _is_alpha(c):
            start = i
            while i < n and _is_alpha(s[i]):
                i += 1
            toks.append(("ident", s[start:i]))
            continue
        single = {
            "+": "plus", "-": "minus", "*": "star", "/": "slash", "%": "percent",
            "^": "caret", "!": "bang", "<": "lt", ">": "gt",
            "(": "lparen", ")": "rparen", ",": "comma",
        }.get(c)
        if single is None:
            return None
        toks.append((single, None))
        i += 1
    toks.append(("eof", None))
    return toks


def _to_num(v: object) -> float:
    """Coerce a value to float. Booleans coerce to 1.0/0.0 (matching mathjs).

    NOTE: bool is an int subclass in Python, so check it before numbers."""
    if isinstance(v, bool):
        return 1.0 if v else 0.0
    if isinstance(v, (int, float)):
        return float(v)
    raise _ParseError("cannot use a function as a number")


class _Parser:
    """Recursive-descent parser over a token list. Each rule returns a value
    (float | bool | _FUNC)."""

    def __init__(self, toks: List[Tuple[str, object]]) -> None:
        self.toks = toks
        self.pos = 0

    def _peek(self) -> Tuple[str, object]:
        return self.toks[self.pos]

    def _next(self) -> Tuple[str, object]:
        """Advance past the current token but never past the trailing eof."""
        t = self.toks[self.pos]
        if self.pos < len(self.toks) - 1:
            self.pos += 1
        return t

    def comparison(self) -> object:
        # Lowest precedence: handles < and >, yielding a bool.
        left = self.additive()
        while True:
            kind = self._peek()[0]
            if kind != "lt" and kind != "gt":
                break
            self._next()
            right = self.additive()
            res = _to_num(left) < _to_num(right) if kind == "lt" else _to_num(left) > _to_num(right)
            left = bool(res)
        return left

    def additive(self) -> object:
        # Handles + and - (left-associative).
        left = self.multiplicative()
        while True:
            kind = self._peek()[0]
            if kind != "plus" and kind != "minus":
                break
            self._next()
            right = self.multiplicative()
            ln, rn = _to_num(left), _to_num(right)
            left = ln + rn if kind == "plus" else ln - rn
        return left

    def multiplicative(self) -> object:
        # Handles *, /, and % (modulo) — left-associative.
        left = self.exponent()
        while True:
            kind = self._peek()[0]
            if kind not in ("star", "slash", "percent"):
                break
            self._next()
            right = self.exponent()
            ln, rn = _to_num(left), _to_num(right)
            if kind == "star":
                left = ln * rn
            elif kind == "slash":
                left = ln / rn
            else:
                left = math.fmod(ln, rn)  # matches Go's math.Mod
        return left

    def exponent(self) -> object:
        # Handles ^ (right-associative, so it recurses on itself).
        left = self.unary()
        if self._peek()[0] == "caret":
            self._next()
            right = self.exponent()
            return math.pow(_to_num(left), _to_num(right))
        return left

    def unary(self) -> object:
        # Prefix - (negation) and + (no-op). Recurses to handle --5 etc.
        kind = self._peek()[0]
        if kind == "minus":
            self._next()
            return -_to_num(self.unary())
        if kind == "plus":
            self._next()
            return self.unary()
        return self.postfix()

    def postfix(self) -> object:
        # Trailing ! (factorial), applied after the primary.
        v = self.primary()
        while self._peek()[0] == "bang":
            self._next()
            f = _factorial(_to_num(v))
            if f is None:
                raise _ParseError("factorial requires a non-negative integer")
            v = f
        return v

    def primary(self) -> object:
        # Number, parenthesized expression, function call, or constant.
        kind, value = self._next()
        if kind == "num":
            return value
        if kind == "lparen":
            v = self.comparison()
            if self._next()[0] != "rparen":
                raise _ParseError("expected )")
            return v
        if kind == "ident":
            name = str(value).lower()
            if self._peek()[0] == "lparen":
                return self._call(name)
            if name == "pi":
                return _CONSTANTS["pi"]
            if name == "e":
                return _CONSTANTS["e"]
            if name in _FUNCTIONS:
                return _FUNC
            raise _ParseError("undefined symbol")
        raise _ParseError("unexpected token")

    def _call(self, name: str) -> object:
        # Parse a function call "name(arg, arg, ...)" whose LParen was peeked.
        self._next()  # consume (
        args: List[float] = []
        if self._peek()[0] != "rparen":
            while True:
                args.append(_to_num(self.comparison()))
                if self._peek()[0] == "comma":
                    self._next()
                    continue
                break
        if self._next()[0] != "rparen":
            raise _ParseError("expected )")
        return _call_function(name, args)


def _call_function(name: str, args: List[float]) -> float:
    """Dispatch a call to its implementation. All functions take exactly one arg."""
    fn = _FUNCTIONS.get(name)
    if fn is None:
        raise _ParseError("unknown function")
    if len(args) != 1:
        raise _ParseError(f"{name} expects 1 argument")
    return float(fn(args[0]))


def _format_number(x: float) -> str:
    """Format a float to match JS String(number): integer-valued floats drop the
    decimal point (3.0 -> "3"), other floats use Python's shortest round-trip
    repr (4.6 -> "4.6", 0.1+0.2 -> "0.30000000000000004"), and non-finite
    values mirror JS ("Infinity"/"-Infinity"/"NaN")."""
    if math.isnan(x):
        return "NaN"
    if math.isinf(x):
        return "Infinity" if x > 0 else "-Infinity"
    if x == math.trunc(x) and abs(x) < 1e16:
        return str(int(x))
    return repr(x)


def evaluate(expr: str) -> Optional[str]:
    """Evaluate a math expression to a display string, or None if unsafe/invalid.

    Mirrors evaluateExpression in src/lib/math-evaluator.ts. Pure; never raises.
    Returns None when the input is empty, over-long, contains disallowed
    characters or forbidden words, is a bare function reference, or fails to
    parse/evaluate."""
    trimmed = expr.strip()
    if trimmed == "" or len(trimmed) > MAX_LEN:
        return None
    if not SAFE_CHARS.match(trimmed) or FORBIDDEN.search(trimmed):
        return None
    toks = _tokenize(trimmed)
    if toks is None:
        return None
    parser = _Parser(toks)
    try:
        result = parser.comparison()
        if parser._peek()[0] != "eof":  # leftover tokens (e.g. "1 2")
            return None
    except _ParseError:
        return None
    if result is _FUNC:
        return None
    if isinstance(result, bool):  # must precede the number branch (bool ⊂ int)
        return "true" if result else "false"
    return _format_number(float(result))


# ---------- showcase tests (the canonical suite lives in src/lib) ----------
if __name__ == "__main__":
    def _check(actual: object, expected: object, label: str) -> None:
        if actual != expected:
            raise SystemExit(f"FAIL {label}: expected {expected!r}, got {actual!r}")
        print(f"ok   {label} -> {actual!r}")

    _check(evaluate("1 + 2"), "3", "basic arithmetic")
    _check(evaluate("2 * 3 + 4"), "10", "precedence (* before +)")
    _check(evaluate("2 ^ 10"), "1024", "exponent")
    _check(evaluate("sqrt(16)"), "4", "function call")
    _check(evaluate("2 > 1"), "true", "comparison -> boolean string")
    _check(evaluate("1 +"), None, "parse error -> None")
    print("all showcase tests passed")

Also available in 13 other languages

Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →