PythonMastery
intermediate 25 min read · lesson 9 of 15 in Projects

Project: CLI Calculator (with History)

1 · The lesson

read

You'll build a command-line calculator: type 2 + 3 * 4 and get 14.0. Then we'll add history, variables and safe evaluation, so that the calculator becomes a tool you can really use.

This project teaches an important security skill: how to evaluate expressions that a user types, safely. (The short answer: never use eval() for this.)

What you'll practise: tokenizing, dispatch dicts, the operator module, ast.literal_eval, exception design, history-as-list pattern.


Step 1: The "Obvious" (but Dangerous) Version

The simplest Python solution is one line:

python
expression = "2 + 3 * 4"
result = eval(expression)
print(result)                       # 14

That works. But never give this code to real users.

eval() runs ANY Python code. If a user types __import__('os').system('rm -rf /'), your program runs a shell command that tries to delete every file on the computer. Even when you trust the user, eval is risky: a typo in the input can crash the program with an error that is hard to understand.

There are safer ways.


Step 2: ast.literal_eval for Plain Values

If you only need to parse literals (numbers, strings, lists, dicts), ast.literal_eval is the safe alternative. It does not run code.

python
import ast

print(ast.literal_eval("42"))               # 42
print(ast.literal_eval("3.14"))             # 3.14
print(ast.literal_eval("[1, 2, 3]"))        # [1, 2, 3]
print(ast.literal_eval("{'a': 1}"))         # {'a': 1}

# This raises an error, because `__import__` is not a literal:
try:
    ast.literal_eval("__import__('os')")
except (ValueError, SyntaxError) as e:
    print(f"safely refused: {e}")

But ast.literal_eval doesn't compute expressions: ast.literal_eval("2 + 3") raises ValueError, because it only accepts literal values, not calculations.

For real arithmetic, we need to parse and evaluate the expression ourselves.


Step 3: A Small Expression Evaluator (Tokenize → Compute)

The standard approach: split the input into tokens (numbers, operators, parentheses), then apply the operators.

python
import re
import operator

OPS = {
    "+": (1, operator.add),
    "-": (1, operator.sub),
    "*": (2, operator.mul),
    "/": (2, operator.truediv),
    "//": (2, operator.floordiv),
    "%": (2, operator.mod),
    "**": (3, operator.pow),
}

def tokenize(expr):
    """Split '2 + 3 * 4' into ['2', '+', '3', '*', '4']."""
    return re.findall(r"\d+\.?\d*|\*\*|//|[+\-*/%()]", expr)

def evaluate(expr):
    """Evaluate a simple expression with +, -, *, /, //, %, **."""
    tokens = tokenize(expr)
    if not tokens:
        raise ValueError("empty expression")

    # Shunting-yard: split into output (numbers) and operators
    output = []
    ops = []
    prev = None

    for tok in tokens:
        if re.match(r"\d", tok):
            output.append(float(tok))
        elif tok == "-" and (prev is None or prev in OPS or prev == "("):
            ops.append("neg")  # a minus with no number before it, as in -5 or 2 * -3
        elif tok in OPS:
            prec = _prec(tok)
            # pop operators that bind tighter; ** is right-associative, so 2 ** 3 ** 2 = 2 ** 9
            while ops and ops[-1] != "(" and (
                _prec(ops[-1]) > prec or (_prec(ops[-1]) == prec and tok != "**")
            ):
                _apply(output, ops.pop())
            ops.append(tok)
        elif tok == "(":
            ops.append(tok)
        elif tok == ")":
            while ops and ops[-1] != "(":
                _apply(output, ops.pop())
            if not ops:
                raise ValueError("mismatched parenthesis")
            ops.pop()  # discard the "("
        else:
            raise ValueError(f"bad token: {tok}")
        prev = tok

    while ops:
        top = ops.pop()
        if top in ("(", ")"):
            raise ValueError("mismatched parenthesis")
        _apply(output, top)

    if len(output) != 1:
        raise ValueError("malformed expression")
    return output[0]

def _prec(op):
    # unary minus binds tighter than * but looser than **, as in Python: -2 ** 2 = -4
    return 2.5 if op == "neg" else OPS[op][0]

def _apply(stack, op):
    if op == "neg":
        if not stack:
            raise ValueError("missing operand for -")
        stack.append(-stack.pop())
        return
    if len(stack) < 2:
        raise ValueError(f"missing operand for {op}")
    b = stack.pop()
    a = stack.pop()
    fn = OPS[op][1]
    try:
        stack.append(fn(a, b))
    except ZeroDivisionError:
        raise ValueError("division by zero")
    except OverflowError:
        raise ValueError("result too large")


# Test
for ex in ["2 + 3", "2 + 3 * 4", "(2 + 3) * 4", "10 / 0", "2 ** 3 ** 2", "-5 + 10", "2 * -3"]:
    try:
        print(f"{ex:<15} = {evaluate(ex)}")
    except ValueError as e:
        print(f"{ex:<15} ⚠️  {e}")

This is the Shunting-yard algorithm, invented by Edsger Dijkstra in 1961. It handles operator precedence and parentheses. The neg lines add one more rule: a minus sign with no number before it (as in -5 or 2 * -3) makes the next value negative. With it, a short Python function can evaluate real expressions.

operator.add, operator.mul and the others are the function versions of +, * and so on. They are in the operator module. They are useful when you want to store an operator in a dict or pass it to a function.


Step 4: History (and Variable Reuse)

A real calculator remembers what you just did. We'll add two features:

  • Last result (ans keyword)
  • Named variables (x = 5)
python
import re
from decimal import Decimal

HISTORY = []          # list of (expression, result)
VARIABLES = {"ans": 0}

def calc(expr):
    """Evaluate, store history, support `name = ...` assignment and `ans`."""
    # Variable assignment?
    if re.match(r"^\s*([a-zA-Z_]\w*)\s*=\s*(.+)$", expr):
        m = re.match(r"^\s*([a-zA-Z_]\w*)\s*=\s*(.+)$", expr)
        name, rhs = m.group(1), m.group(2)
        value = _eval_with_vars(rhs)
        VARIABLES[name] = value
        HISTORY.append((expr, value))
        return f"{name} = {value}"

    value = _eval_with_vars(expr)
    VARIABLES["ans"] = value
    HISTORY.append((expr, value))
    return value


def _eval_with_vars(expr):
    """Substitute variable names with their values, then evaluate."""
    def replace(m):
        name = m.group(0)
        if name in VARIABLES:
            # 1e-05 -> "(0.00001)": the tokenizer reads plain digits only, and the
            # parentheses keep a negative value together, so x ** 2 works when x is -5
            return "(" + format(Decimal(repr(VARIABLES[name])), "f") + ")"
        raise ValueError(f"unknown name: {name}")
    expr = re.sub(r"[a-zA-Z_]\w*", replace, expr)
    return evaluate(expr)


# Demo session
print(calc("2 + 2"))
print(calc("ans * 10"))
print(calc("price = 19.99"))
print(calc("tax = 0.08"))
print(calc("price * (1 + tax)"))
+ setup added so this can run · re-runs 1 earlier block on this page first
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

def evaluate(*_a, **_kw):
    print('-> evaluate() called')
    return _AutoMock('evaluate()')

# Earlier blocks on this page that this one builds on, re-run quietly first.
try:
    import re
    import operator
except Exception:
    pass
_pm_earlier = [
    'import re\nimport operator\n\nOPS = {\n    "+": (1, operator.add),\n    "-": (1, operator.sub),\n    "*": (2, operator.mul),\n    "/": (2, operator.truediv),\n    "//": (2, operator.floordiv),\n    "%": (2, operator.mod),\n    "**": (3, operator.pow),\n}\n\ndef tokenize(expr):\n    """Split \'2 + 3 * 4\' into [\'2\', \'+\', \'3\', \'*\', \'4\']."""\n    return re.findall(r"\\d+\\.?\\d*|\\*\\*|//|[+\\-*/%()]", expr)\n\ndef evaluate(expr):\n    """Evaluate a simple expression with +, -, *, /, //, %, **."""\n    tokens = tokenize(expr)\n    if not tokens:\n        raise ValueError("empty expression")\n\n    # Shunting-yard: split into output (numbers) and operators\n    output = []\n    ops = []\n    prev = None\n\n    for tok in tokens:\n        if re.match(r"\\d", tok):\n            output.append(float(tok))\n        elif tok == "-" and (prev is None or prev in OPS or prev == "("):\n            ops.append("neg")  # a minus with no number before it, as in -5 or 2 * -3\n        elif tok in OPS:\n            prec = _prec(tok)\n            # pop operators that bind tighter; ** is right-associative, so 2 ** 3 ** 2 = 2 ** 9\n            while ops and ops[-1] != "(" and (\n                _prec(ops[-1]) > prec or (_prec(ops[-1]) == prec and tok != "**")\n            ):\n                _apply(output, ops.pop())\n            ops.append(tok)\n        elif tok == "(":\n            ops.append(tok)\n        elif tok == ")":\n            while ops and ops[-1] != "(":\n                _apply(output, ops.pop())\n            if not ops:\n                raise ValueError("mismatched parenthesis")\n            ops.pop()  # discard the "("\n        else:\n            raise ValueError(f"bad token: {tok}")\n        prev = tok\n\n    while ops:\n        top = ops.pop()\n        if top in ("(", ")"):\n            raise ValueError("mismatched parenthesis")\n        _apply(output, top)\n\n    if len(output) != 1:\n        raise ValueError("malformed expression")\n    return output[0]\n\ndef _prec(op):\n    # unary minus binds tighter than * but looser than **, as in Python: -2 ** 2 = -4\n    return 2.5 if op == "neg" else OPS[op][0]\n\ndef _apply(stack, op):\n    if op == "neg":\n        if not stack:\n            raise ValueError("missing operand for -")\n        stack.append(-stack.pop())\n        return\n    if len(stack) < 2:\n        raise ValueError(f"missing operand for {op}")\n    b = stack.pop()\n    a = stack.pop()\n    fn = OPS[op][1]\n    try:\n        stack.append(fn(a, b))\n    except ZeroDivisionError:\n        raise ValueError("division by zero")\n    except OverflowError:\n        raise ValueError("result too large")\n\n\n# Test\nfor ex in ["2 + 3", "2 + 3 * 4", "(2 + 3) * 4", "10 / 0", "2 ** 3 ** 2", "-5 + 10", "2 * -3"]:\n    try:\n        print(f"{ex:<15} = {evaluate(ex)}")\n    except ValueError as e:\n        print(f"{ex:<15} ⚠️  {e}")',
]
def _pm_run_earlier(_srcs, _g):
    import sys as _s, io as _io, warnings as _w
    for _src in _srcs:
        _o, _e = _s.stdout, _s.stderr
        _s.stdout = _s.stderr = _io.StringIO()
        try:
            with _w.catch_warnings():
                _w.simplefilter('ignore')
                exec(compile(_src, '<earlier block>', 'exec'), _g)
        except (Exception, SystemExit):
            pass
        finally:
            _s.stdout, _s.stderr = _o, _e
    if 'matplotlib.pyplot' in _s.modules:
        _s.modules['matplotlib.pyplot'].close('all')   # their plots belong to their own blocks
_pm_run_earlier(_pm_earlier, globals())
del _pm_earlier, _pm_run_earlier

Notice that variables and ans are stored in the same dictionary. Before evaluation, the substitution step turns price * (1 + tax) into (19.99) * (1 + (0.08)). This is a simple method, and it works.


Step 5: Add Functions (sqrt, log, sin, etc.)

Extend the evaluator with named functions from math:

python
import math
import re

ALLOWED_FUNCS = {
    "sqrt": math.sqrt,
    "log": math.log,
    "log10": math.log10,
    "sin": math.sin,
    "cos": math.cos,
    "tan": math.tan,
    "abs": abs,
    "round": round,
    "floor": math.floor,
    "ceil": math.ceil,
}

def call_function(name, arg):
    if name not in ALLOWED_FUNCS:
        raise ValueError(f"unknown function: {name}")
    return ALLOWED_FUNCS[name](arg)

# Manual demos for now (a full parser handling fn(arg) syntax is a stretch goal)
print(call_function("sqrt", 16))            # 4.0
print(call_function("log10", 1000))         # 3.0
print(call_function("ceil", 3.2))           # 4

The important safety step: we list the allowed functions explicitly. There is no __import__, no open and no eval. The user can call only the functions in this list.

This is the whitelist (or allowlist) pattern. Use it whenever users can type input that your program treats like code.


Step 6: A Clean CLI Loop

Now put everything together in a real REPL (read-evaluate-print loop).

python
def print_history():
    if not HISTORY:
        print("(no history yet)")
        return
    for i, (expr, val) in enumerate(HISTORY[-10:], 1):
        print(f"  {i}. {expr} = {val}")

def show_help():
    print("""
Commands:
  <expression>       evaluate (for example `2 + 3 * 4` or `(1 + 2) ** 2`)
  name = <expr>      assign a variable
  ans                last result
  history            show last 10 calculations
  vars               show defined variables
  clear              clear history and variables (except ans)
  help               this message
  quit               exit
""")

def run(commands):
    for raw in commands:
        cmd = raw.strip()
        try:
            if cmd in ("help", "?"): show_help()
            elif cmd == "history": print_history()
            elif cmd == "vars":
                for k, v in VARIABLES.items():
                    print(f"  {k} = {v}")
            elif cmd == "clear":
                HISTORY.clear()
                VARIABLES.clear()
                VARIABLES["ans"] = 0
                print("cleared.")
            elif cmd in ("quit", "exit", "q"): break
            elif not cmd: continue
            else:
                result = calc(cmd)
                print(f"  → {result}")
        except ValueError as e:
            print(f"  ⚠️  {e}")

# Simulated session
demo = [
    "2 + 3 * 4",
    "ans / 2",
    "price = 19.99",
    "tax = 0.08",
    "price * (1 + tax)",
    "history",
    "vars",
]
run(demo)
+ setup added so this can run · re-runs 2 earlier blocks on this page first
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

HISTORY = _AutoMock('HISTORY')
VARIABLES = _AutoMock('VARIABLES')
def calc(*_a, **_kw):
    print('-> calc() called')
    return _AutoMock('calc()')
def evaluate(*_a, **_kw):
    print('-> evaluate() called')
    return _AutoMock('evaluate()')

# Earlier blocks on this page that this one builds on, re-run quietly first.
try:
    import re
    import operator
    from decimal import Decimal
except Exception:
    pass
_pm_earlier = [
    'import re\nimport operator\n\nOPS = {\n    "+": (1, operator.add),\n    "-": (1, operator.sub),\n    "*": (2, operator.mul),\n    "/": (2, operator.truediv),\n    "//": (2, operator.floordiv),\n    "%": (2, operator.mod),\n    "**": (3, operator.pow),\n}\n\ndef tokenize(expr):\n    """Split \'2 + 3 * 4\' into [\'2\', \'+\', \'3\', \'*\', \'4\']."""\n    return re.findall(r"\\d+\\.?\\d*|\\*\\*|//|[+\\-*/%()]", expr)\n\ndef evaluate(expr):\n    """Evaluate a simple expression with +, -, *, /, //, %, **."""\n    tokens = tokenize(expr)\n    if not tokens:\n        raise ValueError("empty expression")\n\n    # Shunting-yard: split into output (numbers) and operators\n    output = []\n    ops = []\n    prev = None\n\n    for tok in tokens:\n        if re.match(r"\\d", tok):\n            output.append(float(tok))\n        elif tok == "-" and (prev is None or prev in OPS or prev == "("):\n            ops.append("neg")  # a minus with no number before it, as in -5 or 2 * -3\n        elif tok in OPS:\n            prec = _prec(tok)\n            # pop operators that bind tighter; ** is right-associative, so 2 ** 3 ** 2 = 2 ** 9\n            while ops and ops[-1] != "(" and (\n                _prec(ops[-1]) > prec or (_prec(ops[-1]) == prec and tok != "**")\n            ):\n                _apply(output, ops.pop())\n            ops.append(tok)\n        elif tok == "(":\n            ops.append(tok)\n        elif tok == ")":\n            while ops and ops[-1] != "(":\n                _apply(output, ops.pop())\n            if not ops:\n                raise ValueError("mismatched parenthesis")\n            ops.pop()  # discard the "("\n        else:\n            raise ValueError(f"bad token: {tok}")\n        prev = tok\n\n    while ops:\n        top = ops.pop()\n        if top in ("(", ")"):\n            raise ValueError("mismatched parenthesis")\n        _apply(output, top)\n\n    if len(output) != 1:\n        raise ValueError("malformed expression")\n    return output[0]\n\ndef _prec(op):\n    # unary minus binds tighter than * but looser than **, as in Python: -2 ** 2 = -4\n    return 2.5 if op == "neg" else OPS[op][0]\n\ndef _apply(stack, op):\n    if op == "neg":\n        if not stack:\n            raise ValueError("missing operand for -")\n        stack.append(-stack.pop())\n        return\n    if len(stack) < 2:\n        raise ValueError(f"missing operand for {op}")\n    b = stack.pop()\n    a = stack.pop()\n    fn = OPS[op][1]\n    try:\n        stack.append(fn(a, b))\n    except ZeroDivisionError:\n        raise ValueError("division by zero")\n    except OverflowError:\n        raise ValueError("result too large")\n\n\n# Test\nfor ex in ["2 + 3", "2 + 3 * 4", "(2 + 3) * 4", "10 / 0", "2 ** 3 ** 2", "-5 + 10", "2 * -3"]:\n    try:\n        print(f"{ex:<15} = {evaluate(ex)}")\n    except ValueError as e:\n        print(f"{ex:<15} ⚠️  {e}")',
    'import re\nfrom decimal import Decimal\n\nHISTORY = []          # list of (expression, result)\nVARIABLES = {"ans": 0}\n\ndef calc(expr):\n    """Evaluate, store history, support `name = ...` assignment and `ans`."""\n    # Variable assignment?\n    if re.match(r"^\\s*([a-zA-Z_]\\w*)\\s*=\\s*(.+)$", expr):\n        m = re.match(r"^\\s*([a-zA-Z_]\\w*)\\s*=\\s*(.+)$", expr)\n        name, rhs = m.group(1), m.group(2)\n        value = _eval_with_vars(rhs)\n        VARIABLES[name] = value\n        HISTORY.append((expr, value))\n        return f"{name} = {value}"\n\n    value = _eval_with_vars(expr)\n    VARIABLES["ans"] = value\n    HISTORY.append((expr, value))\n    return value\n\n\ndef _eval_with_vars(expr):\n    """Substitute variable names with their values, then evaluate."""\n    def replace(m):\n        name = m.group(0)\n        if name in VARIABLES:\n            # 1e-05 -> "(0.00001)": the tokenizer reads plain digits only, and the\n            # parentheses keep a negative value together, so x ** 2 works when x is -5\n            return "(" + format(Decimal(repr(VARIABLES[name])), "f") + ")"\n        raise ValueError(f"unknown name: {name}")\n    expr = re.sub(r"[a-zA-Z_]\\w*", replace, expr)\n    return evaluate(expr)\n\n\n# Demo session\nprint(calc("2 + 2"))\nprint(calc("ans * 10"))\nprint(calc("price = 19.99"))\nprint(calc("tax = 0.08"))\nprint(calc("price * (1 + tax)"))',
]
def _pm_run_earlier(_srcs, _g):
    import sys as _s, io as _io, warnings as _w
    for _src in _srcs:
        _o, _e = _s.stdout, _s.stderr
        _s.stdout = _s.stderr = _io.StringIO()
        try:
            with _w.catch_warnings():
                _w.simplefilter('ignore')
                exec(compile(_src, '<earlier block>', 'exec'), _g)
        except (Exception, SystemExit):
            pass
        finally:
            _s.stdout, _s.stderr = _o, _e
    if 'matplotlib.pyplot' in _s.modules:
        _s.modules['matplotlib.pyplot'].close('all')   # their plots belong to their own blocks
_pm_run_earlier(_pm_earlier, globals())
del _pm_earlier, _pm_run_earlier

In a real terminal, replace for raw in commands: with while True: raw = input("> "). The rest of the code stays the same.


Stretch Goals

1. Function calls in expressions: extend the tokenizer and the evaluator to handle sqrt(16) + 1. This is good parsing practice.
2. Save/load session: persist HISTORY and VARIABLES to JSON, restore on start.
3. Units: support 5 ft + 3 in style mixed units (use the pint package).
4. Symbolic math: integrate with sympy for solve(x**2 - 4).
5. Plot: plot(x, x**2, range=(-5, 5)) to draw a curve (uses matplotlib).
6. Currency conversion: 100 USD to EUR, with live exchange rates from a free API.


🎯 Your Turn: Safe Eval for Just Arithmetic

ast.literal_eval doesn't compute. eval is dangerous. Build something in between: a function that walks Python's AST (abstract syntax tree) and evaluates ONLY numeric expressions. It must refuse anything else, such as a function call, attribute access or import. It must also refuse powers that are too large to compute: the skeleton already includes safe_pow for that.

python
import ast
import math
import operator

def safe_pow(a, b):
    # 9 ** 9 ** 9 has about 370 million digits: computing it would freeze the program.
    # So refuse any power whose result would have more than about 1000 digits.
    if abs(a) > 1 and abs(b) * math.log10(abs(a)) > 1000:
        raise ValueError("result too large")
    return a ** b

OPS = {
    ast.Add: operator.add, ast.Sub: operator.sub,
    ast.Mult: operator.mul, ast.Div: operator.truediv,
    ast.FloorDiv: operator.floordiv, ast.Mod: operator.mod,
    ast.Pow: safe_pow,
    ast.USub: operator.neg, ast.UAdd: operator.pos,
}

def safe_eval(expr):
    """Evaluate a Python arithmetic expression safely.

    Allowed: numbers, +, -, *, /, //, %, **, parentheses, unary +/-.
    Refused: anything else (function calls, names, attribute access...).
    """
    tree = ast.parse(expr, mode="eval")

    def walk(node):
        # TODO 1: if node is ast.Expression, walk its body
        # TODO 2: if node is ast.Constant (a number), return its value
        # TODO 3: if node is ast.BinOp, apply the op to walk(left), walk(right)
        # TODO 4: if node is ast.UnaryOp, apply the unary op to walk(operand)
        # TODO 5: anything else: raise ValueError("not allowed: ...")
        pass

    return walk(tree)

# Test
print(safe_eval("2 + 3 * 4"))            # 14
print(safe_eval("(2 + 3) * 4"))          # 20
print(safe_eval("2 ** 10"))              # 1024
print(safe_eval("-5 + 10"))              # 5
# Should refuse:
# print(safe_eval("__import__('os')"))
Hint 1: Walking the tree Use isinstance(node, ast.Expression), ast.Constant, ast.BinOp, ast.UnaryOp. For BinOp, look up type(node.op) in your OPS dict.
Hint 2: Check the node type if isinstance(node, ast.BinOp): return OPS[type(node.op)](walk(node.left), walk(node.right)). Same shape for UnaryOp.
Show full solution
python
import ast
import math
import operator

def safe_pow(a, b):
    # 9 ** 9 ** 9 has about 370 million digits: computing it would freeze the program.
    # So refuse any power whose result would have more than about 1000 digits.
    if abs(a) > 1 and abs(b) * math.log10(abs(a)) > 1000:
        raise ValueError("result too large")
    return a ** b

OPS = {
    ast.Add: operator.add, ast.Sub: operator.sub,
    ast.Mult: operator.mul, ast.Div: operator.truediv,
    ast.FloorDiv: operator.floordiv, ast.Mod: operator.mod,
    ast.Pow: safe_pow,
    ast.USub: operator.neg, ast.UAdd: operator.pos,
}

def safe_eval(expr):
    tree = ast.parse(expr, mode="eval")

    def walk(node):
        if isinstance(node, ast.Expression):
            return walk(node.body)
        if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)):
            return node.value
        if isinstance(node, ast.BinOp) and type(node.op) in OPS:
            return OPS[type(node.op)](walk(node.left), walk(node.right))
        if isinstance(node, ast.UnaryOp) and type(node.op) in OPS:
            return OPS[type(node.op)](walk(node.operand))
        raise ValueError(f"not allowed: {ast.dump(node)}")

    return walk(tree)

for ex in ["2 + 3 * 4", "(2 + 3) * 4", "2 ** 10", "-5 + 10", "10 / 0", "9 ** 9 ** 9"]:
    try:
        print(f"{ex:<20} = {safe_eval(ex)}")
    except (ValueError, ZeroDivisionError, OverflowError) as e:
        print(f"{ex:<20} ⚠️  {e}")

Python's own compiler also walks the AST node by node. Linters and code formatters use the same technique.


What You Learned

  • eval() is dangerous: never call it on user input. Use ast.literal_eval or your own parser.
  • The Shunting-yard algorithm for operator precedence and parentheses.
  • operator module functions (operator.add instead of lambda a,b: a+b).
  • Whitelisting is the right pattern for "user can call these functions, nothing else".
  • The REPL loop: read a command, run it, print the result, repeat.

You now know how to evaluate untrusted input safely. This is a real security skill, and you will need it whenever users can type commands into your programs.

Next: File Organizer, an automation tool you can use on your own files.

Practice this

on practicepython.in

Short exercises that run in your browser and tell you what your code did, as well as whether a test passed.

Found this useful? Share it with a friend who’s learning Python.