2. Decorators¶
Intermediate · 9 min read
A decorator wraps a function to add behaviour — timing, logging, retries, caching — without
changing the function's own code. You've already seen them: @app.route, @pytest.fixture, @tool.
2.1 Functions are objects¶
def shout(text):
return text.upper()
speak = shout # functions can be stored in variables…
print(speak("hi")) # → HI
def apply(fn, value): # …and passed to other functions
return fn(value)
print(apply(len, "genai")) # → 5
2.2 Closures¶
def make_multiplier(factor):
def multiply(x): # inner function "remembers" factor
return x * factor
return multiply
double = make_multiplier(2)
print(double(21)) # → 42
2.3 Your first decorator¶
import functools
import time
def timed(fn):
@functools.wraps(fn) # keep the original name and docstring
def wrapper(*args, **kwargs): # accept any arguments…
start = time.perf_counter()
result = fn(*args, **kwargs) # …call the real function…
ms = (time.perf_counter() - start) * 1000
print(f"{fn.__name__} took {ms:.0f} ms")
return result # …and pass its result through
return wrapper
@timed # same as: slow_add = timed(slow_add)
def slow_add(a, b):
time.sleep(0.05)
return a + b
print(slow_add(2, 3)) # prints the timing line, then → 5
print(slow_add.__name__) # → slow_add (thanks to functools.wraps)
2.4 Decorators with arguments: a retry decorator¶
import functools
import time
def retry(times=3, delay=0.01, exceptions=(Exception,)):
"""Retry the function up to `times` attempts when it raises one of `exceptions`."""
def decorator(fn):
@functools.wraps(fn)
def wrapper(*args, **kwargs):
for attempt in range(1, times + 1):
try:
return fn(*args, **kwargs)
except exceptions as err:
if attempt == times:
raise # out of attempts: let the error through
print(f"attempt {attempt} failed: {err} — retrying")
time.sleep(delay * 2 ** (attempt - 1)) # exponential back-off
return wrapper
return decorator
calls = {"n": 0}
@retry(times=3, exceptions=(ConnectionError,))
def flaky_llm_call():
calls["n"] += 1
if calls["n"] < 3: # fail twice, then succeed
raise ConnectionError("rate limited")
return "answer"
print(flaky_llm_call()) # two retry messages, then → answer
2.5 Built-in decorators you'll meet¶
import functools
@functools.lru_cache(maxsize=128) # remember results for repeated inputs
def embed(text):
print("computing…")
return len(text) # stand-in for an expensive embedding call
embed("rag"); embed("rag") # "computing…" prints only once
print(embed.cache_info().hits) # → 1
@property, @staticmethod and @classmethod (for classes) and @dataclass are decorators too.
Why it matters for GenAI
Retrying on rate limits, timing every LLM call, and caching embeddings are all one-line decorators once you've written them — and frameworks register tools and routes with decorators.
Practice¶
- Write a
@log_callsdecorator that prints the function name and its arguments before calling it.
Answer
import functools
def log_calls(fn):
@functools.wraps(fn)
def wrapper(*args, **kwargs):
print(f"calling {fn.__name__} args={args} kwargs={kwargs}")
return fn(*args, **kwargs)
return wrapper
@log_calls
def greet(name, punct="!"):
return f"Hi {name}{punct}"
print(greet("Priya", punct=".")) # → Hi Priya.