Using functools.wraps with Class-Based Decorators

Class-based decorators use a class with __init__ and __call__ instead of nested functions. They excel at maintaining state across calls -- counting invocations, accumulating timing data, managing caches with custom eviction. But because the class instance replaces the function in the namespace, the same metadata loss problem exists: __name__, __doc__, and the function signature disappear. The fix is functools.update_wrapper, the function that @functools.wraps calls under the hood, adapted for use inside a class __init__.

How a Class-Based Decorator Works

A class-based decorator is a class that implements the __call__ method, making its instances callable. When applied with the @ syntax, Python creates an instance of the class, passing the decorated function to __init__. Every subsequent call to the decorated function invokes __call__ on that instance.

class MyDecorator:
    def __init__(self, func):
        self.func = func

    def __call__(self, *args, **kwargs):
        print(f"Before {self.func.__name__}")
        result = self.func(*args, **kwargs)
        print(f"After {self.func.__name__}")
        return result

@MyDecorator
def greet(name):
    """Return a greeting."""
    return f"Hello, {name}"

# greet is now a MyDecorator instance, not a function
greet("Alice")    # Before greet / Hello, Alice / After greet

After decoration, the name greet points to a MyDecorator instance. That instance stores the original function as self.func and calls it inside __call__. The decorator works, but the function's identity is gone.

The Metadata Problem

print(greet.__name__)      # MyDecorator  (should be "greet")
print(greet.__doc__)       # None          (should be "Return a greeting.")
print(type(greet))         # <class 'MyDecorator'>  (not a function)

The __name__ attribute returns the class name rather than the function name. The docstring is gone. help(greet) describes a MyDecorator object with no useful information about the original function. This is the exact same metadata loss that function-based decorators suffer, but the fix is slightly different because there is no wrapper function to apply @functools.wraps to.

Applying functools.update_wrapper in __init__

The fix is a single line in __init__:

import functools

class MyDecorator:
    def __init__(self, func):
        functools.update_wrapper(self, func)   # <-- the fix
        self.func = func

    def __call__(self, *args, **kwargs):
        print(f"Before {self.func.__name__}")
        result = self.func(*args, **kwargs)
        print(f"After {self.func.__name__}")
        return result

@MyDecorator
def greet(name: str) -> str:
    """Return a greeting."""
    return f"Hello, {name}"

print(greet.__name__)          # greet
print(greet.__doc__)           # Return a greeting.
print(greet.__wrapped__)       # <function greet at 0x...>

functools.update_wrapper(self, func) copies __name__, __doc__, __module__, __qualname__, and __annotations__ from the original function onto the class instance. It also merges the function's __dict__ and sets __wrapped__ to reference the original function. This is exactly what @functools.wraps(func) does in function-based decorators -- @wraps just calls update_wrapper internally.

Note

functools.update_wrapper(self, func) must be called before any other attribute assignments that depend on the function's metadata. Placing it as the first line in __init__ after self.func = func is the safest pattern.

A Stateful Decorator: Call Counter

The primary advantage of class-based decorators is persistent state. Instance attributes survive across calls, making patterns like call counting trivial:

import functools

class CountCalls:
    """Track how many times the decorated function is called."""

    def __init__(self, func):
        functools.update_wrapper(self, func)
        self.func = func
        self.count = 0

    def __call__(self, *args, **kwargs):
        self.count += 1
        return self.func(*args, **kwargs)

@CountCalls
def process_order(order_id: int) -> str:
    """Process a customer order."""
    return f"Order {order_id} processed"

process_order(101)
process_order(102)
process_order(103)
print(process_order.count)       # 3
print(process_order.__name__)    # process_order

The count attribute persists on the class instance and increments with each call. In a function-based decorator, you would need a mutable container in the closure (like a list) or the nonlocal keyword to achieve the same thing. The class approach is more readable because the state is an explicit attribute with a clear name.

A Stateful Decorator: Timing Accumulator

A more advanced example accumulates execution times across calls and provides summary statistics:

import functools
import time

class TimingStats:
    """Accumulate execution timing statistics across calls."""

    def __init__(self, func):
        functools.update_wrapper(self, func)
        self.func = func
        self.call_count = 0
        self.total_time = 0.0

    def __call__(self, *args, **kwargs):
        start = time.perf_counter()
        result = self.func(*args, **kwargs)
        elapsed = time.perf_counter() - start
        self.call_count += 1
        self.total_time += elapsed
        return result

    @property
    def avg_time(self):
        if self.call_count == 0:
            return 0.0
        return self.total_time / self.call_count

@TimingStats
def compute_report(data: list) -> dict:
    """Generate a summary report from raw data."""
    return {"count": len(data), "total": sum(data)}

for _ in range(100):
    compute_report(list(range(10000)))

print(f"Calls: {compute_report.call_count}")
print(f"Total: {compute_report.total_time:.4f}s")
print(f"Average: {compute_report.avg_time:.6f}s")
print(f"Name: {compute_report.__name__}")     # compute_report

The class instance acts as both the callable replacement and the statistics container. Custom properties like avg_time provide computed summaries that would be awkward to expose from a function-based decorator's closure.

Parameterized Class-Based Decorators

When a class-based decorator needs its own configuration, the pattern changes. The class __init__ receives the parameters, and __call__ receives the function. This means __call__ must return a wrapper, and update_wrapper goes on that wrapper:

import functools

class Repeat:
    """Call the decorated function n times."""

    def __init__(self, n=2):
        self.n = n            # __init__ receives the parameter

    def __call__(self, func):    # __call__ receives the function
        @functools.wraps(func)   # @wraps works here -- it's a function def
        def wrapper(*args, **kwargs):
            result = None
            for _ in range(self.n):
                result = func(*args, **kwargs)
            return result
        return wrapper

@Repeat(n=3)
def say_hello(name: str) -> None:
    """Print a greeting."""
    print(f"Hello, {name}")

say_hello("Alice")
# Hello, Alice
# Hello, Alice
# Hello, Alice
print(say_hello.__name__)   # say_hello

Notice that in this pattern, __call__ returns a regular wrapper function, so the standard @functools.wraps(func) syntax works on that function's def line. The update_wrapper form is only needed when the class instance itself is the callable replacement (the non-parameterized pattern in the sections above).

Pro Tip

The rule for which metadata technique to use: if the class instance replaces the function, use functools.update_wrapper(self, func) in __init__. If the class's __call__ returns a function that replaces the original, use @functools.wraps(func) on that function.

The Method Binding Problem and __get__

A class-based decorator that works on standalone functions may fail silently when applied to a method inside a class. The problem is that regular functions in Python are descriptors -- they implement __get__, which is how Python binds self to methods. A class instance does not implement __get__ by default, so the method binding mechanism breaks.

# This will fail when used on a method
class Greeter:
    @CountCalls
    def say_hi(self, name):
        return f"Hi, {name}"

g = Greeter()
g.say_hi("Alice")    # TypeError: say_hi() missing 'self' argument

The fix is to implement the __get__ method on the decorator class, making it a descriptor that participates in Python's method binding protocol:

import functools

class CountCalls:
    """Track call count -- works on functions AND methods."""

    def __init__(self, func):
        functools.update_wrapper(self, func)
        self.func = func
        self.count = 0

    def __call__(self, *args, **kwargs):
        self.count += 1
        return self.func(*args, **kwargs)

    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        return functools.partial(self, obj)

class Greeter:
    @CountCalls
    def say_hi(self, name):
        """Greet someone by name."""
        return f"Hi, {name}"

g = Greeter()
print(g.say_hi("Alice"))       # Hi, Alice
print(g.say_hi("Bob"))         # Hi, Bob
print(Greeter.say_hi.count)    # 2

The __get__ method is called when the descriptor is accessed as an attribute of an instance. When obj is not None (accessed from an instance), it returns a functools.partial that pre-binds obj as the first argument, simulating the normal method binding that Python performs for regular functions. When accessed from the class directly (obj is None), it returns the decorator instance unchanged.

Warning

If you plan to use your class-based decorator on methods inside classes, you must implement __get__. Without it, the decorator will fail with a TypeError about missing the self argument. This is the single largest pitfall with class-based decorators and catches many developers off guard.

Complete Production Template

This template includes metadata preservation, descriptor protocol support, and explicit state tracking. Copy it as a starting point for any class-based decorator:

import functools

class MyClassDecorator:
    """Template for a production-grade class-based decorator."""

    def __init__(self, func):
        functools.update_wrapper(self, func)
        self.func = func
        # Initialize your state attributes here

    def __call__(self, *args, **kwargs):
        # YOUR PRE-CALL LOGIC HERE
        result = self.func(*args, **kwargs)
        # YOUR POST-CALL LOGIC HERE
        return result

    def __get__(self, obj, objtype=None):
        """Support instance method binding."""
        if obj is None:
            return self
        return functools.partial(self, obj)

Class-Based vs. Function-Based: When to Use Which

Criterion Function-Based Class-Based
Metadata preservation @functools.wraps(func) on wrapper functools.update_wrapper(self, func) in __init__
State across calls Requires mutable closure or nonlocal Natural -- use instance attributes
Methods and properties Transparent -- functions are descriptors Requires __get__ implementation
Readability for simple cases Compact -- two nested functions More verbose -- class definition overhead
Exposing custom attributes Set on wrapper function after definition Natural -- define methods and properties
Parameterized version Three-layer nesting __init__ takes params, __call__ takes func
Testability Bypass via func.__wrapped__ Bypass via instance.__wrapped__ or instance.func

For decorators that do not need state -- such as logging, timing a single call, or access control checks -- function-based decorators are simpler and should be the default choice. For decorators that accumulate data across calls -- counters, cumulators, caches with custom eviction, or rate limiters with token buckets -- the class-based approach is cleaner because the state lives in named instance attributes rather than mutable closures.

Key Takeaways

  1. Use functools.update_wrapper(self, func) in __init__. This is the class-based equivalent of @functools.wraps(func). It copies __name__, __doc__, __module__, __qualname__, __annotations__, merges __dict__, and sets __wrapped__.
  2. Place it as the first operation in __init__ after storing self.func. This ensures all subsequent attribute access on the instance reflects the original function's metadata.
  3. Implement __get__ for method compatibility. Without __get__, a class-based decorator fails when used on instance methods because Python cannot bind self from the enclosing class. The functools.partial(self, obj) pattern restores correct method binding.
  4. Class-based decorators excel at stateful behavior. Call counters, timing accumulators, caches, rate limiters, and any decorator that needs to remember information between calls benefit from instance attributes that persist naturally.
  5. Parameterized class-based decorators flip the pattern. __init__ receives the parameters, __call__ receives and wraps the function. In this case, __call__ returns a regular wrapper function, so @functools.wraps(func) applies to that function's def line.
  6. Use the production template. Every class-based decorator you write should start from the three-method template: __init__ with update_wrapper, __call__ with the wrapper logic, and __get__ with functools.partial binding.

Class-based decorators add a bit more structure than their function-based counterparts, but the payoff is cleaner state management and the ability to expose custom properties and methods on the decorated function. The functools.update_wrapper call preserves identity, and the __get__ method preserves method binding. Together, they make a class-based decorator behave as transparently as a function-based one while carrying persistent state that function closures struggle to express clearly.