Python · lesson 22 of 34
Beginner
16- 1 Introduction to Python
- 2 Setting Up Python
- 3 Python Variables and Type System
- 4 Python Data Types
- 5 Python Operators
- 6 Python Strings
- 7 Control Flow in Python
- 8 Python Functions and Parameters
- 9 Python Data Structures
- 10 Python Collections
- 11 Python Modules and Packages
- 12 Object-Oriented Programming in Python
- 13 Encapsulation and Name Mangling in Python
- 14 Polymorphism and Duck Typing in Python
- 15 Abstraction with Abstract Base Classes in Python
- 21 Building a Python CLI Tool
Intermediate
12- 16 Multiple Inheritance and MRO in Python
- 17 Composition vs Inheritance in Python
- 18 Python Dataclasses
- 19 Error Handling in Python
- 20 File I/O, pathlib, and JSON in Python
- 22 Python Decorators and Closures
- 23 Python Generators
- 24 Python Context Managers
- 25 Python Concurrency
- 27 Testing Python Code
- 28 Python Security
- 34 Python Interview Prep
Advanced
6Python Decorators and Closures
Build closures, then decorators: functools.wraps, decorator factories, stacking order, stateful class decorators and lru_cache, each with real output.
By SkillByExample · Updated
A decorator is a function that takes a function and returns a replacement for it, letting you add behaviour — logging, timing, retries, caching — without editing the original. Decorators are built on closures, so that’s where we start. Output is from Python 3.13.
Closures
An inner function can keep using variables from the function that created it, even after that function has returned.
def make_multiplier(factor: int):
def multiplier(x: int) -> int:
return x * factor # factor comes from the enclosing call
return multiplier
double = make_multiplier(2)
triple = make_multiplier(3)
print(double(5), triple(5))
print(double.__closure__[0].cell_contents)
def make_counter():
count = 0
def increment() -> int:
nonlocal count # rebind the enclosing variable
count += 1
return count
return increment
tick = make_counter()
print(tick(), tick(), tick())
$ python closures.py
10 15
2
1 2 3
make_multiplier(2) had returned long before double(5) ran, yet factor was still 2 — the closure cell kept it alive. Each call to make_multiplier creates a separate cell, so double and triple don’t interfere. To reassign a captured variable, the inner function needs nonlocal; without it, count += 1 raises UnboundLocalError.
Your first decorator — and why wraps matters
The @ line is shorthand for slow_sum = timer(slow_sum).
import functools
import time
def timer(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
start = time.perf_counter()
result = func(*args, **kwargs)
print(f"[timer] {func.__name__} took {time.perf_counter() - start:.2f}s")
return result
return wrapper
def no_wraps(func):
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@timer
def slow_sum(n: int) -> int:
"""Sum 0..n-1."""
time.sleep(0.2)
return sum(range(n))
@no_wraps
def plain(): """Docstring that will vanish."""
print(slow_sum(1_000))
print(slow_sum.__name__, slow_sum.__doc__)
print(plain.__name__, plain.__doc__)
$ python timer.py
[timer] slow_sum took 0.20s
499500
slow_sum Sum 0..n-1.
wrapper None
The wrapper ran the timing code around the real call and passed the return value through — forget return result and every decorated function silently returns None. Without functools.wraps, plain now calls itself wrapper and has lost its docstring, which is what tracebacks, help() and logging would show. (The timing varies by a few milliseconds per run.)
Decorators with arguments
@retry(max_attempts=3) calls a factory that returns the decorator — one extra level of nesting.
import functools
def retry(max_attempts: int = 3, exceptions: tuple = (Exception,)):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
for attempt in range(1, max_attempts + 1):
try:
return func(*args, **kwargs)
except exceptions as e:
print(f"attempt {attempt} failed: {e}")
if attempt == max_attempts:
raise
return wrapper
return decorator
calls = 0
@retry(max_attempts=3, exceptions=(ConnectionError,))
def fetch() -> str:
global calls
calls += 1
if calls < 3:
raise ConnectionError("network unstable")
return "ok"
print(fetch())
@retry(max_attempts=2, exceptions=(ConnectionError,))
def broken() -> None:
raise ValueError("bad input")
try:
broken()
except ValueError as e:
print("not retried:", e)
$ python retry.py
attempt 1 failed: network unstable
attempt 2 failed: network unstable
ok
not retried: bad input
fetch failed twice and succeeded on the third call, so the caller saw only "ok". broken raised a ValueError, which isn’t in exceptions, so it went straight through on the first attempt — retrying is limited to the failures you named.
Stacking order
Decorators apply bottom-up at definition time, and their wrappers run top-down at call time.
import functools
def tag(name: str):
def decorator(func):
print(f"applying {name}")
@functools.wraps(func)
def wrapper(*args):
print(f"enter {name}")
return f"<{name}>{func(*args)}</{name}>"
return wrapper
return decorator
@tag("b")
@tag("i")
def greet(who: str) -> str:
return f"Hello, {who}"
print("--- calling")
print(greet("Alice"))
$ python stacking.py
applying i
applying b
--- calling
enter b
enter i
<b><i>Hello, Alice</i></b>
“applying” printed before any call — decorators run once, when def executes. @tag("i") is nearest the function so it wrapped first; @tag("b") wrapped the result. Calling greet enters the outermost wrapper, b, first. It is exactly tag("b")(tag("i")(greet)).
Stateful decorators with a class
A class whose __call__ returns a wrapper can keep state across calls on self.
import functools
class RateLimit:
def __init__(self, max_calls: int):
self.max_calls = max_calls
self.calls = 0
def __call__(self, func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
if self.calls >= self.max_calls:
raise RuntimeError(f"limit of {self.max_calls} calls reached")
self.calls += 1
return func(*args, **kwargs)
return wrapper
@RateLimit(max_calls=2)
def send(message: str) -> None:
print("sent:", message)
for msg in ["a", "b", "c"]:
try:
send(msg)
except RuntimeError as e:
print("blocked:", e)
$ python ratelimit.py
sent: a
sent: b
blocked: limit of 2 calls reached
RateLimit(max_calls=2) creates one instance, which then decorates send; its calls counter lives as long as the decorated function does. A real limiter would also reset the count per time window.
Built-in decorators: property, classmethod, staticmethod
These three shape how a method is accessed.
import math
class Circle:
def __init__(self, radius: float):
self.radius = radius
@property
def area(self) -> float:
return math.pi * self.radius ** 2
@classmethod
def unit(cls) -> "Circle":
return cls(1.0)
@staticmethod
def is_valid_radius(r: float) -> bool:
return r > 0
c = Circle.unit()
print(round(c.area, 5), type(c).__name__)
print(Circle.is_valid_radius(-1))
try:
c.area = 10
except AttributeError as e:
print("AttributeError:", e)
$ python builtins.py
3.14159 Circle
False
AttributeError: property 'area' of 'Circle' object has no setter
c.area has no parentheses — @property runs the method on attribute access, and without a setter the attribute is read-only. @classmethod receives the class, so unit() would build a subclass instance if called on one; @staticmethod receives neither class nor instance.
Memoising with functools.lru_cache
lru_cache stores results keyed by the arguments, so repeated calls skip the work.
import functools
calls = 0
@functools.lru_cache(maxsize=None)
def fibonacci(n: int) -> int:
global calls
calls += 1
if n < 2:
return n
return fibonacci(n - 1) + fibonacci(n - 2)
print(fibonacci(50))
print("function bodies run:", calls)
print(fibonacci.cache_info())
$ python fib.py
12586269025
function bodies run: 51
CacheInfo(hits=48, misses=51, maxsize=None, currsize=51)
Uncached, fibonacci(50) would run its body about 40 billion times. Cached, each n from 0 to 50 is computed once (51 misses) and every repeat is a hit. @functools.cache is the same thing with no size limit.
| Decorator | Purpose |
|---|---|
@functools.wraps(func) | Preserve the wrapped function’s metadata |
@functools.lru_cache(maxsize=128) | Memoise up to N results |
@functools.cache | Unbounded memoisation (3.9+) |
@functools.cached_property | Compute an attribute once per instance |
Practice
1. Write a @log_calls decorator that prints the function name, arguments and return value of each call.
Solution
import functools
def log_calls(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
result = func(*args, **kwargs)
print(f"{func.__name__}{args} {kwargs} -> {result!r}")
return result
return wrapper
@log_calls
def add(a: int, b: int = 0) -> int:
return a + b
add(2, 3)
add(5, b=10)
$ python log_calls.py
add(2, 3) {} -> 5
add(5,) {'b': 10} -> 15
Positional and keyword arguments arrive separately in *args and **kwargs, which is why b=10 shows up in the dict.
2. Write a decorator factory @repeat(n) that calls the function n times and returns a list of the results.
Solution
import functools
def repeat(n: int):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
return [func(*args, **kwargs) for _ in range(n)]
return wrapper
return decorator
counter = iter(range(100))
@repeat(3)
def next_id() -> int:
return next(counter)
print(next_id())
print(next_id())
$ python repeat.py
[0, 1, 2]
[3, 4, 5]
3. Swap the order of the two @tag decorators in stacking.py and predict both the “applying” lines and the final HTML.
Solution
import functools
def tag(name: str):
def decorator(func):
print(f"applying {name}")
@functools.wraps(func)
def wrapper(*args):
return f"<{name}>{func(*args)}</{name}>"
return wrapper
return decorator
@tag("i")
@tag("b")
def greet(who: str) -> str:
return f"Hello, {who}"
print(greet("Alice"))
$ python stacking_swapped.py
applying b
applying i
<i><b>Hello, Alice</b></i>
The decorator nearest def is still applied first, and it becomes the innermost tag.
4. Use functools.wraps’s __wrapped__ attribute to call the original, undecorated slow_sum and skip the timer.
Solution
import functools
def timer(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
print("[timer] timing...")
return func(*args, **kwargs)
return wrapper
@timer
def slow_sum(n: int) -> int:
return sum(range(n))
print(slow_sum(10))
print(slow_sum.__wrapped__(10))
$ python unwrapped.py
[timer] timing...
45
45
wraps sets __wrapped__ to the original function — handy in tests that want to bypass a decorator.