Project: CLI Calculator (with History)
1 · The lesson
readYou'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:
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.
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.
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 (
anskeyword) - Named variables (
x = 5)
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:
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).
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.
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
Useisinstance(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
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. Useast.literal_evalor your own parser.- The Shunting-yard algorithm for operator precedence and parentheses.
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: 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.inShort 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.