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 →