When your decorator needs to track state across calls - counting invocations, caching results, or managing rate limits - a class-based decorator provides cleaner organization than nested closures with nonlocal variables.

A class-based decorator uses a class with __call__ instead of nested functions.

Basic Pattern

basic.py
Replay: real traced execution (multi-file project)
# Basic class decorator

from functools import wraps


class LogCalls:
    def __init__(self, func):
        wraps(func)(self)
        self.func = func

    def __call__(self, *args, **kwargs):
        print(f"calling {self.func.__name__}")
        return self.func(*args, **kwargs)


@LogCalls
def greet(name):
    return f"hello, {name}"


print(greet("Alice"))

  1. self.func ← ⟨function greet A⟩

    6class LogCalls:7    def __init__(self⟨LogCalls B⟩, func⟨function greet A⟩):8        wraps(func⟨function greet A⟩)(self)9        self.func→ ⟨function greet A⟩ = func⟨function greet A⟩
  2. print(greet("Alice"))

    21print(greet("Alice"))
  3. def __call__(self, *args, **kwargs):

    11def __call__(self⟨LogCalls B⟩, *args('Alice',), **kwargs):12    print(f"calling {self.func.__name__greet}")13    return self.func(*args('Alice',), **kwargs{})
    outputcalling greet
  4. def greet(name):

    16@LogCalls17def greet(nameAlice):18    return f"hello, {nameAlice}"
  5. print(greet("Alice"))

    21print(greet("Alice"))
    outputhello, Alice
__call__ method - makes class instances callable, enabling classes to work as decorators

Stateful Decorators

stateful.py
Replay: real traced execution (multi-file project)
# Stateful class decorator

from functools import wraps


class CountCalls:
    def __init__(self, func):
        wraps(func)(self)
        self.func = func
        self.count = 0

    def __call__(self, *args, **kwargs):
        self.count += 1
        print(f"{self.func.__name__} called {self.count} time(s)")
        return self.func(*args, **kwargs)


@CountCalls
def task():
    return "done"


task()
task()
task()

  1. self.func ← ⟨function task A⟩, self.count ← 0

    6class CountCalls:7    def __init__(self⟨CountCalls B⟩, func⟨function task A⟩):8        wraps(func⟨function task A⟩)(self)9        self.func→ ⟨function task A⟩ = func⟨function task A⟩10        self.count→ 0 = 0
  2. task()

    23task()24task()
  3. self.count ← 1

    pass 1 of 3
    12def __call__(self⟨CountCalls B⟩, *args(), **kwargs):13    self.count→ 1 += 114    print(f"{self.func.__name__task} called {self.count1} time(s)")15    return self.func(*args(), **kwargs{})
    outputtask called 1 time(s)
    All 3 passes — pass 1 is the card above
    passself.count
    10 1
    21 2
    32 3
  4. task()

    23task()24task()25task()
  5. task()

    23task()24task()25task()
  6. task()

    24task()25task()
stateful decorator - a decorator that maintains state between calls using instance attributes

Decorators with Parameters

name
with_params.py
Replay: real traced execution (multi-file project)
# Class decorator with parameters

from functools import wraps


class Repeat:
    def __init__(self, times):
        self.times = times

    def __call__(self, func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            for _ in range(self.times):
                result = func(*args, **kwargs)
            return result

        return wrapper


@Repeat(times=3)
def greet(name):
    print(f"hello, {name}")


name = "Alice"
greet(name)

# Class decorator with parameters

from functools import wraps


class Repeat:
    def __init__(self, times):
        self.times = times

    def __call__(self, func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            for _ in range(self.times):
                result = func(*args, **kwargs)
            return result

        return wrapper


@Repeat(times=3)
def greet(name):
    print(f"hello, {name}")


name = "Maya"
greet(name)

# Class decorator with parameters

from functools import wraps


class Repeat:
    def __init__(self, times):
        self.times = times

    def __call__(self, func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            for _ in range(self.times):
                result = func(*args, **kwargs)
            return result

        return wrapper


@Repeat(times=3)
def greet(name):
    print(f"hello, {name}")


name = "Jordan"
greet(name)

  1. self.times ← 3

    6class Repeat:7    def __init__(self⟨Repeat A⟩, times3):8        self.times→ 3 = times3
  2. def __call__(self, func):

    10def __call__(self⟨Repeat A⟩, func⟨function greet B⟩):11    @wraps(func)12    def wrapper(*args, **kwargs):13        for _ in range(self.times):14            result = func(*args, **kwargs)15        return result1617    return wrapper⟨function greet C⟩
  3. name ← Alice

    25name→ Alice = "Alice"26#@name="Maya", "Jordan"27greet(nameAlice)
  4. def wrapper(*args, **kwargs):

    11@wraps(func)12def wrapper(*args('Alice',), **kwargs):13    for _ in range(self.times):14        result = func(*args, **kwargs)
  5. for _ in range(self.times):

    pass 1 of 3
    12def wrapper(*args, **kwargs):13    for _0 in range(self.times3):14        result = func(*args('Alice',), **kwargs{})15    return result
    All 3 passes — pass 1 is the card above
    pass_
    10
    21
    32
  6. result ← None

    pass 1 of 3
    13            for _ in range(self.times):14                result→ None = func(*args('Alice',), **kwargs{})15            return result1617        return wrapper181920@Repeat(times=3)21def greet(nameAlice):22    print(f"hello, {nameAlice}")
    outputhello, Alice
    All 3 passes — pass 1 is the card above
    passresult
    1None
    2None
    3None
  7. greet(name)

    26#@name="Maya", "Jordan"27greet(nameAlice)
  1. self.times ← 3

    6class Repeat:7    def __init__(self⟨Repeat A⟩, times3):8        self.times→ 3 = times3
  2. def __call__(self, func):

    10def __call__(self⟨Repeat A⟩, func⟨function greet B⟩):11    @wraps(func)12    def wrapper(*args, **kwargs):13        for _ in range(self.times):14            result = func(*args, **kwargs)15        return result1617    return wrapper⟨function greet C⟩
  3. name ← Maya

    25name→ Maya = "Maya"26greet(nameMaya)
  4. def wrapper(*args, **kwargs):

    11@wraps(func)12def wrapper(*args('Maya',), **kwargs):13    for _ in range(self.times):14        result = func(*args, **kwargs)
  5. for _ in range(self.times):

    pass 1 of 3
    12def wrapper(*args, **kwargs):13    for _0 in range(self.times3):14        result = func(*args('Maya',), **kwargs{})15    return result
    All 3 passes — pass 1 is the card above
    pass_
    10
    21
    32
  6. result ← None

    pass 1 of 3
    13            for _ in range(self.times):14                result→ None = func(*args('Maya',), **kwargs{})15            return result1617        return wrapper181920@Repeat(times=3)21def greet(nameMaya):22    print(f"hello, {nameMaya}")
    outputhello, Maya
    All 3 passes — pass 1 is the card above
    passresult
    1None
    2None
    3None
  7. greet(name)

    25name = "Maya"26greet(nameMaya)
  1. self.times ← 3

    6class Repeat:7    def __init__(self⟨Repeat A⟩, times3):8        self.times→ 3 = times3
  2. def __call__(self, func):

    10def __call__(self⟨Repeat A⟩, func⟨function greet B⟩):11    @wraps(func)12    def wrapper(*args, **kwargs):13        for _ in range(self.times):14            result = func(*args, **kwargs)15        return result1617    return wrapper⟨function greet C⟩
  3. name ← Jordan

    25name→ Jordan = "Jordan"26greet(nameJordan)
  4. def wrapper(*args, **kwargs):

    11@wraps(func)12def wrapper(*args('Jordan',), **kwargs):13    for _ in range(self.times):14        result = func(*args, **kwargs)
  5. for _ in range(self.times):

    pass 1 of 3
    12def wrapper(*args, **kwargs):13    for _0 in range(self.times3):14        result = func(*args('Jordan',), **kwargs{})15    return result
    All 3 passes — pass 1 is the card above
    pass_
    10
    21
    32
  6. result ← None

    pass 1 of 3
    13            for _ in range(self.times):14                result→ None = func(*args('Jordan',), **kwargs{})15            return result1617        return wrapper181920@Repeat(times=3)21def greet(nameJordan):22    print(f"hello, {nameJordan}")
    outputhello, Jordan
    All 3 passes — pass 1 is the card above
    passresult
    1None
    2None
    3None
  7. greet(name)

    25name = "Jordan"26greet(nameJordan)

Caching Example

cache.py
Replay: real traced execution (multi-file project)
# Cache decorator

from functools import wraps


class Memoize:
    def __init__(self, func):
        wraps(func)(self)
        self.func = func
        self.cache = {}

    def __call__(self, *args):
        if args not in self.cache:
            print(f"computing {self.func.__name__}{args}")
            self.cache[args] = self.func(*args)
        return self.cache[args]


@Memoize
def fib(n):
    if n <= 1:
        return n
    return fib(n - 1) + fib(n - 2)


print("fib(5):", fib(5))
print("fib(6):", fib(6))

  1. self.func ← ⟨function fib A⟩, self.cache ← {}

    6class Memoize:7    def __init__(self⟨Memoize B⟩, func⟨function fib A⟩):8        wraps(func⟨function fib A⟩)(self)9        self.func→ ⟨function fib A⟩ = func⟨function fib A⟩10        self.cache→ {} = {}
  2. print("fib(5):", fib(5))

    26print("fib(5):", fib(5))27print("fib(6):", fib(6))
  3. def __call__(self, *args):

    pass 1 of 12
    12def __call__(self⟨Memoize B⟩, *args(5,)):13    if args not in self.cache:14        print(f"computing {self.func.__name__}{args}")
    All 12 passes — pass 1 is the card above
    passargsnself.cache[args]
    1(5,)
    2(4,)
    3(3,)
    4(2,)
    5(1,)1
    6(0,)0
    7(1,)1
    8(2,)1
    9(3,)2
    10(6,)
    11(5,)5
    12(4,)3
  4. if args not in self.cache:

    pass 1 of 7
    12def __call__(self, *args):13    if args(5,) not in self.cache{}:14        print(f"computing {self.func.__name__fib}{args(5,)}")15        self.cache[args] = self⟨Memoize B⟩.func(*args(5,))16    return self.cache[args]
    outputcomputing fib(5,)
    All 7 passes — pass 1 is the card above
    passargsself.cachen
    1(5,){}
    2(4,){}
    3(3,){}
    4(2,){}
    5(1,){}1
    6(0,){(1,): 1}0
    7(6,){(1,): 1, (0,): 0, (2,): 1, (3,): 2, (4,): 3, (5,): 5}
  5. def fib(n):

    pass 1 of 7
    19@Memoize20def fib(n5):21    if n <= 1:22        return n23    return fib(n5 - 1) + fib(n - 2)
    All 7 passes — pass 1 is the card above
    passn
    15
    24
    33
    42
    51
    60
    76
  6. if n <= 1:

    pass 1 of 2
    20def fib(n):21    if n1 <= 1:22        return n123    return fib(n - 1) + fib(n - 2)
  7. self.cache[args] ← 1

    14    print(f"computing {self.func.__name__}{args}")15    self.cache[args]→ 1 = self⟨Memoize B⟩.func(*args(1,))16return self.cache[args]1
  8. if n <= 1:

    pass 2 of 2
    20def fib(n):21    if n0 <= 1:22        return n023    return fib(n - 1) + fib(n - 2)
  9. self.cache[args] ← 0

    14    print(f"computing {self.func.__name__}{args}")15    self.cache[args]→ 0 = self⟨Memoize B⟩.func(*args(0,))16return self.cache[args]0
  10. self ← ⟨Memoize B⟩, args ← (2,), self.cache[args] ← 1

    14    print(f"computing {self.func.__name__}{args}")15    self.cache[args]→ 1 = self→ ⟨Memoize B⟩.func(*args→ (2,))16return self.cache[args]1
  11. self ← ⟨Memoize B⟩, args ← (3,), self.cache[args] ← 2

    14    print(f"computing {self.func.__name__}{args}")15    self.cache[args]→ 2 = self→ ⟨Memoize B⟩.func(*args→ (3,))16return self.cache[args]2
  12. self ← ⟨Memoize B⟩, args ← (4,), self.cache[args] ← 3

    14    print(f"computing {self.func.__name__}{args}")15    self.cache[args]→ 3 = self→ ⟨Memoize B⟩.func(*args→ (4,))16return self.cache[args]3
  13. self ← ⟨Memoize B⟩, args ← (5,), self.cache[args] ← 5

    14    print(f"computing {self.func.__name__}{args}")15    self.cache[args]→ 5 = self→ ⟨Memoize B⟩.func(*args→ (5,))16return self.cache[args]5
  14. print("fib(5):", fib(5))

    26print("fib(5):", fib(5))27print("fib(6):", fib(6))
    outputfib(5): 5
  15. self.cache[args] ← 8

    14    print(f"computing {self.func.__name__}{args}")15    self.cache[args]→ 8 = self⟨Memoize B⟩.func(*args(6,))16return self.cache[args]8
  16. print("fib(6):", fib(6))

    26print("fib(5):", fib(5))27print("fib(6):", fib(6))
    outputfib(6): 8

Decorating Methods

When decorating methods, handle self carefully (use functools.wraps and proper signatures).

method_decorator.py
Replay: real traced execution (multi-file project)
# Decorator on class methods

from functools import wraps


class LogMethod:
    def __init__(self, func):
        wraps(func)(self)
        self.func = func

    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        from functools import partial as _partial
        return _partial(self.__call__, obj)

    def __call__(self, instance, *args, **kwargs):
        print(f"calling {self.func.__name__} on {instance.__class__.__name__}")
        return self.func(instance, *args, **kwargs)


class Calculator:
    @LogMethod
    def add(self, a, b):
        return a + b


calc = Calculator()
print("result:", calc.add(2, 3))

  1. self.func ← ⟨function Calculator.add A⟩

    6class LogMethod:7    def __init__(self⟨LogMethod B⟩, func⟨function Calculator.add A⟩):8        wraps(func⟨function Calculator.add A⟩)(self)9        self.func→ ⟨function Calculator.add A⟩ = func⟨function Calculator.add A⟩
  2. calc ← ⟨Calculator C⟩

    28calc→ ⟨Calculator C⟩ = Calculator()29print("result:", calc⟨Calculator C⟩.add(2, 3))
  3. def __get__(self, obj, objtype=None):

    11def __get__(self⟨LogMethod B⟩, obj⟨Calculator C⟩, objtype<class '__main__.Calculator'>=NoneNone):12    if obj is None:13        return self14    from functools import partial as _partial15    return _partial(self.__call__<bound method LogMethod.__call__ of ⟨LogMethod B⟩>, obj⟨Calculator C⟩)
  4. def __call__(self, instance, *args, **kwargs):

    17def __call__(self⟨LogMethod B⟩, instance⟨Calculator C⟩, *args(2, 3), **kwargs):18    print(f"calling {self.func.__name__add} on {instance.__class__.__name__Calculator}")19    return self.func(instance⟨Calculator C⟩, *args(2, 3), **kwargs{})
    outputcalling add on Calculator
  5. def add(self, a, b):

    23@LogMethod24def add(self⟨Calculator C⟩, a2, b3):25    return a2 + b3
  6. print("result:", calc.add(2, 3))

    28calc = Calculator()29print("result:", calc⟨Calculator C⟩.add(2, 3))
    outputresult: 5
method decorator - decorating instance methods requires careful handling of `self`

When to Use Classes

  • Stateful decorators: need to track calls, cache results, etc.
  • Complex logic: easier to organize in methods
  • Reusable configuration: combine with __init__ parameters

Exercise: practical.py

Create a call-counting decorator that tracks function usage