Decorators with Arguments and lru_cache
24 min · Intermediate
Need help? Ask a tutor on WhatsApp (opens in a new tab)Often a decorator needs a setting, such as how many times to retry or which role to allow. Python supports this with one more level of nesting. The same tools give you caching with lru_cache.
Decorators that take arguments
Decorator factory is a function that takes settings and returns a decorator. It lets you write @name(setting) instead of plain @name.
@repeat(3) first calls repeat(3), which must return the real decorator. So a decorator with arguments has three levels: the factory, the decorator, and the wrapper.
from functools import wraps
def repeat(times): def decorator(func): @wraps(func) def wrapper(*args, **kwargs): for _ in range(times): result = func(*args, **kwargs) return result return wrapper return decorator
@repeat(3)def ping(): print("ping")Example 1 · repeat(times)
from functools import wraps
def repeat(times): def decorator(func): @wraps(func) def wrapper(*args, **kwargs): result = None for _ in range(times): result = func(*args, **kwargs) return result return wrapper return decorator
@repeat(3)def notify(user): print("sent to", user)
notify("Asha")sent to Asha sent to Asha sent to Asha
What this code does
- The decorator is built when
@repeat(3)runs. - The wrapper calls
notifythree times.
Example 2 · Checking a role
from functools import wraps
def require_role(role): def decorator(func): @wraps(func) def wrapper(user, *args, **kwargs): if user["role"] != role: raise PermissionError(f"{role} only") return func(user, *args, **kwargs) return wrapper return decorator
@require_role("admin")def delete_user(user, name): return f"{name} deleted"
print(delete_user({"role": "admin"}, "Ravi"))try: delete_user({"role": "learner"}, "Ravi")except PermissionError as err: print("denied:", err)Ravi deleted denied: admin only
What this code does
- The setting
"admin"is captured once and checked on every call. - Web frameworks use the same pattern for login checks.
@repeat(3) becomes ping = repeat(3)(ping). The number 3 is captured by the wrapper as a closure variable.
lru_cache remembers results
Memoization means saving the result of a function for given arguments so a repeat call returns the saved result instead of calculating again.
Some functions are slow and are called again with the same arguments. functools.lru_cache stores each result and returns it directly the next time.
from functools import lru_cache
@lru_cache(maxsize=None)def fib(n): return n if n < 2 else fib(n - 1) + fib(n - 2)
print(fib(30))print(fib.cache_info())Example 3 · Counting real calls with lru_cache
from functools import lru_cache
calls = 0
@lru_cache(maxsize=None)def price_with_tax(price): global calls calls += 1 return round(price * 1.18, 2)
print(price_with_tax(100))print(price_with_tax(100))print(price_with_tax(250))print("body ran", calls, "times")118.0 118.0 295.0 body ran 2 times
What this code does
- The second call with 100 returns the saved value without running the body.
callsshows the body ran only twice.
Without the cache, fib(30) makes over a million calls. With it, each value from 0 to 30 is calculated once.
Rules and limits of lru_cache
LRU cache is a cache with a size limit that, when full, removes the least recently used entry to make room for a new one.
Arguments become dictionary keys, so they must be hashable: use tuples, not lists. maxsize limits how many results are kept, and the least recently used one is dropped first. Cache only pure functions.
from functools import lru_cache
@lru_cache(maxsize=2)def square(n): return n * n
square(1); square(2); square(1); square(3)print(square.cache_info())try: square([1])except TypeError as err: print(err)Example 4 · Evicting the least recently used
from functools import lru_cache
@lru_cache(maxsize=2)def square(n): return n * n
square(1)square(2)square(1)square(3)print(square.cache_info())square(2)print(square.cache_info())CacheInfo(hits=1, misses=3, maxsize=2, currsize=2) CacheInfo(hits=1, misses=4, maxsize=2, currsize=2)
What this code does
- After
square(3), the entry for 2 was dropped because 1 was used more recently. - So
square(2)is a miss again, and the misses count goes up.
square(3) pushed out square(2), the least recently used entry. Call square.cache_clear() to empty the cache.
Decorators as classes
A class can act as a decorator if it takes the function in __init__ and defines __call__. This is handy when the decorator needs readable state.
from functools import update_wrapper
class CountCalls: def __init__(self, func): update_wrapper(self, func) self.func = func self.calls = 0
def __call__(self, *args, **kwargs): self.calls += 1 return self.func(*args, **kwargs)Example 5 · A class-based decorator
from functools import update_wrapper
class CountCalls: def __init__(self, func): update_wrapper(self, func) self.func = func self.calls = 0
def __call__(self, *args, **kwargs): self.calls += 1 return self.func(*args, **kwargs)
@CountCallsdef hello(name): return f"hello {name}"
hello("Asha")hello("Ravi")print(hello.calls, hello.__name__)2 hello
What this code does
CountCalls(hello)builds an object, and the namehellonow points to it.- The state is a normal attribute,
hello.calls.
update_wrapper does for a class what @wraps does for a function. Calling the decorated name runs __call__.
Next step
Check your understanding
12 quick questions