Topic 13 / 15

Decorators & Context Managers

~11 min read  //  Python Series  //  Coding India

Functions Are Values

Python functions can be assigned, passed as arguments, and returned from other functions. A closure is an inner function that remembers variables from its enclosing scope. Decorators are built on exactly these two ideas.

What a Decorator Is

A decorator takes a function and returns a replacement — usually a wrapper that adds behaviour around the original:

import functools, time

def timing(func):
    @functools.wraps(func)               # keeps func's name/docs intact
    def wrapper(*args, **kwargs):
        start = time.perf_counter()
        result = func(*args, **kwargs)
        ms = (time.perf_counter() - start) * 1000
        print(f"{func.__name__} took {ms:.1f}ms")
        return result
    return wrapper

@timing
def slow_report():
    time.sleep(0.5)
    return "done"

slow_report()        # slow_report took 500.4ms

@timing is just syntax sugar for slow_report = timing(slow_report). Always use functools.wraps — without it, the wrapped function loses its identity.

Decorators With Arguments

Add one more layer — a factory that returns the decorator:

def retry(times=3):
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            for attempt in range(1, times + 1):
                try:
                    return func(*args, **kwargs)
                except Exception:
                    if attempt == times:
                        raise
                    print(f"attempt {attempt} failed, retrying…")
        return wrapper
    return decorator

@retry(times=5)
def fetch_data():
    ...

You’ve already met real decorators: Django’s @login_required, pytest’s @pytest.fixture, @property, @dataclass.

Context Managers — the with Protocol

with works on any object that defines __enter__ (setup) and __exit__ (guaranteed teardown):

class Timer:
    def __enter__(self):
        self.start = time.perf_counter()
        return self                        # bound to the `as` name

    def __exit__(self, exc_type, exc, tb):
        self.ms = (time.perf_counter() - self.start) * 1000
        print(f"block took {self.ms:.1f}ms")
        return False                       # don't suppress exceptions

with Timer():
    do_heavy_work()

The Easy Way: @contextmanager

One generator function: everything before yield is setup, everything after is teardown:

from contextlib import contextmanager

@contextmanager
def db_transaction(conn):
    conn.begin()
    try:
        yield conn
        conn.commit()
    except Exception:
        conn.rollback()
        raise

with db_transaction(conn) as tx:
    tx.execute("UPDATE ...")     # commits on success, rolls back on error

Whenever you write setup/teardown pairs — open/close, lock/unlock, start/stop — reach for a context manager.