JEPA4Japan · tutorials

Decorators and Context Managers

1,188 words 6 min read #Python

Write decorators and custom context managers to wrap behavior and manage resources safely.

Course progress Course outline 24 of 24 lessons available

A decorator gives a callable a coat

A function is a callable: an object you can call with (). A decorator is ordinary Python that receives a callable and returns the callable people will use.

  1. Original call the real job
  2. Wrapper policy around the job
  3. Caller uses the wrapped callable
A decorator changes how a call is surrounded, not its main job.
def announce(function):
    def wrapper(*args, **kwargs):
        print("before")
        result = function(*args, **kwargs)
        print("after")
        return result

    return wrapper


@announce
def greeting(name):
    return f"Hello, {name}"


print(greeting("Mina"))

Output:

before
after
Hello, Mina

@announce means greeting = announce(greeting). Decoration happens when Python executes the function definition; the wrapper runs later on each call. A transparent wrapper forwards *args and **kwargs and returns the original result. Forgetting return result quietly gives callers None.

Keep the name and configure the coat

An ordinary wrapper is named wrapper, not greeting. functools.wraps() preserves the original name, documentation, annotations, and __wrapped__ link.

  1. Name useful tracebacks
  2. Docstring help still works
  3. __wrapped__ points to the original
@wraps makes a wrapper honest about the callable underneath.

A configurable decorator is a decorator factory with three small layers:

from functools import wraps


def tagged(label):                       # receive configuration
    if not label:
        raise ValueError("label must not be empty")

    def decorate(function):              # receive the callable
        @wraps(function)
        def wrapper(*args, **kwargs):     # receive call arguments
            return f"[{label}] {function(*args, **kwargs)}"

        return wrapper

    return decorate


@tagged("study")
def total(a, b):
    """Add two study times."""
    return a + b


print(total(20, 15))
print(total.__name__)

Output:

[study] 35
total

tagged("study") and decorate(total) run at definition time. The inner wrapper runs at call time. If decorators are stacked, the one nearest the function is applied first; use stacks only when you can explain their order plainly.

with opens and closes a doorway

A context manager surrounds a block rather than one call. with calls __enter__(), runs the block, and always calls __exit__() afterward.

  1. __enter__ acquire and return a value
  2. with block use the managed thing
  3. __exit__ release even after failure
A managed doorway makes resource lifetime visible.
class Notebook:
    def __enter__(self):
        print("open")
        self.notes = []
        return self.notes

    def __exit__(self, exc_type, exc_value, traceback):
        print("close")
        return False


with Notebook() as notes:
    notes.append("context managers")
    print(notes)

Output:

open
['context managers']
close

The value returned by __enter__() becomes notes. On success, the three exception arguments to __exit__() are None; after failure, they describe the exception. A truthy return suppresses that exception. Returning False or None lets it continue. Suppression must be a narrow, documented choice, never an accidental True.

contextlib makes one doorway smaller

For one simple entry and exit path, contextlib.contextmanager turns a generator into a context manager:

from contextlib import contextmanager


@contextmanager
def section(name):
    print("open", name)
    try:
        yield []
    finally:
        print("close", name)


with section("testing") as notes:
    notes.append("arrange-act-assert")
    print(notes)

Output:

open testing
['arrange-act-assert']
close testing

Code before yield enters; the yielded value belongs to the block; finally guarantees cleanup. If the block fails, its exception arrives at the yield and normally keeps travelling after cleanup. Catch broadly only when the manager has a clear audit or rollback policy, then use bare raise to preserve the original traceback.

Short boundaries: close only resources the manager owns, prefer a resource’s native with support, and create a fresh generator-based manager for each with because one instance is single-use.

Build a transactional reading journal

Save this complete standard-library project as reading_journal.py:

from contextlib import contextmanager
from dataclasses import dataclass, field
from functools import wraps


@dataclass
class Journal:
    entries: list[str] = field(default_factory=list)
    audit: list[str] = field(default_factory=list)


def audited(audit, label):
    def decorate(function):
        @wraps(function)
        def wrapper(*args, **kwargs):
            audit.append(f"{label}:start")
            try:
                result = function(*args, **kwargs)
            except Exception as error:
                audit.append(f"{label}:error:{type(error).__name__}")
                raise
            else:
                audit.append(f"{label}:ok")
                return result

        return wrapper

    return decorate


@contextmanager
def staged(journal):
    pending = []
    journal.audit.append("batch:open")
    try:
        yield pending
    except Exception:
        journal.audit.append("batch:rollback")
        raise
    else:
        journal.entries.extend(pending)
        journal.audit.append(f"batch:commit:{len(pending)}")
    finally:
        journal.audit.append("batch:close")


def parse_entry(line):
    topic, separator, minutes_text = line.partition("|")
    if not separator or not topic.strip():
        raise ValueError("expected topic|minutes")
    try:
        minutes = int(minutes_text)
    except ValueError as cause:
        raise ValueError("minutes must be an integer") from cause
    if minutes <= 0:
        raise ValueError("minutes must be positive")
    return f"{topic.strip()} ({minutes} min)"


def make_importer(journal):
    @audited(journal.audit, "import")
    def import_lines(lines):
        with staged(journal) as pending:
            for line in lines:
                pending.append(parse_entry(line))
        return len(pending)

    return import_lines


journal = Journal()
import_lines = make_importer(journal)

count = import_lines(["Decorators | 25", "Contexts | 30"])
saved = journal.entries.copy()
print("Imported:", count)

try:
    import_lines(["Valid | 10", "Broken | many"])
except ValueError as error:
    print("Rejected:", error)

assert journal.entries == saved
assert import_lines.__name__ == "import_lines"
print("Entries:", journal.entries)
print("Last audit:", journal.audit[-5:])

Run python3 reading_journal.py:

Imported: 2
Rejected: minutes must be an integer
Entries: ['Decorators (25 min)', 'Contexts (30 min)']
Last audit: ['import:start', 'batch:open', 'batch:rollback', 'batch:close', 'import:error:ValueError']

The decorator owns call-level audit events. The context manager owns pending data, commit, rollback, and cleanup. The broad catches have one documented job and immediately re-raise; the failed batch cannot change existing entries.

Three tiny missions

  1. Decorate a function with a docstring and verify its result, __name__, and __doc__.
  2. Make @repeat(times) reject zero during decoration and call a function exactly three times.
  3. Change Notebook.__exit__() to suppress only KeyError, then prove that ValueError still escapes.

Ready for Chapter 19?

  • I know a decorator is an ordinary callable transformation.
  • My wrapper forwards arguments, returns results, and uses @wraps.
  • I can explain the three layers of a decorator factory.
  • I know what __enter__() returns and when __exit__() runs.
  • I put unconditional cleanup in finally and choose exception suppression deliberately.
  • I ran the journal and proved that a failed batch rolls back.

Next, you will collect three kinds of evidence with tests, a debugger, and carefully configured logs.