What This Challenge Tests
3 minBy the end of this lesson you should be comfortable with:
- Picking the right exception type for each failure mode.
- Catching
KeyboardInterruptfor graceful Ctrl+C exit. - Re-raising your own exceptions with clear messages.
- Round-trip persistence — saving history and reloading it on next run.
The Failure-Mode Matrix
5 minBefore you write a line of code, list every weird thing a user might do. Here are 10 to start; add your own as you find them:
Input Right reaction
"3 + 4" 7 (no error)
"" "(blank line — skipped)"
"abc" "Couldn't parse: type a + b or a OP b."
"5 +" "Need three tokens, got 2."
"5 + + 7" "Need three tokens, got 4."
"5 ? 7" "Operator ? not supported."
"5 / 0" "Can't divide by zero."
"5 + abc" "Operand 'abc' isn't a number."
"q" Exit gracefully.
Ctrl+C Exit gracefully ("Bye!").That's the spec. The body of today's lesson is delivering it.
Error-proofing isn't one big try block. It's many small ones, each catching one specific failure with a specific message. Compose them, then loop.
Task 1 · The Pure Parser
10 minWrite a function parse(line) that takes a raw string and returns the three pieces (a, op, b) as a tuple — or raises ValueError with a clear message.
Rules:
- Split on whitespace. Expect exactly 3 tokens.
- First and last tokens must convert to
float. - Middle token must be one of
+ - * /.
Show one possible solution
OPS = "+-*/" def parse(line): parts = line.split() if len(parts) != 3: raise ValueError(f"Need three tokens, got {len(parts)}") a_raw, op, b_raw = parts try: a = float(a_raw) except ValueError: raise ValueError(f"Operand {a_raw!r} isn't a number") try: b = float(b_raw) except ValueError: raise ValueError(f"Operand {b_raw!r} isn't a number") if op not in OPS: raise ValueError(f"Operator {op!r} not supported") return a, op, b
Three separate raises with three specific messages. The caller's except will just print whichever message comes out.
Task 2 · The Evaluator
8 minWrite a function compute(a, op, b) that returns the result, or raises ZeroDivisionError on a / 0.
Show one possible solution
def compute(a, op, b): if op == "+": return a + b elif op == "-": return a - b elif op == "*": return a * b elif op == "/": if b == 0: raise ZeroDivisionError("Can't divide by zero") return a / b
Letting Python raise ZeroDivisionError naturally with a / 0 would also work — we're raising our own here to control the message.
Task 3 · The Loop with History & Ctrl+C
10 minNow wrap it. A while True loop that reads input, parses, computes, prints — catching errors at each step. Also:
- Save every successful computation as a tuple in a list called
history. - Support
has a command — print the entire history so far. - Support
qas a command — quit cleanly. - Catch
KeyboardInterrupt(Ctrl+C) and exit just as cleanly.
Show one possible solution
history = [] try: while True: line = input("calc> ").strip() if line == "": continue if line == "q": break if line == "h": for entry in history: print(" ", entry) continue try: a, op, b = parse(line) except ValueError as e: print(f" ! {e}") continue try: result = compute(a, op, b) except ZeroDivisionError as e: print(f" ! {e}") continue print(f"= {result}") history.append((line, result)) except KeyboardInterrupt: print("\nBye!")
Notice the structure: outer try catches only KeyboardInterrupt. Inside the loop, each step has its own narrow try/except. Each continue means "skip the rest of this iteration" — clean control flow.
Task 4 · Persistence (stretch)
8 minSave the history to calc_history.txt every time a new entry is added. Reload it at start. The user can run the calculator across many sessions and the history persists.
Show one possible solution
HIST_FILE = "calc_history.txt" def load_history(): try: with open(HIST_FILE, encoding="utf-8") as f: out = [] for line in f: parts = line.rstrip("\n").split("\t") if len(parts) == 2: out.append((parts[0], float(parts[1]))) return out except FileNotFoundError: return [] def save_entry(line, result): with open(HIST_FILE, "a", encoding="utf-8") as f: f.write(f"{line}\t{result}\n") history = load_history() print(f"(Loaded {len(history)} past calculations.)") # After computing: history.append((line, result)) save_entry(line, result)
Tab-separated to keep parsing simple even if expressions contain spaces. Load wrapped in try/except FileNotFoundError — first run starts with empty history. Save in append mode so we never lose old entries.
Putting It All Together · The Final Calculator
8 minAssemble all four tasks into one file calc.py. Test against the failure-mode matrix from the warm-up. Every row in that table must produce the right behaviour without crashing.
Show one complete solution
# calc.py — error-proof calculator OPS = "+-*/" HIST_FILE = "calc_history.txt" def parse(line): parts = line.split() if len(parts) != 3: raise ValueError(f"Need three tokens, got {len(parts)}") a_raw, op, b_raw = parts try: a = float(a_raw) except ValueError: raise ValueError(f"Operand {a_raw!r} isn't a number") try: b = float(b_raw) except ValueError: raise ValueError(f"Operand {b_raw!r} isn't a number") if op not in OPS: raise ValueError(f"Operator {op!r} not supported") return a, op, b def compute(a, op, b): if op == "+": return a + b elif op == "-": return a - b elif op == "*": return a * b elif op == "/": if b == 0: raise ZeroDivisionError("Can't divide by zero") return a / b def load_history(): try: with open(HIST_FILE, encoding="utf-8") as f: out = [] for line in f: parts = line.rstrip("\n").split("\t") if len(parts) == 2: out.append((parts[0], parts[1])) return out except FileNotFoundError: return [] def save_entry(line, result): with open(HIST_FILE, "a", encoding="utf-8") as f: f.write(f"{line}\t{result}\n") def main(): history = load_history() print(f"Calculator. Type 'h' for history, 'q' to quit.") print(f"(Loaded {len(history)} past entries.)") while True: try: line = input("calc> ").strip() except EOFError: print() break if line == "": continue if line == "q": break if line == "h": if not history: print(" (no history)") for entry, result in history[-10:]: print(f" {entry:<20} = {result}") continue try: a, op, b = parse(line) result = compute(a, op, b) except (ValueError, ZeroDivisionError) as e: print(f" ! {e}") continue print(f"= {result}") history.append((line, str(result))) save_entry(line, result) try: main() except KeyboardInterrupt: print("\nBye!")
Non-negotiables: parse raises specific ValueErrors, compute raises ZeroDivisionError, the main loop catches both with a tuple-except, history persists between sessions, and both KeyboardInterrupt (Ctrl+C) and EOFError (Ctrl+D / piped end) exit cleanly.
Recap
3 minThree lessons of error handling, all in one program. parse raises ValueError with specific messages for every parsing failure. compute raises ZeroDivisionError for division by zero. The main loop catches both with a tuple-except, and an outer try catches KeyboardInterrupt for clean Ctrl+C exit. FileNotFoundError on load means "empty history". Every branch covered. Zero crashes.
You've seen the full error-handling toolkit. Tomorrow we shift gears — modules. Importing from the standard library, then building your own. Big games and apps live in dozens of files, not one.
Homework
4 minExtend the calculator with three new features:
- Power. Support
^as exponent (2 ^ 10 = 1024). - Negative-second-operand. Allow
5 + -3(currently parses incorrectly). - Clear command. Support
cto wipe the history (delete the file).
Stretch. Add % for modulo and add a recall variable r that always holds the last result — so the user can type r + 5 next time.
Sample · key changes
OPS = "+-*/^%" def compute(a, op, b): if op == "^": return a ** b if op == "%": return a % b # ... existing branches ... # Handle the "clear" command in main: if line == "c": import os try: os.remove(HIST_FILE) print("History cleared.") except FileNotFoundError: print("Nothing to clear.") history.clear() continue # Recall variable — substitute 'r' with last_result last_result = 0.0 # Before parse: substituted = line.replace("r", str(last_result)) a, op, b = parse(substituted) # After compute: last_result = result
Non-negotiables: ^ added to OPS and to compute, the c command wraps os.remove in a try/except, and the recall variable gets substituted into the line before parsing. The negative-second-operand fix is left as an extension — hint: 5 + -3 currently splits into ["5", "+", "-3"] which... actually works correctly! Try it.