PY

Python Fundamentals

18 lessons

Progress0%
1. Introduction to Python
1What is Python?2Setting Up Python
2. Variables and Data Types
1Variables in Python2Data Types
3. Control Flow
Conditional StatementsLoops
4. Functions
Function Basics
5. Data Structures
Lists Deep DiveTuples & SetsDictionaries & Comprehensions
6. Advanced Functions
Closures & Higher-Order FunctionsDecorators & Lambda
7. Object-Oriented Python
Classes & InstancesInheritance & Dunder Methods
8. Exception Handling
try / except / finallyCustom Exceptions & Context Managers
9. Modules & File I/O
Modules & PackagesFile I/O & JSON
All Tutorials
PythonException Handling
Lesson 16 of 18 min
Chapter 8 · Lesson 2

Custom Exceptions & Context Managers

Custom Exceptions & Context Managers

Custom Exception Classes

Defining your own exceptions makes errors more informative and lets callers catch only the errors they care about:

python
class AppError(Exception):
    """Base class for all application errors."""

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

class DatabaseError(AppError):
    """Raised when a database operation fails."""

Having an AppError base class lets callers catch all your exceptions with except AppError while still allowing fine-grained handling when needed.

The with Statement

The with statement is Python's context manager protocol. It guarantees that setup and teardown code runs, even if an exception occurs:

python
with open("data.txt", "r") as f:
    contents = f.read()
# f.close() is called automatically here

enter and exit

Any class that implements __enter__ and __exit__ can be used as a context manager:

python
class ManagedResource:
    def __enter__(self):
        print("Acquiring resource")
        return self          # returned as the 'as' target

    def __exit__(self, exc_type, exc_val, exc_tb):
        print("Releasing resource")
        return False         # False = don't suppress exceptions

If __exit__ returns True, the exception is suppressed; return False (or None) to let it propagate.

contextlib.contextmanager

The @contextmanager decorator turns a generator function into a context manager — far less boilerplate than writing a class:

python
from contextlib import contextmanager

@contextmanager
def timer():
    import time
    start = time.perf_counter()
    try:
        yield                            # code inside the with block runs here
    finally:
        elapsed = time.perf_counter() - start
        print(f"Elapsed: {elapsed:.4f}s")

The yield splits the function into setup (before) and teardown (after). The finally guarantees the teardown runs even on exception.

Practical Error-Handling Patterns

  1. Fail fast: validate inputs at the boundary, not deep inside.
  2. Wrap third-party errors: catch library exceptions and re-raise as your own custom exceptions.
  3. Log then re-raise: log the error for debugging, then propagate it.
  4. Use context managers for any resource that needs explicit cleanup (files, network connections, locks, database transactions).

Code Examples

Custom Exception Hierarchypython
class ShopError(Exception):
    """Base class for all shop errors."""

class OutOfStockError(ShopError):
    def __init__(self, item, requested, available):
        self.item      = item
        self.requested = requested
        self.available = available
        super().__init__(
            f"'{item}' out of stock: requested {requested}, only {available} left"
        )

class InvalidItemError(ShopError):
    pass

inventory = {"apple": 5, "banana": 2}

def purchase(item, quantity):
    if item not in inventory:
        raise InvalidItemError(f"Unknown item: '{item}'")
    if inventory[item] < quantity:
        raise OutOfStockError(item, quantity, inventory[item])
    inventory[item] -= quantity
    return f"Purchased {quantity}x {item}"

# Test each scenario
for item, qty in [("apple", 3), ("banana", 5), ("mango", 1)]:
    try:
        print(purchase(item, qty))
    except OutOfStockError as e:
        print(f"Stock error: {e} (field: {e.item})")
    except InvalidItemError as e:
        print(f"Item error: {e}")
    except ShopError as e:
        print(f"General shop error: {e}")

Custom exceptions carry structured data (item, requested, available) as attributes, enabling programmatic handling. The ShopError base class allows callers to catch all shop-related errors with a single except clause.

Context Manager Class with __enter__ / __exit__python
class DatabaseConnection:
    def __init__(self, host):
        self.host = host
        self.connected = False

    def __enter__(self):
        print(f"Connecting to {self.host}...")
        self.connected = True
        return self   # available as the 'as' variable

    def __exit__(self, exc_type, exc_val, exc_tb):
        if self.connected:
            print(f"Disconnecting from {self.host}")
            self.connected = False
        if exc_type is not None:
            print(f"Handling exception: {exc_type.__name__}: {exc_val}")
        return False  # do not suppress the exception

# Normal use
print("--- Normal ---")
with DatabaseConnection("localhost:5432") as db:
    print(f"Connected: {db.connected}")
    print("Running query...")

# Exception during use
print("\n--- With error ---")
try:
    with DatabaseConnection("remote:5432") as db:
        raise RuntimeError("Query failed")
except RuntimeError:
    print("Caught RuntimeError in outer scope")

__enter__ runs when entering the with block and its return value is bound to the as variable. __exit__ receives exception info (or three Nones if no exception occurred) and always runs, ensuring the connection is closed.

contextlib.contextmanagerpython
from contextlib import contextmanager
import time

@contextmanager
def timer(label="Block"):
    start = time.perf_counter()
    print(f"{label}: starting")
    try:
        yield   # the with-block body executes here
    except Exception as e:
        print(f"{label}: exception — {e}")
        raise
    finally:
        elapsed = time.perf_counter() - start
        print(f"{label}: finished in {elapsed:.6f}s")

@contextmanager
def temporary_value(d, key, value):
    """Temporarily set a dict key, then restore original."""
    original = d.get(key)
    d[key] = value
    try:
        yield d
    finally:
        if original is None:
            del d[key]
        else:
            d[key] = original

# Use the timer
with timer("Sum computation"):
    total = sum(range(100_000))
    print(f"  sum = {total}")

# Use the temporary dict modifier
config = {"debug": False}
print("Before:", config["debug"])
with temporary_value(config, "debug", True) as cfg:
    print("During:", cfg["debug"])
print("After:", config["debug"])

@contextmanager turns a generator into a context manager. Code before yield is __enter__; code after yield is __exit__. The try/finally ensures cleanup even when exceptions occur inside the with block. The actual elapsed time will vary on your machine.

Quick Quiz

1. What value should __exit__ return to suppress an exception?

2. What is the purpose of a base exception class (e.g. AppError(Exception))?

3. In a @contextmanager function, where does the with-block body execute?

Was this lesson helpful?

PreviousNext