Modifying Behavior Dynamically

A decorator is a callable that takes a function (or class) and returns a new one. The syntax @decorator placed above a def is shorthand for f = decorator(f). Decorators are how Python lets you add cross-cutting behavior — logging, timing, caching, retry, authentication — to existing functions without modifying their code.

A typical decorator is a function that defines an inner wrapper function. The wrapper does whatever extra work you want, then calls the original function and returns its result. Wrapping the wrapper with @functools.wraps preserves the original name, docstring, and signature — a small detail that saves a lot of debugging later.

The standard library ships several useful decorators. @functools.lru_cache memoizes a pure function. @functools.cache (Python 3.9+) is the unbounded version. @property makes a method look like an attribute. @classmethod and @staticmethod change how a method receives self/cls. Together they cover most decorator uses you will see.

Parameterized decorators (@retry(attempts=3)) are decorators that return decorators: the outer call configures the behavior, the inner call wraps the function. That two-level structure looks odd at first; once you see a few examples it becomes second nature.

Writing a simple decorator

def log_calls(func): @wraps(func) def wrapper(*args, **kwargs): print(f"calling {func.__name__}"); return func(*args, **kwargs); return wrapper. Apply with @log_calls. The wrapper accepts *args, **kwargs so it works with any signature.

Always use @functools.wraps(func) on the inner wrapper. Without it, help(my_func), inspect.signature, and the function's __name__ refer to the wrapper instead of the decorated function — confusing for everyone.

Parameterized decorators and class decorators

For a decorator that takes arguments, add one more level: def retry(attempts): def outer(func): def wrapper(...): ...; return wrapper; return outer. Apply with @retry(attempts=3).

Decorators also work on classes. @dataclass is the most famous example: it takes a class, reads its annotations, and returns a new class with __init__, __repr__ and __eq__ filled in.

Decorator building blocks.

ToolPurpose
@functools.wraps
decorator
Preserve __name__, __doc__, signature.
@lru_cache
decorator
Memoize a pure function.
@cache
decorator
Unbounded memoization (3.9+).
functools.partial
function
Bind some arguments in advance.
@singledispatch
decorator
Type-based function dispatch.
@dataclass
decorator
Class-level decorator; generates methods.
@contextmanager
decorator
Turn a generator into a context manager.
PEP 318
spec
Decorator syntax.

Modifying Behavior Dynamically code example

The script builds a logging decorator, a timing decorator, a parameterized retry decorator, and uses lru_cache for memoization.

# Lesson: Modifying Behavior Dynamically
import time
from functools import lru_cache, wraps


def log_calls(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        print(f"-> {func.__name__}({args}, {kwargs})")
        result = func(*args, **kwargs)
        print(f"<- {func.__name__} returned {result!r}")
        return result
    return wrapper


def timed(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        start = time.perf_counter()
        try:
            return func(*args, **kwargs)
        finally:
            dur = (time.perf_counter() - start) * 1000
            print(f"[timed] {func.__name__}: {dur:.2f} ms")
    return wrapper


def retry(attempts: int = 3):
    def outer(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            last_err = None
            for i in range(1, attempts + 1):
                try:
                    return func(*args, **kwargs)
                except Exception as err:
                    print(f"[retry] attempt {i} failed: {err}")
                    last_err = err
            raise RuntimeError(f"gave up after {attempts} attempts") from last_err
        return wrapper
    return outer


@log_calls
def greet(name: str) -> str:
    return f"hello {name}"


@timed
@lru_cache(maxsize=None)
def fib(n: int) -> int:
    return n if n < 2 else fib(n - 1) + fib(n - 2)


count = {"n": 0}

@retry(attempts=3)
def flaky() -> str:
    count["n"] += 1
    if count["n"] < 3:
        raise RuntimeError("transient")
    return "ok"


print(greet("ana"))
print("fib(30):", fib(30))
print("flaky:", flaky())
print("greet.__name__:", greet.__name__)  # preserved by @wraps

Study the layering:

1) `@wraps(func)` is the boilerplate that preserves metadata.
2) `@timed` and `@lru_cache` stack; order matters (innermost applies first).
3) `retry(attempts=3)` is a parameterized decorator — two levels of nesting.
4) `@lru_cache` turns exponential fib into linear without rewriting it.

Write a decorator that skips the body if a flag is False.

from functools import wraps

def enabled_if(flag: bool):
    def outer(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            if not flag:
                return None
            return func(*args, **kwargs)
        return wrapper
    return outer

@enabled_if(True)
def greet(name): return f"hello {name}"

@enabled_if(False)
def greet_silent(name): return f"hello {name}"

print(greet("ana"))        # hello ana
print(greet_silent("ana"))  # None

Check the metadata preservation contract.

from functools import wraps
def deco(func):
    @wraps(func)
    def wrapper(*a, **kw):
        return func(*a, **kw)
    return wrapper
@deco
def f(x): """doc"""; return x
assert f.__name__ == "f"
assert f.__doc__ == "doc"

Running prints something like:

-> greet(('ana',), {})
<- greet returned 'hello ana'
hello ana
[timed] fib: 0.02 ms
fib(30): 832040
[retry] attempt 1 failed: transient
[retry] attempt 2 failed: transient
flaky: ok
greet.__name__: greet