Decorators
A decorator is a function that wraps another function to add behavior.
# Basic decorator
def my_decorator(func):
def wrapper(*args, **kwargs):
print("Before call")
result = func(*args, **kwargs)
print("After call")
return result
return wrapper
@my_decorator
def say_hello(name):
print(f"Hello, {name}!")
say_hello("Alice")
# Before call
# Hello, Alice!
# After call
Preserving Metadata with functools.wraps
from functools import wraps
def my_decorator(func):
@wraps(func) # preserves func.__name__, func.__doc__
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
Timer Decorator
import time
from functools import wraps
def timer(func):
@wraps(func)
def wrapper(*args, **kwargs):
start = time.perf_counter()
result = func(*args, **kwargs)
elapsed = time.perf_counter() - start
print(f"{func.__name__} took {elapsed:.4f}s")
return result
return wrapper
@timer
def slow_function():
time.sleep(0.1)
return "done"
slow_function() # slow_function took 0.1004s
Decorator with Arguments (Decorator Factory)
def repeat(n):
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
for _ in range(n):
result = func(*args, **kwargs)
return result
return wrapper
return decorator
@repeat(3)
def hello():
print("Hello!")
hello()
# Hello!
# Hello!
# Hello!
Caching with functools.cache
from functools import cache, lru_cache
@cache
def fibonacci(n):
if n < 2:
return n
return fibonacci(n - 1) + fibonacci(n - 2)
print(fibonacci(100)) # 354224848179261915075 โ instant!
# lru_cache with size limit:
@lru_cache(maxsize=128)
def expensive(n):
return sum(range(n))
Class-Based Decorators
class Retry:
def __init__(self, times):
self.times = times
def __call__(self, func):
@wraps(func)
def wrapper(*args, **kwargs):
for attempt in range(self.times):
try:
return func(*args, **kwargs)
except Exception as e:
if attempt == self.times - 1:
raise
print(f"Attempt {attempt+1} failed: {e}, retrying...")
return wrapper
@Retry(3)
def flaky_function():
import random
if random.random() < 0.7:
raise ValueError("Random failure")
return "Success!"
Generators
Generators produce values lazily โ they yield one at a time.
# Generator function
def count_up(start, stop):
n = start
while n <= stop:
yield n
n += 1
gen = count_up(1, 5)
print(next(gen)) # 1
print(next(gen)) # 2
for n in count_up(1, 5):
print(n) # 1, 2, 3, 4, 5
yield from
def flatten(nested):
for item in nested:
if isinstance(item, list):
yield from flatten(item)
else:
yield item
data = [1, [2, [3, 4], 5], [6, 7]]
print(list(flatten(data))) # [1, 2, 3, 4, 5, 6, 7]
Infinite Generators
def natural_numbers():
n = 1
while True:
yield n
n += 1
def take(n, iterable):
for i, item in enumerate(iterable):
if i >= n:
break
yield item
print(list(take(5, natural_numbers()))) # [1, 2, 3, 4, 5]
Generator Expressions
# Like list comprehensions but lazy
squares_gen = (x**2 for x in range(1000000)) # no memory cost!
print(next(squares_gen)) # 0
print(next(squares_gen)) # 1
print(sum(x**2 for x in range(100))) # 328350
# Large file processing:
def read_large_file(path):
with open(path) as f:
yield from f # yields one line at a time
Send Values to Generators
def accumulator():
total = 0
while True:
value = yield total
if value is None:
break
total += value
gen = accumulator()
next(gen) # prime the generator
print(gen.send(10)) # 10
print(gen.send(20)) # 30
print(gen.send(5)) # 35
Exercises
Exercise 1: Logger Decorator
Write a decorator that logs function name, arguments, and return value.
Solution:
from functools import wraps
def logger(func):
@wraps(func)
def wrapper(*args, **kwargs):
arg_str = ", ".join(map(repr, args))
kwarg_str = ", ".join(f"{k}={v!r}" for k, v in kwargs.items())
all_args = ", ".join(filter(None, [arg_str, kwarg_str]))
result = func(*args, **kwargs)
print(f"{func.__name__}({all_args}) = {result!r}")
return result
return wrapper
@logger
def add(a, b):
return a + b
add(3, 5) # add(3, 5) = 8
Exercise 2: Fibonacci Generator
Write a generator that produces Fibonacci numbers indefinitely.
Solution:
def fibonacci():
a, b = 0, 1
while True:
yield a
a, b = b, a + b
gen = fibonacci()
fibs = [next(gen) for _ in range(10)]
print(fibs) # [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]
Exercise 3: Pipeline with Generators
Create a generator pipeline: generate numbers โ filter evens โ square them โ take first 5.
Solution:
def numbers():
n = 1
while True:
yield n
n += 1
def evens(nums):
for n in nums:
if n % 2 == 0:
yield n
def squared(nums):
for n in nums:
yield n ** 2
def take(n, nums):
for i, num in enumerate(nums):
if i >= n:
break
yield num
pipeline = take(5, squared(evens(numbers())))
print(list(pipeline)) # [4, 16, 36, 64, 100]