🏗️ Phase 3 · OOP 🟡 Intermediate MODULE 14

Exception Handling

⏱️ 40 min
📖 Theory + Code
🧩 5 Questions
🏗️ 1 Challenge
Phase 3 progress67%
🎯 What you'll learn: The full try / except / else / finally structure. Python's exception hierarchy. Catching multiple exception types. Raising exceptions with raise. Creating custom exception classes. The EAFP principle. Using exceptions as control flow in real programs.

What Are Exceptions?

An exception is a signal that something went wrong at runtime. When Python hits an error it cannot handle (dividing by zero, missing file, wrong type), it raises an exception object and unwinds the call stack until something catches it — or the program crashes with a traceback.

Without exception handling, even one bad user input can crash an entire program. With it, you can detect errors, respond gracefully, log them, and keep running.

BaseException
├── SystemExitraised by sys.exit()
├── KeyboardInterruptCtrl+C
└── Exception← catch most things here
├── ValueErrorright type, wrong value: int("abc")
├── TypeErrorwrong type: "2" + 2
├── KeyErrordict key missing: d["x"]
├── IndexErrorlist index out of range
├── AttributeErrorobj.nonexistent
├── FileNotFoundErroropen("missing.txt")
├── ZeroDivisionError10 / 0
├── ImportErrorimport nonexistent_module
└── NameErrorundefined variable

try / except / else / finally

The full try block has four clauses. You don't need all of them every time — but understanding each one is essential for writing production-quality code.

try:← code that might raise an exception
    risky_code()
except ValueError as e:← handle specific exception, bind to e
    handle_value_error(e)
except (TypeError, KeyError):← handle multiple types together
    handle_other()
else:← runs ONLY if no exception occurred
    success_code()
finally:← ALWAYS runs — cleanup guaranteed
    cleanup()
try_except.py
PYTHON
# ── Basic try/except ──────────────────────────
def safe_divide(a, b):
    try:
        result = a / b
    except ZeroDivisionError:
        return None
    else:
        return result   # only reached if no exception

print(safe_divide(10, 2))   # 5.0
print(safe_divide(10, 0))   # None

# ── Full try/except/else/finally ──────────────
def load_data(path):
    f = None
    try:
        f = open(path, encoding="utf-8")
        data = f.read()
    except FileNotFoundError:
        print(f"❌ File not found: {path}")
        return None
    except PermissionError as e:
        print(f"🔒 Permission denied: {e}")
        return None
    else:
        print(f"✅ Loaded {len(data)} characters")
        return data
    finally:
        if f: f.close()   # always close, even on error

# ── Catching multiple exceptions ──────────────
def parse_input(text):
    try:
        value = int(text.strip())
        return value
    except ValueError:
        print(f"'{text}' is not a valid integer")
    except AttributeError:
        print("Input must be a string")
    return None

print(parse_input("42"))    # 42
print(parse_input("hello")) # 'hello' is not a valid integer → None
print(parse_input(None))    # Input must be a string → None
⚠️
Never use bare except: — always catch specific exceptions
A bare except: catches everything — including SystemExit and KeyboardInterrupt, which prevents Ctrl+C from working. At minimum use except Exception:. Better yet, name the specific exception you expect. Broad catches hide bugs instead of fixing them.

Raising Exceptions & Custom Exceptions

You can raise exceptions yourself with the raise keyword — when input is invalid, a business rule is violated, or a state is impossible. Creating custom exception classes lets you give errors meaningful names and carry extra data.

raising_custom.py
PYTHON
# ── Raising built-in exceptions ───────────────
def set_age(age):
    if not isinstance(age, int):
        raise TypeError(f"Age must be int, got {type(age).__name__}")
    if age < 0 or age > 150:
        raise ValueError(f"Age {age} is out of valid range (0–150)")
    return age

# ── re-raise — catch, log, then pass on ───────
try:
    set_age("twenty")
except TypeError as e:
    print(f"Caught: {e}")
    raise   # re-raise the same exception

# ── Custom exception hierarchy ────────────────
class AppError(Exception):
    """Base class for all BitWithBite app errors."""
    pass

class ValidationError(AppError):
    """Raised when user input fails validation."""
    def __init__(self, field, message):
        self.field   = field
        self.message = message
        super().__init__(f"[{field}] {message}")

class DatabaseError(AppError):
    """Raised on database connection or query failure."""
    def __init__(self, query, cause):
        self.query = query
        super().__init__(f"DB error on query '{query}': {cause}")


def register_student(name, age, gpa):
    if not name.strip():
        raise ValidationError("name", "Name cannot be blank")
    if not (0 <= gpa <= 4.0):
        raise ValidationError("gpa", f"GPA {gpa} not in 0.0–4.0")
    print(f"✅ Registered: {name}, age {age}, GPA {gpa}")


try:
    register_student("Ali", 20, 5.2)
except ValidationError as e:
    print(f"❌ Validation failed: {e}")
    print(f"   Field: {e.field}")
except AppError as e:
    print(f"❌ App error: {e}")
🎭
EAFP — Easier to Ask Forgiveness than Permission
Python style prefers trying an operation and catching exceptions over checking preconditions first. Instead of if key in d: return d[key], write try: return d[key] except KeyError: .... This is faster (one lookup not two), more idiomatic, and handles race conditions better. It's called the EAFP principle and is Python's preferred style.

Context Managers & __enter__ / __exit__

The with statement you've been using for files is powered by the context manager protocol. Any class implementing __enter__ and __exit__ can be used with with. __exit__ receives exception info and can suppress the exception by returning True.

context_managers.py
PYTHON
import time

class Timer:
    """Context manager that times a code block."""

    def __enter__(self):
        self.start = time.perf_counter()
        return self   # bound to 'as t'

    def __exit__(self, exc_type, exc_val, exc_tb):
        self.elapsed = time.perf_counter() - self.start
        print(f"⏱️  Elapsed: {self.elapsed:.4f}s")
        return False  # don't suppress exceptions


with Timer() as t:
    total = sum(range(1_000_000))
# ⏱️  Elapsed: 0.0312s


# ── contextlib.contextmanager — simpler decorator approach ──
from contextlib import contextmanager

@contextmanager
def managed_resource(name):
    print(f"Opening {name}")
    try:
        yield name         # 'with' body runs here
    except Exception as e:
        print(f"Error during {name}: {e}")
    finally:
        print(f"Closing {name}")


with managed_resource("database") as r:
    print(f"Using {r}...")
# Opening database
# Using database...
# Closing database
Use contextlib.suppress() to silently ignore specific errors
from contextlib import suppress
with suppress(FileNotFoundError): os.remove("temp.txt")
This is cleaner than a try/except pass block when you genuinely want to ignore a specific error. Never use it to suppress exceptions you haven't thought about.
🧩 Knowledge Check
5 questions — Exception Handling
1. When does the else block in a try statement execute?
2. What's wrong with a bare except: clause?
3. What does the finally block guarantee?
4. How do you create a custom exception that carries a field attribute?
5. In Python's EAFP style, how would you safely access a dict key?
🏗️
Challenge — Bulletproof Input System
Custom exceptions + retry loops + context managers
Task: Build a robust user registration form with:

1. Custom exceptions: ValidationError(field, message), AgeRangeError(age) inheriting from ValidationError
2. A validate_name(name) function — raises ValidationError if blank or too short (<2 chars)
3. A validate_age(age_str) — raises ValueError if not a number, AgeRangeError if outside 5–120
4. A validate_email(email) — raises ValidationError if no "@" or no "." after "@"
5. A get_input(prompt, validator) helper that loops until valid input (max 3 tries, then raises ValidationError)
6. A Timer context manager wrapping the whole registration to show how long it took
💡 Show hints
  • AgeRangeError: class AgeRangeError(ValidationError): def __init__(self, age): super().__init__("age", f"Age {age} out of range 5-120")
  • get_input loop: for attempt in range(1, max_tries+1): try: val = input(...); validator(val); return val except ValidationError as e: print(e)
  • Email check: if "@" not in email or "." not in email.split("@")[1]: raise ValidationError(...)
  • Use Timer as context manager around the registration block
bulletproof_input.py — Sample Solution
PYTHON
import time

# ── Custom exceptions ─────────────────────────
class ValidationError(Exception):
    def __init__(self, field, msg):
        self.field = field; super().__init__(f"[{field}] {msg}")

class AgeRangeError(ValidationError):
    def __init__(self, age):
        super().__init__("age", f"Age {age} not in 5–120")

# ── Validators ────────────────────────────────
def validate_name(name):
    if len(name.strip()) < 2:
        raise ValidationError("name", "At least 2 characters required")
    return name.strip().title()

def validate_age(s):
    try:
        age = int(s)
    except ValueError:
        raise ValidationError("age", "Must be a whole number")
    if not (5 <= age <= 120): raise AgeRangeError(age)
    return age

def validate_email(email):
    parts = email.split("@")
    if len(parts) != 2 or "." not in parts[1]:
        raise ValidationError("email", "Invalid email format")
    return email.lower()

# ── Retry helper ──────────────────────────────
def get_input(prompt, validator, max_tries=3):
    for attempt in range(1, max_tries+1):
        try:
            raw = input(f"  {prompt}: ")
            return validator(raw)
        except ValidationError as e:
            print(f"  ⚠️  {e}  ({max_tries-attempt} tries left)")
    raise ValidationError("input", "Too many failed attempts")

# ── Timer context manager ─────────────────────
class Timer:
    def __enter__(self): self.t0 = time.time(); return self
    def __exit__(self, *_):
        print(f"\n  ⏱️  Registration took {time.time()-self.t0:.2f}s"); return False

# ── Main ──────────────────────────────────────
print("═" * 40)
print("   🎓  STUDENT REGISTRATION")
print("═" * 40)
try:
    with Timer():
        name  = get_input("Name",  validate_name)
        age   = get_input("Age",   validate_age)
        email = get_input("Email", validate_email)
        print(f"\n  ✅ Registered: {name}, {age}, {email}")
except ValidationError as e:
    print(f"\n  ❌ Registration failed: {e}")
🎉
Lesson 14 Complete!
Exception handling mastered — your programs are now bulletproof. One lesson left: Modules & Packages!
← Course Home
Phase 3 · OOPLesson 14 of 6