Chapter 12 of 13 · Free, no signup

Support Us

Python Mastery · Chapter 12 · ~25 min · Advanced

Decorators, Protocols & Metaprogramming

Unlock the architectural patterns that power industry frameworks like FastAPI, Pydantic, and SQLAlchemy: higher-order decorators with functools.wraps, static duck-typing with typing.Protocol, descriptor attribute interception, and generator context managers.

  • See that @dec means fn = dec(fn)
  • Keep metadata with functools.wraps
  • Use Protocol for typed duck typing
  • Write context managers with contextlib

1Decorators & Middleware Wrapping

Decorators are functions that take another function as an argument, augment its behavior, and return a new function. They are the foundation of authentication guards, caching layers, rate limiters, and telemetry tracking in modern web frameworks.

Decorator Wrapping & Middleware Pipeline

Decorators wrap functions like layers of an onion. Watch execution traverse down and return back up.

Active Layers:
Wrapper Stack Hierarchy__name__ = "fetch_user_balance"
Outer Layer:@require_auth (Inspects request headers & credentials)
Inner Layer:@measure_time (high resolution benchmark)
Core Business Logic:def fetch_user_balance(user_id: int) -> float: ...
Live Call TraceCall stack

Click "Invoke Function" to trace execution flow.

Always use @functools.wraps: Without it, your wrapper replaces the original function's identity, erasing its docstring, name, and type signatures. Always wrap your inner wrapper function with @wraps(fn).

Mental model

The one formula that makes decorators click

The @ symbol is just shorthand. These two are exactly the same:

@log_calls
def greet(): ...

# is EXACTLY:
def greet(): ...
greet = log_calls(greet)

So a decorator is simply a function that takes your function and returns a new (wrapper) function.

Without @functools.wraps, what does greet.__name__ show?

2Duck Typing with Types: typing.Protocol

Python's historic motto has always been "If it walks like a duck and quacks like a duck, it is a duck". With PEP 544 (typing.Protocol), Python introduced structural subtyping. You can define an interface, and any class that implements the required methods satisfies the type checker—without needing to inherit from a rigid base class!

typing.Protocol & Structural Subtyping (Duck Typing with Types)

Static duck typing: If an object implements the protocol methods, Mypy accepts it without rigid inheritance hierarchies.

The Protocol Contract (PEP 544)typing.Protocol
from typing import Protocol

class StorageBackend(Protocol):
    """Any object with these 3 methods satisfies this type!"""
    def read(self, key: str) -> bytes: ...
    def write(self, key: str, data: bytes) -> None: ...
    def delete(self, key: str) -> None: ...

def backup_database(storage: StorageBackend) -> None:
    # Completely decoupled from concrete AWS or disk classes!
    storage.write("backup.sql", b"DATA")
No abc.ABC, no coupled inheritance. Perfect for clean plugin architectures.
✔ Mypy Compatible
class S3BlobStorage:
    # Notice: ZERO inheritance from StorageBackend!
    def read(self, key: str) -> bytes:
        return s3_client.get_object(Key=key)["Body"].read()

    def write(self, key: str, data: bytes) -> None:
        s3_client.put_object(Key=key, Body=data)

    def delete(self, key: str) -> None:
        s3_client.delete_object(Key=key)

✔ Satisfies StorageBackend because it implements read, write, and delete.

Nominal Typing (Rigid abc.ABC):

from abc import ABC, abstractmethod

class BaseStorage(ABC):
    @abstractmethod
    def read(self): ...

# MUST explicitly inherit:
class S3(BaseStorage):
    def read(self): ...

Structural Typing (Flexible Protocol):

from typing import Protocol

class Storage(Protocol):
    def read(self) -> bytes: ...

# Zero inheritance required!
class S3:
    def read(self) -> bytes: ...

A class has a .read() method but never inherits from Readable(Protocol). Does it match?

3Descriptors: The Engine Behind Properties & ORMs

Have you ever wondered how @property works, or how Pydantic validates fields, or how SQLAlchemy maps database columns to class attributes? The answer is Descriptors: objects that define any of __get__, __set__, or __set_name__.

Descriptors: How @property, Pydantic & ORMs Intercept Attributes

A descriptor is an object that implements __get__, __set__, and __set_name__.

PositiveInt DescriptorPython 3.6+ __set_name__
class PositiveInt:
    def __set_name__(self, owner, name):
        self.storage_name = f"_{name}"

    def __get__(self, instance, owner):
        if instance is None:
            return self
        return getattr(instance, self.storage_name, 0)

    def __set__(self, instance, value):
        if not isinstance(value, int) or value <= 0:
            raise ValueError(f"'{self.storage_name}' must be > 0")
        setattr(instance, self.storage_name, value)

class Product:
    price = PositiveInt()  # Descriptor instance on class
product = Product()Current product._price = 25
product.price =

Descriptor Validation Passed

Value 25 written to product.__dict__["_price"]

Descriptors power every @property, Django model field, and Pydantic validator in Python.

4Lightweight Context Managers with contextlib

Writing a class with __enter__ and __exit__ can be verbose. The standard library's @contextlib.contextmanager decorator allows you to turn a simple generator function into a bulletproof context manager using a try / yield / finally block.

Writing Custom Context Managers with @contextlib.contextmanager

Turn any generator function into an automated resource manager with zero boilerplate.

@contextmanager Patternfrom contextlib import contextmanager
from contextlib import contextmanager

@contextmanager
def db_transaction(db_pool):
    # 1. SETUP (Before yield)
    conn = db_pool.acquire()
    print("BEGIN TRANSACTION")
    try:
        # 2. YIELD to with body
        yield conn
        # 4a. SUCCESS COMMIT
        conn.commit()
    except Exception:
        # 4b. ROLLBACK ON ERROR
        conn.rollback()
        raise
    finally:
        # 4c. GUARANTEED CLEANUP
        conn.close()
Execution PointerStep 0 / 4

1. Setup (__enter__)

Code before 'yield' executes: Acquiring database connection & starting transaction.

2. Yield Resource

Resource passed to variable: 'with db_transaction() as tx:'.

3. User Code Executes

Operations inside the with block: tx.insert_order(order_data).

4. Cleanup (__exit__ / finally)

Code after 'yield' executes: Committing transaction & closing socket connection!

The finally block guarantees database connection release even if code inside with raises a fatal exception.

5The 4 Fatal Traps of Python Metaprogramming

Metaprogramming gives you the ability to reshape Python's syntax and execution model. Review the four most common pitfalls before building framework-level code.

The 4 Fatal Traps of Advanced Metaprogramming

Metaprogramming gives you immense power. Avoid these four subtle traps that cause bizarre shared-state bugs and silent failures.

Forgetting @functools.wraps on Decorators

Without @functools.wraps, decorators erase the original function's name, docstrings, and type annotations, breaking debuggers, logging libraries, and FastAPI endpoint routers.

❌ Subtle Trap:
def my_decorator(fn):
    def wrapper(*args, **kwargs):
        return fn(*args, **kwargs)
    return wrapper

@my_decorator
def calculate_vat():
    """Calculates EU VAT."""
    pass

print(calculate_vat.__name__)  # Prints 'wrapper'!
print(calculate_vat.__doc__)   # Prints None! Breaks documentation generators!
✔ Idiomatic Architecture:
from functools import wraps

def my_decorator(fn):
    @wraps(fn)  # Copies __name__, __doc__, and annotations!
    def wrapper(*args, **kwargs):
        return fn(*args, **kwargs)
    return wrapper

print(calculate_vat.__name__)  # 'calculate_vat'
print(calculate_vat.__doc__)   # 'Calculates EU VAT.'

6Capstone Challenge: Python Mastery Architect

Test your understanding of decorator lifecycles, structural protocols, descriptor attribute binding, and contextlib exception handling. Score 5/5 to complete the entire Python Mastery Series!

Decorators, Protocols & Metaprogramming · code lab

Edit the example, run it with Python, or step through the lines.

Python
Line-by-line view
1def announce(func):
2 def wrapper(*args, **kwargs):
3 print("Calling", func.__name__)
4 return func(*args, **kwargs)
5 return wrapper
6
7@announce
8def greet(name):
9 return f"Hello, {name}!"
10
11print(greet("Ada"))

Names

Run the example to inspect local names.

Objects in memory

Memory snapshots appear as Python runs.

Python output

Run the code to see its output.

Advanced Python Capstone Challenge

Pick an answer — you get feedback instantly. Get every question right to finish the chapter.

0 / 5 correct
  1. 1Why should custom decorators almost always use @functools.wraps(fn)?

  2. 2When does the outer code of a decorator function execute in Python?

  3. 3What is the key difference between typing.Protocol and an abstract base class (abc.ABC)?

  4. 4Why does Python 3.6+ provide __set_name__(self, owner, name) for descriptors?

  5. 5In a @contextmanager generator, what happens if an exception is raised inside the 'with' block?

Run and experiment with the full metaprogramming framework in the Python Playground (code preloaded).

7Recap & Course Completion

  • Higher-Order Decorators: Wrap function execution cleanly; always decorate your inner wrapper with @functools.wraps.
  • Import vs Call Time: Remember that outer decorator code runs once on import; wrapper code runs on invocation.
  • typing.Protocol: Leverage modern static duck typing to decouple interfaces from concrete implementations.
  • Descriptors: Use __get__, __set__, and __set_name__ to build reusable attribute validators and ORM fields.
  • @contextmanager: Use generators with try ... yield ... finally to guarantee resource cleanup.

Finish the capstone challenge above to complete Chapter 12 and the entire course!

Score 5/5 or click below to manually mark the capstone complete.

Enjoying this free lesson? Your support helps us build more free animated chapters.

Support Us