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. Then we'll layer on history, variables, and safe evaluation — turning a toy into a tool you'd genuinely use.

This project teaches one critical skill no other project does: how to safely evaluate user-supplied expressions. (Spoiler: never use eval().)

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


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

The naive Python solution is one line:

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

That works. Never ship this.

eval() runs ANY Python code. If your user types __import__('os').system('rm -rf /'), you've just nuked their home directory. Even from a trusted source, eval is a footgun — typos in user input can crash the program in surprising ways.

We can do better.


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 refuses to execute 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 would raise — `__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") works only because Python folds simple constants. "x + 3" would not.

For real arithmetic we need to parse and evaluate ourselves.


Step 3 — A Tiny Expression Evaluator (Tokenize → Compute)

The classic approach: turn the input into tokens, then apply 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 = []

    for tok in tokens:
        if re.match(r"\d", tok):
            output.append(float(tok))
        elif tok in OPS:
            prec, _ = OPS[tok]
            while ops and ops[-1] in OPS and OPS[ops[-1]][0] >= prec:
                _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}")

    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 _apply(stack, op):
    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")


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

This is the Shunting-yard algorithm, invented by Dijkstra in 1961. It handles precedence and parentheses cleanly. ~30 lines of Python implements a real expression engine.

operator.add, operator.mul, etc. are the function versions of +, *. They live in the operator module — very handy when you want to pass an operator as a value.


Step 4 — History (and Variable Reuse)

A real calculator remembers what you just did. Two features at once:

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

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:
            return str(VARIABLES[name])
        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 · defines evaluate
# 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()')

Notice the elegant design: variables and ans use the same dictionary. The substitution step turns price * (1 + tax) into 19.99 * (1 + 0.08) before evaluation. Old-school but effective.


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 key safety move: we explicitly list the allowed functions. No __import__, no open, no eval. The user can use what we let them use — nothing else.

This is the whitelist pattern. Use it any time you accept user-provided code-like input.


Step 6 — A Clean CLI Loop

Putting it together as a real REPL.

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 (e.g. `2 + 3 * 4`, `sqrt(16) + 1`)
  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 · defines HISTORY, VARIABLES, calc
# 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()')

In a real terminal, swap the for raw in commands: for while True: raw = input("> "). The rest is identical.


Stretch Goals

1. Function calls in expressions: extend the tokenizer + evaluator to handle sqrt(16) + 1. Real parsing exercise.
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 — pulls live rates via 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 and evaluates ONLY numeric expressions — refusing anything that looks like a function call, attribute access, or import.

python
import ast
import operator

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: operator.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 — Pattern match on 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 operator

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: operator.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"]:
    try:
        print(f"{ex:<20} = {safe_eval(ex)}")
    except (ValueError, ZeroDivisionError) as e:
        print(f"{ex:<20} ⚠️  {e}")

Walking the AST node by node is how Python's own compiler works. This is the same technique linters and code-formatters use.


What You Learned

  • eval() is dangerous — never call it on user input. Use ast.literal_eval or a custom parser.
  • The Shunting-yard algorithm in ~30 lines of Python.
  • 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 — load state, take input, dispatch command, save state, repeat.

You now know how to take untrusted input and run it safely. That's a real security skill — and one that 90% of beginner Python tutorials skip entirely.

Next: File Organizer — a real automation tool you'll keep.

Practice this

on practicepython.in

Short exercises that run in your browser and tell you what your code actually did, not just whether a test passed.