Decorators
Class-based Decorators
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"))
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⟩print(greet("Alice"))
21print(greet("Alice"))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 greetdef greet(name):
16@LogCalls17def greet(nameAlice):18 return f"hello, {nameAlice}"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()
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 = 0task()
23task()24task()self.count ← 1
pass 1 of 312def __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 pass self.count1 0 → 1 2 1 → 2 3 2 → 3 task()
23task()24task()25task()task()
23task()24task()25task()task()
24task()25task()
stateful decorator - a decorator that maintains state between calls using instance attributes
Decorators with Parameters
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)
self.times ← 3
6class Repeat:7 def __init__(self⟨Repeat A⟩, times3):8 self.times→ 3 = times3def __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⟩name ← Alice
25name→ Alice = "Alice"26#@name="Maya", "Jordan"27greet(nameAlice)def wrapper(*args, **kwargs):
11@wraps(func)12def wrapper(*args('Alice',), **kwargs):13 for _ in range(self.times):14 result = func(*args, **kwargs)for _ in range(self.times):
pass 1 of 312def wrapper(*args, **kwargs):13 for _0 in range(self.times3):14 result = func(*args('Alice',), **kwargs{})15 return resultAll 3 passes — pass 1 is the card above pass _1 0 2 1 3 2 result ← None
pass 1 of 313 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, AliceAll 3 passes — pass 1 is the card above pass result1 None 2 None 3 None greet(name)
26#@name="Maya", "Jordan"27greet(nameAlice)
self.times ← 3
6class Repeat:7 def __init__(self⟨Repeat A⟩, times3):8 self.times→ 3 = times3def __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⟩name ← Maya
25name→ Maya = "Maya"26greet(nameMaya)def wrapper(*args, **kwargs):
11@wraps(func)12def wrapper(*args('Maya',), **kwargs):13 for _ in range(self.times):14 result = func(*args, **kwargs)for _ in range(self.times):
pass 1 of 312def wrapper(*args, **kwargs):13 for _0 in range(self.times3):14 result = func(*args('Maya',), **kwargs{})15 return resultAll 3 passes — pass 1 is the card above pass _1 0 2 1 3 2 result ← None
pass 1 of 313 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, MayaAll 3 passes — pass 1 is the card above pass result1 None 2 None 3 None greet(name)
25name = "Maya"26greet(nameMaya)
self.times ← 3
6class Repeat:7 def __init__(self⟨Repeat A⟩, times3):8 self.times→ 3 = times3def __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⟩name ← Jordan
25name→ Jordan = "Jordan"26greet(nameJordan)def wrapper(*args, **kwargs):
11@wraps(func)12def wrapper(*args('Jordan',), **kwargs):13 for _ in range(self.times):14 result = func(*args, **kwargs)for _ in range(self.times):
pass 1 of 312def wrapper(*args, **kwargs):13 for _0 in range(self.times3):14 result = func(*args('Jordan',), **kwargs{})15 return resultAll 3 passes — pass 1 is the card above pass _1 0 2 1 3 2 result ← None
pass 1 of 313 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, JordanAll 3 passes — pass 1 is the card above pass result1 None 2 None 3 None 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))
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→ {} = {}print("fib(5):", fib(5))
26print("fib(5):", fib(5))27print("fib(6):", fib(6))def __call__(self, *args):
pass 1 of 1212def __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 pass argsnself.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 if args not in self.cache:
pass 1 of 712def __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 pass argsself.cachen1 (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} — def fib(n):
pass 1 of 719@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 pass n1 5 2 4 3 3 4 2 5 1 6 0 7 6 if n <= 1:
pass 1 of 220def fib(n):21 if n1 <= 1:22 return n123 return fib(n - 1) + fib(n - 2)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]1if n <= 1:
pass 2 of 220def fib(n):21 if n0 <= 1:22 return n023 return fib(n - 1) + fib(n - 2)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]0self ← ⟨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]1self ← ⟨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]2self ← ⟨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]3self ← ⟨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]5print("fib(5):", fib(5))
26print("fib(5):", fib(5))27print("fib(6):", fib(6))outputfib(5): 5self.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]8print("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))
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⟩calc ← ⟨Calculator C⟩
28calc→ ⟨Calculator C⟩ = Calculator()29print("result:", calc⟨Calculator C⟩.add(2, 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⟩)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 Calculatordef add(self, a, b):
23@LogMethod24def add(self⟨Calculator C⟩, a2, b3):25 return a2 + b3print("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