Project: CLI Calculator (with History)
1 · The lesson
readYou'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:
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.
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.
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 (
anskeyword) - Named variables (
x = 5)
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:
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.
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.
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
Useisinstance(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
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. Useast.literal_evalor a custom parser.- The Shunting-yard algorithm in ~30 lines of Python.
operatormodule functions (operator.addinstead oflambda 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.inShort exercises that run in your browser and tell you what your code actually did, not just whether a test passed.