alkahest
Core concepts

Parsing expressions from strings

alkahest.parse converts a human-readable math string into an Expr node using a Pratt (top-down operator precedence) recursive-descent parser.

Python
import alkahest
from alkahest import ExprPool, parse, diff, simplify

pool = ExprPool()
x = pool.symbol("x")

e = parse("x^2 + 2*x + 1", pool, {"x": x})
print(e)                    # x^2 + 2*x + 1

dr = diff(e, x)
print(dr.value)             # 2*x + 2

Syntax

Form Meaning
42, 3.14, 1.5e-3 Integer or float literal
x, alpha, x_1 Symbol (created in pool on first use)
a + b, a - b Addition / subtraction
a * b, a / b Multiplication / division
a ^ b, a ** b Exponentiation (right-associative)
-a, +a Unary negation / identity
(expr) Grouping
sin(x), atan2(y, x) Function call (one or two arguments)

Whitespace (spaces, tabs, newlines) is ignored everywhere.

Operator precedence

From lowest to highest:

Level Operators
10 + - (infix)
20 * /
25 Unary - +
30 ^ ** (right-associative)

So -x^2 parses as -(x^2), not (-x)^2, and x^2^3 parses as x^(2^3) = x^8.

Supported functions

abs, acos, asin, atan, atan2, ceil, cos, cosh, erf, erfc, exp, floor, gamma, log, round, sign, sin, sinh, sqrt, tan, tanh

RootOf(poly, index) and RootOf(poly, var, index) read an exact algebraic root back, in the order root_of uses (real roots ascending, then non-real by real part, then imaginary part). Its arguments are parsed against an empty symbol table, so the polynomial's variable is local to the call. The printer's placeholder x is never looked up in, or added to, your symbols map. With two arguments the polynomial must have exactly one free symbol; with three, every free symbol must be var. Symbolic coefficients are not supported and raise E-PARSE-002. parse(str(r)) == r for every RootOf the library prints.

The symbols map

By default, every new identifier is interned as a fresh pool.symbol(name). Pass a pre-built symbols dict to bind identifiers to existing Expr objects, or to collect the symbols that were created:

Python
# Pre-bind x to an existing symbol
x = pool.symbol("x")
e = parse("sin(x)^2 + cos(x)^2", pool, {"x": x})

# Collect auto-created symbols after parsing
sym_map: dict = {}
e = parse("a*x^2 + b*x + c", pool, sym_map)
print(sym_map.keys())   # dict_keys(['a', 'x', 'b', 'c'])

Identifiers not in the map are created and then added to the map, so the same string name always resolves to the same Expr within a single parse call.

Error handling

parse raises ParseError on any malformed input — never RecursionError, ValueError, TypeError or ZeroDivisionError. The code says which kind:

Code Cause
E-PARSE-001 Lexical: an unexpected character, or a number literal out of range. A float literal past the f64 exponent range (1e999999) is read at 53 bits with an arbitrary-precision exponent rather than as inf (or 1e-999999 as 0); one past even that range (about ±3·10⁸ decimal digits of exponent) is refused. Integer literals of any length are exact — Python's int-string digit limit does not apply
E-PARSE-002 Syntactic, including a wrong argument count for a built-in (sin()) and division by the literal 0 (2/0; that exception is also a ZeroDivisionError, as it was before)
E-PARSE-003 Unknown function
E-PARSE-004 Nesting deeper than 2048 levels — parentheses, prefix signs, right-associated powers and function arguments each count one. This is the same ceiling as E-DEPTH-001, and both parsers enforce it; the Python parser keeps its pending productions on a list, not the interpreter stack, so no input depth raises RecursionError

The exception's .span attribute gives the (start, end) byte range of the offending token, and .remediation provides a hint:

Python
from alkahest import ParseError

try:
    parse("sin(x) @ 2", pool, {"x": x})
except ParseError as e:
    print(e)           # unexpected character '@' at offset 7
    print(e.span)      # (7, 8)

try:
    parse("zeta(x)", pool, {"x": x})
except ParseError as e:
    print(e.remediation)  # known functions: abs, acos, asin, ...

Round-trip with pretty-printing

parse is the inverse of str() for expressions built from the operators and functions listed above:

Python
from alkahest import latex, unicode_str

e = parse("sin(x)^2 + cos(x)^2", pool, {"x": x})
print(latex(e))        # \sin\!\left(x\right)^2 + \cos\!\left(x\right)^2
print(unicode_str(e))  # sin(x)² + cos(x)²