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:

  1. retry(times) — takes the settings, returns decorator
  2. decorator(fn) — takes the function, returns wrapper
  3. wrapper(*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.