Decorators II: arguments, classes and built-ins
Part of the free LogicWiz Python course, Module 11: Functions in depth. The examples and practice run in the app.
The UPI server at the canteen fails now and then. Aarav wants @retry(times=3) on the payment function, and Meher wants the menu's slow fib-style calculations cached. Both need decorators one level deeper.
You'll learn to
- Write decorators that take arguments, like `@retry(times=3)`
- Write a class-based decorator that keeps state
- Use `@lru_cache` and `@cache`, and know their limits
- See that `@property`, `@classmethod` and `@staticmethod` are ordinary decorators
Learn
A decorator with arguments is a decorator factory
@retry(times=3) has brackets, so retry(times=3) is called first, and whatever it returns must be the real decorator.
That means three levels:
retry(times)— takes the settings, returnsdecoratordecorator(fn)— takes the function, returnswrapperwrapper(*args, **kwargs)— runs on every call
@retry(times=3) above def pay means pay = retry(times=3)(pay).
@repeat(times=n)
from functools import wraps
def repeat(times):
def decorator(fn):
@wraps(fn)
def wrapper(*args, **kwargs):
for _ in range(times):
result = fn(*args, **kwargs)
return result
return wrapper
return decorator
@repeat(times=3)
def bell(name):
print("Order ready,", name)
bell("Riya")
@retry(times=3) for a flaky payment
from functools import wraps
def retry(times):
def decorator(fn):
@wraps(fn)
def wrapper(*args, **kwargs):
for attempt in range(1, times + 1):
try:
return fn(*args, **kwargs)
except ConnectionError as e:
print(f"attempt {attempt} failed: {e}")
raise ConnectionError(f"{fn.__name__} failed {times} times")
return wrapper
return decorator
calls = 0
@retry(times=3)
def send_upi(amount):
global calls
calls += 1
if calls < 3: # pretend the bank is busy twice
raise ConnectionError("bank server busy")
return f"Rs {amount} sent"
print(send_upi(250))
Class-based decorators
Anything you can call can be a decorator, including a class. @CountCalls above def f means f = CountCalls(f), so:
__init__(self, fn)stores the function__call__(self, *args, **kwargs)runs on every call
The state (like a call count) lives neatly on self. Use a class when the decorator needs to remember things or offer extra methods like reset().
@CountCalls
class CountCalls:
def __init__(self, fn):
self.fn = fn
self.calls = 0
def __call__(self, *args, **kwargs):
self.calls += 1
return self.fn(*args, **kwargs)
@CountCalls
def make_bill(amount):
return round(amount * 1.05, 2)
make_bill(100)
make_bill(40)
print(make_bill(20))
print("bills made:", make_bill.calls)
Built-in caching: @lru_cache and @cache
from functools import lru_cache
calls = 0
@lru_cache(maxsize=None)
def fib(n):
global calls
calls += 1
return n if n < 2 else fib(n - 1) + fib(n - 2)
print(fib(80))
print("real calls:", calls)
print(fib.cache_info())
Without the cache, fib(80) would make about 10^16 calls and never finish. @cache (Python 3.9+) is the same as @lru_cache(maxsize=None). maxsize=128 keeps only the 128 most recently used results.
@property, @classmethod, @staticmethod are decorators too
class MenuItem:
def __init__(self, name, price):
self.name = name
self.price = price # goes through the setter
@property
def price(self):
return self._price
@price.setter
def price(self, value):
if value < 0:
raise ValueError("price can't be negative")
self._price = value
@classmethod
def from_text(cls, text): # "Samosa,15"
name, price = text.split(",")
return cls(name, int(price))
@staticmethod
def is_veg(name):
return name.lower() not in {"egg roll", "chicken roll"}
s = MenuItem.from_text("Samosa,15")
print(s.name, s.price)
print(MenuItem.is_veg("Egg Roll"))
@property is just price = property(price): it wraps the method so reading s.price calls it without brackets.
@lru_cache stores results in a dict keyed by the arguments, so every argument must be hashable. total([15, 20]) raises TypeError: unhashable type: 'list'; pass a tuple instead. And only cache pure functions: same input, same output, no side effects. Caching print_receipt() would print only the first time.
Interviewers often say "write a decorator that…": retries, caches, counts calls, limits calls, checks arguments. The recipe never changes: factory (if it has settings) → decorator(fn) → @wraps(fn) wrapper(*args, **kwargs) → do the extra work → return fn(...).
In one line
A decorator with settings is a factory: @retry(3) means f = retry(3)(f); a class with __call__ can decorate; @lru_cache needs hashable arguments; @property is just a decorator.
Interview corner
How do you write a decorator that takes arguments?
Add an outer function that takes the settings and returns the real decorator: def retry(times): def decorator(fn): @wraps(fn) def wrapper(*a, **k): ...; return wrapper; return decorator. Then @retry(times=3) means f = retry(times=3)(f). That is three levels: factory, decorator, wrapper.
Write a decorator that retries a function n times.
def retry(times):
def decorator(fn):
@wraps(fn)
def wrapper(*args, **kwargs):
for _ in range(times):
try:
return fn(*args, **kwargs)
except Exception as e:
last = e
raise last
return wrapper
return decorator
Good follow-ups: catch only specific exceptions, and sleep with back-off between attempts.
What does functools.lru_cache do, and when should you not use it?
It memoises a function: results are stored in a dict keyed by the arguments, so repeat calls return instantly; maxsize caps the number kept, dropping the least recently used. cache_info() shows hits and misses. Avoid it for functions with side effects or results that change over time, and remember every argument must be hashable.
Are @property, @classmethod and @staticmethod special syntax?
No, they are ordinary decorators (built-in descriptor classes). @property def price(self) is just price = property(price). classmethod makes the method receive the class as cls; staticmethod makes it receive nothing extra.